Improve discovery UX on PVE/PBS settings

This commit is contained in:
rcourtman
2025-09-29 16:16:37 +00:00
parent ccdc080181
commit fa9ce3cb69
3 changed files with 371 additions and 252 deletions
@@ -35,19 +35,7 @@ export const NodeModal: Component<NodeModalProps> = (props) => {
tokenName: '',
tokenValue: '',
fingerprint: '',
verifySSL: true,
// PVE specific
monitorVMs: true,
monitorContainers: true,
monitorStorage: true,
monitorBackups: true,
enableBackupManagement: true, // New field for backup write permissions
// PBS specific
monitorDatastores: true,
monitorSyncJobs: true,
monitorVerifyJobs: true,
monitorPruneJobs: true,
monitorGarbageJobs: false
verifySSL: true
});
const [formData, setFormData] = createSignal(getCleanFormData());
@@ -121,17 +109,7 @@ export const NodeModal: Component<NodeModalProps> = (props) => {
tokenName: tokenName,
tokenValue: '', // Don't show existing token
fingerprint: ('fingerprint' in node ? node.fingerprint : '') || '',
verifySSL: node.verifySSL ?? true,
monitorVMs: (node.type === 'pve' && 'monitorVMs' in node ? node.monitorVMs : true) ?? true,
monitorContainers: (node.type === 'pve' && 'monitorContainers' in node ? node.monitorContainers : true) ?? true,
monitorStorage: (node.type === 'pve' && 'monitorStorage' in node ? node.monitorStorage : true) ?? true,
monitorBackups: (node.type === 'pve' && 'monitorBackups' in node ? node.monitorBackups : true) ?? true,
enableBackupManagement: true, // Default to true for existing nodes
monitorDatastores: (node.type === 'pbs' && 'monitorDatastores' in node ? node.monitorDatastores : true) ?? true,
monitorSyncJobs: (node.type === 'pbs' && 'monitorSyncJobs' in node ? node.monitorSyncJobs : true) ?? true,
monitorVerifyJobs: (node.type === 'pbs' && 'monitorVerifyJobs' in node ? node.monitorVerifyJobs : true) ?? true,
monitorPruneJobs: (node.type === 'pbs' && 'monitorPruneJobs' in node ? node.monitorPruneJobs : true) ?? true,
monitorGarbageJobs: (node.type === 'pbs' && 'monitorGarbageJobs' in node ? node.monitorGarbageJobs : false) ?? false
verifySSL: node.verifySSL ?? true
});
}
});
@@ -165,18 +143,18 @@ export const NodeModal: Component<NodeModalProps> = (props) => {
// Add monitor settings based on type
if (props.nodeType === 'pve') {
Object.assign(nodeData, {
monitorVMs: data.monitorVMs,
monitorContainers: data.monitorContainers,
monitorStorage: data.monitorStorage,
monitorBackups: data.monitorBackups
monitorVMs: true,
monitorContainers: true,
monitorStorage: true,
monitorBackups: true
});
} else {
Object.assign(nodeData, {
monitorDatastores: data.monitorDatastores,
monitorSyncJobs: data.monitorSyncJobs,
monitorVerifyJobs: data.monitorVerifyJobs,
monitorPruneJobs: data.monitorPruneJobs,
monitorGarbageJobs: data.monitorGarbageJobs
monitorDatastores: true,
monitorSyncJobs: true,
monitorVerifyJobs: true,
monitorPruneJobs: true,
monitorGarbageJobs: true
});
}
@@ -476,26 +454,9 @@ export const NodeModal: Component<NodeModalProps> = (props) => {
{/* Quick Setup Tab */}
<Show when={formData().setupMode === 'auto' || !formData().setupMode}>
{/* Backup Management Checkbox */}
<div class="mb-3">
<label class="flex items-center gap-2 text-sm">
<input
type="checkbox"
checked={formData().enableBackupManagement}
onChange={(e) => setFormData({ ...formData(), enableBackupManagement: e.currentTarget.checked })}
class={formCheckbox}
/>
<span class="text-gray-700 dark:text-gray-300">
Enable storage permissions for backup visibility
</span>
</label>
<p class="text-xs text-gray-500 dark:text-gray-400 ml-6 mt-1">
{formData().enableBackupManagement
? 'Required to read PVE backup files and display them in the Backups tab (Proxmox API limitation)'
: 'Backups tab will not show PVE backups without these permissions'}
</p>
</div>
<p class="text-xs text-gray-600 dark:text-gray-400 mb-3">
The command below creates the monitoring user, applies read-only access, and adds the storage permissions Pulse needs to display backups.
</p>
<p class="text-blue-800 dark:text-blue-200">Just copy and run this one command on your Proxmox VE server:</p>
{/* One-line command */}
@@ -518,7 +479,7 @@ export const NodeModal: Component<NodeModalProps> = (props) => {
body: JSON.stringify({
type: 'pve',
host: formData().host,
backupPerms: formData().enableBackupManagement
backupPerms: true
})
});
@@ -611,8 +572,7 @@ export const NodeModal: Component<NodeModalProps> = (props) => {
const hostValue = formData().host || '';
const encodedHost = encodeURIComponent(hostValue);
const pulseUrl = encodeURIComponent(window.location.origin);
const backupPerms = formData().enableBackupManagement ? '&backup_perms=true' : '';
const scriptUrl = `/api/setup-script?type=pve&host=${encodedHost}&pulse_url=${pulseUrl}${backupPerms}`;
const scriptUrl = `/api/setup-script?type=pve&host=${encodedHost}&pulse_url=${pulseUrl}&backup_perms=true`;
// Fetch the script using the current session
const response = await fetch(scriptUrl);
@@ -664,7 +624,7 @@ export const NodeModal: Component<NodeModalProps> = (props) => {
</li>
<li class="flex items-start">
<span class="text-green-500 mr-2 mt-0.5">✓</span>
<span>Sets up monitoring permissions (PVEAuditor + guest agent access{formData().enableBackupManagement ? ' + backup access' : ''})</span>
<span>Sets up monitoring permissions (PVEAuditor + guest agent access + backup visibility)</span>
</li>
<li class="flex items-start">
<span class="text-green-500 mr-2 mt-0.5">✓</span>
@@ -1193,104 +1153,17 @@ export const NodeModal: Component<NodeModalProps> = (props) => {
</div>
</div>
{/* Monitoring Options */}
{/* Monitoring Overview */}
<div>
<SectionHeader
title="Monitoring options"
title="Monitoring coverage"
size="sm"
class="mb-4"
class="mb-2"
titleClass="text-gray-900 dark:text-gray-100"
/>
<div class="space-y-2">
{props.nodeType === 'pve' ? (
<>
<label class="flex items-center gap-2 text-sm text-gray-700 dark:text-gray-300">
<input
type="checkbox"
checked={formData().monitorVMs}
onChange={(e) => updateField('monitorVMs', e.currentTarget.checked)}
class={formCheckbox}
/>
<span>Monitor Virtual Machines</span>
</label>
<label class="flex items-center gap-2 text-sm text-gray-700 dark:text-gray-300">
<input
type="checkbox"
checked={formData().monitorContainers}
onChange={(e) => updateField('monitorContainers', e.currentTarget.checked)}
class={formCheckbox}
/>
<span>Monitor Containers</span>
</label>
<label class="flex items-center gap-2 text-sm text-gray-700 dark:text-gray-300">
<input
type="checkbox"
checked={formData().monitorStorage}
onChange={(e) => updateField('monitorStorage', e.currentTarget.checked)}
class={formCheckbox}
/>
<span>Monitor Storage</span>
</label>
<label class="flex items-center gap-2 text-sm text-gray-700 dark:text-gray-300">
<input
type="checkbox"
checked={formData().monitorBackups}
onChange={(e) => updateField('monitorBackups', e.currentTarget.checked)}
class={formCheckbox}
/>
<span>Monitor Backups</span>
</label>
</>
) : (
<>
<label class="flex items-center gap-2 text-sm text-gray-700 dark:text-gray-300">
<input
type="checkbox"
checked={formData().monitorDatastores}
onChange={(e) => updateField('monitorDatastores', e.currentTarget.checked)}
class={formCheckbox}
/>
<span>Monitor Datastores</span>
</label>
<label class="flex items-center gap-2 text-sm text-gray-700 dark:text-gray-300">
<input
type="checkbox"
checked={formData().monitorSyncJobs}
onChange={(e) => updateField('monitorSyncJobs', e.currentTarget.checked)}
class={formCheckbox}
/>
<span>Monitor Sync Jobs</span>
</label>
<label class="flex items-center gap-2 text-sm text-gray-700 dark:text-gray-300">
<input
type="checkbox"
checked={formData().monitorVerifyJobs}
onChange={(e) => updateField('monitorVerifyJobs', e.currentTarget.checked)}
class={formCheckbox}
/>
<span>Monitor Verify Jobs</span>
</label>
<label class="flex items-center gap-2 text-sm text-gray-700 dark:text-gray-300">
<input
type="checkbox"
checked={formData().monitorPruneJobs}
onChange={(e) => updateField('monitorPruneJobs', e.currentTarget.checked)}
class={formCheckbox}
/>
<span>Monitor Prune Jobs</span>
</label>
<label class="flex items-center gap-2 text-sm text-gray-700 dark:text-gray-300">
<input
type="checkbox"
checked={formData().monitorGarbageJobs}
onChange={(e) => updateField('monitorGarbageJobs', e.currentTarget.checked)}
class={formCheckbox}
/>
<span>Monitor Garbage Collection Jobs</span>
</label>
</>
)}
</div>
<p class="text-sm text-gray-600 dark:text-gray-400">
Pulse automatically tracks all supported resources for this node — virtual machines, containers, storage usage, backups, and PBS job activity — so you always get full visibility without extra configuration.
</p>
</div>
</div>
@@ -93,13 +93,21 @@ interface DiagnosticsData {
errors: string[];
}
interface DiscoveryScanStatus {
scanning: boolean;
subnet?: string;
lastScanStartedAt?: number;
lastResultAt?: number;
errors?: string[];
}
type SettingsTab = 'pve' | 'pbs' | 'system' | 'urls' | 'security' | 'diagnostics';
// Node with UI-specific fields
type NodeConfigWithStatus = NodeConfig & {
hasPassword?: boolean;
hasToken?: boolean;
status: 'connected' | 'disconnected' | 'error';
status: 'connected' | 'disconnected' | 'error' | 'pending';
};
const Settings: Component = () => {
@@ -114,6 +122,7 @@ const Settings: Component = () => {
const [modalResetKey, setModalResetKey] = createSignal(0);
const [showPasswordModal, setShowPasswordModal] = createSignal(false);
const [initialLoadComplete, setInitialLoadComplete] = createSignal(false);
const [discoveryScanStatus, setDiscoveryScanStatus] = createSignal<DiscoveryScanStatus>({ scanning: false });
// System settings
// PBS polling interval removed - fixed at 10 seconds
@@ -167,6 +176,34 @@ const Settings: Component = () => {
return date.toLocaleString();
};
const formatRelativeTime = (timestamp?: number) => {
if (!timestamp) {
return '';
}
const delta = Date.now() - timestamp;
if (delta < 0) {
return 'just now';
}
const seconds = Math.round(delta / 1000);
if (seconds < 60) {
return `${seconds}s ago`;
}
const minutes = Math.round(seconds / 60);
if (minutes < 60) {
return `${minutes}m ago`;
}
const hours = Math.round(minutes / 60);
if (hours < 24) {
return `${hours}h ago`;
}
return new Date(timestamp).toLocaleString();
};
const tabs: { id: SettingsTab; label: string; icon: string }[] = [
{
id: 'pve',
@@ -210,7 +247,7 @@ const Settings: Component = () => {
// Use the hasPassword/hasToken from the API if available, otherwise check local fields
hasPassword: node.hasPassword ?? !!node.password,
hasToken: node.hasToken ?? !!node.tokenValue,
status: node.status || 'disconnected' as const
status: node.status || 'pending' as const
}));
setNodes(nodesWithStatus);
} catch (error) {
@@ -324,6 +361,11 @@ const Settings: Component = () => {
} else {
setDiscoveredNodes(filtered);
}
setDiscoveryScanStatus(prev => ({
...prev,
lastResultAt: Date.now()
}));
};
const loadDiscoveredNodes = async () => {
@@ -334,8 +376,18 @@ const Settings: Component = () => {
const data = await response.json();
if (Array.isArray(data.servers)) {
updateDiscoveredNodesFromServers(data.servers as RawDiscoveredServer[]);
setDiscoveryScanStatus(prev => ({
...prev,
lastResultAt: typeof data.timestamp === 'number' ? data.timestamp : Date.now(),
errors: Array.isArray(data.errors) && data.errors.length > 0 ? data.errors : undefined
}));
} else {
updateDiscoveredNodesFromServers([]);
setDiscoveryScanStatus(prev => ({
...prev,
lastResultAt: typeof data?.timestamp === 'number' ? data.timestamp : prev.lastResultAt,
errors: Array.isArray(data?.errors) && data.errors.length > 0 ? data.errors : undefined
}));
}
}
} catch (error) {
@@ -343,6 +395,45 @@ const Settings: Component = () => {
}
};
const triggerDiscoveryScan = async (options: { quiet?: boolean } = {}) => {
const { quiet = false } = options;
setDiscoveryScanStatus(prev => ({
...prev,
scanning: true,
subnet: discoverySubnet() || prev.subnet,
lastScanStartedAt: Date.now(),
errors: undefined
}));
try {
const { apiFetch } = await import('@/utils/apiClient');
const response = await apiFetch('/api/discover', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({ subnet: discoverySubnet() || 'auto' })
});
if (!response.ok) {
const message = await response.text();
throw new Error(message || 'Discovery request failed');
}
if (!quiet) {
notificationStore.info('Discovery scan started', 2000);
}
} catch (error) {
console.error('Failed to start discovery scan:', error);
notificationStore.error('Failed to start discovery scan');
setDiscoveryScanStatus(prev => ({
...prev,
scanning: false
}));
}
};
// Load nodes and system settings on mount
onMount(async () => {
// Subscribe to events
@@ -362,16 +453,60 @@ const Settings: Component = () => {
const unsubscribeDiscovery = eventBus.on('discovery_updated', (data) => {
if (!data) {
updateDiscoveredNodesFromServers([]);
setDiscoveryScanStatus(prev => ({
...prev,
scanning: false
}));
return;
}
if (Array.isArray(data.servers)) {
updateDiscoveredNodesFromServers(data.servers as RawDiscoveredServer[], { merge: !!data.immediate });
setDiscoveryScanStatus(prev => ({
...prev,
scanning: data.scanning ?? prev.scanning,
lastResultAt: data.timestamp ?? Date.now(),
errors: Array.isArray(data.errors) && data.errors.length > 0 ? data.errors : undefined
}));
} else if (!data.immediate) {
// Ensure we clear stale results when the update explicitly reports no servers
updateDiscoveredNodesFromServers([]);
setDiscoveryScanStatus(prev => ({
...prev,
scanning: data.scanning ?? prev.scanning,
lastResultAt: data.timestamp ?? prev.lastResultAt,
errors: Array.isArray(data.errors) && data.errors.length > 0 ? data.errors : undefined
}));
} else {
setDiscoveryScanStatus(prev => ({
...prev,
scanning: data.scanning ?? prev.scanning,
errors: Array.isArray(data.errors) && data.errors.length > 0 ? data.errors : undefined
}));
}
});
const unsubscribeDiscoveryStatus = eventBus.on('discovery_status', (data) => {
if (!data) {
setDiscoveryScanStatus(prev => ({
...prev,
scanning: false
}));
return;
}
setDiscoveryScanStatus(prev => ({
...prev,
scanning: !!data.scanning,
subnet: data.subnet || prev.subnet,
lastScanStartedAt: data.scanning
? (data.timestamp ?? Date.now())
: prev.lastScanStartedAt,
lastResultAt: !data.scanning && data.timestamp
? data.timestamp
: prev.lastResultAt
}));
});
// Poll for node updates when modal is open
let pollInterval: ReturnType<typeof setInterval> | undefined;
@@ -401,6 +536,7 @@ const Settings: Component = () => {
unsubscribeAutoRegister();
unsubscribeRefresh();
unsubscribeDiscovery();
unsubscribeDiscoveryStatus();
if (pollInterval) {
clearInterval(pollInterval);
}
@@ -866,15 +1002,21 @@ const Settings: Component = () => {
discoverySubnet: discoverySubnet()
});
if (newValue) {
loadDiscoveredNodes();
notificationStore.success('Discovery enabled', 2000);
await triggerDiscoveryScan({ quiet: true });
notificationStore.success('Discovery enabled — scanning network...', 2000);
} else {
notificationStore.info('Discovery disabled', 2000);
setDiscoveryScanStatus(prev => ({
...prev,
scanning: false
}));
}
} catch (error) {
console.error('Failed to update discovery setting:', error);
notificationStore.error('Failed to update discovery setting');
setDiscoveryEnabled(!newValue);
} finally {
await loadDiscoveredNodes();
}
}}
disabled={envOverrides().discoveryEnabled}
@@ -885,9 +1027,13 @@ const Settings: Component = () => {
<Show when={discoveryEnabled()}>
<button type="button"
onClick={() => {
loadDiscoveredNodes();
onClick={async () => {
notificationStore.info('Refreshing discovery...', 2000);
try {
await triggerDiscoveryScan({ quiet: true });
} finally {
await loadDiscoveredNodes();
}
}}
class="px-2 sm:px-4 py-1.5 sm:py-2 text-xs sm:text-sm bg-gray-600 text-white rounded-lg hover:bg-gray-700 transition-colors flex items-center gap-1"
title="Refresh discovered servers"
@@ -948,7 +1094,13 @@ const Settings: Component = () => {
if (node.status === 'connected') {
return 'bg-green-500';
}
return 'bg-red-500';
if (node.status === 'error') {
return 'bg-red-500';
}
if (node.status === 'pending' || node.status === 'disconnected') {
return 'bg-amber-500 animate-pulse';
}
return 'bg-gray-400';
})()
}`}></div>
</div>
@@ -1050,59 +1202,97 @@ const Settings: Component = () => {
{/* Discovered PVE nodes - only show when discovery is enabled */}
<Show when={discoveryEnabled()}>
<For each={discoveredNodes().filter(n => n.type === 'pve')}>
{(server) => (
<div
class="bg-gray-50/50 dark:bg-gray-700/30 rounded-lg p-4 border border-gray-200/50 dark:border-gray-600/50 opacity-75 hover:opacity-100 transition-opacity cursor-pointer"
onClick={() => {
// Pre-fill the modal with discovered server info
setEditingNode({
id: '',
type: 'pve',
<div class="space-y-3">
<div class="flex items-center gap-2 text-xs text-gray-600 dark:text-gray-400">
<Show when={discoveryScanStatus().scanning}>
<span class="flex items-center gap-2">
<svg class="h-4 w-4 animate-spin" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="12" cy="12" r="10" stroke-opacity="0.25"></circle>
<path d="M22 12a10 10 0 00-10-10" stroke-linecap="round"></path>
</svg>
<span>Scanning your network for Proxmox VE servers…</span>
</span>
</Show>
<Show when={!discoveryScanStatus().scanning && (discoveryScanStatus().lastResultAt || discoveryScanStatus().lastScanStartedAt)}>
<span>
Last scan {formatRelativeTime(discoveryScanStatus().lastResultAt ?? discoveryScanStatus().lastScanStartedAt)}
</span>
</Show>
</div>
<Show when={discoveryScanStatus().errors && discoveryScanStatus().errors!.length}>
<div class="text-xs text-amber-600 dark:text-amber-400 bg-amber-50 dark:bg-amber-900/20 border border-amber-200 dark:border-amber-800 rounded-lg p-2">
<span class="font-medium">Discovery issues:</span>
<ul class="list-disc ml-4 mt-1 space-y-0.5">
<For each={discoveryScanStatus().errors || []}>
{(err) => <li>{err}</li>}
</For>
</ul>
</div>
</Show>
<Show when={discoveryScanStatus().scanning && discoveredNodes().filter(n => n.type === 'pve').length === 0}>
<div class="flex items-center gap-2 text-xs text-gray-500 dark:text-gray-400">
<svg class="h-4 w-4 animate-spin" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="12" cy="12" r="10" stroke-opacity="0.25"></circle>
<path d="M22 12a10 10 0 00-10-10" stroke-linecap="round"></path>
</svg>
<span>Waiting for responses… this can take up to a minute depending on your network size.</span>
</div>
</Show>
<For each={discoveredNodes().filter(n => n.type === 'pve')}>
{(server) => (
<div
class="bg-gray-50/50 dark:bg-gray-700/30 rounded-lg p-4 border border-gray-200/50 dark:border-gray-600/50 opacity-75 hover:opacity-100 transition-opacity cursor-pointer"
onClick={() => {
// Pre-fill the modal with discovered server info
setEditingNode({
id: '',
type: 'pve',
name: server.hostname || `pve-${server.ip}`,
host: `https://${server.ip}:${server.port}`,
user: '',
tokenName: '',
tokenValue: '',
verifySSL: false,
monitorVMs: true,
monitorContainers: true,
monitorStorage: true,
monitorBackups: true,
status: 'disconnected'
} as NodeConfigWithStatus);
setCurrentNodeType('pve');
setShowNodeModal(true);
}}
>
<div class="flex flex-col sm:flex-row sm:items-start sm:justify-between gap-2">
<div class="flex-1 min-w-0">
<div class="relative">
<div class="w-3 h-3 rounded-full mt-1.5 bg-gray-400 animate-pulse"></div>
</div>
<div class="flex-1">
<h4 class="font-medium text-gray-700 dark:text-gray-300">
{server.hostname || `Proxmox VE at ${server.ip}`}
</h4>
<p class="text-sm text-gray-500 dark:text-gray-500 mt-1">
{server.ip}:{server.port}
</p>
<div class="flex items-center gap-2 mt-2">
<span class="text-xs px-2 py-1 bg-green-100 dark:bg-green-900/30 text-green-700 dark:text-green-400 rounded">
Discovered
</span>
<span class="text-xs text-gray-500 dark:text-gray-400">
Click to configure
</span>
monitorVMs: true,
monitorContainers: true,
monitorStorage: true,
monitorBackups: true,
status: 'pending'
} as NodeConfigWithStatus);
setCurrentNodeType('pve');
setShowNodeModal(true);
}}
>
<div class="flex flex-col sm:flex-row sm:items-start sm:justify-between gap-2">
<div class="flex-1 min-w-0">
<div class="relative">
<div class="w-3 h-3 rounded-full mt-1.5 bg-gray-400 animate-pulse"></div>
</div>
<div class="flex-1">
<h4 class="font-medium text-gray-700 dark:text-gray-300">
{server.hostname || `Proxmox VE at ${server.ip}`}
</h4>
<p class="text-sm text-gray-500 dark:text-gray-500 mt-1">
{server.ip}:{server.port}
</p>
<div class="flex items-center gap-2 mt-2">
<span class="text-xs px-2 py-1 bg-green-100 dark:bg-green-900/30 text-green-700 dark:text-green-400 rounded">
Discovered
</span>
<span class="text-xs text-gray-500 dark:text-gray-400">
Click to configure
</span>
</div>
</div>
</div>
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" class="text-gray-400 mt-1">
<path d="M12 5v14m-7-7h14" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
</svg>
</div>
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" class="text-gray-400 mt-1">
<path d="M12 5v14m-7-7h14" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
</svg>
</div>
</div>
)}
</For>
)}
</For>
</div>
</Show>
</div>
</Show>
@@ -1138,15 +1328,21 @@ const Settings: Component = () => {
discoverySubnet: discoverySubnet()
});
if (newValue) {
loadDiscoveredNodes();
notificationStore.success('Discovery enabled', 2000);
await triggerDiscoveryScan({ quiet: true });
notificationStore.success('Discovery enabled — scanning network...', 2000);
} else {
notificationStore.info('Discovery disabled', 2000);
setDiscoveryScanStatus(prev => ({
...prev,
scanning: false
}));
}
} catch (error) {
console.error('Failed to update discovery setting:', error);
notificationStore.error('Failed to update discovery setting');
setDiscoveryEnabled(!newValue);
} finally {
await loadDiscoveredNodes();
}
}}
disabled={envOverrides().discoveryEnabled}
@@ -1157,9 +1353,13 @@ const Settings: Component = () => {
<Show when={discoveryEnabled()}>
<button type="button"
onClick={() => {
loadDiscoveredNodes();
onClick={async () => {
notificationStore.info('Refreshing discovery...', 2000);
try {
await triggerDiscoveryScan({ quiet: true });
} finally {
await loadDiscoveredNodes();
}
}}
class="px-2 sm:px-4 py-1.5 sm:py-2 text-xs sm:text-sm bg-gray-600 text-white rounded-lg hover:bg-gray-700 transition-colors flex items-center gap-1"
title="Refresh discovered servers"
@@ -1218,7 +1418,13 @@ const Settings: Component = () => {
if (node.status === 'connected') {
return 'bg-green-500';
}
return 'bg-red-500';
if (node.status === 'error') {
return 'bg-red-500';
}
if (node.status === 'pending' || node.status === 'disconnected') {
return 'bg-amber-500 animate-pulse';
}
return 'bg-gray-400';
})()
}`}></div>
<div>
@@ -1282,59 +1488,98 @@ const Settings: Component = () => {
{/* Discovered PBS nodes - only show when discovery is enabled */}
<Show when={discoveryEnabled()}>
<For each={discoveredNodes().filter(n => n.type === 'pbs')}>
{(server) => (
<div
class="bg-gray-50/50 dark:bg-gray-700/30 rounded-lg p-4 border border-gray-200/50 dark:border-gray-600/50 opacity-75 hover:opacity-100 transition-opacity cursor-pointer"
onClick={() => {
// Pre-fill the modal with discovered server info
setEditingNode({
id: '',
type: 'pbs',
<div class="space-y-3">
<div class="flex items-center gap-2 text-xs text-gray-600 dark:text-gray-400">
<Show when={discoveryScanStatus().scanning}>
<span class="flex items-center gap-2">
<svg class="h-4 w-4 animate-spin" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="12" cy="12" r="10" stroke-opacity="0.25"></circle>
<path d="M22 12a10 10 0 00-10-10" stroke-linecap="round"></path>
</svg>
<span>Scanning your network for Proxmox Backup Servers…</span>
</span>
</Show>
<Show when={!discoveryScanStatus().scanning && (discoveryScanStatus().lastResultAt || discoveryScanStatus().lastScanStartedAt)}>
<span>
Last scan {formatRelativeTime(discoveryScanStatus().lastResultAt ?? discoveryScanStatus().lastScanStartedAt)}
</span>
</Show>
</div>
<Show when={discoveryScanStatus().errors && discoveryScanStatus().errors!.length}>
<div class="text-xs text-amber-600 dark:text-amber-400 bg-amber-50 dark:bg-amber-900/20 border border-amber-200 dark:border-amber-800 rounded-lg p-2">
<span class="font-medium">Discovery issues:</span>
<ul class="list-disc ml-4 mt-1 space-y-0.5">
<For each={discoveryScanStatus().errors || []}>
{(err) => <li>{err}</li>}
</For>
</ul>
</div>
</Show>
<Show when={discoveryScanStatus().scanning && discoveredNodes().filter(n => n.type === 'pbs').length === 0}>
<div class="flex items-center gap-2 text-xs text-gray-500 dark:text-gray-400">
<svg class="h-4 w-4 animate-spin" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="12" cy="12" r="10" stroke-opacity="0.25"></circle>
<path d="M22 12a10 10 0 00-10-10" stroke-linecap="round"></path>
</svg>
<span>Waiting for responses… this can take up to a minute depending on your network size.</span>
</div>
</Show>
<For each={discoveredNodes().filter(n => n.type === 'pbs')}>
{(server) => (
<div
class="bg-gray-50/50 dark:bg-gray-700/30 rounded-lg p-4 border border-gray-200/50 dark:border-gray-600/50 opacity-75 hover:opacity-100 transition-opacity cursor-pointer"
onClick={() => {
// Pre-fill the modal with discovered server info
setEditingNode({
id: '',
type: 'pbs',
name: server.hostname || `pbs-${server.ip}`,
host: `https://${server.ip}:${server.port}`,
user: '',
tokenName: '',
tokenValue: '',
verifySSL: false,
verifySSL: false,
monitorDatastores: true,
monitorSyncJobs: true,
monitorVerifyJobs: true,
monitorPruneJobs: true,
status: 'disconnected'
monitorGarbageJobs: true,
status: 'pending'
} as NodeConfigWithStatus);
setCurrentNodeType('pbs');
setShowNodeModal(true);
}}
>
<div class="flex flex-col sm:flex-row sm:items-start sm:justify-between gap-2">
<div class="flex-1 min-w-0">
<div class="relative">
<div class="w-3 h-3 rounded-full mt-1.5 bg-gray-400 animate-pulse"></div>
</div>
<div class="flex-1">
<h4 class="font-medium text-gray-700 dark:text-gray-300">
{server.hostname || `Backup Server at ${server.ip}`}
</h4>
<p class="text-sm text-gray-500 dark:text-gray-500 mt-1">
{server.ip}:{server.port}
</p>
<div class="flex items-center gap-2 mt-2">
<span class="text-xs px-2 py-1 bg-green-100 dark:bg-green-900/30 text-green-700 dark:text-green-400 rounded">
Discovered
</span>
<span class="text-xs text-gray-500 dark:text-gray-400">
Click to configure
</span>
setCurrentNodeType('pbs');
setShowNodeModal(true);
}}
>
<div class="flex flex-col sm:flex-row sm:items-start sm:justify-between gap-2">
<div class="flex-1 min-w-0">
<div class="relative">
<div class="w-3 h-3 rounded-full mt-1.5 bg-gray-400 animate-pulse"></div>
</div>
<div class="flex-1">
<h4 class="font-medium text-gray-700 dark:text-gray-300">
{server.hostname || `Backup Server at ${server.ip}`}
</h4>
<p class="text-sm text-gray-500 dark:text-gray-500 mt-1">
{server.ip}:{server.port}
</p>
<div class="flex items-center gap-2 mt-2">
<span class="text-xs px-2 py-1 bg-green-100 dark:bg-green-900/30 text-green-700 dark:text-green-400 rounded">
Discovered
</span>
<span class="text-xs text-gray-500 dark:text-gray-400">
Click to configure
</span>
</div>
</div>
</div>
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" class="text-gray-400 mt-1">
<path d="M12 5v14m-7-7h14" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
</svg>
</div>
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" class="text-gray-400 mt-1">
<path d="M12 5v14m-7-7h14" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
</svg>
</div>
</div>
)}
</For>
)}
</For>
</div>
</Show>
</div>
</Show>
@@ -2605,7 +2850,8 @@ const Settings: Component = () => {
...nodeData,
// Update hasPassword/hasToken based on whether credentials were provided
hasPassword: nodeData.password ? true : n.hasPassword,
hasToken: nodeData.tokenValue ? true : n.hasToken
hasToken: nodeData.tokenValue ? true : n.hasToken,
status: 'pending'
}
: n
));
@@ -2621,7 +2867,7 @@ const Settings: Component = () => {
// Use the hasPassword/hasToken from the API if available, otherwise check local fields
hasPassword: node.hasPassword ?? !!node.password,
hasToken: node.hasToken ?? !!node.tokenValue,
status: node.status || 'disconnected' as const
status: node.status || 'pending' as const
}));
setNodes(nodesWithStatus);
showSuccess('Node added successfully');
@@ -2664,7 +2910,7 @@ const Settings: Component = () => {
...nodeData,
hasPassword: nodeData.password ? true : n.hasPassword,
hasToken: nodeData.tokenValue ? true : n.hasToken,
status: n.status
status: 'pending'
}
: n
));
@@ -2680,7 +2926,7 @@ const Settings: Component = () => {
// Use the hasPassword/hasToken from the API if available, otherwise check local fields
hasPassword: node.hasPassword ?? !!node.password,
hasToken: node.hasToken ?? !!node.tokenValue,
status: node.status || 'disconnected' as const
status: node.status || 'pending' as const
}));
setNodes(nodesWithStatus);
showSuccess('Node added successfully');
+2 -2
View File
@@ -51,7 +51,7 @@ export interface PBSNodeConfig {
export type NodeConfig = (PVENodeConfig | PBSNodeConfig) & {
type: 'pve' | 'pbs';
status?: 'connected' | 'disconnected' | 'error';
status?: 'connected' | 'disconnected' | 'error' | 'pending';
};
export interface NodesResponse {
@@ -66,4 +66,4 @@ export interface NodeUpdateRequest {
export interface NodeDeleteResponse {
success: boolean;
message: string;
}
}