Files
dockhand/src/routes/settings/general/GeneralTab.svelte
T
2026-04-03 11:51:42 +02:00

690 lines
26 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 } from 'lucide-svelte';
import { appSettings, type DateFormat, type DownloadFormat, type EventCollectionMode } 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 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 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 logBufferSizeKb = $derived($appSettings.logBufferSizeKb);
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() {
appSettings.setScheduleCleanupEnabled(!scheduleCleanupEnabled);
toast.success(scheduleCleanupEnabled ? 'Schedule cleanup disabled' : 'Schedule cleanup enabled');
}
function handleEventCleanupEnabledChange() {
appSettings.setEventCleanupEnabled(!eventCleanupEnabled);
toast.success(eventCleanupEnabled ? 'Event cleanup disabled' : 'Event cleanup enabled');
}
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');
}
}
function handleLogBufferSizeChange(e: Event) {
const value = Math.max(100, Math.min(5000, parseInt((e.target as HTMLInputElement).value) || 500));
appSettings.setLogBufferSizeKb(value);
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={() => {
appSettings.setShowStoppedContainers(!showStoppedContainers);
toast.success(showStoppedContainers ? 'Stopped containers hidden' : 'Stopped containers shown');
}}
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={() => {
appSettings.setHighlightUpdates(!highlightUpdates);
toast.success(highlightUpdates ? 'Update highlighting disabled' : 'Update highlighting enabled');
}}
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={() => {
appSettings.setCompactPorts(!compactPorts);
toast.success(compactPorts ? 'Showing all ports' : 'Compact port display enabled');
}}
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>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={() => {
appSettings.setConfirmDestructive(!confirmDestructive);
toast.success(confirmDestructive ? 'Confirmations disabled' : 'Confirmations enabled');
}}
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-buffer-size">Log buffer size (KB)</Label>
<div class="flex items-center gap-2">
<Input
id="log-buffer-size"
type="number"
min="100"
max="5000"
value={logBufferSizeKb}
onchange={handleLogBufferSizeChange}
disabled={!$canAccess('settings', 'edit')}
class="w-24"
/>
<span class="text-sm text-muted-foreground">KB</span>
</div>
<p class="text-xs text-muted-foreground">Maximum log buffer per container panel. Older logs are truncated when exceeded.</p>
{#if logBufferSizeKb > 1000}
<div class="flex items-start gap-2 p-2 rounded-md bg-amber-500/10 border border-amber-500/20">
<AlertTriangle class="w-4 h-4 text-amber-500 shrink-0 mt-0.5" />
<p class="text-xs text-amber-600 dark:text-amber-400">High values may degrade browser performance with verbose containers. Recommended: 250-1000 KB.</p>
</div>
{/if}
</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={() => {
appSettings.setFormatLogTimestamps(!formatLogTimestamps);
toast.success(formatLogTimestamps ? 'Log timestamp formatting disabled' : 'Log timestamp formatting enabled');
}}
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>
</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>
</Card.Content>
</Card.Root>
</div>
</div>
</div>