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