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:
parent
75874687fb
commit
c5ac33e8c6
11 changed files with 2705 additions and 1745 deletions
|
|
@ -23,6 +23,9 @@ interface BackupsFilterProps {
|
||||||
onReset?: () => void;
|
onReset?: () => void;
|
||||||
statusFilter?: () => 'all' | 'verified' | 'unverified';
|
statusFilter?: () => 'all' | 'verified' | 'unverified';
|
||||||
setStatusFilter?: (value: 'all' | 'verified' | 'unverified') => void;
|
setStatusFilter?: (value: 'all' | 'verified' | 'unverified') => void;
|
||||||
|
// Time format toggle
|
||||||
|
useRelativeTime?: () => boolean;
|
||||||
|
setUseRelativeTime?: (value: boolean) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const BackupsFilter: Component<BackupsFilterProps> = (props) => {
|
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>
|
<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 */}
|
{/* Reset Button */}
|
||||||
<button
|
<button
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
|
|
@ -518,14 +540,14 @@ export const BackupsFilter: Component<BackupsFilterProps> = (props) => {
|
||||||
}}
|
}}
|
||||||
title="Reset all filters"
|
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() !== '' ||
|
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.viewMode() !== 'all' ||
|
||||||
props.groupBy() !== 'date' ||
|
props.groupBy() !== 'date' ||
|
||||||
props.sortKey() !== 'backupTime' ||
|
props.sortKey() !== 'backupTime' ||
|
||||||
props.sortDirection() !== 'desc' ||
|
props.sortDirection() !== 'desc' ||
|
||||||
(props.typeFilter && props.typeFilter() !== 'all') ||
|
(props.typeFilter && props.typeFilter() !== 'all') ||
|
||||||
(props.statusFilter && props.statusFilter() !== '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-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'
|
: 'text-gray-600 dark:text-gray-400 bg-gray-100 dark:bg-gray-700 hover:bg-gray-200 dark:hover:bg-gray-600'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<svg
|
<svg
|
||||||
|
|
|
||||||
|
|
@ -134,12 +134,10 @@ const UnifiedBackups: Component = () => {
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
const [useRelativeTime] = createLocalStorageBooleanSignal(
|
const [useRelativeTime, setUseRelativeTime] = createLocalStorageBooleanSignal(
|
||||||
STORAGE_KEYS.BACKUPS_USE_RELATIVE_TIME,
|
STORAGE_KEYS.BACKUPS_USE_RELATIVE_TIME,
|
||||||
false, // Default to absolute time
|
false, // Default to absolute time
|
||||||
);
|
);
|
||||||
// TODO: Add time format toggle to BackupsFilter component
|
|
||||||
// const setUseRelativeTime = ...;
|
|
||||||
|
|
||||||
// Helper functions
|
// Helper functions
|
||||||
const getDaySuffix = (day: number) => {
|
const getDaySuffix = (day: number) => {
|
||||||
|
|
@ -1932,6 +1930,8 @@ const UnifiedBackups: Component = () => {
|
||||||
sortDirection={sortDirection}
|
sortDirection={sortDirection}
|
||||||
setSortDirection={setSortDirection}
|
setSortDirection={setSortDirection}
|
||||||
onReset={resetFilters}
|
onReset={resetFilters}
|
||||||
|
useRelativeTime={useRelativeTime}
|
||||||
|
setUseRelativeTime={setUseRelativeTime}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Table */}
|
{/* Table */}
|
||||||
|
|
|
||||||
57
frontend-modern/src/components/Settings/APIAccessPanel.tsx
Normal file
57
frontend-modern/src/components/Settings/APIAccessPanel.tsx
Normal 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 once—store 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>
|
||||||
|
);
|
||||||
|
};
|
||||||
364
frontend-modern/src/components/Settings/BackupsSettingsPanel.tsx
Normal file
364
frontend-modern/src/components/Settings/BackupsSettingsPanel.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
};
|
||||||
208
frontend-modern/src/components/Settings/GeneralSettingsPanel.tsx
Normal file
208
frontend-modern/src/components/Settings/GeneralSettingsPanel.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
};
|
||||||
621
frontend-modern/src/components/Settings/NetworkSettingsPanel.tsx
Normal file
621
frontend-modern/src/components/Settings/NetworkSettingsPanel.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
};
|
||||||
320
frontend-modern/src/components/Settings/SecurityAuthPanel.tsx
Normal file
320
frontend-modern/src/components/Settings/SecurityAuthPanel.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
@ -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
310
frontend-modern/src/components/Settings/UpdatesSettingsPanel.tsx
Normal file
310
frontend-modern/src/components/Settings/UpdatesSettingsPanel.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
};
|
||||||
Loading…
Reference in a new issue