Files
dockhand-upstream/src/routes/settings/general/GeneralTab.svelte
T
2026-06-06 16:18:05 +02:00

863 lines
32 KiB
Svelte

<script lang="ts">
import * as Card from '$lib/components/ui/card';
import * as Select from '$lib/components/ui/select';
import { Label } from '$lib/components/ui/label';
import { Input } from '$lib/components/ui/input';
import { Button } from '$lib/components/ui/button';
import { Badge } from '$lib/components/ui/badge';
import { TogglePill, ToggleSwitch } from '$lib/components/ui/toggle-pill';
import CronEditor from '$lib/components/cron-editor.svelte';
import TimezoneSelector from '$lib/components/TimezoneSelector.svelte';
import { Eye, Bell, Database, Calendar, ShieldCheck, FileText, AlertTriangle, HelpCircle, Globe, Activity, Clock, Info, Save, RotateCcw, LayoutDashboard, Tags } from 'lucide-svelte';
import CodeEditor from '$lib/components/CodeEditor.svelte';
import { appSettings, type DateFormat, type DownloadFormat, type EventCollectionMode, type LabelFilterMode } from '$lib/stores/settings';
import { canAccess, authStore } from '$lib/stores/auth';
import { toast } from 'svelte-sonner';
import ThemeSelector from '$lib/components/ThemeSelector.svelte';
import * as Tooltip from '$lib/components/ui/tooltip';
// General settings state - these derive from the store
let confirmDestructive = $derived($appSettings.confirmDestructive);
let showStoppedContainers = $derived($appSettings.showStoppedContainers);
let highlightUpdates = $derived($appSettings.highlightUpdates);
let compactPorts = $derived($appSettings.compactPorts);
let showExposedPorts = $derived($appSettings.showExposedPorts);
let timeFormat = $derived($appSettings.timeFormat);
let dateFormat = $derived($appSettings.dateFormat);
let downloadFormat = $derived($appSettings.downloadFormat);
let defaultGrypeArgs = $derived($appSettings.defaultGrypeArgs);
let defaultTrivyArgs = $derived($appSettings.defaultTrivyArgs);
let defaultGrypeImage = $derived($appSettings.defaultGrypeImage);
let defaultTrivyImage = $derived($appSettings.defaultTrivyImage);
let defaultComposeTemplate = $derived($appSettings.defaultComposeTemplate);
let labelFilterMode = $derived($appSettings.labelFilterMode);
let composeTemplateWIP = $state('');
let composeTemplateInitialized = false;
$effect(() => {
if (!composeTemplateInitialized && defaultComposeTemplate !== undefined) {
composeTemplateWIP = defaultComposeTemplate;
composeTemplateInitialized = true;
}
});
const builtinComposeTemplate = `version: "3.8"
services:
app:
image: nginx:alpine
ports:
- "8080:80"
environment:
- APP_ENV=\${APP_ENV:-production}
volumes:
- ./html:/usr/share/nginx/html:ro
restart: unless-stopped
# Add more services as needed
# networks:
# default:
# driver: bridge
`;
function saveComposeTemplate() {
appSettings.setDefaultComposeTemplate(composeTemplateWIP);
toast.success('Compose template updated');
}
function revertComposeTemplate() {
composeTemplateWIP = builtinComposeTemplate;
toast.info('Template reverted to default');
}
let scheduleRetentionDays = $derived($appSettings.scheduleRetentionDays);
let eventRetentionDays = $derived($appSettings.eventRetentionDays);
let scheduleCleanupCron = $derived($appSettings.scheduleCleanupCron);
let eventCleanupCron = $derived($appSettings.eventCleanupCron);
let scheduleCleanupEnabled = $derived($appSettings.scheduleCleanupEnabled);
let eventCleanupEnabled = $derived($appSettings.eventCleanupEnabled);
let scannerCleanupCron = $derived($appSettings.scannerCleanupCron);
let scannerCleanupEnabled = $derived($appSettings.scannerCleanupEnabled);
let logMaxLines = $derived($appSettings.logMaxLines);
let formatLogTimestamps = $derived($appSettings.formatLogTimestamps);
let defaultTimezone = $derived($appSettings.defaultTimezone);
let eventCollectionMode = $derived($appSettings.eventCollectionMode);
let eventPollInterval = $derived($appSettings.eventPollInterval);
let metricsCollectionInterval = $derived($appSettings.metricsCollectionInterval);
let clearingCache = $state(false);
async function clearScannerCache() {
clearingCache = true;
try {
const res = await fetch('/api/settings/scanner/cache', { method: 'DELETE' });
const data = await res.json();
if (res.ok && data.success) {
const total = (data.removedVolumes?.length || 0) + (data.removedDirs?.length || 0);
if (total > 0) {
toast.success(`Scanner cache cleared (${total} items removed)`);
} else {
toast.info('Scanner cache was already empty');
}
} else {
toast.error(data.error || 'Failed to clear scanner cache');
}
} catch {
toast.error('Failed to clear scanner cache');
} finally {
clearingCache = false;
}
}
const dateFormatOptions: { value: DateFormat; label: string; example: string }[] = [
{ value: 'DD.MM.YYYY', label: 'DD.MM.YYYY', example: '31.12.2024' },
{ value: 'DD/MM/YYYY', label: 'DD/MM/YYYY', example: '31/12/2024' },
{ value: 'MM/DD/YYYY', label: 'MM/DD/YYYY', example: '12/31/2024' },
{ value: 'YYYY-MM-DD', label: 'YYYY-MM-DD', example: '2024-12-31' }
];
function handleScheduleRetentionChange(e: Event) {
const value = Math.max(1, Math.min(365, parseInt((e.target as HTMLInputElement).value) || 30));
appSettings.setScheduleRetentionDays(value);
toast.success('Schedule retention updated');
}
function handleEventRetentionChange(e: Event) {
const value = Math.max(1, Math.min(365, parseInt((e.target as HTMLInputElement).value) || 30));
appSettings.setEventRetentionDays(value);
toast.success('Event retention updated');
}
function handleScheduleCleanupCronChange(cron: string) {
appSettings.setScheduleCleanupCron(cron);
toast.success('Schedule cleanup cron updated');
}
function handleEventCleanupCronChange(cron: string) {
appSettings.setEventCleanupCron(cron);
toast.success('Event cleanup cron updated');
}
function handleScheduleCleanupEnabledChange() {
const newState = !scheduleCleanupEnabled;
appSettings.setScheduleCleanupEnabled(newState);
toast.success(newState ? 'Schedule cleanup enabled' : 'Schedule cleanup disabled');
}
function handleEventCleanupEnabledChange() {
const newState = !eventCleanupEnabled;
appSettings.setEventCleanupEnabled(newState);
toast.success(newState ? 'Event cleanup enabled' : 'Event cleanup disabled');
}
function handleScannerCleanupCronChange(cron: string) {
appSettings.setScannerCleanupCron(cron);
toast.success('Scanner cleanup cron updated');
}
function handleScannerCleanupEnabledChange() {
const newState = !scannerCleanupEnabled;
appSettings.setScannerCleanupEnabled(newState);
toast.success(newState ? 'Scanner cleanup enabled' : 'Scanner cleanup disabled');
}
function handleGrypeImageBlur(e: Event) {
const value = (e.target as HTMLInputElement).value.trim();
if (value && value !== defaultGrypeImage) {
appSettings.setDefaultGrypeImage(value);
toast.success('Grype image updated');
}
}
function handleTrivyImageBlur(e: Event) {
const value = (e.target as HTMLInputElement).value.trim();
if (value && value !== defaultTrivyImage) {
appSettings.setDefaultTrivyImage(value);
toast.success('Trivy image updated');
}
}
function handleGrypeArgsBlur(e: Event) {
const value = (e.target as HTMLInputElement).value.trim();
if (value !== defaultGrypeArgs) {
appSettings.setDefaultGrypeArgs(value);
toast.success('Grype default arguments updated');
}
}
function handleTrivyArgsBlur(e: Event) {
const value = (e.target as HTMLInputElement).value.trim();
if (value !== defaultTrivyArgs) {
appSettings.setDefaultTrivyArgs(value);
toast.success('Trivy default arguments updated');
}
}
// Anything above 2K starts feeling laggy in browsers without virtualized rendering.
const logMaxLinesOptions = [
{ value: '500', label: '500 lines' },
{ value: '1000', label: '1,000 lines' },
{ value: '2000', label: '2,000 lines' }
];
function handleLogMaxLinesChange(value: string | undefined) {
const n = parseInt(value ?? '');
if (!Number.isFinite(n) || n <= 0) return;
appSettings.setLogMaxLines(Math.min(2000, Math.max(100, n)));
toast.success('Log buffer size updated');
}
function handleEventCollectionModeChange(value: string | undefined) {
if (value === 'stream' || value === 'poll') {
appSettings.setEventCollectionMode(value);
toast.success(`Event collection mode: ${value}`);
}
}
function handleEventPollIntervalChange(selected: { value: number } | undefined) {
if (selected?.value) {
appSettings.setEventPollInterval(selected.value);
toast.success(`Event poll interval: ${selected.value / 1000}s`);
}
}
function handleMetricsIntervalChange(selected: { value: number } | undefined) {
if (selected?.value) {
appSettings.setMetricsCollectionInterval(selected.value);
toast.success(`Metrics interval: ${selected.value / 1000}s`);
}
}
</script>
<div class="flex-1 min-h-0 overflow-y-auto">
<div class="grid grid-cols-1 xl:grid-cols-2 gap-4">
<!-- Left column -->
<div class="space-y-4">
<Card.Root>
<Card.Header>
<Card.Title class="text-sm font-medium flex items-center gap-2">
<Eye class="w-4 h-4" />
Appearance
<Tooltip.Provider delayDuration={100}>
<Tooltip.Root>
<Tooltip.Trigger>
<HelpCircle class="w-4 h-4 text-muted-foreground cursor-help" />
</Tooltip.Trigger>
<Tooltip.Portal>
<Tooltip.Content side="right" sideOffset={8} class="!w-80">
{#if $authStore.authEnabled}
These settings apply to the login page and as defaults. Personal preferences can be configured in your profile.
{:else}
Theme and font settings are global when authentication is disabled.
{/if}
</Tooltip.Content>
</Tooltip.Portal>
</Tooltip.Root>
</Tooltip.Provider>
</Card.Title>
</Card.Header>
<Card.Content>
<div class="grid grid-cols-1 md:grid-cols-2 gap-x-6 gap-y-4">
<!-- Left column -->
<div class="space-y-4">
<div class="space-y-1">
<div class="flex items-center gap-3">
<Label>Show stopped containers</Label>
<TogglePill
checked={showStoppedContainers}
onchange={(checked) => {
appSettings.setShowStoppedContainers(checked);
toast.success(checked ? 'Stopped containers shown' : 'Stopped containers hidden');
}}
disabled={!$canAccess('settings', 'edit')}
/>
</div>
<p class="text-xs text-muted-foreground">Display stopped and exited containers in lists</p>
</div>
<div class="space-y-1">
<div class="flex items-center gap-3">
<Label>Highlight available updates</Label>
<TogglePill
checked={highlightUpdates}
onchange={(checked) => {
appSettings.setHighlightUpdates(checked);
toast.success(checked ? 'Update highlighting enabled' : 'Update highlighting disabled');
}}
disabled={!$canAccess('settings', 'edit')}
/>
</div>
<p class="text-xs text-muted-foreground">Highlight container rows in amber when updates are available</p>
</div>
<div class="space-y-1">
<div class="flex items-center gap-3">
<Label>Compact port display</Label>
<TogglePill
checked={compactPorts}
onchange={(checked) => {
appSettings.setCompactPorts(checked);
toast.success(checked ? 'Compact port display enabled' : 'Showing all ports');
}}
disabled={!$canAccess('settings', 'edit')}
/>
</div>
<p class="text-xs text-muted-foreground">Show first port with +N count instead of all ports</p>
</div>
<div class="space-y-1">
<div class="flex items-center gap-3">
<Label>Show exposed ports</Label>
<Tooltip.Root>
<Tooltip.Trigger>
<HelpCircle class="w-3.5 h-3.5 text-muted-foreground" />
</Tooltip.Trigger>
<Tooltip.Content side="top" class="max-w-xs">
<p>Shows internal container ports (from EXPOSE directives) that are not published to the host. These appear in the container list with an amber badge to distinguish them from published port mappings.</p>
</Tooltip.Content>
</Tooltip.Root>
<TogglePill
checked={showExposedPorts}
onchange={(checked) => {
appSettings.setShowExposedPorts(checked);
toast.success(checked ? 'Showing exposed ports in container list' : 'Exposed ports hidden from container list');
}}
disabled={!$canAccess('settings', 'edit')}
/>
</div>
<p class="text-xs text-muted-foreground">Display internal container ports in the container list grid</p>
</div>
<div class="space-y-1">
<div class="flex items-center gap-3">
<Label>Time format</Label>
<ToggleSwitch
value={timeFormat}
leftValue="24h"
rightValue="12h"
onchange={(newFormat) => {
appSettings.setTimeFormat(newFormat as '12h' | '24h');
toast.success(`Time format set to ${newFormat === '12h' ? '12-hour (AM/PM)' : '24-hour'}`);
}}
disabled={!$canAccess('settings', 'edit')}
/>
</div>
<p class="text-xs text-muted-foreground">Display timestamps in 12-hour (AM/PM) or 24-hour format</p>
</div>
<div class="space-y-1">
<div class="flex items-center gap-3">
<Label>Date format</Label>
<Select.Root
type="single"
value={dateFormat}
onValueChange={(value) => {
if (value) {
appSettings.setDateFormat(value as DateFormat);
toast.success(`Date format set to ${value}`);
}
}}
disabled={!$canAccess('settings', 'edit')}
>
<Select.Trigger class="w-[180px]">
<Calendar class="w-4 h-4 mr-2" />
<span>{dateFormat}</span>
</Select.Trigger>
<Select.Content>
{#each dateFormatOptions as option}
<Select.Item value={option.value}>
<div class="flex items-center justify-between w-full gap-4">
<span>{option.label}</span>
<span class="text-xs text-muted-foreground">{option.example}</span>
</div>
</Select.Item>
{/each}
</Select.Content>
</Select.Root>
</div>
<p class="text-xs text-muted-foreground">How dates are displayed throughout the app</p>
</div>
</div>
<!-- Right column: Theme settings (always shown, with hint when auth enabled) -->
<div class="space-y-4">
<ThemeSelector />
{#if $authStore.authEnabled}
<div class="text-xs text-muted-foreground flex items-start gap-1.5 mt-2 p-2 bg-muted/50 rounded-md">
<HelpCircle class="w-3.5 h-3.5 shrink-0 mt-0.5" />
<div>
<p>Personal theme preferences can be configured in your <a href="/profile" class="text-primary hover:underline">profile</a>.</p>
</div>
</div>
{/if}
</div>
</div>
</Card.Content>
</Card.Root>
<Card.Root>
<Card.Header>
<Card.Title class="text-sm font-medium flex items-center gap-2">
<Globe class="w-4 h-4" />
Scheduling
</Card.Title>
</Card.Header>
<Card.Content class="space-y-4">
<div class="space-y-2">
<Label>Default timezone</Label>
<TimezoneSelector
value={defaultTimezone}
onchange={(value) => {
appSettings.setDefaultTimezone(value);
toast.success(`Default timezone set to ${value}`);
}}
class="w-[320px]"
/>
<p class="text-xs text-muted-foreground">Default timezone for new environments. Used for scheduled tasks like auto-updates.</p>
</div>
</Card.Content>
</Card.Root>
<Card.Root>
<Card.Header>
<Card.Title class="text-sm font-medium flex items-center gap-2">
<Bell class="w-4 h-4" />
Confirmations
</Card.Title>
</Card.Header>
<Card.Content class="space-y-4">
<div class="space-y-1">
<div class="flex items-center gap-3">
<Label>Confirm destructive actions</Label>
<TogglePill
checked={confirmDestructive}
onchange={(checked) => {
appSettings.setConfirmDestructive(checked);
toast.success(checked ? 'Confirmations enabled' : 'Confirmations disabled');
}}
disabled={!$canAccess('settings', 'edit')}
/>
</div>
<p class="text-xs text-muted-foreground">Show confirmation dialogs before deleting resources</p>
</div>
</Card.Content>
</Card.Root>
<Card.Root>
<Card.Header>
<Card.Title class="text-sm font-medium flex items-center gap-2">
<FileText class="w-4 h-4" />
Logs & files
</Card.Title>
</Card.Header>
<Card.Content>
<div class="grid grid-cols-1 md:grid-cols-2 gap-x-6 gap-y-4">
<div class="space-y-4">
<div class="space-y-2">
<Label for="log-max-lines">Log buffer size</Label>
<Select.Root
type="single"
value={String(logMaxLines)}
onValueChange={handleLogMaxLinesChange}
disabled={!$canAccess('settings', 'edit')}
>
<Select.Trigger id="log-max-lines" class="w-48">
{logMaxLines.toLocaleString()} lines
</Select.Trigger>
<Select.Content>
{#each logMaxLinesOptions as opt}
<Select.Item value={opt.value}>{opt.label}</Select.Item>
{/each}
</Select.Content>
</Select.Root>
<p class="text-xs text-muted-foreground">Maximum number of log lines kept per container panel. Older lines are dropped when the limit is exceeded.</p>
</div>
<div class="space-y-1">
<div class="flex items-center gap-3">
<Label>Download format</Label>
<ToggleSwitch
value={downloadFormat}
leftValue="tar"
rightValue="tar.gz"
onchange={(newFormat) => {
appSettings.setDownloadFormat(newFormat as DownloadFormat);
toast.success(`Download format set to ${newFormat}`);
}}
disabled={!$canAccess('settings', 'edit')}
/>
</div>
<p class="text-xs text-muted-foreground">Archive format when downloading files from containers</p>
</div>
</div>
<div class="space-y-4">
<div class="space-y-1">
<div class="flex items-center gap-3">
<Label>Format log timestamps</Label>
<TogglePill
checked={formatLogTimestamps}
onchange={(checked) => {
appSettings.setFormatLogTimestamps(checked);
toast.success(checked ? 'Log timestamp formatting enabled' : 'Log timestamp formatting disabled');
}}
disabled={!$canAccess('settings', 'edit')}
/>
</div>
<p class="text-xs text-muted-foreground">Convert ISO timestamps in logs to your configured date/time format</p>
<div class="flex items-start gap-1.5 mt-1">
<Info class="w-3.5 h-3.5 text-muted-foreground shrink-0 mt-0.5" />
<p class="text-xs text-muted-foreground">Docker logs use UTC timestamps by default. When enabled, timestamps like <code class="bg-muted px-1 rounded">2026-01-12T07:47:44Z</code> are converted to local time using your date/time settings.</p>
</div>
</div>
</div>
</div>
</Card.Content>
</Card.Root>
<Card.Root>
<Card.Header>
<Card.Title class="text-sm font-medium flex items-center gap-2">
<FileText class="w-4 h-4" />
Compose template
</Card.Title>
<p class="text-xs text-muted-foreground">Default YAML content when creating a new stack.</p>
</Card.Header>
<Card.Content class="space-y-3">
<div class="h-64">
<CodeEditor
value={composeTemplateWIP}
onchange={(v) => { composeTemplateWIP = v; }}
language="yaml"
readonly={!$canAccess('settings', 'edit')}
class="h-full rounded-md overflow-hidden border border-zinc-200 dark:border-zinc-700"
/>
</div>
{#if $canAccess('settings', 'edit')}
<div class="flex gap-2">
<Button size="sm" variant="outline" onclick={saveComposeTemplate}>
<Save class="w-3.5 h-3.5" />
Save template
</Button>
<Button size="sm" variant="ghost" onclick={revertComposeTemplate}>
<RotateCcw class="w-3.5 h-3.5" />
Revert to default
</Button>
</div>
{/if}
</Card.Content>
</Card.Root>
</div>
<!-- Right column -->
<div class="space-y-4">
<Card.Root>
<Card.Header>
<Card.Title class="text-sm font-medium flex items-center gap-2">
<ShieldCheck class="w-4 h-4" />
Vulnerability scanners
</Card.Title>
</Card.Header>
<Card.Content class="space-y-4">
<div class="space-y-2">
<Label for="grype-image">Grype image</Label>
<Input
id="grype-image"
value={defaultGrypeImage}
onblur={handleGrypeImageBlur}
disabled={!$canAccess('settings', 'edit')}
placeholder={"anchore/grype:v0.110.0"}
/>
<p class="text-xs text-muted-foreground">Docker image for Grype scanner. Pin to a specific version for supply chain security.</p>
</div>
<div class="space-y-2">
<Label for="trivy-image">Trivy image</Label>
<Input
id="trivy-image"
value={defaultTrivyImage}
onblur={handleTrivyImageBlur}
disabled={!$canAccess('settings', 'edit')}
placeholder={"aquasec/trivy:0.69.3"}
/>
<p class="text-xs text-muted-foreground">Docker image for Trivy scanner. Pin to a specific version for supply chain security.</p>
</div>
<div class="space-y-2">
<Label for="grype-args">Default Grype arguments</Label>
<Input
id="grype-args"
value={defaultGrypeArgs}
onblur={handleGrypeArgsBlur}
disabled={!$canAccess('settings', 'edit')}
placeholder={"-o json -v {image}"}
/>
<p class="text-xs text-muted-foreground">Use <code class="bg-muted px-1 rounded">{'{image}'}</code> as placeholder for the image name</p>
</div>
<div class="space-y-2">
<Label for="trivy-args">Default Trivy arguments</Label>
<Input
id="trivy-args"
value={defaultTrivyArgs}
onblur={handleTrivyArgsBlur}
disabled={!$canAccess('settings', 'edit')}
placeholder={"image --format json {image}"}
/>
<p class="text-xs text-muted-foreground">Use <code class="bg-muted px-1 rounded">{'{image}'}</code> as placeholder for the image name</p>
</div>
<div class="pt-2 border-t">
<div class="flex items-center justify-between">
<div>
<p class="text-sm font-medium">Scanner cache</p>
<p class="text-xs text-muted-foreground">Remove cached vulnerability databases to free disk space. Next scan will re-download fresh data (~200MB).</p>
</div>
<Button
variant="outline"
size="sm"
disabled={clearingCache || !$canAccess('settings', 'edit')}
onclick={clearScannerCache}
>
{#if clearingCache}
Clearing...
{:else}
Clear cache
{/if}
</Button>
</div>
</div>
</Card.Content>
</Card.Root>
<Card.Root>
<Card.Header>
<Card.Title class="text-sm font-medium flex items-center gap-2">
<Database class="w-4 h-4" />
System jobs
</Card.Title>
</Card.Header>
<Card.Content class="space-y-4">
<div class="space-y-3">
<div>
<div class="flex items-center gap-2">
<Label>Activity event collection mode</Label>
<Tooltip.Root>
<Tooltip.Trigger>
<HelpCircle class="w-3.5 h-3.5 text-muted-foreground" />
</Tooltip.Trigger>
<Tooltip.Content class="w-80">
<p class="text-xs">
<strong>Stream:</strong> Continuous event stream from Docker, instant notifications, higher CPU usage<br />
<strong>Poll:</strong> Periodic checks for new events, slight notification delay, lower CPU usage
</p>
</Tooltip.Content>
</Tooltip.Root>
</div>
<div class="flex items-center gap-4 mt-2">
<label class="flex items-center gap-2 cursor-pointer">
<input
type="radio"
name="eventCollectionMode"
value="stream"
checked={(eventCollectionMode || 'stream') === 'stream'}
onchange={() => handleEventCollectionModeChange('stream')}
disabled={!$canAccess('settings', 'edit')}
class="accent-primary w-4 h-4"
/>
<Activity class="w-3.5 h-3.5" />
<span class="text-sm">Stream</span>
</label>
<label class="flex items-center gap-2 cursor-pointer">
<input
type="radio"
name="eventCollectionMode"
value="poll"
checked={(eventCollectionMode || 'stream') === 'poll'}
onchange={() => handleEventCollectionModeChange('poll')}
disabled={!$canAccess('settings', 'edit')}
class="accent-primary w-4 h-4"
/>
<Clock class="w-3.5 h-3.5" />
<span class="text-sm">Poll</span>
</label>
<span class="text-xs text-muted-foreground {(eventCollectionMode || 'stream') === 'poll' ? '' : 'invisible'}">every</span>
<Select.Root
type="single"
value={String(eventPollInterval || 60000)}
onValueChange={(v) => v && handleEventPollIntervalChange({ value: parseInt(v) })}
disabled={!$canAccess('settings', 'edit') || (eventCollectionMode || 'stream') !== 'poll'}
>
<Select.Trigger class="w-24 h-8 {(eventCollectionMode || 'stream') === 'poll' ? '' : 'invisible'}">
{(eventPollInterval || 60000) === 30000 ? '30s' : (eventPollInterval || 60000) === 60000 ? '60s' : (eventPollInterval || 60000) === 120000 ? '120s' : '300s'}
</Select.Trigger>
<Select.Content>
<Select.Item value="30000">30s</Select.Item>
<Select.Item value="60000">60s</Select.Item>
<Select.Item value="120000">120s</Select.Item>
<Select.Item value="300000">300s</Select.Item>
</Select.Content>
</Select.Root>
</div>
</div>
</div>
<div class="space-y-1 pt-2 border-t">
<div class="flex items-center gap-2">
<Label for="metrics-interval">Metrics collection interval</Label>
<Tooltip.Root>
<Tooltip.Trigger>
<HelpCircle class="w-3.5 h-3.5 text-muted-foreground" />
</Tooltip.Trigger>
<Tooltip.Content class="w-80">
<p class="text-xs">
How often to collect CPU/memory metrics from running containers. Lower intervals
provide more frequent updates but increase CPU usage.
</p>
</Tooltip.Content>
</Tooltip.Root>
</div>
<div class="flex items-center gap-2 mt-2">
<Select.Root
type="single"
value={String(metricsCollectionInterval || 30000)}
onValueChange={(v) => v && handleMetricsIntervalChange({ value: parseInt(v) })}
disabled={!$canAccess('settings', 'edit')}
>
<Select.Trigger class="w-24 h-8">
{(metricsCollectionInterval || 30000) === 10000 ? '10s' : (metricsCollectionInterval || 30000) === 30000 ? '30s' : (metricsCollectionInterval || 30000) === 60000 ? '60s' : '120s'}
</Select.Trigger>
<Select.Content>
<Select.Item value="10000">10s</Select.Item>
<Select.Item value="30000">30s</Select.Item>
<Select.Item value="60000">60s</Select.Item>
<Select.Item value="120000">120s</Select.Item>
</Select.Content>
</Select.Root>
</div>
</div>
<div class="space-y-1 pt-2 border-t">
<div class="flex items-center gap-3">
<Label for="schedule-retention">Schedule execution cleanup</Label>
<TogglePill
checked={scheduleCleanupEnabled}
onchange={handleScheduleCleanupEnabledChange}
disabled={!$canAccess('settings', 'edit')}
/>
</div>
<p class="text-xs text-muted-foreground">Delete executions older than specified days</p>
<div class="flex items-center gap-2 mt-2">
<Input
id="schedule-retention"
type="number"
min="1"
max="365"
value={scheduleRetentionDays}
onchange={handleScheduleRetentionChange}
disabled={!$canAccess('settings', 'edit') || !scheduleCleanupEnabled}
class="w-20"
/>
<span class="text-sm text-muted-foreground">days</span>
<div class="ml-auto">
<CronEditor
value={scheduleCleanupCron}
onchange={handleScheduleCleanupCronChange}
disabled={!$canAccess('settings', 'edit') || !scheduleCleanupEnabled}
/>
</div>
</div>
</div>
<div class="space-y-1">
<div class="flex items-center gap-3">
<Label for="event-retention">Container event cleanup</Label>
<TogglePill
checked={eventCleanupEnabled}
onchange={handleEventCleanupEnabledChange}
disabled={!$canAccess('settings', 'edit')}
/>
</div>
<p class="text-xs text-muted-foreground">Delete events older than specified days</p>
<div class="flex items-center gap-2 mt-2">
<Input
id="event-retention"
type="number"
min="1"
max="365"
value={eventRetentionDays}
onchange={handleEventRetentionChange}
disabled={!$canAccess('settings', 'edit') || !eventCleanupEnabled}
class="w-20"
/>
<span class="text-sm text-muted-foreground">days</span>
<div class="ml-auto">
<CronEditor
value={eventCleanupCron}
onchange={handleEventCleanupCronChange}
disabled={!$canAccess('settings', 'edit') || !eventCleanupEnabled}
/>
</div>
</div>
</div>
<div class="space-y-1 pt-2 border-t">
<div class="flex items-center gap-3">
<Label>Volume helper cleanup</Label>
<Badge variant="secondary" class="text-xs">Always enabled</Badge>
</div>
<p class="text-xs text-muted-foreground">
Automatically removes temporary containers used for browsing volume contents.
Runs every 30 minutes and on startup.
</p>
</div>
<div class="space-y-1 pt-2 border-t">
<div class="flex items-center gap-3">
<Label>Scanner cache cleanup</Label>
<TogglePill
checked={scannerCleanupEnabled}
onchange={handleScannerCleanupEnabledChange}
disabled={!$canAccess('settings', 'edit')}
/>
</div>
<p class="text-xs text-muted-foreground">Remove cached vulnerability databases to reclaim disk space</p>
<div class="flex items-center gap-2 mt-2">
<div class="ml-auto">
<CronEditor
value={scannerCleanupCron}
onchange={handleScannerCleanupCronChange}
disabled={!$canAccess('settings', 'edit') || !scannerCleanupEnabled}
/>
</div>
</div>
</div>
</Card.Content>
</Card.Root>
<Card.Root>
<Card.Header>
<Card.Title class="text-sm font-medium flex items-center gap-2">
<LayoutDashboard class="w-4 h-4" />
Dashboard
</Card.Title>
</Card.Header>
<Card.Content class="space-y-4">
<div class="space-y-3">
<div class="space-y-1">
<div class="flex items-center gap-3">
<Label>Label filter matching</Label>
<Tooltip.Root>
<Tooltip.Trigger>
<HelpCircle class="w-3.5 h-3.5 text-muted-foreground" />
</Tooltip.Trigger>
<Tooltip.Content class="w-80">
<p class="text-xs">
Controls how multiple selected labels filter environments on the dashboard.
<strong>"Any"</strong>: shows environments that have at least one of the selected labels.
<strong>"All"</strong>: shows only environments that have every selected label.
</p>
</Tooltip.Content>
</Tooltip.Root>
<ToggleSwitch
value={labelFilterMode}
leftValue="any"
rightValue="all"
onchange={(mode) => appSettings.setLabelFilterMode(mode as LabelFilterMode)}
disabled={!$canAccess('settings', 'edit')}
/>
</div>
</div>
</div>
</Card.Content>
</Card.Root>
</div>
</div>
</div>