fix: improve guest URL validation and error handling (addresses #427)

- Add client-side URL validation with instant feedback
- Show validation errors inline below URL input fields
- Prevent saving when URLs have validation errors
- Improve error message extraction in API client
- Handle incomplete URLs like 'https://emby.' gracefully
- Backend already had validation, now frontend shows it properly
This commit is contained in:
Pulse Monitor
2025-09-07 14:27:03 +00:00
parent 095a31ddb9
commit 2dc8174afd
3 changed files with 125 additions and 20 deletions
@@ -17,6 +17,7 @@ export function GuestURLs(props: GuestURLsProps) {
const [searchTerm, setSearchTerm] = createSignal('');
const [loading, setLoading] = createSignal(false);
const [initialLoad, setInitialLoad] = createSignal(true);
const [urlErrors, setUrlErrors] = createSignal<Record<string, string>>({});
// Combine VMs and containers into a single list
const allGuests = createMemo(() => {
@@ -76,18 +77,29 @@ export function GuestURLs(props: GuestURLsProps) {
setLoading(true);
try {
const metadata = guestMetadata();
const promises: Promise<void>[] = [];
const errors: string[] = [];
// Update each guest that has changes
for (const [guestId, meta] of Object.entries(metadata)) {
if (meta.customUrl !== undefined) {
promises.push(GuestMetadataAPI.updateMetadata(guestId, { customUrl: meta.customUrl }));
try {
await GuestMetadataAPI.updateMetadata(guestId, { customUrl: meta.customUrl });
} catch (err: any) {
// Extract error message from response
const errorMsg = err.message || err.toString();
errors.push(`${guestId}: ${errorMsg}`);
console.error(`Failed to save URL for ${guestId}:`, err);
}
}
}
await Promise.all(promises);
showSuccess('Guest URLs saved');
props.setHasUnsavedChanges(false);
if (errors.length > 0) {
// Show specific validation errors
showError(errors.join('\n'));
} else {
showSuccess('Guest URLs saved');
props.setHasUnsavedChanges(false);
}
} catch (err) {
console.error('Failed to save guest URLs:', err);
showError('Failed to save guest URLs');
@@ -96,6 +108,39 @@ export function GuestURLs(props: GuestURLsProps) {
}
};
// Validate URL format
const validateURL = (url: string): string | null => {
if (!url) return null; // Empty is valid
// Check for incomplete URLs like "https://emby."
if (url.endsWith('.') && !url.includes('..')) {
return 'URL appears incomplete - please enter a complete domain or IP address';
}
// Check for missing protocol
if (!url.startsWith('http://') && !url.startsWith('https://')) {
return 'URL must start with http:// or https://';
}
try {
const parsed = new URL(url);
// Check for valid host
if (!parsed.hostname) {
return 'URL must include a valid hostname or IP address';
}
// Check for incomplete hostnames
if (parsed.hostname.endsWith('.') && !parsed.hostname.includes('..')) {
return 'Hostname appears incomplete';
}
return null; // Valid
} catch (e) {
return 'Invalid URL format';
}
};
// Update a guest's URL configuration
const updateGuestURL = (guestId: string, url: string) => {
setGuestMetadata({
@@ -106,6 +151,16 @@ export function GuestURLs(props: GuestURLsProps) {
}
});
// Validate and update errors
const error = validateURL(url);
const errors = { ...urlErrors() };
if (error) {
errors[guestId] = error;
} else {
delete errors[guestId];
}
setUrlErrors(errors);
props.setHasUnsavedChanges(true);
};
@@ -154,7 +209,7 @@ export function GuestURLs(props: GuestURLsProps) {
</div>
{/* Save Button */}
<Show when={props.hasUnsavedChanges()}>
<Show when={props.hasUnsavedChanges() && Object.keys(urlErrors()).length === 0}>
<div class="flex justify-end">
<button type="button"
onClick={saveURLs}
@@ -215,6 +270,8 @@ export function GuestURLs(props: GuestURLsProps) {
const meta = guestMetadata()[guestId];
const url = getURL(guestId);
const urlError = urlErrors()[guestId];
return (
<tr class="hover:bg-gray-50 dark:hover:bg-gray-900/50 transition-colors">
<td class="p-1 px-2">
@@ -235,16 +292,27 @@ export function GuestURLs(props: GuestURLsProps) {
{guest.vmid}
</td>
<td class="p-1 px-2">
<input
type="text"
placeholder="https://192.168.1.100:8006"
value={meta?.customUrl || ''}
onInput={(e) => updateGuestURL(guestId, e.currentTarget.value)}
class="w-full min-w-[300px] px-2 py-1 text-sm border border-gray-300 dark:border-gray-600 rounded
bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100
focus:ring-2 focus:ring-blue-500 focus:border-transparent"
style="min-width: 300px;"
/>
<div>
<input
type="text"
placeholder="https://192.168.1.100:8006"
value={meta?.customUrl || ''}
onInput={(e) => updateGuestURL(guestId, e.currentTarget.value)}
class={`w-full min-w-[300px] px-2 py-1 text-sm border rounded
bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100
focus:ring-2 focus:border-transparent ${
urlError
? 'border-red-500 dark:border-red-400 focus:ring-red-500'
: 'border-gray-300 dark:border-gray-600 focus:ring-blue-500'
}`}
style="min-width: 300px;"
/>
<Show when={urlError}>
<div class="text-xs text-red-600 dark:text-red-400 mt-1">
{urlError}
</div>
</Show>
</div>
</td>
<td class="p-1 px-2">
<div class="flex items-center gap-2">
+18 -1
View File
@@ -185,7 +185,24 @@ class ApiClient {
if (!response.ok) {
const text = await response.text();
throw new Error(`API request failed: ${response.status} ${text}`);
// Try to extract just the error message without HTTP status codes
let errorMessage = text;
// If it looks like an HTML error page, try to extract the message
if (text.includes('<pre>') && text.includes('</pre>')) {
const match = text.match(/<pre>(.*?)<\/pre>/s);
if (match) errorMessage = match[1];
}
// If the backend sent a plain text error, use it directly
if (!text.includes('<') && text.length < 200) {
errorMessage = text;
} else if (text.length > 200) {
// For long responses, just use a generic message
errorMessage = `Request failed with status ${response.status}`;
}
throw new Error(errorMessage || `Request failed with status ${response.status}`);
}
const text = await response.text();
+23 -3
View File
@@ -3,6 +3,7 @@ package api
import (
"encoding/json"
"net/http"
"net/url"
"strings"
"github.com/rcourtman/pulse-go-rewrite/internal/config"
@@ -90,9 +91,28 @@ func (h *GuestMetadataHandler) HandleUpdateMetadata(w http.ResponseWriter, r *ht
// Validate URL if provided
if meta.CustomURL != "" {
// Basic URL validation - just check it starts with http:// or https://
if !strings.HasPrefix(meta.CustomURL, "http://") && !strings.HasPrefix(meta.CustomURL, "https://") {
http.Error(w, "Custom URL must start with http:// or https://", http.StatusBadRequest)
// Parse and validate the URL
parsedURL, err := url.Parse(meta.CustomURL)
if err != nil {
http.Error(w, "Invalid URL format: "+err.Error(), http.StatusBadRequest)
return
}
// Check scheme
if parsedURL.Scheme != "http" && parsedURL.Scheme != "https" {
http.Error(w, "URL must use http:// or https:// scheme", http.StatusBadRequest)
return
}
// Check host is present and valid
if parsedURL.Host == "" {
http.Error(w, "Invalid URL: missing host/domain (e.g., use https://192.168.1.100:8006 or https://emby.local)", http.StatusBadRequest)
return
}
// Check for incomplete URLs like "https://emby."
if strings.HasSuffix(parsedURL.Host, ".") && !strings.Contains(parsedURL.Host, "..") {
http.Error(w, "Incomplete URL: '"+meta.CustomURL+"' - please enter a complete domain or IP address", http.StatusBadRequest)
return
}
}