9437a74147
Add a 'Re-sync now' button to the Proxmox auto-sync settings section that triggers an immediate inventory import (POST /proxmox/sync-now) using the server env config — the manual counterpart to scheduled auto-sync. Also fix a dual-source-of-truth bug: Proxmox connection config (host, port, token, verify_tls) is now env-only and never persisted to scan_config.json. Previously save_overrides() dumped host/port/verify alongside scan settings, so saving an unrelated setting wrote an empty proxmox_host that load_overrides then clobbered PROXMOX_HOST with on every boot. Only the auto-sync activation (sync_enabled + sync_interval) stays user-editable and persisted. ha-relevant: no
287 lines
12 KiB
TypeScript
287 lines
12 KiB
TypeScript
import { useState, useEffect } from 'react'
|
|
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from '@/components/ui/dialog'
|
|
import { Button } from '@/components/ui/button'
|
|
import { settingsApi, proxmoxApi, type ProxmoxConfigData } from '@/api/client'
|
|
import { useCanvasStore } from '@/stores/canvasStore'
|
|
import { toast } from 'sonner'
|
|
import {
|
|
type AlignmentSettings,
|
|
readAlignmentSettings,
|
|
writeAlignmentSettings,
|
|
subscribeAlignmentSettings,
|
|
} from '@/utils/alignmentSettings'
|
|
|
|
const STANDALONE = import.meta.env.VITE_STANDALONE === 'true'
|
|
|
|
interface SettingsModalProps {
|
|
open: boolean
|
|
onClose: () => void
|
|
}
|
|
|
|
export function SettingsModal({ open, onClose }: SettingsModalProps) {
|
|
const [interval, setIntervalValue] = useState(60)
|
|
const [serviceCheckEnabled, setServiceCheckEnabled] = useState(false)
|
|
const [serviceInterval, setServiceInterval] = useState(300)
|
|
const [saving, setSaving] = useState(false)
|
|
const [pmConfig, setPmConfig] = useState<ProxmoxConfigData | null>(null)
|
|
const [pmSyncEnabled, setPmSyncEnabled] = useState(false)
|
|
const [pmInterval, setPmInterval] = useState(3600)
|
|
const [pmSyncing, setPmSyncing] = useState(false)
|
|
const [alignment, setAlignment] = useState<AlignmentSettings>(readAlignmentSettings)
|
|
const hideIp = useCanvasStore((s) => s.hideIp)
|
|
const setHideIp = useCanvasStore((s) => s.setHideIp)
|
|
|
|
useEffect(() => {
|
|
if (!open || STANDALONE) return
|
|
settingsApi.get()
|
|
.then((res) => {
|
|
setIntervalValue(res.data.interval_seconds)
|
|
setServiceCheckEnabled(res.data.service_check_enabled)
|
|
setServiceInterval(res.data.service_check_interval)
|
|
})
|
|
.catch(() => {/* use default */})
|
|
proxmoxApi.getConfig()
|
|
.then((res) => {
|
|
setPmConfig(res.data)
|
|
setPmSyncEnabled(res.data.sync_enabled)
|
|
setPmInterval(res.data.sync_interval)
|
|
})
|
|
.catch(() => {/* proxmox not configured */})
|
|
}, [open])
|
|
|
|
useEffect(() => subscribeAlignmentSettings(setAlignment), [])
|
|
|
|
const updateAlignment = (patch: Partial<AlignmentSettings>) => {
|
|
const next = { ...alignment, ...patch }
|
|
setAlignment(next)
|
|
writeAlignmentSettings(next)
|
|
}
|
|
|
|
const handleSyncNow = async () => {
|
|
setPmSyncing(true)
|
|
try {
|
|
await proxmoxApi.syncNow()
|
|
toast.success('Proxmox sync started')
|
|
} catch {
|
|
toast.error('Failed to start Proxmox sync')
|
|
} finally {
|
|
setPmSyncing(false)
|
|
}
|
|
}
|
|
|
|
const handleSave = async () => {
|
|
// Canvas prefs (alignment, hide-IP) persist on change; only the backend
|
|
// status-check interval needs an API round-trip.
|
|
if (STANDALONE) {
|
|
onClose()
|
|
return
|
|
}
|
|
setSaving(true)
|
|
try {
|
|
await settingsApi.save({
|
|
interval_seconds: interval,
|
|
service_check_enabled: serviceCheckEnabled,
|
|
service_check_interval: serviceInterval,
|
|
})
|
|
if (pmConfig) {
|
|
// Connection config (host/port/token/verify) is env-only; only the
|
|
// auto-sync activation is persisted.
|
|
await proxmoxApi.saveConfig({
|
|
sync_enabled: pmSyncEnabled,
|
|
sync_interval: pmInterval,
|
|
})
|
|
}
|
|
toast.success('Settings saved')
|
|
onClose()
|
|
} catch {
|
|
toast.error('Failed to save settings')
|
|
} finally {
|
|
setSaving(false)
|
|
}
|
|
}
|
|
|
|
return (
|
|
<Dialog open={open} onOpenChange={(v) => !v && onClose()}>
|
|
<DialogContent className="bg-[#161b22] border-border max-w-md">
|
|
<DialogHeader>
|
|
<DialogTitle className="text-foreground">Settings</DialogTitle>
|
|
</DialogHeader>
|
|
|
|
<div className="space-y-5 py-2">
|
|
{/* Status checker */}
|
|
{!STANDALONE && (
|
|
<div className="space-y-1.5">
|
|
<label className="text-xs text-muted-foreground">Status check interval (s)</label>
|
|
<div className="flex items-center gap-2">
|
|
<input
|
|
type="number"
|
|
min={10}
|
|
max={3600}
|
|
value={interval}
|
|
onChange={(e) => { const v = Number(e.target.value); if (!isNaN(v)) setIntervalValue(v) }}
|
|
className="w-24 px-2 py-1 rounded-md text-xs font-mono bg-[#0d1117] border border-border text-foreground focus:outline-none focus:border-[#00d4ff]"
|
|
/>
|
|
<span className="text-xs text-muted-foreground">seconds</span>
|
|
</div>
|
|
<p className="text-[10px] text-muted-foreground leading-tight">
|
|
How often node health is polled (ping, HTTP, SSH…)
|
|
</p>
|
|
|
|
<label className="flex items-center justify-between gap-2 cursor-pointer pt-2">
|
|
<span className="text-xs text-foreground">Check services individually</span>
|
|
<input
|
|
type="checkbox"
|
|
checked={serviceCheckEnabled}
|
|
onChange={(e) => setServiceCheckEnabled(e.target.checked)}
|
|
className="cursor-pointer accent-[#00d4ff]"
|
|
aria-label="Toggle per-service status checks"
|
|
/>
|
|
</label>
|
|
|
|
<div className={serviceCheckEnabled ? 'space-y-1.5' : 'space-y-1.5 opacity-50 pointer-events-none'}>
|
|
<label className="text-xs text-muted-foreground">Service check interval (s)</label>
|
|
<div className="flex items-center gap-2">
|
|
<input
|
|
type="number"
|
|
min={30}
|
|
max={3600}
|
|
value={serviceInterval}
|
|
onChange={(e) => { const v = Number(e.target.value); if (!isNaN(v)) setServiceInterval(v) }}
|
|
className="w-24 px-2 py-1 rounded-md text-xs font-mono bg-[#0d1117] border border-border text-foreground focus:outline-none focus:border-[#00d4ff]"
|
|
aria-label="Service check interval"
|
|
/>
|
|
<span className="text-xs text-muted-foreground">seconds</span>
|
|
</div>
|
|
<p className="text-[10px] text-muted-foreground leading-tight">
|
|
Probes each service port. Offline services turn red. Default 300s (5 min).
|
|
</p>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Proxmox auto-sync */}
|
|
{!STANDALONE && pmConfig && (
|
|
<div className="pt-3 border-t border-border space-y-2">
|
|
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wider">Proxmox auto-sync</span>
|
|
{!pmConfig.token_configured ? (
|
|
<p className="text-[10px] text-[#e3b341] leading-tight">
|
|
No API token configured. Set <span className="font-mono">PROXMOX_TOKEN_ID</span> and{' '}
|
|
<span className="font-mono">PROXMOX_TOKEN_SECRET</span> in the server .env to enable auto-sync.
|
|
</p>
|
|
) : (
|
|
<>
|
|
<label className="flex items-center justify-between gap-2 cursor-pointer">
|
|
<span className="text-xs text-foreground">Auto-sync Proxmox inventory</span>
|
|
<input
|
|
type="checkbox"
|
|
checked={pmSyncEnabled}
|
|
onChange={(e) => setPmSyncEnabled(e.target.checked)}
|
|
className="cursor-pointer accent-[#e57000]"
|
|
aria-label="Toggle Proxmox auto-sync"
|
|
/>
|
|
</label>
|
|
<div className={pmSyncEnabled ? 'space-y-1.5' : 'space-y-1.5 opacity-50 pointer-events-none'}>
|
|
<label className="text-xs text-muted-foreground">Sync interval (s)</label>
|
|
<div className="flex items-center gap-2">
|
|
<input
|
|
type="number"
|
|
min={300}
|
|
max={86400}
|
|
value={pmInterval}
|
|
onChange={(e) => { const v = Number(e.target.value); if (!isNaN(v)) setPmInterval(v) }}
|
|
className="w-24 px-2 py-1 rounded-md text-xs font-mono bg-[#0d1117] border border-border text-foreground focus:outline-none focus:border-[#e57000]"
|
|
aria-label="Proxmox sync interval"
|
|
/>
|
|
<span className="text-xs text-muted-foreground">seconds</span>
|
|
</div>
|
|
<p className="text-[10px] text-muted-foreground leading-tight">
|
|
Re-imports hosts/VMs/LXC into the pending inventory. Min 300s (5 min).
|
|
</p>
|
|
</div>
|
|
{pmConfig.host ? (
|
|
<div className="flex items-center gap-2 pt-1">
|
|
<Button
|
|
variant="outline"
|
|
onClick={handleSyncNow}
|
|
disabled={pmSyncing}
|
|
className="h-7 text-xs border-[#e57000] text-[#e57000] hover:bg-[#e57000]/10"
|
|
>
|
|
{pmSyncing ? 'Syncing…' : 'Re-sync now'}
|
|
</Button>
|
|
<span className="text-[10px] text-muted-foreground leading-tight">
|
|
Runs one import immediately using the server .env config.
|
|
</span>
|
|
</div>
|
|
) : (
|
|
<p className="text-[10px] text-[#e3b341] leading-tight pt-1">
|
|
Set <span className="font-mono">PROXMOX_HOST</span> in the server .env to enable manual re-sync.
|
|
</p>
|
|
)}
|
|
</>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
{/* Canvas */}
|
|
<div className="pt-3 border-t border-border space-y-3">
|
|
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wider">Canvas</span>
|
|
|
|
<label className="flex items-center justify-between gap-2 cursor-pointer">
|
|
<span className="text-xs text-foreground">Snap to nodes</span>
|
|
<input
|
|
type="checkbox"
|
|
checked={alignment.enabled}
|
|
onChange={(e) => updateAlignment({ enabled: e.target.checked })}
|
|
className="cursor-pointer accent-[#00d4ff]"
|
|
aria-label="Toggle alignment guides"
|
|
/>
|
|
</label>
|
|
|
|
<label className="flex items-center justify-between gap-2 cursor-pointer">
|
|
<span className="text-xs text-foreground">Hide IP addresses</span>
|
|
<input
|
|
type="checkbox"
|
|
checked={hideIp}
|
|
onChange={(e) => setHideIp(e.target.checked)}
|
|
className="cursor-pointer accent-[#00d4ff]"
|
|
aria-label="Toggle IP address masking"
|
|
/>
|
|
</label>
|
|
|
|
<div className={alignment.enabled ? 'space-y-1.5' : 'space-y-1.5 opacity-50 pointer-events-none'}>
|
|
<label className="text-xs text-muted-foreground">Snap distance</label>
|
|
<div className="flex items-center gap-2">
|
|
<input
|
|
type="range"
|
|
min={2}
|
|
max={16}
|
|
step={1}
|
|
value={alignment.threshold}
|
|
onChange={(e) => updateAlignment({ threshold: Number(e.target.value) })}
|
|
className="flex-1 cursor-pointer accent-[#00d4ff]"
|
|
aria-label="Alignment snap threshold"
|
|
/>
|
|
<span className="font-mono text-[11px] text-foreground w-8 text-right">{alignment.threshold}px</span>
|
|
</div>
|
|
<p className="text-[10px] text-muted-foreground leading-tight">
|
|
Distance at which dragged nodes snap to neighbours. Hold Alt while dragging to disable.
|
|
</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<DialogFooter className="gap-2">
|
|
<Button variant="ghost" onClick={onClose}>Cancel</Button>
|
|
<Button
|
|
onClick={handleSave}
|
|
disabled={saving}
|
|
style={{ background: '#00d4ff', color: '#0d1117' }}
|
|
>
|
|
{saving ? 'Saving…' : 'Save'}
|
|
</Button>
|
|
</DialogFooter>
|
|
</DialogContent>
|
|
</Dialog>
|
|
)
|
|
}
|