feat(backups): Add time format toggle to BackupsFilter

- Added toggle button to switch between relative and absolute time display
- Time preference is persisted to localStorage
- Toggle shows clock icon with 'Relative' or 'Absolute' label
- Implements the TODO from UnifiedBackups.tsx
This commit is contained in:
rcourtman 2025-12-13 13:17:35 +00:00
parent 75874687fb
commit c5ac33e8c6
11 changed files with 2705 additions and 1745 deletions

View file

@ -23,6 +23,9 @@ interface BackupsFilterProps {
onReset?: () => void;
statusFilter?: () => 'all' | 'verified' | 'unverified';
setStatusFilter?: (value: 'all' | 'verified' | 'unverified') => void;
// Time format toggle
useRelativeTime?: () => boolean;
setUseRelativeTime?: (value: boolean) => void;
}
export const BackupsFilter: Component<BackupsFilterProps> = (props) => {
@ -504,6 +507,25 @@ export const BackupsFilter: Component<BackupsFilterProps> = (props) => {
<div class="h-5 w-px bg-gray-200 dark:bg-gray-600 hidden sm:block"></div>
{/* Time Format Toggle */}
<Show when={props.useRelativeTime && props.setUseRelativeTime}>
<button
type="button"
onClick={() => props.setUseRelativeTime!(!props.useRelativeTime!())}
title={props.useRelativeTime!() ? 'Switch to absolute time' : 'Switch to relative time'}
class={`inline-flex items-center gap-1.5 px-2.5 py-1 text-xs font-medium rounded-lg transition-all ${props.useRelativeTime!()
? 'bg-blue-100 dark:bg-blue-900/50 text-blue-700 dark:text-blue-300 border border-blue-200 dark:border-blue-800'
: 'bg-gray-100 dark:bg-gray-700 text-gray-600 dark:text-gray-400 border border-gray-200 dark:border-gray-600 hover:bg-gray-200 dark:hover:bg-gray-600'
}`}
>
<svg class="h-3.5 w-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<span class="hidden sm:inline">{props.useRelativeTime!() ? 'Relative' : 'Absolute'}</span>
</button>
<div class="h-5 w-px bg-gray-200 dark:bg-gray-600 hidden sm:block"></div>
</Show>
{/* Reset Button */}
<button
onClick={() => {
@ -518,14 +540,14 @@ export const BackupsFilter: Component<BackupsFilterProps> = (props) => {
}}
title="Reset all filters"
class={`flex items-center justify-center px-2.5 py-1 text-xs font-medium rounded-lg transition-colors ${props.search().trim() !== '' ||
props.viewMode() !== 'all' ||
props.groupBy() !== 'date' ||
props.sortKey() !== 'backupTime' ||
props.sortDirection() !== 'desc' ||
(props.typeFilter && props.typeFilter() !== 'all') ||
(props.statusFilter && props.statusFilter() !== 'all')
? 'text-blue-700 dark:text-blue-300 bg-blue-100 dark:bg-blue-900/50 hover:bg-blue-200 dark:hover:bg-blue-900/70'
: 'text-gray-600 dark:text-gray-400 bg-gray-100 dark:bg-gray-700 hover:bg-gray-200 dark:hover:bg-gray-600'
props.viewMode() !== 'all' ||
props.groupBy() !== 'date' ||
props.sortKey() !== 'backupTime' ||
props.sortDirection() !== 'desc' ||
(props.typeFilter && props.typeFilter() !== 'all') ||
(props.statusFilter && props.statusFilter() !== 'all')
? 'text-blue-700 dark:text-blue-300 bg-blue-100 dark:bg-blue-900/50 hover:bg-blue-200 dark:hover:bg-blue-900/70'
: 'text-gray-600 dark:text-gray-400 bg-gray-100 dark:bg-gray-700 hover:bg-gray-200 dark:hover:bg-gray-600'
}`}
>
<svg

View file

@ -134,12 +134,10 @@ const UnifiedBackups: Component = () => {
}
});
const [useRelativeTime] = createLocalStorageBooleanSignal(
const [useRelativeTime, setUseRelativeTime] = createLocalStorageBooleanSignal(
STORAGE_KEYS.BACKUPS_USE_RELATIVE_TIME,
false, // Default to absolute time
);
// TODO: Add time format toggle to BackupsFilter component
// const setUseRelativeTime = ...;
// Helper functions
const getDaySuffix = (day: number) => {
@ -1932,6 +1930,8 @@ const UnifiedBackups: Component = () => {
sortDirection={sortDirection}
setSortDirection={setSortDirection}
onReset={resetFilters}
useRelativeTime={useRelativeTime}
setUseRelativeTime={setUseRelativeTime}
/>
{/* Table */}

View file

@ -0,0 +1,57 @@
import { Component } from 'solid-js';
import { Card } from '@/components/shared/Card';
import { SectionHeader } from '@/components/shared/SectionHeader';
import APITokenManager from './APITokenManager';
import BadgeCheck from 'lucide-solid/icons/badge-check';
interface APIAccessPanelProps {
currentTokenHint?: string;
onTokensChanged: () => void;
refreshing: boolean;
}
export const APIAccessPanel: Component<APIAccessPanelProps> = (props) => {
return (
<div class="space-y-6">
<Card
padding="none"
class="overflow-hidden border border-gray-200 dark:border-gray-700"
border={false}
>
<div class="bg-gradient-to-r from-blue-50 to-indigo-50 dark:from-blue-900/20 dark:to-indigo-900/20 px-6 py-4 border-b border-gray-200 dark:border-gray-700">
<div class="flex items-center gap-3">
<div class="p-2 bg-blue-100 dark:bg-blue-900/40 rounded-lg">
<BadgeCheck class="w-5 h-5 text-blue-600 dark:text-blue-300" />
</div>
<SectionHeader
title="API Access"
description="Generate scoped tokens for agents and automation"
size="sm"
class="flex-1"
/>
</div>
</div>
<div class="p-6 space-y-3">
<p class="text-sm text-gray-600 dark:text-gray-400">
Generate scoped tokens for Docker agents, host agents, and automation pipelines. Tokens
are shown oncestore them securely and rotate when infrastructure changes.
</p>
<a
href="https://github.com/rcourtman/Pulse/blob/main/docs/CONFIGURATION.md#token-scopes"
target="_blank"
rel="noreferrer"
class="inline-flex w-fit items-center gap-2 rounded-md border border-blue-200 bg-blue-50 px-3 py-1.5 text-xs font-semibold text-blue-700 transition-colors hover:bg-blue-100 dark:border-blue-700 dark:bg-blue-900/30 dark:text-blue-200"
>
View scope reference
</a>
</div>
</Card>
<APITokenManager
currentTokenHint={props.currentTokenHint}
onTokensChanged={props.onTokensChanged}
refreshing={props.refreshing}
/>
</div>
);
};

View file

@ -0,0 +1,364 @@
import { Component, Show, For, Accessor, Setter } from 'solid-js';
import { Card } from '@/components/shared/Card';
import { SectionHeader } from '@/components/shared/SectionHeader';
import Clock from 'lucide-solid/icons/clock';
const BACKUP_INTERVAL_OPTIONS = [
{ value: 0, label: 'Default (~90s)' },
{ value: 300, label: '5 minutes' },
{ value: 900, label: '15 minutes' },
{ value: 1800, label: '30 minutes' },
{ value: 3600, label: '1 hour' },
{ value: 21600, label: '6 hours' },
{ value: 86400, label: '24 hours' },
];
const BACKUP_INTERVAL_MAX_MINUTES = 7 * 24 * 60; // 7 days
interface BackupsSettingsPanelProps {
backupPollingEnabled: Accessor<boolean>;
setBackupPollingEnabled: Setter<boolean>;
backupPollingInterval: Accessor<number>;
setBackupPollingInterval: Setter<number>;
backupPollingCustomMinutes: Accessor<number>;
setBackupPollingCustomMinutes: Setter<number>;
backupPollingUseCustom: Accessor<boolean>;
setBackupPollingUseCustom: Setter<boolean>;
backupPollingEnvLocked: () => boolean;
backupIntervalSelectValue: () => string;
backupIntervalSummary: () => string;
setHasUnsavedChanges: Setter<boolean>;
// Export/Import handlers
showExportDialog: Accessor<boolean>;
setShowExportDialog: Setter<boolean>;
showImportDialog: Accessor<boolean>;
setShowImportDialog: Setter<boolean>;
setUseCustomPassphrase: Setter<boolean>;
securityStatus: Accessor<{ hasAuthentication: boolean } | null>;
}
export const BackupsSettingsPanel: Component<BackupsSettingsPanelProps> = (props) => {
return (
<div class="space-y-6">
<Card
padding="none"
class="overflow-hidden border border-gray-200 dark:border-gray-700"
border={false}
>
{/* Header */}
<div class="bg-gradient-to-r from-blue-50 to-indigo-50 dark:from-blue-900/20 dark:to-indigo-900/20 px-6 py-4 border-b border-gray-200 dark:border-gray-700">
<div class="flex items-center gap-3">
<div class="p-2 bg-blue-100 dark:bg-blue-900/40 rounded-lg">
<Clock class="w-5 h-5 text-blue-600 dark:text-blue-300" strokeWidth={2} />
</div>
<SectionHeader
title="Backups"
description="Backup polling and configuration management"
size="sm"
class="flex-1"
/>
</div>
</div>
<div class="p-6 space-y-8">
{/* Backup Polling Section */}
<section class="space-y-3">
<h4 class="flex items-center gap-2 text-sm font-medium text-gray-700 dark:text-gray-300">
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
>
<circle cx="12" cy="12" r="9" stroke-width="2" />
<path
d="M12 7v5l3 3"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
/>
</svg>
Backup polling
</h4>
<p class="text-xs text-gray-600 dark:text-gray-400">
Control how often Pulse queries Proxmox backup tasks, datastore contents, and guest
snapshots. Longer intervals reduce disk activity and API load.
</p>
<div class="space-y-3">
{/* Enable toggle */}
<div class="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div>
<p class="text-sm font-medium text-gray-900 dark:text-gray-100">
Enable backup polling
</p>
<p class="text-xs text-gray-600 dark:text-gray-400">
Required for dashboard backup status, storage snapshots, and alerting.
</p>
</div>
<label class="relative inline-flex items-center cursor-pointer">
<input
type="checkbox"
class="sr-only peer"
checked={props.backupPollingEnabled()}
disabled={props.backupPollingEnvLocked()}
onChange={(e) => {
props.setBackupPollingEnabled(e.currentTarget.checked);
if (!props.backupPollingEnvLocked()) {
props.setHasUnsavedChanges(true);
}
}}
/>
<div class="w-11 h-6 bg-gray-200 peer-focus:outline-none rounded-full peer dark:bg-gray-700 peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-blue-600 peer-disabled:opacity-50"></div>
</label>
</div>
{/* Polling interval options */}
<Show when={props.backupPollingEnabled()}>
<div class="space-y-3 rounded-md border border-gray-200 dark:border-gray-600 p-3">
<div class="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div>
<label class="text-sm font-medium text-gray-900 dark:text-gray-100">
Polling interval
</label>
<p class="text-xs text-gray-600 dark:text-gray-400">
{props.backupIntervalSummary()}
</p>
</div>
<select
value={props.backupIntervalSelectValue()}
disabled={props.backupPollingEnvLocked()}
onChange={(e) => {
const value = e.currentTarget.value;
if (value === 'custom') {
props.setBackupPollingUseCustom(true);
const minutes = Math.max(1, props.backupPollingCustomMinutes());
props.setBackupPollingInterval(minutes * 60);
} else {
props.setBackupPollingUseCustom(false);
const seconds = parseInt(value, 10);
if (!Number.isNaN(seconds)) {
props.setBackupPollingInterval(seconds);
if (seconds > 0) {
props.setBackupPollingCustomMinutes(
Math.max(1, Math.round(seconds / 60))
);
}
}
}
if (!props.backupPollingEnvLocked()) {
props.setHasUnsavedChanges(true);
}
}}
class="px-3 py-1.5 text-sm border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 disabled:opacity-50"
>
<For each={BACKUP_INTERVAL_OPTIONS}>
{(option) => <option value={String(option.value)}>{option.label}</option>}
</For>
<option value="custom">Custom interval...</option>
</select>
</div>
{/* Custom interval input */}
<Show when={props.backupIntervalSelectValue() === 'custom'}>
<div class="space-y-2">
<label class="text-xs font-medium text-gray-700 dark:text-gray-300">
Custom interval (minutes)
</label>
<div class="flex items-center gap-3">
<input
type="number"
min="1"
max={BACKUP_INTERVAL_MAX_MINUTES}
value={props.backupPollingCustomMinutes()}
disabled={props.backupPollingEnvLocked()}
onInput={(e) => {
const value = Number(e.currentTarget.value);
if (Number.isNaN(value)) {
return;
}
const clamped = Math.max(
1,
Math.min(BACKUP_INTERVAL_MAX_MINUTES, Math.floor(value))
);
props.setBackupPollingCustomMinutes(clamped);
props.setBackupPollingInterval(clamped * 60);
if (!props.backupPollingEnvLocked()) {
props.setHasUnsavedChanges(true);
}
}}
class="w-24 px-3 py-1.5 text-sm border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 disabled:opacity-50"
/>
<span class="text-xs text-gray-500 dark:text-gray-400">
1 - {BACKUP_INTERVAL_MAX_MINUTES} minutes (~7 days max)
</span>
</div>
</div>
</Show>
</div>
</Show>
{/* Env override warning */}
<Show when={props.backupPollingEnvLocked()}>
<div class="flex items-start gap-2 rounded-md border border-amber-200 bg-amber-50 dark:border-amber-700 dark:bg-amber-900/20 p-3 text-xs text-amber-700 dark:text-amber-200">
<svg
class="w-4 h-4 flex-shrink-0 mt-0.5"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"
/>
</svg>
<div>
<p class="font-medium">Environment override detected</p>
<p class="mt-1">
The <code class="font-mono">ENABLE_BACKUP_POLLING</code> or{' '}
<code class="font-mono">BACKUP_POLLING_INTERVAL</code> environment variables
are set. Remove them and restart Pulse to manage backup polling here.
</p>
</div>
</div>
</Show>
</div>
</section>
{/* Backup & Restore Section */}
<SectionHeader
title="Backup & restore"
description="Backup your node configurations and credentials or restore from a previous backup."
size="md"
class="mb-4"
/>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
{/* Export Section */}
<div class="border border-gray-200 dark:border-gray-700 rounded-lg p-4">
<div class="flex items-start gap-3">
<div class="flex-shrink-0 w-10 h-10 bg-blue-100 dark:bg-blue-900/30 rounded-lg flex items-center justify-center">
<svg
class="w-5 h-5 text-blue-600 dark:text-blue-400"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M9 19l3 3m0 0l3-3m-3 3V10"
/>
</svg>
</div>
<div class="flex-1">
<h4 class="text-sm font-medium text-gray-900 dark:text-gray-100 mb-1">
Export Configuration
</h4>
<p class="text-xs text-gray-600 dark:text-gray-400 mb-3">
Download an encrypted backup of all nodes and settings
</p>
<button
type="button"
onClick={() => {
// Default to custom passphrase if no auth is configured
props.setUseCustomPassphrase(!props.securityStatus()?.hasAuthentication);
props.setShowExportDialog(true);
}}
class="px-3 py-1.5 bg-blue-600 text-white text-sm rounded-md hover:bg-blue-700 transition-colors inline-flex items-center gap-2"
>
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4"
/>
</svg>
Export Backup
</button>
</div>
</div>
</div>
{/* Import Section */}
<div class="border border-gray-200 dark:border-gray-700 rounded-lg p-4">
<div class="flex items-start gap-3">
<div class="flex-shrink-0 w-10 h-10 bg-gray-100 dark:bg-gray-700 rounded-lg flex items-center justify-center">
<svg
class="w-5 h-5 text-gray-600 dark:text-gray-400"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M15 13l-3-3m0 0l-3 3m3-3v12"
/>
</svg>
</div>
<div class="flex-1">
<h4 class="text-sm font-medium text-gray-900 dark:text-gray-100 mb-1">
Restore Configuration
</h4>
<p class="text-xs text-gray-600 dark:text-gray-400 mb-3">
Upload a backup file to restore nodes and settings
</p>
<button
type="button"
onClick={() => props.setShowImportDialog(true)}
class="px-3 py-1.5 bg-gray-600 text-white text-sm rounded-md hover:bg-gray-700 transition-colors inline-flex items-center gap-2"
>
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-8l-4-4m0 0L8 8m4-4v12"
/>
</svg>
Restore Backup
</button>
</div>
</div>
</div>
</div>
{/* Important Notes */}
<div class="mt-4 p-3 bg-amber-50 dark:bg-amber-900/20 rounded-lg border border-amber-200 dark:border-amber-800">
<div class="flex gap-2">
<svg
class="w-4 h-4 text-amber-600 dark:text-amber-400 flex-shrink-0 mt-0.5"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"
/>
</svg>
<div class="text-xs text-amber-700 dark:text-amber-300">
<p class="font-medium mb-1">Important Notes</p>
<ul class="space-y-0.5 text-amber-600 dark:text-amber-400">
<li> Backups contain encrypted credentials and sensitive data</li>
<li> Use a strong passphrase to protect your backup</li>
<li> Store backup files securely and never share the passphrase</li>
</ul>
</div>
</div>
</div>
</div>
</Card>
</div>
);
};

View file

@ -0,0 +1,208 @@
import { Component, Show, For, Accessor, Setter } from 'solid-js';
import { Card } from '@/components/shared/Card';
import { SectionHeader } from '@/components/shared/SectionHeader';
import { Toggle } from '@/components/shared/Toggle';
import Sliders from 'lucide-solid/icons/sliders-horizontal';
import Activity from 'lucide-solid/icons/activity';
const PVE_POLLING_MIN_SECONDS = 10;
const PVE_POLLING_MAX_SECONDS = 3600;
const PVE_POLLING_PRESETS = [
{ label: 'Realtime (10s)', value: 10 },
{ label: 'Balanced (30s)', value: 30 },
{ label: 'Low (60s)', value: 60 },
{ label: 'Very low (5m)', value: 300 },
];
interface GeneralSettingsPanelProps {
darkMode: Accessor<boolean>;
toggleDarkMode: () => void;
pvePollingInterval: Accessor<number>;
setPVEPollingInterval: Setter<number>;
pvePollingSelection: Accessor<number | 'custom'>;
setPVEPollingSelection: Setter<number | 'custom'>;
pvePollingCustomSeconds: Accessor<number>;
setPVEPollingCustomSeconds: Setter<number>;
pvePollingEnvLocked: () => boolean;
setHasUnsavedChanges: Setter<boolean>;
}
export const GeneralSettingsPanel: Component<GeneralSettingsPanelProps> = (props) => {
return (
<div class="space-y-6">
{/* Appearance Card */}
<Card
padding="none"
class="overflow-hidden border border-gray-200 dark:border-gray-700"
border={false}
>
<div class="bg-gradient-to-r from-blue-50 to-indigo-50 dark:from-blue-900/20 dark:to-indigo-900/20 px-6 py-4 border-b border-gray-200 dark:border-gray-700">
<div class="flex items-center gap-3">
<div class="p-2 bg-blue-100 dark:bg-blue-900/40 rounded-lg">
<Sliders class="w-5 h-5 text-blue-600 dark:text-blue-300" strokeWidth={2} />
</div>
<SectionHeader
title="General"
description="Appearance and display preferences"
size="sm"
class="flex-1"
/>
</div>
</div>
<div class="p-6 space-y-5">
<div class="flex items-center justify-between gap-3">
<div class="text-sm text-gray-600 dark:text-gray-400">
<p class="font-medium text-gray-900 dark:text-gray-100">Dark mode</p>
<p class="text-xs text-gray-500 dark:text-gray-400">
Toggle to match your environment. Pulse remembers this preference on each browser.
</p>
</div>
<Toggle
checked={props.darkMode()}
onChange={(event) => {
const desired = (event.currentTarget as HTMLInputElement).checked;
if (desired !== props.darkMode()) {
props.toggleDarkMode();
}
}}
/>
</div>
</div>
</Card>
{/* Monitoring Cadence Card */}
<Card
padding="none"
class="overflow-hidden border border-gray-200 dark:border-gray-700"
border={false}
>
<div class="bg-gradient-to-r from-blue-50 to-indigo-50 dark:from-blue-900/20 dark:to-indigo-900/20 px-6 py-4 border-b border-gray-200 dark:border-gray-700">
<div class="flex items-center gap-3">
<div class="p-2 bg-blue-100 dark:bg-blue-900/40 rounded-lg">
<Activity class="w-5 h-5 text-blue-600 dark:text-blue-300" strokeWidth={2} />
</div>
<SectionHeader
title="Monitoring cadence"
description="Control how frequently Pulse polls Proxmox VE nodes."
size="sm"
class="flex-1"
/>
</div>
</div>
<div class="p-6 space-y-5">
<div class="space-y-2">
<p class="text-sm text-gray-600 dark:text-gray-400">
Shorter intervals provide near-real-time updates at the cost of higher API and CPU
usage on each node. Set a longer interval to reduce load on busy clusters.
</p>
<p class="text-xs text-gray-500 dark:text-gray-400">
Current cadence: {props.pvePollingInterval()} seconds (
{props.pvePollingInterval() >= 60
? `${(props.pvePollingInterval() / 60).toFixed(
props.pvePollingInterval() % 60 === 0 ? 0 : 1
)} minute${props.pvePollingInterval() / 60 === 1 ? '' : 's'}`
: 'under a minute'}
).
</p>
</div>
<div class="space-y-4">
{/* Preset buttons */}
<div class="grid gap-2 sm:grid-cols-3">
<For each={PVE_POLLING_PRESETS}>
{(option) => (
<button
type="button"
class={`rounded-lg border px-3 py-2 text-left text-sm transition-colors ${
props.pvePollingSelection() === option.value
? 'border-blue-500 bg-blue-50 text-blue-700 dark:border-blue-400 dark:bg-blue-900/30 dark:text-blue-100'
: 'border-gray-200 bg-white text-gray-700 hover:border-blue-400 hover:text-blue-600 dark:border-gray-600 dark:bg-gray-900/50 dark:text-gray-200'
} ${props.pvePollingEnvLocked() ? 'opacity-60 cursor-not-allowed' : ''}`}
disabled={props.pvePollingEnvLocked()}
onClick={() => {
if (props.pvePollingEnvLocked()) return;
props.setPVEPollingSelection(option.value);
props.setPVEPollingInterval(option.value);
props.setHasUnsavedChanges(true);
}}
>
{option.label}
</button>
)}
</For>
<button
type="button"
class={`rounded-lg border px-3 py-2 text-left text-sm transition-colors ${
props.pvePollingSelection() === 'custom'
? 'border-blue-500 bg-blue-50 text-blue-700 dark:border-blue-400 dark:bg-blue-900/30 dark:text-blue-100'
: 'border-gray-200 bg-white text-gray-700 hover:border-blue-400 hover:text-blue-600 dark:border-gray-600 dark:bg-gray-900/50 dark:text-gray-200'
} ${props.pvePollingEnvLocked() ? 'opacity-60 cursor-not-allowed' : ''}`}
disabled={props.pvePollingEnvLocked()}
onClick={() => {
if (props.pvePollingEnvLocked()) return;
props.setPVEPollingSelection('custom');
props.setPVEPollingInterval(props.pvePollingCustomSeconds());
props.setHasUnsavedChanges(true);
}}
>
Custom interval
</button>
</div>
{/* Custom interval input */}
<Show when={props.pvePollingSelection() === 'custom'}>
<div class="space-y-2 rounded-md border border-dashed border-gray-300 p-4 dark:border-gray-600">
<label class="text-xs font-medium text-gray-700 dark:text-gray-200">
Custom polling interval (10-3600 seconds)
</label>
<input
type="number"
min={PVE_POLLING_MIN_SECONDS}
max={PVE_POLLING_MAX_SECONDS}
value={props.pvePollingCustomSeconds()}
class="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-900/60"
disabled={props.pvePollingEnvLocked()}
onInput={(e) => {
if (props.pvePollingEnvLocked()) return;
const parsed = Math.floor(Number(e.currentTarget.value));
if (Number.isNaN(parsed)) {
return;
}
const clamped = Math.min(
PVE_POLLING_MAX_SECONDS,
Math.max(PVE_POLLING_MIN_SECONDS, parsed)
);
props.setPVEPollingCustomSeconds(clamped);
props.setPVEPollingInterval(clamped);
props.setHasUnsavedChanges(true);
}}
/>
<p class="text-[0.68rem] text-gray-500 dark:text-gray-400">
Applies to all PVE clusters and standalone nodes.
</p>
</div>
</Show>
{/* Env override warning */}
<Show when={props.pvePollingEnvLocked()}>
<div class="flex items-center gap-2 rounded-md border border-amber-200 bg-amber-50 px-3 py-2 text-xs text-amber-800 dark:border-amber-800 dark:bg-amber-900/30 dark:text-amber-200">
<svg
class="h-4 w-4 flex-shrink-0"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<circle cx="12" cy="12" r="10" />
<line x1="12" y1="8" x2="12" y2="12" />
<circle cx="12" cy="16" r="0.5" />
</svg>
<span>Managed via environment variable PVE_POLLING_INTERVAL.</span>
</div>
</Show>
</div>
</div>
</Card>
</div>
);
};

View file

@ -0,0 +1,621 @@
import { Component, Show, For, Accessor, Setter } from 'solid-js';
import { Card } from '@/components/shared/Card';
import { SectionHeader } from '@/components/shared/SectionHeader';
import { Toggle } from '@/components/shared/Toggle';
import type { ToggleChangeEvent } from '@/components/shared/Toggle';
import Network from 'lucide-solid/icons/network';
const COMMON_DISCOVERY_SUBNETS = [
'192.168.1.0/24',
'192.168.0.0/24',
'10.0.0.0/24',
'172.16.0.0/24',
'192.168.10.0/24',
];
interface NetworkSettingsPanelProps {
// Discovery settings
discoveryEnabled: Accessor<boolean>;
discoveryMode: Accessor<'auto' | 'custom'>;
discoverySubnetDraft: Accessor<string>;
discoverySubnetError: Accessor<string | undefined>;
savingDiscoverySettings: Accessor<boolean>;
envOverrides: Accessor<Record<string, boolean>>;
// Network settings
allowedOrigins: Accessor<string>;
setAllowedOrigins: Setter<string>;
allowEmbedding: Accessor<boolean>;
setAllowEmbedding: Setter<boolean>;
allowedEmbedOrigins: Accessor<string>;
setAllowedEmbedOrigins: Setter<string>;
webhookAllowedPrivateCIDRs: Accessor<string>;
setWebhookAllowedPrivateCIDRs: Setter<string>;
// Handlers
handleDiscoveryEnabledChange: (enabled: boolean) => Promise<boolean>;
handleDiscoveryModeChange: (mode: 'auto' | 'custom') => Promise<void>;
setDiscoveryMode: Setter<'auto' | 'custom'>;
setDiscoverySubnetDraft: Setter<string>;
setDiscoverySubnetError: Setter<string | undefined>;
setLastCustomSubnet: Setter<string>;
commitDiscoverySubnet: (value: string) => Promise<boolean>;
setHasUnsavedChanges: Setter<boolean>;
// Utility functions
parseSubnetList: (value: string) => string[];
normalizeSubnetList: (value: string) => string;
isValidCIDR: (value: string) => boolean;
currentDraftSubnetValue: () => string;
// Ref for input
discoverySubnetInputRef?: (el: HTMLInputElement) => void;
}
export const NetworkSettingsPanel: Component<NetworkSettingsPanelProps> = (props) => {
return (
<div class="space-y-6">
{/* Info Card */}
<Card
tone="info"
padding="md"
border={false}
class="border border-blue-200 dark:border-blue-800"
>
<div class="flex items-start gap-3">
<svg
class="w-5 h-5 text-blue-600 dark:text-blue-400 mt-0.5 flex-shrink-0"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
<div class="text-sm text-blue-800 dark:text-blue-200">
<p class="font-medium mb-1">Configuration Priority</p>
<ul class="space-y-1">
<li>
Some env vars override settings (API_TOKENS, legacy API_TOKEN, PORTS, AUTH)
</li>
<li> Changes made here are saved to system.json immediately</li>
<li> Settings persist unless overridden by env vars</li>
</ul>
</div>
</div>
</Card>
{/* Main Network Card */}
<Card
padding="none"
class="overflow-hidden border border-gray-200 dark:border-gray-700"
border={false}
>
{/* Header */}
<div class="bg-gradient-to-r from-blue-50 to-indigo-50 dark:from-blue-900/20 dark:to-indigo-900/20 px-6 py-4 border-b border-gray-200 dark:border-gray-700">
<div class="flex items-center gap-3">
<div class="p-2 bg-blue-100 dark:bg-blue-900/40 rounded-lg">
<Network class="w-5 h-5 text-blue-600 dark:text-blue-300" strokeWidth={2} />
</div>
<SectionHeader
title="Network"
description="Discovery, CORS, and embedding settings"
size="sm"
class="flex-1"
/>
</div>
</div>
<div class="p-6 space-y-8">
{/* Network Discovery Section */}
<section class="space-y-5">
<SectionHeader
title="Network discovery"
description="Control how Pulse scans for Proxmox services on your network."
size="sm"
align="left"
/>
{/* Discovery Toggle */}
<div class="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
<div class="text-sm text-gray-600 dark:text-gray-400">
<p class="font-medium text-gray-900 dark:text-gray-100">Automatic scanning</p>
<p class="text-xs text-gray-500 dark:text-gray-400">
Enable discovery to surface Proxmox VE, PBS, and PMG endpoints automatically.
</p>
</div>
<Toggle
checked={props.discoveryEnabled()}
onChange={async (e: ToggleChangeEvent) => {
if (props.envOverrides().discoveryEnabled || props.savingDiscoverySettings()) {
e.preventDefault();
return;
}
const success = await props.handleDiscoveryEnabledChange(e.currentTarget.checked);
if (!success) {
e.currentTarget.checked = props.discoveryEnabled();
}
}}
disabled={props.envOverrides().discoveryEnabled || props.savingDiscoverySettings()}
containerClass="gap-2"
label={
<span class="text-xs font-medium text-gray-600 dark:text-gray-400">
{props.discoveryEnabled() ? 'On' : 'Off'}
</span>
}
/>
</div>
{/* Discovery Options (shown when enabled) */}
<Show when={props.discoveryEnabled()}>
<div class="space-y-4 rounded-lg border border-gray-200 bg-white/40 p-3 dark:border-gray-600 dark:bg-gray-800/40">
<fieldset class="space-y-2">
<legend class="text-xs font-medium text-gray-700 dark:text-gray-300">
Scan scope
</legend>
<div class="space-y-2">
{/* Auto mode */}
<label
class={`flex items-start gap-3 rounded-lg border p-2 transition-colors ${
props.discoveryMode() === 'auto'
? 'border-blue-200 bg-blue-50/80 dark:border-blue-700 dark:bg-blue-900/20'
: 'border-transparent hover:border-gray-200 dark:hover:border-gray-600'
}`}
>
<input
type="radio"
name="discoveryMode"
value="auto"
checked={props.discoveryMode() === 'auto'}
onChange={async () => {
if (props.discoveryMode() !== 'auto') {
await props.handleDiscoveryModeChange('auto');
}
}}
disabled={
props.envOverrides().discoverySubnet || props.savingDiscoverySettings()
}
class="mt-1 h-4 w-4 border-gray-300 text-blue-600 focus:ring-blue-500"
/>
<div class="space-y-1">
<p class="text-sm font-medium text-gray-900 dark:text-gray-100">
Auto (slower, full scan)
</p>
<p class="text-xs text-gray-500 dark:text-gray-400">
Scans container, local, and gateway networks. Large networks may time out
after two minutes.
</p>
</div>
</label>
{/* Custom mode */}
<label
class={`flex items-start gap-3 rounded-lg border p-2 transition-colors ${
props.discoveryMode() === 'custom'
? 'border-blue-200 bg-blue-50/80 dark:border-blue-700 dark:bg-blue-900/20'
: 'border-transparent hover:border-gray-200 dark:hover:border-gray-600'
}`}
>
<input
type="radio"
name="discoveryMode"
value="custom"
checked={props.discoveryMode() === 'custom'}
onChange={() => {
if (props.discoveryMode() !== 'custom') {
props.handleDiscoveryModeChange('custom');
}
}}
disabled={
props.envOverrides().discoverySubnet || props.savingDiscoverySettings()
}
class="mt-1 h-4 w-4 border-gray-300 text-blue-600 focus:ring-blue-500"
/>
<div class="space-y-1">
<p class="text-sm font-medium text-gray-900 dark:text-gray-100">
Custom subnet (faster)
</p>
<p class="text-xs text-gray-500 dark:text-gray-400">
Limit discovery to one or more CIDR ranges to finish faster on large
networks.
</p>
</div>
</label>
{/* Common subnet presets */}
<Show when={props.discoveryMode() === 'custom'}>
<div class="flex flex-wrap items-center gap-2 pl-9 pr-2">
<span class="text-[0.68rem] uppercase tracking-wide text-gray-500 dark:text-gray-400">
Common networks:
</span>
<For each={COMMON_DISCOVERY_SUBNETS}>
{(preset) => {
const baseValue = props.currentDraftSubnetValue();
const currentSelections = props.parseSubnetList(baseValue);
const isActive = currentSelections.includes(preset);
return (
<button
type="button"
class={`rounded border px-2.5 py-1 text-[0.7rem] transition-colors ${
isActive
? 'border-blue-500 bg-blue-600 text-white dark:border-blue-400 dark:bg-blue-500'
: 'border-gray-300 text-gray-700 hover:border-blue-400 hover:bg-blue-50 dark:border-gray-600 dark:text-gray-300 dark:hover:border-blue-500 dark:hover:bg-blue-900/30'
}`}
onClick={async () => {
if (props.envOverrides().discoverySubnet) {
return;
}
let selections = [...currentSelections];
if (isActive) {
selections = selections.filter((item) => item !== preset);
} else {
selections.push(preset);
}
if (selections.length === 0) {
props.setDiscoverySubnetDraft('');
props.setLastCustomSubnet('');
props.setDiscoverySubnetError(
'Enter at least one subnet in CIDR format (e.g., 192.168.1.0/24)'
);
return;
}
const updatedValue = props.normalizeSubnetList(
selections.join(', ')
);
props.setDiscoveryMode('custom');
props.setDiscoverySubnetDraft(updatedValue);
props.setLastCustomSubnet(updatedValue);
props.setDiscoverySubnetError(undefined);
await props.commitDiscoverySubnet(updatedValue);
}}
disabled={props.envOverrides().discoverySubnet}
classList={{
'cursor-not-allowed opacity-60':
props.envOverrides().discoverySubnet,
}}
>
{preset}
</button>
);
}}
</For>
</div>
</Show>
</div>
</fieldset>
{/* Subnet Input */}
<div class="space-y-2">
<div class="flex items-center justify-between gap-2">
<label
for="discoverySubnetInput"
class="text-xs font-medium text-gray-700 dark:text-gray-300"
>
Discovery subnet
</label>
<span
class="text-gray-400 hover:text-gray-500 dark:text-gray-500 dark:hover:text-gray-300"
title="Use CIDR notation (comma-separated for multiple), e.g. 192.168.1.0/24, 10.0.0.0/24. Smaller ranges keep scans quick."
>
<svg
class="h-4 w-4"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<circle cx="12" cy="12" r="10"></circle>
<path d="M12 16v-4"></path>
<path d="M12 8h.01"></path>
</svg>
</span>
</div>
<input
id="discoverySubnetInput"
ref={props.discoverySubnetInputRef}
type="text"
value={props.discoverySubnetDraft()}
placeholder={
props.discoveryMode() === 'auto'
? 'auto (scan every network phase)'
: '192.168.1.0/24, 10.0.0.0/24'
}
class={`w-full rounded-lg border px-3 py-2 text-sm transition-colors focus:outline-none focus:ring-2 focus:ring-blue-500 ${
props.envOverrides().discoverySubnet
? 'border-amber-300 bg-amber-50 text-amber-800 dark:border-amber-600 dark:bg-amber-900/20 dark:text-amber-200 cursor-not-allowed opacity-60'
: 'border-gray-300 bg-white dark:border-gray-600 dark:bg-gray-900/70'
}`}
disabled={props.envOverrides().discoverySubnet}
onInput={(e) => {
if (props.envOverrides().discoverySubnet) {
return;
}
const rawValue = e.currentTarget.value;
props.setDiscoverySubnetDraft(rawValue);
if (props.discoveryMode() !== 'custom') {
props.setDiscoveryMode('custom');
}
props.setLastCustomSubnet(rawValue);
const trimmed = rawValue.trim();
if (!trimmed) {
props.setDiscoverySubnetError(undefined);
return;
}
if (!props.isValidCIDR(trimmed)) {
props.setDiscoverySubnetError(
'Use CIDR format such as 192.168.1.0/24 (comma-separated for multiple)'
);
} else {
props.setDiscoverySubnetError(undefined);
}
}}
onBlur={async (e) => {
if (
props.envOverrides().discoverySubnet ||
props.discoveryMode() !== 'custom'
) {
return;
}
const rawValue = e.currentTarget.value;
props.setDiscoverySubnetDraft(rawValue);
const trimmed = rawValue.trim();
if (!trimmed) {
props.setDiscoverySubnetError(
'Enter at least one subnet in CIDR format (e.g., 192.168.1.0/24)'
);
return;
}
if (!props.isValidCIDR(trimmed)) {
props.setDiscoverySubnetError(
'Use CIDR format such as 192.168.1.0/24 (comma-separated for multiple)'
);
return;
}
props.setDiscoverySubnetError(undefined);
await props.commitDiscoverySubnet(rawValue);
}}
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.preventDefault();
(e.currentTarget as HTMLInputElement).blur();
}
}}
/>
<Show when={props.discoverySubnetError()}>
<p class="text-xs text-red-600 dark:text-red-400">
{props.discoverySubnetError()}
</p>
</Show>
<Show when={!props.discoverySubnetError() && props.discoveryMode() === 'auto'}>
<p class="text-xs text-gray-500 dark:text-gray-400">
Auto scans every reachable network phase. Large networks may time out switch
to custom subnets to narrow the search.
</p>
</Show>
<Show when={!props.discoverySubnetError() && props.discoveryMode() === 'custom'}>
<p class="text-xs text-gray-500 dark:text-gray-400">
Example: 192.168.1.0/24, 10.0.0.0/24 (comma-separated). Smaller ranges finish
faster and avoid timeouts.
</p>
</Show>
</div>
</div>
</Show>
{/* Env override warning */}
<Show when={props.envOverrides().discoveryEnabled || props.envOverrides().discoverySubnet}>
<div class="rounded-lg border border-amber-200 bg-amber-100/80 p-3 text-xs text-amber-800 dark:border-amber-700 dark:bg-amber-900/20 dark:text-amber-200">
Discovery settings are locked by environment variables. Update the service
configuration and restart Pulse to change them here.
</div>
</Show>
</section>
{/* CORS Settings Section */}
<section class="space-y-3">
<h4 class="flex items-center gap-2 text-sm font-medium text-gray-700 dark:text-gray-300">
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<circle cx="12" cy="12" r="10"></circle>
<path d="M2 12h20M12 2a15.3 15.3 0 014 10 15.3 15.3 0 01-4 10 15.3 15.3 0 01-4-10 15.3 15.3 0 014-10z"></path>
</svg>
Network Settings
</h4>
<div class="space-y-2">
<label class="text-sm font-medium text-gray-900 dark:text-gray-100">
CORS Allowed Origins
</label>
<p class="text-xs text-gray-600 dark:text-gray-400">
For reverse proxy setups (* = allow all, empty = same-origin only)
</p>
<div class="relative">
<input
type="text"
value={props.allowedOrigins()}
onChange={(e) => {
if (!props.envOverrides().allowedOrigins) {
props.setAllowedOrigins(e.currentTarget.value);
props.setHasUnsavedChanges(true);
}
}}
disabled={props.envOverrides().allowedOrigins}
placeholder="* or https://example.com"
class={`w-full px-3 py-1.5 text-sm border rounded-lg ${
props.envOverrides().allowedOrigins
? 'border-amber-300 dark:border-amber-600 bg-amber-50 dark:bg-amber-900/20 cursor-not-allowed opacity-75'
: 'border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800'
}`}
/>
{props.envOverrides().allowedOrigins && (
<div class="mt-2 p-2 bg-amber-100 dark:bg-amber-900/30 border border-amber-300 dark:border-amber-700 rounded text-xs text-amber-800 dark:text-amber-200">
<div class="flex items-center gap-1">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"
/>
</svg>
<span>Overridden by ALLOWED_ORIGINS environment variable</span>
</div>
<div class="mt-1 text-amber-700 dark:text-amber-300">
Remove the env var and restart to enable UI configuration
</div>
</div>
)}
</div>
</div>
</section>
{/* Embedding Section */}
<section class="space-y-3">
<h4 class="flex items-center gap-2 text-sm font-medium text-gray-700 dark:text-gray-300">
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<rect x="3" y="4" width="18" height="14" rx="2"></rect>
<path d="M7 20h10"></path>
</svg>
Embedding
</h4>
<p class="text-xs text-gray-600 dark:text-gray-400">
Allow Pulse to be embedded in iframes (e.g., Homepage dashboard)
</p>
<div class="space-y-3">
<div class="flex items-center gap-2">
<input
type="checkbox"
id="allowEmbedding"
checked={props.allowEmbedding()}
onChange={(e) => {
props.setAllowEmbedding(e.currentTarget.checked);
props.setHasUnsavedChanges(true);
}}
class="rounded border-gray-300 dark:border-gray-600 text-blue-600 focus:ring-blue-500"
/>
<label for="allowEmbedding" class="text-sm text-gray-700 dark:text-gray-300">
Allow iframe embedding
</label>
</div>
<Show when={props.allowEmbedding()}>
<div class="space-y-2">
<label class="text-xs font-medium text-gray-700 dark:text-gray-300">
Allowed Embed Origins (optional)
</label>
<p class="text-xs text-gray-600 dark:text-gray-400">
Comma-separated list of origins that can embed Pulse (leave empty for same-origin
only)
</p>
<input
type="text"
value={props.allowedEmbedOrigins()}
onChange={(e) => {
props.setAllowedEmbedOrigins(e.currentTarget.value);
props.setHasUnsavedChanges(true);
}}
placeholder="https://my.domain, https://dashboard.example.com"
class="w-full px-3 py-1.5 text-sm border rounded-lg border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800"
/>
<p class="text-xs text-gray-500 dark:text-gray-400">
Example: If Pulse is at <code>pulse.my.domain</code> and your dashboard is at{' '}
<code>my.domain</code>, add <code>https://my.domain</code> here.
</p>
</div>
</Show>
</div>
</section>
{/* Webhook Security Section */}
<section class="space-y-3">
<h3 class="text-sm font-semibold text-gray-900 dark:text-gray-100 flex items-center gap-2">
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-4 w-4"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width={2}
d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z"
/>
</svg>
Webhook Security
</h3>
<div class="space-y-3">
<div>
<label class="text-sm font-medium text-gray-700 dark:text-gray-300">
Allowed Private IP Ranges for Webhooks
</label>
<p class="text-xs text-gray-500 dark:text-gray-400 mb-2">
By default, webhooks to private IP addresses are blocked for security. Enter
trusted CIDR ranges to allow webhooks to internal services (leave empty to block
all private IPs).
</p>
<input
type="text"
value={props.webhookAllowedPrivateCIDRs()}
onChange={(e) => {
props.setWebhookAllowedPrivateCIDRs(e.currentTarget.value);
props.setHasUnsavedChanges(true);
}}
placeholder="192.168.1.0/24, 10.0.0.0/8"
class="w-full px-3 py-1.5 text-sm border rounded-lg border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800"
/>
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">
Example: <code>192.168.1.0/24,10.0.0.0/8</code> allows webhooks to these private
networks. Localhost and cloud metadata services remain blocked.
</p>
</div>
</div>
</section>
{/* Port Configuration Notice */}
<Card
tone="warning"
padding="sm"
border={false}
class="border border-amber-200 dark:border-amber-800"
>
<p class="text-xs text-amber-800 dark:text-amber-200 mb-2">
<strong>Port Configuration:</strong> Use{' '}
<code class="font-mono bg-amber-100 dark:bg-amber-800 px-1 rounded">
systemctl edit pulse
</code>
</p>
<p class="text-xs text-amber-700 dark:text-amber-300 font-mono">
[Service]
<br />
Environment="FRONTEND_PORT=8080"
<br />
<span class="text-xs text-amber-600 dark:text-amber-400">
Then restart: sudo systemctl restart pulse
</span>
</p>
</Card>
</div>
</Card>
</div>
);
};

View file

@ -0,0 +1,590 @@
import { Component, Show, For, Accessor, Setter, JSX } from 'solid-js';
import { Card } from '@/components/shared/Card';
import { Toggle } from '@/components/shared/Toggle';
import type { ToggleChangeEvent } from '@/components/shared/Toggle';
import {
PveNodesTable,
PbsNodesTable,
PmgNodesTable,
type TemperatureTransportInfo,
} from './ConfiguredNodeTables';
import { notificationStore } from '@/stores/notifications';
import Server from 'lucide-solid/icons/server';
import HardDrive from 'lucide-solid/icons/hard-drive';
import Mail from 'lucide-solid/icons/mail';
import Loader from 'lucide-solid/icons/loader-2';
import type { NodeConfig } from '@/api/nodes';
type AgentType = 'pve' | 'pbs' | 'pmg';
interface DiscoveredServer {
ip: string;
port: number;
type: 'pve' | 'pbs' | 'pmg';
hostname?: string;
}
interface DiscoveryScanStatus {
scanning: boolean;
lastScanStartedAt?: string;
lastResultAt?: string;
errors?: string[];
}
type NodeConfigWithStatus = NodeConfig & {
hasPassword?: boolean;
hasToken?: boolean;
connectionStatus?: 'connected' | 'error' | 'pending' | 'unknown';
connectionError?: string;
status?: 'connected' | 'error' | 'pending' | 'unknown';
};
interface ProxmoxAgentNodesPanelProps {
agentType: AgentType;
// Node data
nodes: Accessor<NodeConfigWithStatus[]>;
discoveredNodes: Accessor<DiscoveredServer[]>;
// State data for tables
stateNodes?: any[];
stateHosts?: any[];
statePbs?: any[];
statePmg?: any[];
// Temperature monitoring
temperatureMonitoringEnabled: Accessor<boolean>;
temperatureTransports?: Accessor<TemperatureTransportInfo[]>;
// Discovery settings
discoveryEnabled: Accessor<boolean>;
discoveryMode: Accessor<'auto' | 'custom'>;
discoveryScanStatus: Accessor<DiscoveryScanStatus>;
envOverrides: Accessor<Record<string, boolean>>;
savingDiscoverySettings: Accessor<boolean>;
// Loading state
initialLoadComplete: Accessor<boolean>;
// Actions
handleDiscoveryEnabledChange: (enabled: boolean) => Promise<boolean>;
triggerDiscoveryScan: (opts: { quiet: boolean }) => Promise<void>;
loadDiscoveredNodes: () => Promise<void>;
testNodeConnection: (node: NodeConfig) => Promise<void>;
requestDeleteNode: (node: NodeConfig) => void;
refreshClusterNodes?: (clusterId: string, sampleNodeId: string) => Promise<void>;
// Modal controls
setEditingNode: Setter<NodeConfigWithStatus | null>;
setCurrentNodeType: Setter<AgentType>;
setModalResetKey: Setter<number>;
setShowNodeModal: Setter<boolean>;
// For PMG edit workaround
allNodes?: Accessor<NodeConfig[]>;
// Utility
formatRelativeTime: (date: string | undefined) => string;
}
const AGENT_CONFIG: Record<AgentType, {
title: string;
addButtonText: string;
emptyTitle: string;
emptyDescription: string;
scanningText: string;
icon: () => JSX.Element;
discoveryTooltip: string;
}> = {
pve: {
title: 'Proxmox VE nodes',
addButtonText: 'Add PVE Node',
emptyTitle: 'No PVE nodes configured',
emptyDescription: 'Add a Proxmox VE node to start monitoring your infrastructure',
scanningText: 'Scanning your network for Proxmox VE servers…',
icon: () => <Server class="h-8 w-8 text-gray-400 dark:text-gray-500" />,
discoveryTooltip: 'Enable automatic discovery of Proxmox servers on your network',
},
pbs: {
title: 'Proxmox Backup Server nodes',
addButtonText: 'Add PBS Node',
emptyTitle: 'No PBS nodes configured',
emptyDescription: 'Add a Proxmox Backup Server to monitor your backup infrastructure',
scanningText: 'Scanning your network for Proxmox Backup Servers…',
icon: () => <HardDrive class="h-8 w-8 text-gray-400 dark:text-gray-500" />,
discoveryTooltip: 'Enable automatic discovery of PBS servers on your network',
},
pmg: {
title: 'Proxmox Mail Gateway nodes',
addButtonText: 'Add PMG Node',
emptyTitle: 'No PMG nodes configured',
emptyDescription: 'Add a Proxmox Mail Gateway to monitor mail queue and quarantine metrics',
scanningText: 'Scanning network...',
icon: () => <Mail class="h-8 w-8 text-gray-400 dark:text-gray-500" />,
discoveryTooltip: 'Enable automatic discovery of PMG servers on your network',
},
};
export const ProxmoxAgentNodesPanel: Component<ProxmoxAgentNodesPanelProps> = (props) => {
const config = () => AGENT_CONFIG[props.agentType];
const filteredDiscoveredNodes = () => props.discoveredNodes().filter((n) => n.type === props.agentType);
const handleAddNode = () => {
props.setEditingNode(null);
props.setCurrentNodeType(props.agentType);
props.setModalResetKey((prev) => prev + 1);
props.setShowNodeModal(true);
};
const handleRefreshDiscovery = async () => {
notificationStore.info('Refreshing discovery...', 2000);
try {
await props.triggerDiscoveryScan({ quiet: true });
} finally {
await props.loadDiscoveredNodes();
}
};
const handleDiscoveredNodeClick = (server: DiscoveredServer) => {
if (props.agentType === 'pmg') {
// PMG uses a different approach - sets up modal then fills input
props.setEditingNode(null);
props.setCurrentNodeType('pmg');
props.setModalResetKey((prev) => prev + 1);
props.setShowNodeModal(true);
setTimeout(() => {
const hostInput = document.querySelector(
'input[placeholder*="192.168"]',
) as HTMLInputElement;
if (hostInput) {
hostInput.value = server.ip;
hostInput.dispatchEvent(new Event('input', { bubbles: true }));
}
}, 50);
} else {
// PVE and PBS pre-fill the node object
const baseNode: NodeConfigWithStatus = {
id: '',
type: props.agentType,
name: server.hostname || `${props.agentType}-${server.ip}`,
host: `https://${server.ip}:${server.port}`,
user: '',
tokenName: '',
tokenValue: '',
verifySSL: false,
status: 'pending',
};
if (props.agentType === 'pve') {
Object.assign(baseNode, {
monitorVMs: true,
monitorContainers: true,
monitorStorage: true,
monitorBackups: true,
monitorPhysicalDisks: false,
});
} else if (props.agentType === 'pbs') {
Object.assign(baseNode, {
monitorDatastores: true,
monitorSyncJobs: true,
monitorVerifyJobs: true,
monitorPruneJobs: true,
monitorGarbageJobs: true,
});
}
props.setEditingNode(baseNode);
props.setCurrentNodeType(props.agentType);
props.setShowNodeModal(true);
}
};
const renderNodesTable = () => {
if (props.agentType === 'pve') {
return (
<PveNodesTable
nodes={props.nodes() as any}
stateNodes={props.stateNodes ?? []}
stateHosts={props.stateHosts ?? []}
globalTemperatureMonitoringEnabled={props.temperatureMonitoringEnabled()}
temperatureTransports={props.temperatureTransports?.() ?? []}
onTestConnection={props.testNodeConnection}
onEdit={(node) => {
props.setEditingNode(node as NodeConfigWithStatus);
props.setCurrentNodeType('pve');
props.setShowNodeModal(true);
}}
onDelete={props.requestDeleteNode}
onRefreshCluster={props.refreshClusterNodes}
/>
);
} else if (props.agentType === 'pbs') {
return (
<PbsNodesTable
nodes={props.nodes() as any}
statePbs={props.statePbs ?? []}
globalTemperatureMonitoringEnabled={props.temperatureMonitoringEnabled()}
onTestConnection={props.testNodeConnection}
onEdit={(node) => {
props.setEditingNode(node as NodeConfigWithStatus);
props.setCurrentNodeType('pbs');
props.setShowNodeModal(true);
}}
onDelete={props.requestDeleteNode}
/>
);
} else {
return (
<PmgNodesTable
nodes={props.nodes() as any}
statePmg={props.statePmg ?? []}
globalTemperatureMonitoringEnabled={props.temperatureMonitoringEnabled()}
onTestConnection={props.testNodeConnection}
onEdit={(node) => {
props.setEditingNode(props.allNodes?.().find((n) => n.id === node.id) as NodeConfigWithStatus ?? null);
props.setCurrentNodeType('pmg');
props.setModalResetKey((prev) => prev + 1);
props.setShowNodeModal(true);
}}
onDelete={props.requestDeleteNode}
/>
);
}
};
return (
<div class="space-y-6 mt-6">
<div class="space-y-4">
{/* Loading state */}
<Show when={!props.initialLoadComplete()}>
<div class="flex items-center justify-center rounded-lg border border-dashed border-gray-300 dark:border-gray-700 bg-gray-50 dark:bg-gray-800/40 py-12 text-sm text-gray-500 dark:text-gray-400">
Loading configuration...
</div>
</Show>
{/* Main content */}
<Show when={props.initialLoadComplete()}>
<Card padding="lg">
<div class="space-y-4">
{/* Header with actions */}
<div class="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3">
<h4 class="text-base font-semibold text-gray-900 dark:text-gray-100">
{config().title}
</h4>
<div class="flex flex-wrap items-center justify-start gap-2 sm:justify-end">
{/* Discovery toggle */}
<div
class="flex items-center gap-2 sm:gap-3"
title={config().discoveryTooltip}
>
<span class="text-xs sm:text-sm text-gray-600 dark:text-gray-400">
Discovery
</span>
<Toggle
checked={props.discoveryEnabled()}
onChange={async (e: ToggleChangeEvent) => {
if (
props.envOverrides().discoveryEnabled ||
props.savingDiscoverySettings()
) {
e.preventDefault();
return;
}
const success = await props.handleDiscoveryEnabledChange(
e.currentTarget.checked,
);
if (!success) {
e.currentTarget.checked = props.discoveryEnabled();
}
}}
disabled={
props.envOverrides().discoveryEnabled || props.savingDiscoverySettings()
}
containerClass="gap-2"
label={
<span class="text-xs font-medium text-gray-600 dark:text-gray-400">
{props.discoveryEnabled() ? 'On' : 'Off'}
</span>
}
/>
</div>
{/* Refresh button */}
<Show when={props.discoveryEnabled()}>
<button
type="button"
onClick={handleRefreshDiscovery}
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"
>
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<polyline points="23 4 23 10 17 10"></polyline>
<polyline points="1 20 1 14 7 14"></polyline>
<path d="M3.51 9a9 9 0 0114.85-3.36L23 10M1 14l4.64 4.36A9 9 0 0020.49 15"></path>
</svg>
<span class="hidden sm:inline">Refresh</span>
</button>
</Show>
{/* Add button */}
<button
type="button"
onClick={handleAddNode}
class="px-2 sm:px-4 py-1.5 sm:py-2 text-xs sm:text-sm bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors flex items-center gap-1"
>
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<line x1="12" y1="5" x2="12" y2="19"></line>
<line x1="5" y1="12" x2="19" y2="12"></line>
</svg>
<span class="sm:hidden">Add</span>
<span class="hidden sm:inline">{config().addButtonText}</span>
</button>
</div>
</div>
{/* Nodes table */}
<Show when={props.nodes().length > 0}>
{renderNodesTable()}
</Show>
{/* Empty state */}
<Show
when={
props.nodes().length === 0 &&
filteredDiscoveredNodes().length === 0
}
>
<div class="flex flex-col items-center justify-center py-12 px-4 text-center">
<div class="rounded-full bg-gray-100 dark:bg-gray-800 p-4 mb-4">
{config().icon()}
</div>
<p class="text-base font-medium text-gray-900 dark:text-gray-100 mb-1">
{config().emptyTitle}
</p>
<p class="text-sm text-gray-500 dark:text-gray-400">
{config().emptyDescription}
</p>
</div>
</Show>
</div>
</Card>
</Show>
{/* Discovery section */}
<Show when={props.discoveryEnabled()}>
<div class="space-y-3">
{/* Scan status */}
<div class="flex items-center gap-2 text-xs text-gray-600 dark:text-gray-400">
<Show when={props.discoveryScanStatus().scanning}>
<span class="flex items-center gap-2">
<Loader class="h-4 w-4 animate-spin" />
<span>{config().scanningText}</span>
</span>
</Show>
<Show
when={
!props.discoveryScanStatus().scanning &&
(props.discoveryScanStatus().lastResultAt ||
props.discoveryScanStatus().lastScanStartedAt)
}
>
<span>
Last scan{' '}
{props.formatRelativeTime(
props.discoveryScanStatus().lastResultAt ??
props.discoveryScanStatus().lastScanStartedAt,
)}
</span>
</Show>
</div>
{/* Discovery errors */}
<Show
when={
props.discoveryScanStatus().errors && props.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={props.discoveryScanStatus().errors || []}>
{(err) => <li>{err}</li>}
</For>
</ul>
<Show
when={
props.discoveryMode() === 'auto' &&
(props.discoveryScanStatus().errors || []).some((err) =>
/timed out|timeout/i.test(err),
)
}
>
<p class="mt-2 text-[0.7rem] font-medium text-amber-700 dark:text-amber-300">
Large networks can time out in auto mode. Switch to a custom subnet
for faster, targeted scans.
</p>
</Show>
</div>
</Show>
{/* Scanning placeholder */}
<Show
when={
props.discoveryScanStatus().scanning &&
filteredDiscoveredNodes().length === 0
}
>
<Show when={props.agentType === 'pmg'}>
<div class="text-center py-6 text-gray-500 dark:text-gray-400 bg-gray-50 dark:bg-gray-800/50 rounded-lg border-2 border-dashed border-gray-300 dark:border-gray-600">
<svg
class="h-8 w-8 mx-auto mb-2 animate-pulse text-purple-500"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<circle cx="11" cy="11" r="8" />
<path d="m21 21-4.35-4.35" />
</svg>
<p class="text-sm">Scanning for PMG servers...</p>
</div>
</Show>
<Show when={props.agentType !== 'pmg'}>
<div class="flex items-center gap-2 text-xs text-gray-500 dark:text-gray-400">
<Loader class="h-4 w-4 animate-spin" />
<span>
Waiting for responses this can take up to a minute depending on your
network size.
</span>
</div>
</Show>
</Show>
{/* Discovered nodes list */}
<For each={filteredDiscoveredNodes()}>
{(server) => (
<Show when={props.agentType === 'pmg'}>
{/* PMG style */}
<div
class="bg-gradient-to-r from-purple-50 to-transparent dark:from-purple-900/20 dark:to-transparent border-l-4 border-purple-500 rounded-lg p-4 cursor-pointer hover:shadow-md transition-all"
onClick={() => handleDiscoveredNodeClick(server)}
>
<div class="flex items-start justify-between">
<div class="flex items-start gap-3 flex-1 min-w-0">
<svg
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
class="text-purple-500 flex-shrink-0 mt-0.5"
>
<path d="M4 4h16c1.1 0 2 .9 2 2v12c0 1.1-.9 2-2 2H4c-1.1 0-2-.9-2-2V6c0-1.1.9-2 2-2z"></path>
<polyline points="22,6 12,13 2,6"></polyline>
</svg>
<div class="flex-1 min-w-0">
<h4 class="font-medium text-gray-900 dark:text-gray-100 truncate">
{server.hostname || `PMG 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>
</div>
</Show>
)}
</For>
<For each={filteredDiscoveredNodes()}>
{(server) => (
<Show when={props.agentType !== 'pmg'}>
{/* PVE/PBS style */}
<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={() => handleDiscoveredNodeClick(server)}
>
<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="flex items-start gap-3">
<div class="flex-shrink-0 w-3 h-3 mt-1.5 rounded-full bg-gray-400 animate-pulse"></div>
<div class="flex-1 min-w-0">
<h4 class="font-medium text-gray-700 dark:text-gray-300">
{server.hostname || `${props.agentType === 'pve' ? 'Proxmox VE' : '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>
</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>
</Show>
)}
</For>
</div>
</Show>
</div>
</div>
);
};

View file

@ -0,0 +1,320 @@
import { Component, Show, Accessor, Setter } from 'solid-js';
import { Card } from '@/components/shared/Card';
import { SectionHeader } from '@/components/shared/SectionHeader';
import { Toggle } from '@/components/shared/Toggle';
import type { ToggleChangeEvent } from '@/components/shared/Toggle';
import { QuickSecuritySetup } from './QuickSecuritySetup';
import Lock from 'lucide-solid/icons/lock';
import type { VersionInfo } from '@/api/updates';
interface SecurityStatusInfo {
hasAuthentication: boolean;
apiTokenConfigured: boolean;
authUsername?: string;
configuredButPendingRestart?: boolean;
hasProxyAuth?: boolean;
proxyAuthUsername?: string;
proxyAuthIsAdmin?: boolean;
proxyAuthLogoutURL?: string;
deprecatedDisableAuth?: boolean;
}
interface SecurityAuthPanelProps {
securityStatus: Accessor<SecurityStatusInfo | null>;
securityStatusLoading: Accessor<boolean>;
versionInfo: Accessor<VersionInfo | null>;
authDisabledByEnv: Accessor<boolean>;
showQuickSecuritySetup: Accessor<boolean>;
setShowQuickSecuritySetup: Setter<boolean>;
showQuickSecurityWizard: Accessor<boolean>;
setShowQuickSecurityWizard: Setter<boolean>;
showPasswordModal: Accessor<boolean>;
setShowPasswordModal: Setter<boolean>;
hideLocalLogin: Accessor<boolean>;
hideLocalLoginLocked: () => boolean;
savingHideLocalLogin: Accessor<boolean>;
handleHideLocalLoginChange: (enabled: boolean) => Promise<void>;
loadSecurityStatus: () => Promise<void>;
}
export const SecurityAuthPanel: Component<SecurityAuthPanelProps> = (props) => {
return (
<div class="space-y-6">
{/* Show message when auth is disabled */}
<Show when={!props.securityStatus()?.hasAuthentication}>
<Card
padding="none"
class="overflow-hidden border border-amber-200 dark:border-amber-800"
border={false}
>
{/* Header */}
<div class="bg-gradient-to-r from-amber-50 to-amber-50 dark:from-amber-900/20 dark:to-amber-900/20 px-6 py-4 border-b border-amber-200 dark:border-amber-700">
<div class="flex items-center gap-3">
<div class="p-2 bg-amber-100 dark:bg-amber-900/50 rounded-lg">
<svg
class="w-5 h-5 text-amber-600 dark:text-amber-400"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"
/>
</svg>
</div>
<SectionHeader title="Authentication disabled" size="sm" class="flex-1" />
<Show
when={!props.authDisabledByEnv()}
fallback={
<span class="px-3 py-1.5 text-xs font-semibold rounded-lg border border-amber-300 text-amber-800 bg-amber-100/60 dark:border-amber-700 dark:text-amber-100 dark:bg-amber-900/40 whitespace-nowrap">
Controlled by DISABLE_AUTH
</span>
}
>
<button
type="button"
onClick={() => props.setShowQuickSecuritySetup(!props.showQuickSecuritySetup())}
class="px-3 py-1.5 text-xs font-medium rounded-lg border border-amber-300 text-amber-800 bg-amber-100/50 hover:bg-amber-100 transition-colors dark:border-amber-700 dark:text-amber-200 dark:bg-amber-900/30 dark:hover:bg-amber-900/40 whitespace-nowrap"
>
Setup
</button>
</Show>
</div>
</div>
{/* Content */}
<div class="p-6">
<p class="text-sm text-amber-700 dark:text-amber-300 mb-4">
<Show
when={props.authDisabledByEnv()}
fallback={
<>
Authentication is currently disabled. Set up password authentication to protect
your Pulse instance.
</>
}
>
Authentication settings are locked by the legacy{' '}
<code class="font-mono text-xs text-amber-800 dark:text-amber-200">
DISABLE_AUTH
</code>{' '}
environment variable. Remove it from your deployment and restart Pulse before
enabling security from this page.
</Show>
</p>
<Show when={props.showQuickSecuritySetup() && !props.authDisabledByEnv()}>
<QuickSecuritySetup
onConfigured={() => {
props.setShowQuickSecuritySetup(false);
props.loadSecurityStatus();
}}
/>
</Show>
</div>
</Card>
</Show>
{/* Authentication */}
<Show
when={
!props.securityStatusLoading() &&
(props.securityStatus()?.hasAuthentication || props.securityStatus()?.apiTokenConfigured)
}
>
<Card
padding="none"
class="overflow-hidden border border-gray-200 dark:border-gray-700"
border={false}
>
{/* Header */}
<div class="bg-gradient-to-r from-blue-50 to-indigo-50 dark:from-blue-900/20 dark:to-indigo-900/20 px-6 py-4 border-b border-gray-200 dark:border-gray-700">
<div class="flex items-center gap-3">
<div class="p-2 bg-blue-100 dark:bg-blue-900/40 rounded-lg">
<Lock class="w-5 h-5 text-blue-600 dark:text-blue-300" strokeWidth={2} />
</div>
<SectionHeader
title="Authentication"
description="Password management and credential rotation"
size="sm"
class="flex-1"
/>
</div>
</div>
{/* Content */}
<div class="p-6">
<div class="flex flex-wrap items-center gap-3">
<button
type="button"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
props.setShowPasswordModal(true);
}}
class="px-4 py-2 text-sm font-medium bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors"
>
Change password
</button>
<Show
when={!props.authDisabledByEnv()}
fallback={
<span class="px-4 py-2 text-sm font-semibold border border-amber-300 text-amber-800 bg-amber-50 dark:border-amber-700 dark:text-amber-200 dark:bg-amber-900/30 rounded-lg">
Remove DISABLE_AUTH to rotate credentials
</span>
}
>
<button
type="button"
onClick={() => props.setShowQuickSecurityWizard(!props.showQuickSecurityWizard())}
class="px-4 py-2 text-sm font-medium border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-800 transition-colors"
>
Rotate credentials
</button>
</Show>
<div class="flex-1"></div>
<div class="text-xs text-gray-600 dark:text-gray-400">
<span class="font-medium text-gray-800 dark:text-gray-200">User:</span>{' '}
{props.securityStatus()?.authUsername || 'Not configured'}
</div>
</div>
<div class="mt-4 pt-4 border-t border-gray-200 dark:border-gray-700">
<Toggle
label="Hide local login form"
description="Hide the username/password form on the login page. Users will only see SSO options unless ?show_local=true is used."
checked={props.hideLocalLogin()}
onChange={(e: ToggleChangeEvent) =>
props.handleHideLocalLoginChange(e.currentTarget.checked)
}
disabled={props.hideLocalLoginLocked() || props.savingHideLocalLogin()}
locked={props.hideLocalLoginLocked()}
lockedMessage="This setting is managed by the PULSE_AUTH_HIDE_LOCAL_LOGIN environment variable"
/>
</div>
<Show when={!props.authDisabledByEnv() && props.showQuickSecurityWizard()}>
<div class="mt-4 pt-4 border-t border-gray-200 dark:border-gray-700">
<QuickSecuritySetup
mode="rotate"
defaultUsername={props.securityStatus()?.authUsername || 'admin'}
onConfigured={() => {
props.setShowQuickSecurityWizard(false);
props.loadSecurityStatus();
}}
/>
</div>
</Show>
</div>
</Card>
</Show>
{/* Show pending restart message if configured but not loaded */}
<Show when={props.securityStatus()?.configuredButPendingRestart}>
<div class="bg-amber-50 dark:bg-amber-900/20 border border-amber-200 dark:border-amber-800 rounded-lg p-4">
<div class="flex items-start space-x-3">
<div class="flex-shrink-0">
<svg
class="h-6 w-6 text-amber-600 dark:text-amber-400"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"
/>
</svg>
</div>
<div class="flex-1">
<h4 class="text-sm font-semibold text-amber-900 dark:text-amber-100">
Security Configured - Restart Required
</h4>
<p class="text-xs text-amber-700 dark:text-amber-300 mt-1">
Security settings have been configured but the service needs to be restarted to
activate them.
</p>
<p class="text-xs text-amber-600 dark:text-amber-400 mt-2">
After restarting, you'll need to log in with your saved credentials.
</p>
<div class="mt-4 bg-white dark:bg-gray-800 rounded-lg p-3 border border-amber-200 dark:border-amber-700">
<p class="text-xs font-semibold text-gray-900 dark:text-gray-100 mb-2">
How to restart Pulse:
</p>
<Show when={props.versionInfo()?.deploymentType === 'proxmoxve'}>
<div class="space-y-2">
<p class="text-xs text-gray-700 dark:text-gray-300">
Type{' '}
<code class="px-1 py-0.5 bg-gray-100 dark:bg-gray-700 rounded">update</code>{' '}
in your ProxmoxVE console
</p>
<p class="text-xs text-gray-600 dark:text-gray-400 italic">
Or restart manually with: <code class="text-xs">systemctl restart pulse</code>
</p>
</div>
</Show>
<Show when={props.versionInfo()?.deploymentType === 'docker'}>
<div class="space-y-1">
<p class="text-xs text-gray-700 dark:text-gray-300">
Restart your Docker container:
</p>
<code class="block text-xs bg-gray-100 dark:bg-gray-700 p-2 rounded mt-1">
docker restart pulse
</code>
</div>
</Show>
<Show
when={
props.versionInfo()?.deploymentType === 'systemd' ||
props.versionInfo()?.deploymentType === 'manual'
}
>
<div class="space-y-1">
<p class="text-xs text-gray-700 dark:text-gray-300">Restart the service:</p>
<code class="block text-xs bg-gray-100 dark:bg-gray-700 p-2 rounded mt-1">
sudo systemctl restart pulse
</code>
</div>
</Show>
<Show when={props.versionInfo()?.deploymentType === 'development'}>
<div class="space-y-1">
<p class="text-xs text-gray-700 dark:text-gray-300">
Restart the development server:
</p>
<code class="block text-xs bg-gray-100 dark:bg-gray-700 p-2 rounded mt-1">
sudo systemctl restart pulse-hot-dev
</code>
</div>
</Show>
<Show when={!props.versionInfo()?.deploymentType}>
<div class="space-y-1">
<p class="text-xs text-gray-700 dark:text-gray-300">
Restart Pulse using your deployment method
</p>
</div>
</Show>
</div>
<div class="mt-3 p-2 bg-green-50 dark:bg-green-900/20 border border-green-200 dark:border-green-800 rounded">
<p class="text-xs text-green-700 dark:text-green-300">
<strong>Tip:</strong> Make sure you've saved your credentials before restarting!
</p>
</div>
</div>
</div>
</div>
</Show>
</div>
);
};

View file

@ -0,0 +1,89 @@
import { Component, Show, Accessor } from 'solid-js';
import { Card } from '@/components/shared/Card';
import { SecurityPostureSummary } from './SecurityPostureSummary';
interface SecurityStatusInfo {
hasAuthentication: boolean;
oidcEnabled?: boolean;
hasProxyAuth?: boolean;
apiTokenConfigured: boolean;
exportProtected: boolean;
unprotectedExportAllowed?: boolean;
hasHTTPS?: boolean;
hasAuditLogging: boolean;
requiresAuth: boolean;
publicAccess?: boolean;
isPrivateNetwork?: boolean;
clientIP?: string;
proxyAuthUsername?: string;
proxyAuthIsAdmin?: boolean;
proxyAuthLogoutURL?: string;
}
interface SecurityOverviewPanelProps {
securityStatus: Accessor<SecurityStatusInfo | null>;
securityStatusLoading: Accessor<boolean>;
}
export const SecurityOverviewPanel: Component<SecurityOverviewPanelProps> = (props) => {
return (
<div class="space-y-6">
<Show when={!props.securityStatusLoading() && props.securityStatus()}>
<SecurityPostureSummary status={props.securityStatus()!} />
</Show>
<Show when={!props.securityStatusLoading() && props.securityStatus()?.hasProxyAuth}>
<Card
padding="sm"
class="border border-blue-200 dark:border-blue-800 bg-blue-50 dark:bg-blue-900/20"
>
<div class="flex flex-col gap-2 text-xs text-blue-800 dark:text-blue-200">
<div class="flex items-center gap-2">
<svg
class="w-4 h-4 flex-shrink-0"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
<span class="font-semibold text-blue-900 dark:text-blue-100">
Proxy authentication detected
</span>
</div>
<p>
Requests are validated by an upstream proxy. The current proxied user is
{props.securityStatus()?.proxyAuthUsername
? ` ${props.securityStatus()?.proxyAuthUsername}`
: ' available once a request is received'}
.
{props.securityStatus()?.proxyAuthIsAdmin ? ' Admin privileges confirmed.' : ''}
<Show when={props.securityStatus()?.proxyAuthLogoutURL}>
{' '}
<a class="underline font-medium" href={props.securityStatus()?.proxyAuthLogoutURL}>
Proxy logout
</a>
</Show>
</p>
<p>
Need configuration tips? Review the proxy auth guide in the docs.{' '}
<a
class="underline font-medium"
href="https://github.com/rcourtman/Pulse/blob/main/docs/PROXY_AUTH.md"
target="_blank"
rel="noreferrer"
>
Read proxy auth guide
</a>
</p>
</div>
</Card>
</Show>
</div>
);
};

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,310 @@
import { Component, Show, Accessor, Setter } from 'solid-js';
import { Card } from '@/components/shared/Card';
import { SectionHeader } from '@/components/shared/SectionHeader';
import RefreshCw from 'lucide-solid/icons/refresh-cw';
import type { UpdateInfo, VersionInfo } from '@/api/updates';
interface UpdatesSettingsPanelProps {
versionInfo: Accessor<VersionInfo | null>;
updateInfo: Accessor<UpdateInfo | null>;
checkingForUpdates: Accessor<boolean>;
updateChannel: Accessor<'stable' | 'rc'>;
setUpdateChannel: Setter<'stable' | 'rc'>;
autoUpdateEnabled: Accessor<boolean>;
setAutoUpdateEnabled: Setter<boolean>;
autoUpdateCheckInterval: Accessor<number>;
setAutoUpdateCheckInterval: Setter<number>;
autoUpdateTime: Accessor<string>;
setAutoUpdateTime: Setter<string>;
checkForUpdates: () => Promise<void>;
setHasUnsavedChanges: Setter<boolean>;
}
export const UpdatesSettingsPanel: Component<UpdatesSettingsPanelProps> = (props) => {
return (
<div class="space-y-6">
<Card
padding="none"
class="overflow-hidden border border-gray-200 dark:border-gray-700"
border={false}
>
{/* Header */}
<div class="bg-gradient-to-r from-blue-50 to-indigo-50 dark:from-blue-900/20 dark:to-indigo-900/20 px-6 py-4 border-b border-gray-200 dark:border-gray-700">
<div class="flex items-center gap-3">
<div class="p-2 bg-blue-100 dark:bg-blue-900/40 rounded-lg">
<RefreshCw class="w-5 h-5 text-blue-600 dark:text-blue-300" strokeWidth={2} />
</div>
<SectionHeader
title="Updates"
description="Version checking and automatic update configuration"
size="sm"
class="flex-1"
/>
</div>
</div>
<div class="p-6 space-y-6">
<section class="space-y-4">
<div class="space-y-4">
{/* Current Version */}
<div class="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div>
<label class="text-sm font-medium text-gray-900 dark:text-gray-100">
Current Version
</label>
<p class="text-xs text-gray-600 dark:text-gray-400">
{props.versionInfo()?.version || 'Loading...'}
{props.versionInfo()?.isDevelopment && ' (Development)'}
{props.versionInfo()?.isDocker && ' - Docker'}
</p>
</div>
<button
type="button"
onClick={props.checkForUpdates}
disabled={
props.checkingForUpdates() ||
props.versionInfo()?.isDocker ||
props.versionInfo()?.isSourceBuild
}
class={`px-4 py-2 text-sm rounded-lg transition-colors flex items-center gap-2 ${
props.versionInfo()?.isDocker || props.versionInfo()?.isSourceBuild
? 'bg-gray-100 dark:bg-gray-700 text-gray-400 dark:text-gray-500 cursor-not-allowed'
: 'bg-blue-600 text-white hover:bg-blue-700'
}`}
>
{props.checkingForUpdates() ? (
<>
<div class="animate-spin h-4 w-4 border-2 border-white border-t-transparent rounded-full"></div>
Checking...
</>
) : (
<>Check for Updates</>
)}
</button>
</div>
{/* Docker installation notice */}
<Show when={props.versionInfo()?.isDocker && !props.updateInfo()?.available}>
<div class="p-3 bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-lg">
<p class="text-xs text-blue-800 dark:text-blue-200">
<strong>Docker Installation:</strong> Updates are managed through Docker. Pull
the latest image to update.
</p>
</div>
</Show>
{/* Source build notice */}
<Show when={props.versionInfo()?.isSourceBuild}>
<div class="p-3 bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-lg">
<p class="text-xs text-blue-800 dark:text-blue-200">
<strong>Built from source:</strong> Pull the latest code from git and rebuild to
update.
</p>
</div>
</Show>
{/* Warning message */}
<Show when={Boolean(props.updateInfo()?.warning)}>
<div class="p-3 bg-amber-50 dark:bg-amber-900/20 border border-amber-200 dark:border-amber-700 rounded-lg">
<p class="text-xs text-amber-800 dark:text-amber-200">
{props.updateInfo()?.warning}
</p>
</div>
</Show>
{/* Update available */}
<Show when={props.updateInfo()?.available}>
<div class="p-3 bg-green-50 dark:bg-green-900/20 border border-green-200 dark:border-green-800 rounded-lg space-y-3">
<div>
<p class="text-sm font-medium text-green-800 dark:text-green-200">
Update Available: {props.updateInfo()?.latestVersion}
</p>
<p class="text-xs text-green-700 dark:text-green-300 mt-1">
Released:{' '}
{props.updateInfo()?.releaseDate
? new Date(props.updateInfo()!.releaseDate).toLocaleDateString()
: 'Unknown'}
</p>
</div>
<div class="p-2 bg-green-100 dark:bg-green-900/40 rounded space-y-2">
<p class="text-xs font-medium text-green-800 dark:text-green-200">
How to update:
</p>
<Show when={props.versionInfo()?.deploymentType === 'proxmoxve'}>
<p class="text-xs text-green-700 dark:text-green-300">
Type{' '}
<code class="px-1 py-0.5 bg-green-200 dark:bg-green-800 rounded">update</code>{' '}
in the LXC console
</p>
</Show>
<Show when={props.versionInfo()?.deploymentType === 'docker'}>
<div class="text-xs text-green-700 dark:text-green-300 space-y-1">
<p>Run these commands:</p>
<code class="block p-1 bg-green-200 dark:bg-green-800 rounded text-xs">
docker pull rcourtman/pulse:latest
<br />
docker restart pulse
</code>
</div>
</Show>
<Show
when={
props.versionInfo()?.deploymentType === 'systemd' ||
props.versionInfo()?.deploymentType === 'manual'
}
>
<div class="text-xs text-green-700 dark:text-green-300 space-y-1">
<p>
Click the "Install Update" button below, or download and install manually:
</p>
<code class="block p-1 bg-green-200 dark:bg-green-800 rounded text-xs">
curl -LO
https://github.com/rcourtman/Pulse/releases/download/
{props.updateInfo()?.latestVersion}/pulse-{props.updateInfo()?.latestVersion}
-linux-amd64.tar.gz
<br />
sudo systemctl stop pulse
<br />
sudo tar -xzf pulse-{props.updateInfo()?.latestVersion}
-linux-amd64.tar.gz -C /usr/local/bin pulse
<br />
sudo systemctl start pulse
</code>
</div>
</Show>
<Show when={props.versionInfo()?.deploymentType === 'development'}>
<p class="text-xs text-green-700 dark:text-green-300">
Pull latest changes and rebuild
</p>
</Show>
<Show when={!props.versionInfo()?.deploymentType && props.versionInfo()?.isDocker}>
<p class="text-xs text-green-700 dark:text-green-300">
Pull the latest Pulse Docker image and recreate your container.
</p>
</Show>
</div>
{/* Release notes */}
<Show when={props.updateInfo()?.releaseNotes}>
<details class="mt-1">
<summary class="text-xs text-green-700 dark:text-green-300 cursor-pointer">
Release Notes
</summary>
<pre class="mt-2 text-xs text-green-600 dark:text-green-400 whitespace-pre-wrap font-mono bg-green-100 dark:bg-green-900/30 p-2 rounded">
{props.updateInfo()?.releaseNotes}
</pre>
</details>
</Show>
</div>
</Show>
{/* Update settings */}
<div class="border-t border-gray-200 dark:border-gray-600 pt-4 space-y-4">
{/* Update Channel */}
<div class="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div>
<label class="text-sm font-medium text-gray-900 dark:text-gray-100">
Update Channel
</label>
<p class="text-xs text-gray-600 dark:text-gray-400">
Choose between stable and release candidate versions
</p>
</div>
<select
value={props.updateChannel()}
onChange={(e) => {
props.setUpdateChannel(e.currentTarget.value as 'stable' | 'rc');
props.setHasUnsavedChanges(true);
}}
disabled={props.versionInfo()?.isDocker}
class="px-3 py-1.5 text-sm border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 disabled:opacity-50"
>
<option value="stable">Stable</option>
<option value="rc">Release Candidate</option>
</select>
</div>
{/* Auto Update Toggle */}
<div class="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div>
<label class="text-sm font-medium text-gray-900 dark:text-gray-100">
Update Checks
</label>
<p class="text-xs text-gray-600 dark:text-gray-400">
Automatically check for updates (installation is manual)
</p>
</div>
<label class="relative inline-flex items-center cursor-pointer">
<input
type="checkbox"
checked={props.autoUpdateEnabled()}
onChange={(e) => {
props.setAutoUpdateEnabled(e.currentTarget.checked);
props.setHasUnsavedChanges(true);
}}
disabled={props.versionInfo()?.isDocker}
class="sr-only peer"
/>
<div class="w-11 h-6 bg-gray-200 peer-focus:outline-none rounded-full peer dark:bg-gray-700 peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-blue-600 peer-disabled:opacity-50"></div>
</label>
</div>
{/* Auto update options (shown when enabled) */}
<Show when={props.autoUpdateEnabled()}>
<div class="space-y-4 rounded-md border border-gray-200 dark:border-gray-600 p-3">
{/* Check Interval */}
<div class="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div>
<label class="text-sm font-medium text-gray-900 dark:text-gray-100">
Check Interval
</label>
<p class="text-xs text-gray-600 dark:text-gray-400">
How often to check for updates
</p>
</div>
<select
value={props.autoUpdateCheckInterval()}
onChange={(e) => {
props.setAutoUpdateCheckInterval(parseInt(e.currentTarget.value));
props.setHasUnsavedChanges(true);
}}
class="px-3 py-1.5 text-sm border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800"
>
<option value="6">Every 6 hours</option>
<option value="12">Every 12 hours</option>
<option value="24">Daily</option>
<option value="168">Weekly</option>
</select>
</div>
{/* Check Time */}
<div class="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div>
<label class="text-sm font-medium text-gray-900 dark:text-gray-100">
Check Time
</label>
<p class="text-xs text-gray-600 dark:text-gray-400">
Preferred time to check for updates
</p>
</div>
<input
type="time"
value={props.autoUpdateTime()}
onChange={(e) => {
props.setAutoUpdateTime(e.currentTarget.value);
props.setHasUnsavedChanges(true);
}}
class="px-3 py-1.5 text-sm border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800"
/>
</div>
</div>
</Show>
</div>
</div>
</section>
</div>
</Card>
</div>
);
};