This commit is contained in:
jarek
2026-01-28 07:34:12 +01:00
parent a88d3d5788
commit 8f3a7eb435
96 changed files with 5236 additions and 1871 deletions
+1
View File
@@ -51,6 +51,7 @@ RUN APKO_ARCH=$([ "$TARGETARCH" = "arm64" ] && echo "aarch64" || echo "x86_64")
" - docker-compose" \
" - docker-cli-buildx" \
" - sqlite" \
" - postgresql-client" \
" - git" \
" - openssh-client" \
" - curl" \
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "dockhand",
"private": true,
"version": "1.0.12",
"version": "1.0.13",
"type": "module",
"scripts": {
"dev": "bunx --bun vite dev",
+2 -1
View File
@@ -142,7 +142,8 @@ const PUBLIC_PATHS = [
'/api/license',
'/api/changelog',
'/api/dependencies',
'/api/health'
'/api/health',
'/api/settings/theme'
];
// Check if path is public
+4 -4
View File
@@ -548,14 +548,14 @@
fontSize: '13px'
},
'.cm-content': {
fontFamily: 'Menlo, Monaco, "Courier New", monospace',
fontFamily: 'var(--font-editor, ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace)',
padding: '8px 0'
},
'.cm-gutters': {
backgroundColor: '#1a1a1a',
color: '#858585',
border: 'none',
fontFamily: 'Menlo, Monaco, "Courier New", monospace',
fontFamily: 'var(--font-editor, ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace)',
fontSize: '13px'
},
'.cm-activeLineGutter': {
@@ -590,14 +590,14 @@
fontSize: '13px'
},
'.cm-content': {
fontFamily: 'Menlo, Monaco, "Courier New", monospace',
fontFamily: 'var(--font-editor, ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace)',
padding: '8px 0'
},
'.cm-gutters': {
backgroundColor: '#fafafa',
color: '#a1a1aa',
border: 'none',
fontFamily: 'Menlo, Monaco, "Courier New", monospace',
fontFamily: 'var(--font-editor, ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace)',
fontSize: '13px'
},
'.cm-activeLineGutter': {
+75
View File
@@ -0,0 +1,75 @@
<script lang="ts">
import { ArrowRight } from 'lucide-svelte';
import { formatFieldName, type AuditDiff, type FieldChange } from '$lib/utils/diff';
interface Props {
diff: AuditDiff | null;
}
let { diff }: Props = $props();
function formatDisplayValue(value: any): string {
if (value === null || value === undefined) {
return '—';
}
if (typeof value === 'boolean') {
return value ? 'Yes' : 'No';
}
if (Array.isArray(value)) {
if (value.length === 0) return '(empty)';
if (value.every(v => typeof v === 'string' || typeof v === 'number')) {
return value.join(', ');
}
return JSON.stringify(value, null, 2);
}
if (typeof value === 'object') {
return JSON.stringify(value, null, 2);
}
return String(value);
}
function isComplex(value: any): boolean {
if (value === null || value === undefined) return false;
if (Array.isArray(value) && value.length > 0) {
return !value.every(v => typeof v === 'string' || typeof v === 'number');
}
if (typeof value === 'object') return true;
return false;
}
</script>
{#if diff && diff.changes.length > 0}
<div class="max-h-64 overflow-y-auto border rounded-md divide-y">
{#each diff.changes as change}
{@const oldComplex = isComplex(change.oldValue)}
{@const newComplex = isComplex(change.newValue)}
<div class="flex items-start gap-3 px-3 py-2 text-sm hover:bg-muted/30">
<span class="font-medium text-muted-foreground shrink-0 w-32 truncate" title={formatFieldName(change.field)}>
{formatFieldName(change.field)}
</span>
{#if oldComplex || newComplex}
<!-- Complex values: stacked -->
<div class="flex-1 min-w-0 space-y-1">
<pre class="text-xs text-muted-foreground bg-muted/50 rounded px-2 py-1 overflow-x-auto whitespace-pre-wrap">{formatDisplayValue(change.oldValue)}</pre>
<pre class="text-xs text-amber-600 dark:text-amber-400 bg-amber-500/10 rounded px-2 py-1 overflow-x-auto whitespace-pre-wrap">{formatDisplayValue(change.newValue)}</pre>
</div>
{:else}
<!-- Simple values: inline -->
<div class="flex items-center gap-2 flex-1 min-w-0">
<span class="text-muted-foreground truncate" title={formatDisplayValue(change.oldValue)}>
{formatDisplayValue(change.oldValue)}
</span>
<ArrowRight class="w-3.5 h-3.5 text-muted-foreground shrink-0" />
<span class="text-amber-600 dark:text-amber-400 font-medium truncate" title={formatDisplayValue(change.newValue)}>
{formatDisplayValue(change.newValue)}
</span>
</div>
{/if}
</div>
{/each}
</div>
{:else}
<p class="text-sm text-muted-foreground italic">No changes recorded</p>
{/if}
+495
View File
@@ -0,0 +1,495 @@
<script lang="ts">
import * as Dialog from '$lib/components/ui/dialog';
import { Button } from '$lib/components/ui/button';
import { Input } from '$lib/components/ui/input';
import { Label } from '$lib/components/ui/label';
import { Badge } from '$lib/components/ui/badge';
import * as Select from '$lib/components/ui/select';
import { CheckCircle2, XCircle, Download, ShieldCheck, ShieldAlert, ShieldX, ArrowBigRight, Settings2, Server, Trash2, Loader2, Icon } from 'lucide-svelte';
import { whale } from '@lucide/lab';
import { currentEnvironment } from '$lib/stores/environment';
import PullTab from '$lib/components/PullTab.svelte';
import ScanTab from '$lib/components/ScanTab.svelte';
import type { ScanResult } from '$lib/components/ScanTab.svelte';
interface Registry {
id: number;
name: string;
url: string;
hasCredentials: boolean;
is_default: boolean;
}
interface Props {
open: boolean;
imageName?: string; // Optional - if not provided, show configure step
registries?: Registry[]; // For registry selection in configure step
envHasScanning?: boolean;
envId?: number | null;
showDeleteButton?: boolean; // Show "Remove image" after scan (for Images page)
onClose?: () => void;
onComplete?: () => void;
}
let { open = $bindable(), imageName = '', registries = [], envHasScanning = false, envId, showDeleteButton = false, onClose, onComplete }: Props = $props();
// Component refs
let pullTabRef = $state<PullTab | undefined>();
let scanTabRef = $state<ScanTab | undefined>();
// Determine if we need configure step (when imageName is not provided)
const needsConfigureStep = $derived(!imageName);
// Tab state - use 'configure' | 'pull' | 'scan'
let activeTab = $state<'configure' | 'pull' | 'scan'>('pull');
// Configure step state
let selectedRegistryId = $state<number | 'dockerhub' | null>('dockerhub');
let configImageName = $state('');
// Track status from components
let pullStatus = $state<'idle' | 'pulling' | 'complete' | 'error'>('idle');
let scanStatus = $state<'idle' | 'scanning' | 'complete' | 'error'>('idle');
let scanResults = $state<ScanResult[]>([]);
let hasStarted = $state(false);
let pullStarted = $state(false);
let scanStarted = $state(false);
let autoSwitchedToScan = $state(false);
// Delete state
let isDeleting = $state(false);
// Check if a registry is Docker Hub
function isDockerHub(registry: Registry): boolean {
const url = registry.url.toLowerCase();
return url.includes('docker.io') ||
url.includes('hub.docker.com') ||
url.includes('registry.hub.docker.com');
}
// Get all registries plus a Docker Hub option
const allRegistries = $derived([
{ id: 'dockerhub' as const, name: 'Docker Hub (public)', url: 'https://hub.docker.com', hasCredentials: false, is_default: false },
...registries.filter(r => !isDockerHub(r))
]);
const selectedRegistry = $derived(
selectedRegistryId === 'dockerhub'
? allRegistries[0]
: registries.find(r => r.id === selectedRegistryId)
);
// Build full image reference for configure mode
const fullImageReference = $derived.by(() => {
if (!configImageName.trim()) return '';
const name = configImageName.trim();
// For Docker Hub, use as-is (docker handles it)
if (selectedRegistryId === 'dockerhub') {
return name.includes(':') ? name : `${name}:latest`;
}
// For other registries, prefix with registry URL
const registry = registries.find(r => r.id === selectedRegistryId);
if (!registry) return name;
const url = new URL(registry.url);
const hostWithPath = url.host + (url.pathname !== '/' ? url.pathname.replace(/\/$/, '') : '');
const imageWithTag = name.includes(':') ? name : `${name}:latest`;
return `${hostWithPath}/${imageWithTag}`;
});
// The actual image name to pull (either from prop or from configure step)
const effectiveImageName = $derived(imageName || fullImageReference);
$effect(() => {
if (open && imageName && !hasStarted) {
// When imageName is provided (registry page), go directly to pull
hasStarted = true;
pullStarted = true;
activeTab = 'pull';
}
if (open && !imageName && !hasStarted) {
// When no imageName (images page), show configure step
activeTab = 'configure';
}
if (!open) {
// Reset when modal closes
hasStarted = false;
pullStarted = false;
scanStarted = false;
pullStatus = 'idle';
scanStatus = 'idle';
scanResults = [];
activeTab = imageName ? 'pull' : 'configure';
autoSwitchedToScan = false;
isDeleting = false;
// Reset configure state
selectedRegistryId = 'dockerhub';
configImageName = '';
pullTabRef?.reset();
scanTabRef?.reset();
}
});
function handlePullComplete() {
pullStatus = 'complete';
if (envHasScanning && !autoSwitchedToScan) {
autoSwitchedToScan = true;
scanStarted = true;
activeTab = 'scan';
setTimeout(() => scanTabRef?.startScan(), 100);
} else {
onComplete?.();
}
}
function handlePullError(_error: string) {
pullStatus = 'error';
}
function handlePullStatusChange(status: 'idle' | 'pulling' | 'complete' | 'error') {
pullStatus = status;
}
function handleScanComplete(results: ScanResult[]) {
scanResults = results;
onComplete?.();
}
function handleScanError(_error: string) {
// Error is handled by ScanTab display
}
function handleScanStatusChange(status: 'idle' | 'scanning' | 'complete' | 'error') {
scanStatus = status;
}
function handleClose() {
if (pullStatus !== 'pulling' && scanStatus !== 'scanning' && !isDeleting) {
open = false;
onClose?.();
}
}
function startPullFromConfigure() {
// Switch to pull tab and start pulling
hasStarted = true;
pullStarted = true;
activeTab = 'pull';
}
async function deleteImage() {
if (!effectiveImageName) return;
isDeleting = true;
try {
const deleteUrl = effectiveEnvId
? `/api/images/${encodeURIComponent(effectiveImageName)}?env=${effectiveEnvId}`
: `/api/images/${encodeURIComponent(effectiveImageName)}`;
const response = await fetch(deleteUrl, { method: 'DELETE' });
if (!response.ok) {
const data = await response.json().catch(() => ({}));
throw new Error(data.error || 'Failed to delete image');
}
// Close modal after successful delete
onComplete?.();
open = false;
onClose?.();
} catch (error: any) {
console.error('Failed to delete image:', error);
// Could add error display here if needed
} finally {
isDeleting = false;
}
}
const totalVulnerabilities = $derived(
scanResults.reduce((total, r) => total + r.vulnerabilities.length, 0)
);
const hasCriticalOrHigh = $derived(
scanResults.some(r => r.summary.critical > 0 || r.summary.high > 0)
);
const isProcessing = $derived(pullStatus === 'pulling' || scanStatus === 'scanning' || isDeleting);
const effectiveEnvId = $derived(envId ?? $currentEnvironment?.id ?? null);
const title = $derived(envHasScanning ? 'Pull & scan image' : 'Pull image');
</script>
<Dialog.Root bind:open onOpenChange={handleClose}>
<Dialog.Content class="max-w-4xl h-[85vh] flex flex-col">
<Dialog.Header class="shrink-0 pb-2">
<Dialog.Title class="flex items-center gap-2">
{#if scanStatus === 'complete' && scanResults.length > 0}
{#if hasCriticalOrHigh}
<ShieldX class="w-5 h-5 text-red-500" />
{:else if totalVulnerabilities > 0}
<ShieldAlert class="w-5 h-5 text-yellow-500" />
{:else}
<ShieldCheck class="w-5 h-5 text-green-500" />
{/if}
{:else if pullStatus === 'complete' && !envHasScanning}
<CheckCircle2 class="w-5 h-5 text-green-500" />
{:else if pullStatus === 'error' || scanStatus === 'error'}
<XCircle class="w-5 h-5 text-red-500" />
{:else}
<Download class="w-5 h-5" />
{/if}
{title}
{#if effectiveImageName}
<code class="text-sm font-normal bg-muted px-1.5 py-0.5 rounded ml-1">{effectiveImageName}</code>
{/if}
</Dialog.Title>
</Dialog.Header>
<!-- Step tabs - show configure tab only when needed -->
<div class="flex items-center border-b shrink-0">
{#if needsConfigureStep}
<button
class="px-4 py-2 text-sm font-medium border-b-2 transition-colors cursor-pointer {activeTab === 'configure' ? 'border-primary text-foreground' : 'border-transparent text-muted-foreground hover:text-foreground'}"
onclick={() => { if (!isProcessing && activeTab !== 'configure') activeTab = 'configure'; }}
disabled={isProcessing}
>
<Settings2 class="w-3.5 h-3.5 inline mr-1.5" />
Configure
</button>
<ArrowBigRight class="w-3.5 h-3.5 text-muted-foreground/50 shrink-0" />
{/if}
<button
class="px-4 py-2 text-sm font-medium border-b-2 transition-colors cursor-pointer {activeTab === 'pull' ? 'border-primary text-foreground' : 'border-transparent text-muted-foreground hover:text-foreground'}"
onclick={() => { if (!isProcessing && pullStatus !== 'idle') activeTab = 'pull'; }}
disabled={isProcessing || (needsConfigureStep && pullStatus === 'idle')}
>
<Download class="w-3.5 h-3.5 inline mr-1.5" />
Pull
{#if pullStatus === 'complete'}
<CheckCircle2 class="w-3.5 h-3.5 inline ml-1 text-green-500" />
{:else if pullStatus === 'error'}
<XCircle class="w-3.5 h-3.5 inline ml-1 text-red-500" />
{:else}
<CheckCircle2 class="w-3.5 h-3.5 inline ml-1 invisible" />
{/if}
</button>
{#if envHasScanning}
<ArrowBigRight class="w-3.5 h-3.5 text-muted-foreground/50 shrink-0" />
<button
class="px-4 py-2 text-sm font-medium border-b-2 transition-colors cursor-pointer {activeTab === 'scan' ? 'border-primary text-foreground' : 'border-transparent text-muted-foreground hover:text-foreground'}"
onclick={() => { if (!isProcessing && scanStarted) activeTab = 'scan'; }}
disabled={isProcessing || !scanStarted}
>
{#if scanStatus === 'complete' && scanResults.length > 0}
{#if hasCriticalOrHigh}
<ShieldX class="w-3.5 h-3.5 inline mr-1.5 text-red-500" />
{:else if totalVulnerabilities > 0}
<ShieldAlert class="w-3.5 h-3.5 inline mr-1.5 text-yellow-500" />
{:else}
<ShieldCheck class="w-3.5 h-3.5 inline mr-1.5 text-green-500" />
{/if}
{:else}
<ShieldCheck class="w-3.5 h-3.5 inline mr-1.5" />
{/if}
Scan
{#if scanStatus === 'complete'}
<CheckCircle2 class="w-3.5 h-3.5 inline ml-1 text-green-500" />
{:else if scanStatus === 'error'}
<XCircle class="w-3.5 h-3.5 inline ml-1 text-red-500" />
{:else}
<CheckCircle2 class="w-3.5 h-3.5 inline ml-1 invisible" />
{/if}
</button>
{/if}
</div>
<div class="flex-1 min-h-0 flex flex-col overflow-hidden py-2">
<!-- Configure Tab -->
{#if needsConfigureStep}
<div class="space-y-4 px-1 overflow-auto" class:hidden={activeTab !== 'configure'}>
<div class="space-y-2">
<Label>Registry</Label>
<Select.Root
type="single"
value={selectedRegistryId === 'dockerhub' ? 'dockerhub' : selectedRegistryId ? String(selectedRegistryId) : undefined}
onValueChange={(v) => selectedRegistryId = v === 'dockerhub' ? 'dockerhub' : Number(v)}
>
<Select.Trigger class="w-full h-9 justify-start">
{#if selectedRegistry}
{#if selectedRegistryId === 'dockerhub'}
<Icon iconNode={whale} class="w-4 h-4 mr-2 text-muted-foreground" />
{:else}
<Server class="w-4 h-4 mr-2 text-muted-foreground" />
{/if}
<span class="flex-1 text-left">{selectedRegistry.name}</span>
{:else}
<span class="text-muted-foreground">Select registry</span>
{/if}
</Select.Trigger>
<Select.Content>
{#each allRegistries as registry}
<Select.Item value={registry.id === 'dockerhub' ? 'dockerhub' : String(registry.id)} label={registry.name}>
{#if registry.id === 'dockerhub'}
<Icon iconNode={whale} class="w-4 h-4 mr-2 text-muted-foreground" />
{:else}
<Server class="w-4 h-4 mr-2 text-muted-foreground" />
{/if}
{registry.name}
{#if registry.hasCredentials}
<Badge variant="outline" class="ml-2 text-xs">auth</Badge>
{/if}
</Select.Item>
{/each}
</Select.Content>
</Select.Root>
</div>
<div class="space-y-2">
<Label>Image name</Label>
<Input
bind:value={configImageName}
placeholder={selectedRegistryId === 'dockerhub' ? 'nginx:latest or library/nginx:1.25' : 'myimage:latest'}
onkeydown={(e: KeyboardEvent) => {
if (e.key === 'Enter' && configImageName.trim()) {
startPullFromConfigure();
}
}}
/>
<p class="text-xs text-muted-foreground">
Format: <code class="bg-muted px-1 py-0.5 rounded">image:tag</code> or <code class="bg-muted px-1 py-0.5 rounded">namespace/image:tag</code>
</p>
</div>
{#if configImageName.trim()}
<div class="space-y-2">
<Label class="text-muted-foreground">Full image reference</Label>
<div class="p-2 bg-muted rounded text-sm">
<code class="break-all">{fullImageReference}</code>
</div>
</div>
{/if}
</div>
{/if}
<!-- Pull Tab -->
<div class="flex flex-col flex-1 min-h-0" class:hidden={activeTab !== 'pull'}>
<PullTab
bind:this={pullTabRef}
imageName={effectiveImageName}
envId={effectiveEnvId}
showImageInput={false}
autoStart={pullStarted && pullStatus === 'idle'}
onComplete={handlePullComplete}
onError={handlePullError}
onStatusChange={handlePullStatusChange}
/>
</div>
<!-- Scan Tab -->
{#if envHasScanning}
<div class="flex flex-col flex-1 min-h-0" class:hidden={activeTab !== 'scan'}>
<ScanTab
bind:this={scanTabRef}
imageName={effectiveImageName}
envId={effectiveEnvId}
autoStart={scanStarted && scanStatus === 'idle'}
onComplete={handleScanComplete}
onError={handleScanError}
onStatusChange={handleScanStatusChange}
/>
</div>
{/if}
</div>
<Dialog.Footer class="shrink-0 flex justify-between">
<div>
{#if activeTab === 'pull' && pullStatus === 'error'}
<Button variant="outline" onclick={() => pullTabRef?.startPull()}>
Retry
</Button>
{:else if activeTab === 'scan' && scanStatus === 'error'}
<Button variant="outline" onclick={() => scanTabRef?.startScan()}>
Retry scan
</Button>
{/if}
</div>
<div class="flex gap-2">
{#if showDeleteButton && scanStatus === 'complete'}
<!-- Show Keep/Remove buttons after scan completes (Images page usage) -->
<Button
variant="destructive"
onclick={deleteImage}
disabled={isDeleting}
>
{#if isDeleting}
<Loader2 class="w-4 h-4 mr-2 animate-spin" />
Removing...
{:else}
<Trash2 class="w-4 h-4 mr-2" />
Remove image
{/if}
</Button>
<Button
variant="default"
onclick={handleClose}
disabled={isDeleting}
>
<CheckCircle2 class="w-4 h-4 mr-2" />
Keep image
</Button>
{:else if showDeleteButton && pullStatus === 'complete' && !envHasScanning}
<!-- Show Keep/Remove buttons after pull completes when no scanning (Images page) -->
<Button
variant="destructive"
onclick={deleteImage}
disabled={isDeleting}
>
{#if isDeleting}
<Loader2 class="w-4 h-4 mr-2 animate-spin" />
Removing...
{:else}
<Trash2 class="w-4 h-4 mr-2" />
Remove image
{/if}
</Button>
<Button
variant="default"
onclick={handleClose}
disabled={isDeleting}
>
<CheckCircle2 class="w-4 h-4 mr-2" />
Keep image
</Button>
{:else}
<Button
variant="outline"
onclick={handleClose}
disabled={isProcessing}
>
{pullStatus === 'complete' && !envHasScanning ? 'Done' : 'Cancel'}
</Button>
{#if activeTab === 'configure'}
<Button
onclick={startPullFromConfigure}
disabled={!configImageName.trim()}
>
<Download class="w-4 h-4 mr-2" />
Pull
</Button>
{:else if pullStatus === 'complete' || scanStatus === 'complete'}
<Button
variant="default"
onclick={handleClose}
disabled={isProcessing}
>
OK
</Button>
{/if}
{/if}
</div>
</Dialog.Footer>
</Dialog.Content>
</Dialog.Root>
+21 -4
View File
@@ -2,7 +2,7 @@
import { Button } from '$lib/components/ui/button';
import { Input } from '$lib/components/ui/input';
import * as Tooltip from '$lib/components/ui/tooltip';
import { Plus, Trash2, Key, AlertCircle, CheckCircle2, FileText, Pencil, CircleDot } from 'lucide-svelte';
import { Plus, Trash2, Key, AlertCircle, CheckCircle2, FileText, Pencil, CircleDot, Undo2 } from 'lucide-svelte';
export interface EnvVar {
key: string;
@@ -25,6 +25,7 @@
readonly?: boolean;
showSource?: boolean; // For git stacks - show where variable comes from
sources?: Record<string, 'file' | 'override'>; // Key -> source mapping
fileValues?: Record<string, string>; // Original file values for revert
placeholder?: { key: string; value: string };
existingSecretKeys?: Set<string>; // Keys of secrets loaded from DB (can't toggle visibility)
onchange?: () => void;
@@ -36,6 +37,7 @@
readonly = false,
showSource = false,
sources = {},
fileValues = {},
placeholder = { key: 'VARIABLE_NAME', value: 'value' },
existingSecretKeys = new Set<string>(),
onchange
@@ -119,14 +121,29 @@
<Tooltip.Trigger>
<FileText class="w-3.5 h-3.5 text-muted-foreground" />
</Tooltip.Trigger>
<Tooltip.Content><p>From .env file</p></Tooltip.Content>
<Tooltip.Content side="bottom"><p class="whitespace-nowrap">From env file in repository</p></Tooltip.Content>
</Tooltip.Root>
{:else if source === 'override'}
<Tooltip.Root>
<Tooltip.Trigger>
<Pencil class="w-3.5 h-3.5 text-blue-500" />
{#if fileValues[variable.key] !== undefined}
<button
type="button"
class="cursor-pointer hover:text-orange-400 transition-colors"
onclick={() => {
variables = variables.map(v =>
v.key === variable.key ? { ...v, value: fileValues[variable.key] } : v
);
onchange?.();
}}
>
<Undo2 class="w-3.5 h-3.5 text-blue-500 hover:text-orange-400" />
</button>
{:else}
<Pencil class="w-3.5 h-3.5 text-blue-500" />
{/if}
</Tooltip.Trigger>
<Tooltip.Content><p>Manual override</p></Tooltip.Content>
<Tooltip.Content side="bottom"><p class="whitespace-nowrap">{fileValues[variable.key] !== undefined ? 'Revert to file value' : 'Manual override (not in file)'}</p></Tooltip.Content>
</Tooltip.Root>
{/if}
</div>
+18 -10
View File
@@ -1,25 +1,27 @@
<script lang="ts">
import { tick } from 'svelte';
import { tick, type Snippet } from 'svelte';
import { Button } from '$lib/components/ui/button';
import StackEnvVarsEditor, { type EnvVar, type ValidationResult } from '$lib/components/StackEnvVarsEditor.svelte';
import CodeEditor from '$lib/components/CodeEditor.svelte';
import ConfirmPopover from '$lib/components/ConfirmPopover.svelte';
import { Plus, Info, Upload, Trash2, List, FileText, AlertTriangle, ShieldAlert } from 'lucide-svelte';
import { Plus, Upload, Trash2, List, FileText, AlertTriangle, ShieldAlert, HelpCircle } from 'lucide-svelte';
import * as Tooltip from '$lib/components/ui/tooltip';
interface Props {
variables: EnvVar[]; // Bindable - ALL variables (secrets + non-secrets)
rawContent: string; // Bindable - raw .env file content (comments preserved, no secrets)
rawContent?: string; // Bindable - raw .env file content (comments preserved, no secrets)
validation?: ValidationResult | null;
readonly?: boolean;
showSource?: boolean;
sources?: Record<string, 'file' | 'override'>;
fileValues?: Record<string, string>;
placeholder?: { key: string; value: string };
infoText?: string;
existingSecretKeys?: Set<string>;
theme?: 'light' | 'dark';
class?: string;
onchange?: () => void;
headerActions?: Snippet;
}
let {
@@ -29,12 +31,14 @@
readonly = false,
showSource = false,
sources = {},
fileValues = {},
placeholder = { key: 'VARIABLE_NAME', value: 'value' },
infoText,
existingSecretKeys = new Set<string>(),
theme = 'dark',
class: className = '',
onchange
onchange,
headerActions
}: Props = $props();
const STORAGE_KEY_VIEW_MODE = 'dockhand-env-vars-view-mode';
@@ -291,13 +295,13 @@
{#if infoText}
<Tooltip.Root>
<Tooltip.Trigger>
<Info class="w-3.5 h-3.5 text-blue-400 shrink-0" />
<HelpCircle class="w-3.5 h-3.5 text-muted-foreground cursor-help shrink-0" />
</Tooltip.Trigger>
<Tooltip.Portal>
<Tooltip.Content side="bottom" sideOffset={8} class="max-w-xs w-64 bg-white dark:bg-zinc-900 text-zinc-900 dark:text-zinc-100 border-zinc-200 dark:border-zinc-700">
<p class="text-xs text-left">{infoText}</p>
</Tooltip.Content>
</Tooltip.Portal>
<Tooltip.Content>
<div class="w-64">
<p class="text-xs text-left">{@html infoText}</p>
</div>
</Tooltip.Content>
</Tooltip.Root>
{/if}
<!-- View mode toggle -->
@@ -343,6 +347,9 @@
<!-- Actions - right-aligned -->
{#if !readonly}
<div class="flex items-center gap-1 shrink-0">
{#if headerActions}
{@render headerActions()}
{/if}
<Button type="button" size="sm" variant="ghost" onclick={handleLoadFromFile} class="h-6 text-xs px-2">
<Upload class="w-3.5 h-3.5 mr-1" />
Load
@@ -445,6 +452,7 @@
{readonly}
{showSource}
{sources}
{fileValues}
{placeholder}
{existingSecretKeys}
{onchange}
+51 -1
View File
@@ -1,10 +1,27 @@
<script lang="ts">
import { Sun, Moon, Type, AArrowUp, Table, Terminal } from 'lucide-svelte';
import { onMount } from 'svelte';
import { Sun, Moon, Type, AArrowUp, Table, Terminal, CodeXml } from 'lucide-svelte';
import * as Select from '$lib/components/ui/select';
import { Label } from '$lib/components/ui/label';
import { lightThemes, darkThemes, fonts, monospaceFonts } from '$lib/themes';
import { themeStore, applyTheme, type FontSize } from '$lib/stores/theme';
// Preload all monospace Google Fonts so dropdown previews render correctly
let monoFontsLoaded = $state(false);
onMount(() => {
const fontsToLoad = monospaceFonts.filter(f => f.googleFont);
if (fontsToLoad.length === 0) {
monoFontsLoaded = true;
return;
}
const families = fontsToLoad.map(f => `family=${f.googleFont}`).join('&');
const link = document.createElement('link');
link.rel = 'stylesheet';
link.href = `https://fonts.googleapis.com/css2?${families}&display=swap`;
link.onload = () => { monoFontsLoaded = true; };
document.head.appendChild(link);
});
// Font size options
const fontSizes: { id: FontSize; name: string }[] = [
{ id: 'xsmall', name: 'Extra Small' },
@@ -28,6 +45,7 @@
let selectedFontSize = $state($themeStore.fontSize);
let selectedGridFontSize = $state($themeStore.gridFontSize);
let selectedTerminalFont = $state($themeStore.terminalFont);
let selectedEditorFont = $state($themeStore.editorFont);
// Sync local state with store changes
$effect(() => {
@@ -37,6 +55,7 @@
selectedFontSize = $themeStore.fontSize;
selectedGridFontSize = $themeStore.gridFontSize;
selectedTerminalFont = $themeStore.terminalFont;
selectedEditorFont = $themeStore.editorFont;
});
async function handleLightThemeChange(value: string | undefined) {
@@ -74,6 +93,13 @@
selectedTerminalFont = value;
await themeStore.setPreference('terminalFont', value, userId);
}
async function handleEditorFontChange(value: string | undefined) {
if (!value) return;
selectedEditorFont = value;
await themeStore.setPreference('editorFont', value, userId);
}
</script>
<div class="space-y-4">
@@ -244,4 +270,28 @@
</Select.Content>
</Select.Root>
</div>
<!-- Editor Font -->
<div class="flex items-center justify-between">
<div class="flex items-center gap-2">
<CodeXml class="w-4 h-4 text-muted-foreground" />
<Label>Editor font</Label>
</div>
<Select.Root type="single" value={selectedEditorFont} onValueChange={handleEditorFontChange}>
<Select.Trigger class="w-56">
{#each monospaceFonts as font}
{#if font.id === selectedEditorFont}
<span style="font-family: {font.family}">{font.name}</span>
{/if}
{/each}
</Select.Trigger>
<Select.Content>
{#each monospaceFonts as font}
<Select.Item value={font.id}>
<span style="font-family: {font.family}">{font.name}</span>
</Select.Item>
{/each}
</Select.Content>
</Select.Root>
</div>
</div>
+15 -2
View File
@@ -6,7 +6,7 @@ export const containerColumns: ColumnConfig[] = [
{ id: 'name', label: 'Name', sortable: true, sortField: 'name', width: 140, minWidth: 80, grow: true },
{ id: 'image', label: 'Image', sortable: true, sortField: 'image', width: 180, minWidth: 100, grow: true },
{ id: 'state', label: 'State', sortable: true, sortField: 'state', width: 90, minWidth: 70, noTruncate: true },
{ id: 'health', label: 'Health', width: 55, minWidth: 40 },
{ id: 'health', label: 'Health', sortable: true, sortField: 'health', width: 55, minWidth: 40 },
{ id: 'uptime', label: 'Uptime', sortable: true, sortField: 'uptime', width: 80, minWidth: 60 },
{ id: 'restartCount', label: 'Restarts', width: 70, minWidth: 50 },
{ id: 'cpu', label: 'CPU', sortable: true, sortField: 'cpu', width: 50, minWidth: 40, align: 'right' },
@@ -94,6 +94,18 @@ export const activityColumns: ColumnConfig[] = [
{ id: 'actions', label: '', fixed: 'end', width: 50, resizable: false }
];
// Audit log grid columns
export const auditColumns: ColumnConfig[] = [
{ id: 'timestamp', label: 'Timestamp', width: 165, minWidth: 140 },
{ id: 'environment', label: 'Environment', width: 140, minWidth: 100 },
{ id: 'user', label: 'User', width: 120, minWidth: 80 },
{ id: 'action', label: 'Action', width: 55, resizable: false },
{ id: 'entity', label: 'Entity', width: 100, minWidth: 80 },
{ id: 'name', label: 'Name', width: 200, minWidth: 100, grow: true },
{ id: 'ip', label: 'IP address', width: 120, minWidth: 90 },
{ id: 'actions', label: '', fixed: 'end', width: 50, resizable: false }
];
// Schedule grid columns
export const scheduleColumns: ColumnConfig[] = [
{ id: 'expand', label: '', fixed: 'start', width: 24, resizable: false },
@@ -115,7 +127,8 @@ export const gridColumnConfigs: Record<GridId, ColumnConfig[]> = {
stacks: stackColumns,
volumes: volumeColumns,
activity: activityColumns,
schedules: scheduleColumns
schedules: scheduleColumns,
audit: auditColumns
};
// Get configurable columns (not fixed)
+25
View File
@@ -1,4 +1,29 @@
[
{
"version": "1.0.13",
"date": "2026-01-23",
"changes": [
{ "type": "feature", "text": "Add DISABLE_LOCAL_LOGIN env var to hide local password login when SSO/LDAP is configured" },
{ "type": "feature", "text": "Add ntfy authentication support (user:pass@host/topic format)" },
{ "type": "feature", "text": "Sortable health column in containers grid (unhealthy containers first)" },
{ "type": "feature", "text": "GPU device configuration in container create/edit/inspect" },
{ "type": "feature", "text": "Editor font setting with expanded monospace font options" },
{ "type": "feature", "text": "Dedicated NFS/CIFS form fields in create volume modal" },
{ "type": "feature", "text": "Scheduled image pruning per environment" },
{ "type": "feature", "text": "Git stack env populate button to preview overridable variables before deploy" },
{ "type": "fix", "text": "Fix vulnerability scanning failing with rootless Docker" },
{ "type": "fix", "text": "Honor DATA_DIR env var in hawser SQLite operations" },
{ "type": "fix", "text": "Show detailed error messages when notification test fails" },
{ "type": "fix", "text": "Fix compose file browse in create mode showing default path instead of selected file" },
{ "type": "fix", "text": "Fix custom env file path not preserved in create mode" },
{ "type": "fix", "text": "Fix git stacks creating duplicate compose.yaml alongside repo file" },
{ "type": "fix", "text": "Fix env vars not showing after stack create" },
{ "type": "fix", "text": "Fix stack path defaults accidentally enforced over custom paths" },
{ "type": "fix", "text": "Fix adopted stack save & restart breaking paths and env vars" }
],
"imageTag": "fnsys/dockhand:v1.0.13",
"comingSoon": true
},
{
"version": "1.0.12",
"date": "2026-01-22",
+146
View File
@@ -207,6 +207,24 @@ export async function auditUser(
});
}
/**
* Helper for role actions
*/
export async function auditRole(
event: RequestEvent,
action: AuditAction,
roleId: number,
roleName: string,
details?: any
): Promise<void> {
await audit(event, action, 'role', {
entityId: String(roleId),
entityName: roleName,
description: `Role ${roleName} ${action}`,
details
});
}
/**
* Helper for settings actions
*/
@@ -261,6 +279,134 @@ export async function auditRegistry(
});
}
/**
* Helper for git repository actions
*/
export async function auditGitRepository(
event: RequestEvent,
action: AuditAction,
repositoryId: number,
repositoryName: string,
details?: any
): Promise<void> {
await audit(event, action, 'git_repository', {
entityId: String(repositoryId),
entityName: repositoryName,
description: `Git repository ${repositoryName} ${action}`,
details
});
}
/**
* Helper for git credential actions
*/
export async function auditGitCredential(
event: RequestEvent,
action: AuditAction,
credentialId: number,
credentialName: string,
details?: any
): Promise<void> {
await audit(event, action, 'git_credential', {
entityId: String(credentialId),
entityName: credentialName,
description: `Git credential ${credentialName} ${action}`,
details
});
}
/**
* Helper for config set actions
*/
export async function auditConfigSet(
event: RequestEvent,
action: AuditAction,
configSetId: number,
configSetName: string,
details?: any
): Promise<void> {
await audit(event, action, 'config_set', {
entityId: String(configSetId),
entityName: configSetName,
description: `Config set ${configSetName} ${action}`,
details
});
}
/**
* Helper for notification channel actions
*/
export async function auditNotification(
event: RequestEvent,
action: AuditAction,
notificationId: number,
notificationName: string,
details?: any
): Promise<void> {
await audit(event, action, 'notification', {
entityId: String(notificationId),
entityName: notificationName,
description: `Notification channel ${notificationName} ${action}`,
details
});
}
/**
* Helper for OIDC provider actions
*/
export async function auditOidcProvider(
event: RequestEvent,
action: AuditAction,
providerId: number,
providerName: string,
details?: any
): Promise<void> {
await audit(event, action, 'oidc_provider', {
entityId: String(providerId),
entityName: providerName,
description: `OIDC provider ${providerName} ${action}`,
details
});
}
/**
* Helper for LDAP config actions
*/
export async function auditLdapConfig(
event: RequestEvent,
action: AuditAction,
configId: number,
configName: string,
details?: any
): Promise<void> {
await audit(event, action, 'ldap_config', {
entityId: String(configId),
entityName: configName,
description: `LDAP config ${configName} ${action}`,
details
});
}
/**
* Helper for git stack actions
*/
export async function auditGitStack(
event: RequestEvent,
action: AuditAction,
stackId: number,
stackName: string,
environmentId?: number | null,
details?: any
): Promise<void> {
await audit(event, action, 'git_stack', {
entityId: String(stackId),
entityName: stackName,
environmentId,
description: `Git stack ${stackName} ${action}`,
details
});
}
/**
* Helper for auth actions (login/logout)
*/
+76 -5
View File
@@ -382,14 +382,16 @@ export async function getUserThemePreferences(userId: number): Promise<{
fontSize: string;
gridFontSize: string;
terminalFont: string;
editorFont: string;
}> {
const [lightTheme, darkTheme, font, fontSize, gridFontSize, terminalFont] = await Promise.all([
const [lightTheme, darkTheme, font, fontSize, gridFontSize, terminalFont, editorFont] = await Promise.all([
getUserSetting(userId, 'light_theme'),
getUserSetting(userId, 'dark_theme'),
getUserSetting(userId, 'font'),
getUserSetting(userId, 'font_size'),
getUserSetting(userId, 'grid_font_size'),
getUserSetting(userId, 'terminal_font')
getUserSetting(userId, 'terminal_font'),
getUserSetting(userId, 'editor_font')
]);
return {
lightTheme: lightTheme || 'default',
@@ -397,13 +399,14 @@ export async function getUserThemePreferences(userId: number): Promise<{
font: font || 'system',
fontSize: fontSize || 'normal',
gridFontSize: gridFontSize || 'normal',
terminalFont: terminalFont || 'system-mono'
terminalFont: terminalFont || 'system-mono',
editorFont: editorFont || 'system-mono'
};
}
export async function setUserThemePreferences(
userId: number,
prefs: { lightTheme?: string; darkTheme?: string; font?: string; fontSize?: string; gridFontSize?: string; terminalFont?: string }
prefs: { lightTheme?: string; darkTheme?: string; font?: string; fontSize?: string; gridFontSize?: string; terminalFont?: string; editorFont?: string }
): Promise<void> {
const updates: Promise<void>[] = [];
if (prefs.lightTheme !== undefined) {
@@ -424,6 +427,9 @@ export async function setUserThemePreferences(
if (prefs.terminalFont !== undefined) {
updates.push(setUserSetting(userId, 'terminal_font', prefs.terminalFont));
}
if (prefs.editorFont !== undefined) {
updates.push(setUserSetting(userId, 'editor_font', prefs.editorFont));
}
await Promise.all(updates);
}
@@ -803,6 +809,8 @@ export const NOTIFICATION_EVENT_TYPES = [
{ id: 'environment_offline', label: 'Environment offline', description: 'Environment became unreachable', group: 'system', scope: 'environment' },
{ id: 'environment_online', label: 'Environment online', description: 'Environment came back online', group: 'system', scope: 'environment' },
{ id: 'disk_space_warning', label: 'Disk space warning', description: 'Docker disk usage exceeds threshold', group: 'system', scope: 'environment' },
{ id: 'image_prune_success', label: 'Image prune success', description: 'Scheduled image prune completed successfully', group: 'system', scope: 'environment' },
{ id: 'image_prune_failed', label: 'Image prune failed', description: 'Scheduled image prune failed', group: 'system', scope: 'environment' },
{ id: 'license_expiring', label: 'License expiring', description: 'Enterprise license expiring soon (global)', group: 'system', scope: 'system' }
] as const;
@@ -2941,7 +2949,8 @@ export type AuditAction =
export type AuditEntityType =
| 'container' | 'image' | 'stack' | 'volume' | 'network'
| 'user' | 'settings' | 'environment' | 'registry';
| 'user' | 'role' | 'settings' | 'environment' | 'registry' | 'git_repository' | 'git_credential'
| 'config_set' | 'notification' | 'oidc_provider' | 'ldap_config' | 'git_stack';
export interface AuditLogData {
id: number;
@@ -4160,6 +4169,68 @@ export async function getAllEnvUpdateCheckSettings(): Promise<Array<{ envId: num
return results;
}
// =============================================================================
// IMAGE PRUNE SCHEDULE SETTINGS
// =============================================================================
export interface ImagePruneSettings {
enabled: boolean;
cronExpression: string;
pruneMode: 'dangling' | 'all';
lastPruned?: string;
lastResult?: {
spaceReclaimed: number;
imagesRemoved: number;
};
}
export async function getImagePruneSettings(envId: number): Promise<ImagePruneSettings | null> {
const key = `env_${envId}_image_prune`;
const result = await db.select().from(settings).where(eq(settings.key, key));
if (!result[0]) return null;
try {
return JSON.parse(result[0].value);
} catch {
return null;
}
}
export async function setImagePruneSettings(envId: number, config: ImagePruneSettings): Promise<void> {
const key = `env_${envId}_image_prune`;
const value = JSON.stringify(config);
const existing = await db.select().from(settings).where(eq(settings.key, key));
if (existing.length > 0) {
await db.update(settings)
.set({ value, updatedAt: new Date().toISOString() })
.where(eq(settings.key, key));
} else {
await db.insert(settings).values({ key, value });
}
}
export async function deleteImagePruneSettings(envId: number): Promise<void> {
const key = `env_${envId}_image_prune`;
await db.delete(settings).where(eq(settings.key, key));
}
export async function getAllImagePruneSettings(): Promise<Array<{ envId: number; settings: ImagePruneSettings }>> {
const rows = await db.select().from(settings).where(sql`${settings.key} LIKE 'env_%_image_prune'`);
const results: Array<{ envId: number; settings: ImagePruneSettings }> = [];
for (const row of rows) {
try {
const match = row.key.match(/^env_(\d+)_image_prune$/);
if (!match) continue;
const envId = parseInt(match[1]);
const config = JSON.parse(row.value) as ImagePruneSettings;
// Return all settings, not just enabled ones (UI needs to show disabled schedules too)
results.push({ envId, settings: config });
} catch {
// Skip invalid entries
}
}
return results;
}
// =============================================================================
// ENVIRONMENT TIMEZONE SETTINGS
// =============================================================================
+311 -2
View File
@@ -1299,17 +1299,320 @@ export async function createContainer(options: CreateContainerOptions, envId?: n
return { id: result.Id, start: () => startContainer(result.Id, envId) };
}
export async function updateContainer(id: string, options: CreateContainerOptions, startAfterUpdate = false, envId?: number | null) {
/**
* Extract all container options from Docker inspect data.
* This preserves ALL container settings for recreation.
* Used by both updateContainer and recreateContainer to ensure consistency.
*/
export function extractContainerOptions(inspectData: any): CreateContainerOptions {
const config = inspectData.Config;
const hostConfig = inspectData.HostConfig;
const name = inspectData.Name?.replace(/^\//, '') || '';
// Port bindings - preserve all host port mappings including HostIp
const ports: { [key: string]: { HostIp?: string; HostPort: string } } = {};
if (hostConfig.PortBindings) {
for (const [containerPort, bindings] of Object.entries(hostConfig.PortBindings)) {
if (bindings && (bindings as any[]).length > 0) {
const binding = (bindings as any[])[0];
ports[containerPort] = {
HostPort: binding.HostPort || ''
};
// Preserve HostIp if specified (e.g., '192.168.0.250:80:80' in compose)
if (binding.HostIp) {
ports[containerPort].HostIp = binding.HostIp;
}
}
}
}
// Volume bindings - preserve ALL volumes including anonymous volumes
const volumeBinds: string[] = [];
const mountedPaths = new Set<string>();
// First, add all entries from hostConfig.Binds (named volumes and bind mounts)
if (hostConfig.Binds && Array.isArray(hostConfig.Binds)) {
for (const bind of hostConfig.Binds) {
volumeBinds.push(bind);
const parts = bind.split(':');
if (parts.length >= 2) {
mountedPaths.add(parts[1].split(':')[0]);
}
}
}
// Then, add anonymous volumes from Mounts that aren't already in Binds
const mounts = inspectData.Mounts || [];
for (const mount of mounts) {
if (mount.Type === 'volume' && mount.Name && mount.Destination) {
if (!mountedPaths.has(mount.Destination)) {
const bindStr = mount.RW === false
? `${mount.Name}:${mount.Destination}:ro`
: `${mount.Name}:${mount.Destination}`;
volumeBinds.push(bindStr);
}
}
}
// Healthcheck configuration
let healthcheck: HealthcheckConfig | undefined = undefined;
if (config.Healthcheck && config.Healthcheck.Test && config.Healthcheck.Test.length > 0) {
if (config.Healthcheck.Test[0] !== 'NONE') {
healthcheck = {
test: config.Healthcheck.Test,
interval: config.Healthcheck.Interval,
timeout: config.Healthcheck.Timeout,
retries: config.Healthcheck.Retries,
startPeriod: config.Healthcheck.StartPeriod
};
}
}
// Device mappings
const devices = (hostConfig.Devices || []).map((d: any) => ({
hostPath: d.PathOnHost || '',
containerPath: d.PathInContainer || '',
permissions: d.CgroupPermissions || 'rwm'
})).filter((d: any) => d.hostPath && d.containerPath);
// Ulimits
const ulimits = (hostConfig.Ulimits || []).map((u: any) => ({
name: u.Name,
soft: u.Soft,
hard: u.Hard
}));
// Extract network settings
const networkSettings = inspectData.NetworkSettings?.Networks || {};
const primaryNetwork = hostConfig.NetworkMode || 'bridge';
// Extract primary network aliases, static IP, and gateway priority
let networkAliases: string[] | undefined;
let networkIpv4Address: string | undefined;
let networkIpv6Address: string | undefined;
let macAddress: string | undefined;
let networkGwPriority: number | undefined;
const containerId = inspectData.Id || '';
const shortContainerId = containerId.substring(0, 12);
// Extract compose labels for alias reconstruction
const composeProject = config.Labels?.['com.docker.compose.project'];
const composeService = config.Labels?.['com.docker.compose.service'];
for (const [netName, netConfig] of Object.entries(networkSettings)) {
const netConf = netConfig as any;
const isPrimary = netName === primaryNetwork ||
(primaryNetwork === 'bridge' && (netName === 'bridge' || netName === 'default'));
if (isPrimary) {
// Filter out auto-generated container IDs
const allAliases = (netConf.Aliases?.length > 0 ? netConf.Aliases : netConf.DNSNames) || [];
networkAliases = allAliases.filter((a: string) =>
a !== containerId && a !== shortContainerId
);
// For compose containers, ensure service name and project-service aliases
if (composeProject && composeService) {
if (!networkAliases) networkAliases = [];
if (!networkAliases.includes(composeService)) {
networkAliases.push(composeService);
}
const projectService = `${composeProject}-${composeService}`;
if (!networkAliases.includes(projectService)) {
networkAliases.push(projectService);
}
}
if (!networkAliases || networkAliases.length === 0) {
networkAliases = undefined;
}
networkIpv4Address = netConf.IPAMConfig?.IPv4Address || undefined;
networkIpv6Address = netConf.IPAMConfig?.IPv6Address || undefined;
macAddress = netConf.MacAddress || undefined;
networkGwPriority = netConf.GwPriority !== undefined && netConf.GwPriority !== 0
? netConf.GwPriority : undefined;
break;
}
}
// Device requests (GPU, etc.)
const deviceRequests = hostConfig.DeviceRequests?.length > 0
? hostConfig.DeviceRequests.map((dr: any) => ({
driver: dr.Driver || undefined,
count: dr.Count,
deviceIDs: dr.DeviceIDs?.length > 0 ? dr.DeviceIDs : undefined,
capabilities: dr.Capabilities?.length > 0 ? dr.Capabilities : undefined,
options: dr.Options && Object.keys(dr.Options).length > 0 ? dr.Options : undefined
}))
: undefined;
return {
name,
image: config.Image,
// Command and entrypoint
cmd: config.Cmd || undefined,
entrypoint: config.Entrypoint || undefined,
workingDir: config.WorkingDir || undefined,
// Environment and labels
env: config.Env || [],
labels: config.Labels || {},
// Port mappings
ports: Object.keys(ports).length > 0 ? ports : undefined,
// Volume bindings
volumeBinds: volumeBinds.length > 0 ? volumeBinds : undefined,
// Restart policy
restartPolicy: hostConfig.RestartPolicy?.Name || 'no',
restartMaxRetries: hostConfig.RestartPolicy?.MaximumRetryCount,
// Network settings
networkMode: hostConfig.NetworkMode || undefined,
networkAliases,
networkIpv4Address,
networkIpv6Address,
networkGwPriority,
// User and hostname
user: config.User || undefined,
hostname: config.Hostname || undefined,
domainname: config.Domainname || undefined,
// Privileged mode
privileged: hostConfig.Privileged || undefined,
// Healthcheck
healthcheck,
// Terminal settings
tty: config.Tty || undefined,
stdinOpen: config.OpenStdin || undefined,
// Memory limits
memory: hostConfig.Memory || undefined,
memoryReservation: hostConfig.MemoryReservation || undefined,
memorySwap: hostConfig.MemorySwap || undefined,
// CPU limits
cpuShares: hostConfig.CpuShares || undefined,
cpuQuota: hostConfig.CpuQuota || undefined,
cpuPeriod: hostConfig.CpuPeriod || undefined,
nanoCpus: hostConfig.NanoCpus || undefined,
// Capabilities
capAdd: hostConfig.CapAdd?.length > 0 ? hostConfig.CapAdd : undefined,
capDrop: hostConfig.CapDrop?.length > 0 ? hostConfig.CapDrop : undefined,
// Devices
devices: devices.length > 0 ? devices : undefined,
// DNS settings
dns: hostConfig.Dns?.length > 0 ? hostConfig.Dns : undefined,
dnsSearch: hostConfig.DnsSearch?.length > 0 ? hostConfig.DnsSearch : undefined,
dnsOptions: hostConfig.DnsOptions?.length > 0 ? hostConfig.DnsOptions : undefined,
// Security options
securityOpt: hostConfig.SecurityOpt?.length > 0 ? hostConfig.SecurityOpt : undefined,
// Ulimits
ulimits: ulimits.length > 0 ? ulimits : undefined,
// Process and memory settings
oomKillDisable: hostConfig.OomKillDisable || undefined,
pidsLimit: hostConfig.PidsLimit || undefined,
shmSize: hostConfig.ShmSize || undefined,
// Tmpfs mounts
tmpfs: hostConfig.Tmpfs && Object.keys(hostConfig.Tmpfs).length > 0 ? hostConfig.Tmpfs : undefined,
// Sysctls
sysctls: hostConfig.Sysctls && Object.keys(hostConfig.Sysctls).length > 0 ? hostConfig.Sysctls : undefined,
// Logging configuration
logDriver: hostConfig.LogConfig?.Type || undefined,
logOptions: hostConfig.LogConfig?.Config && Object.keys(hostConfig.LogConfig.Config).length > 0
? hostConfig.LogConfig.Config : undefined,
// Namespace settings
ipcMode: hostConfig.IpcMode || undefined,
pidMode: hostConfig.PidMode || undefined,
utsMode: hostConfig.UTSMode || undefined,
// Cgroup parent
cgroupParent: hostConfig.CgroupParent || undefined,
// Stop signal and timeout
stopSignal: config.StopSignal || undefined,
stopTimeout: config.StopTimeout || undefined,
// Init process
init: hostConfig.Init === true ? true : undefined,
// MAC address
macAddress,
// Extra hosts
extraHosts: hostConfig.ExtraHosts?.length > 0 ? hostConfig.ExtraHosts : undefined,
// Device requests (GPU)
deviceRequests,
// Container runtime
runtime: hostConfig.Runtime && hostConfig.Runtime !== 'runc' ? hostConfig.Runtime : undefined,
// Read-only root filesystem
readonlyRootfs: hostConfig.ReadonlyRootfs === true ? true : undefined,
// CPU pinning
cpusetCpus: hostConfig.CpusetCpus || undefined,
cpusetMems: hostConfig.CpusetMems || undefined,
// Additional groups
groupAdd: hostConfig.GroupAdd?.length > 0 ? hostConfig.GroupAdd : undefined,
// Memory swappiness
memorySwappiness: hostConfig.MemorySwappiness !== null ? hostConfig.MemorySwappiness : undefined,
// User namespace mode
usernsMode: hostConfig.UsernsMode || undefined
};
}
/**
* Update a container by recreating it with merged options.
* Preserves ALL existing container settings and merges user-provided options on top.
*/
export async function updateContainer(id: string, options: Partial<CreateContainerOptions>, startAfterUpdate = false, envId?: number | null) {
const oldContainerInfo = await inspectContainer(id, envId);
const wasRunning = oldContainerInfo.State.Running;
// Extract ALL existing container options
const existingOptions = extractContainerOptions(oldContainerInfo);
// Merge user-provided options on top of existing options
// User options take precedence, but we preserve everything not explicitly provided
const mergedOptions: CreateContainerOptions = {
...existingOptions,
...options,
// Special handling for labels - merge instead of replace to preserve Docker internal labels
labels: {
...existingOptions.labels,
...options.labels
}
};
if (wasRunning) {
await stopContainer(id, envId);
}
await removeContainer(id, true, envId);
const newContainer = await createContainer(options, envId);
const newContainer = await createContainer(mergedOptions, envId);
if (startAfterUpdate || wasRunning) {
await newContainer.start();
@@ -2746,6 +3049,7 @@ export async function runContainerWithStreaming(options: {
binds?: string[];
env?: string[];
name?: string;
user?: string;
envId?: number | null;
onStdout?: (data: string) => void;
onStderr?: (data: string) => void;
@@ -2765,6 +3069,11 @@ export async function runContainerWithStreaming(options: {
}
};
// Set user if specified (needed for rootless Docker socket access)
if (options.user) {
containerConfig.User = options.user;
}
const createResult = await dockerJsonRequest<{ Id: string }>(
`/containers/create?name=${encodeURIComponent(containerName)}`,
{ method: 'POST', body: JSON.stringify(containerConfig) },
+151 -15
View File
@@ -7,6 +7,7 @@ import {
getGitStack,
updateGitStack,
upsertStackSource,
getEnvironment,
type GitRepository,
type GitCredential,
type GitStackWithRepo
@@ -14,7 +15,8 @@ import {
import { deployStack, getStackDir } from './stacks';
// Directory for storing cloned repositories
const GIT_REPOS_DIR = process.env.GIT_REPOS_DIR || './data/git-repos';
const dataDir = process.env.DATA_DIR || './data';
const GIT_REPOS_DIR = resolve(process.env.GIT_REPOS_DIR || join(dataDir, 'git-repos'));
// Ensure git repos directory exists
if (!existsSync(GIT_REPOS_DIR)) {
@@ -544,7 +546,21 @@ export function deleteRepositoryFiles(repoId: number): void {
// === Git Stack Functions ===
function getStackRepoPath(stackId: number): string {
async function getStackRepoPath(stackId: number, stackName?: string, environmentId?: number | null): Promise<string> {
if (stackName && environmentId) {
// Use old path if it already exists (backward compat), otherwise use name-based path
const oldPath = join(GIT_REPOS_DIR, `stack-${stackId}`);
if (existsSync(oldPath)) {
return oldPath;
}
// Format: envName/stackName (e.g. production/webapp) - consistent with internal stacks
const env = await getEnvironment(environmentId);
const envDir = join(GIT_REPOS_DIR, env ? env.name : String(environmentId));
if (!existsSync(envDir)) {
mkdirSync(envDir, { recursive: true });
}
return join(envDir, stackName);
}
return join(GIT_REPOS_DIR, `stack-${stackId}`);
}
@@ -597,7 +613,7 @@ export async function syncGitStack(stackId: number): Promise<SyncResult> {
console.log(`${logPrefix} Repository branch:`, repo.branch);
const credential = repo.credentialId ? await getGitCredential(repo.credentialId) : null;
const repoPath = getStackRepoPath(stackId);
const repoPath = await getStackRepoPath(stackId, gitStack.stackName, gitStack.environmentId);
const env = await buildGitEnv(credential);
console.log(`${logPrefix} Local repo path:`, repoPath);
@@ -818,14 +834,13 @@ export async function deployGitStack(stackId: number, options?: { force?: boolea
};
}
const forceRecreate = syncResult.updated && !!gitStack.envFilePath;
console.log(`${logPrefix} Will force recreate:`, forceRecreate, `(updated=${syncResult.updated}, hasEnvFile=${!!gitStack.envFilePath})`);
const forceRecreate = syncResult.updated;
console.log(`${logPrefix} Will force recreate:`, forceRecreate, `(updated=${syncResult.updated})`);
// Deploy using unified function - handles both new and existing stacks
// Uses `docker compose up -d --remove-orphans` which only recreates changed services
// Force recreate when git detected changes AND stack has .env file configured
// This ensures containers pick up new env var values even if compose file didn't change
// Note: Without this, docker compose only detects compose file changes, not env var changes
// Force recreate whenever git detected changes to ensure containers pick up
// new env var values even if compose file itself didn't change
console.log(`${logPrefix} Calling deployStack...`);
console.log(`${logPrefix} Source directory (composeDir):`, syncResult.composeDir);
console.log(`${logPrefix} Compose filename:`, syncResult.composeFileName);
@@ -835,7 +850,6 @@ export async function deployGitStack(stackId: number, options?: { force?: boolea
name: gitStack.stackName,
compose: syncResult.composeContent!,
envId: gitStack.environmentId,
envFileVars: syncResult.envFileVars,
sourceDir: syncResult.composeDir, // Copy entire directory from git repo
composeFileName: syncResult.composeFileName, // Use original compose filename from repo
envFileName: syncResult.envFileName, // Env file relative to compose dir (for --env-file flag, optional)
@@ -923,8 +937,8 @@ export async function testGitStack(stackId: number): Promise<TestResult> {
}
}
export function deleteGitStackFiles(stackId: number): void {
const repoPath = getStackRepoPath(stackId);
export async function deleteGitStackFiles(stackId: number, stackName?: string, environmentId?: number | null): Promise<void> {
const repoPath = await getStackRepoPath(stackId, stackName, environmentId);
try {
if (existsSync(repoPath)) {
rmSync(repoPath, { recursive: true, force: true });
@@ -967,7 +981,7 @@ export async function deployGitStackWithProgress(
}
const credential = repo.credentialId ? await getGitCredential(repo.credentialId) : null;
const repoPath = getStackRepoPath(stackId);
const repoPath = await getStackRepoPath(stackId, gitStack.stackName, gitStack.environmentId);
const env = await buildGitEnv(credential);
const totalSteps = 5;
@@ -1079,7 +1093,6 @@ export async function deployGitStackWithProgress(
name: gitStack.stackName,
compose: composeContent,
envId: gitStack.environmentId,
envFileVars,
sourceDir: composeDir // Copy entire directory from git repo
});
@@ -1124,7 +1137,7 @@ export async function listGitStackEnvFiles(stackId: number): Promise<{ files: st
return { files: [], error: 'Git stack not found' };
}
const repoPath = getStackRepoPath(stackId);
const repoPath = await getStackRepoPath(stackId, gitStack.stackName, gitStack.environmentId);
if (!existsSync(repoPath)) {
return { files: [], error: 'Repository not synced - deploy the stack first' };
}
@@ -1237,7 +1250,7 @@ export async function readGitStackEnvFile(
return { vars: {}, error: 'Git stack not found' };
}
const repoPath = getStackRepoPath(stackId);
const repoPath = await getStackRepoPath(stackId, gitStack.stackName, gitStack.environmentId);
if (!existsSync(repoPath)) {
return { vars: {}, error: 'Repository not synced - deploy the stack first' };
}
@@ -1262,3 +1275,126 @@ export async function readGitStackEnvFile(
return { vars: {}, error: error.message };
}
}
interface PreviewEnvOptions {
repoUrl: string;
branch: string;
credential: {
id: number;
authType: string;
sshPrivateKey?: string | null;
username?: string | null;
password?: string | null;
} | null;
composePath: string;
envFilePath: string | null;
}
interface PreviewEnvResult {
vars: Record<string, string>;
sources: Record<string, '.env' | 'envFile'>;
error?: string;
}
/**
* Clone a repository to a temp directory and read env files for preview.
* Used to populate env editor when creating a new git stack.
* Cleans up temp directory after reading.
*/
export async function previewRepoEnvFiles(options: PreviewEnvOptions): Promise<PreviewEnvResult> {
const { repoUrl, branch, credential, composePath, envFilePath } = options;
const logPrefix = '[Git:Preview]';
// Create a unique temp directory
const tempId = `preview-${Date.now()}-${Math.random().toString(36).substring(2, 8)}`;
const tempDir = join(GIT_REPOS_DIR, tempId);
console.log(`${logPrefix} Starting preview for ${repoUrl}`);
console.log(`${logPrefix} Temp directory: ${tempDir}`);
try {
// Ensure temp directory exists
mkdirSync(tempDir, { recursive: true });
// Build git environment with credentials
// Cast credential to GitCredential type (only uses id, authType, sshPrivateKey)
const env = await buildGitEnv(credential as GitCredential | null);
const authenticatedUrl = buildRepoUrl(repoUrl, credential as GitCredential | null);
// Clone with depth 1 (shallow clone for speed)
const cloneProc = Bun.spawn(
['git', 'clone', '--depth', '1', '--branch', branch, '--single-branch', authenticatedUrl, tempDir],
{
stdout: 'pipe',
stderr: 'pipe',
env
}
);
const cloneStderr = await new Response(cloneProc.stderr).text();
const cloneExitCode = await cloneProc.exited;
if (cloneExitCode !== 0) {
console.error(`${logPrefix} Clone failed:`, cloneStderr);
return { vars: {}, sources: {}, error: `Failed to clone repository: ${cloneStderr.trim()}` };
}
console.log(`${logPrefix} Clone successful`);
// Determine the compose directory (where .env file should be)
const composeDir = dirname(composePath);
const baseEnvPath = join(tempDir, composeDir, '.env');
const vars: Record<string, string> = {};
const sources: Record<string, '.env' | 'envFile'> = {};
// Read base .env file if it exists
if (existsSync(baseEnvPath)) {
console.log(`${logPrefix} Reading .env from: ${baseEnvPath}`);
const content = await Bun.file(baseEnvPath).text();
const baseVars = parseEnvFileContent(content, 'preview');
for (const [key, value] of Object.entries(baseVars)) {
vars[key] = value;
sources[key] = '.env';
}
console.log(`${logPrefix} Found ${Object.keys(baseVars).length} vars in .env`);
} else {
console.log(`${logPrefix} No .env file at ${baseEnvPath}`);
}
// Read additional env file if specified
if (envFilePath) {
const additionalEnvPath = join(tempDir, envFilePath);
if (existsSync(additionalEnvPath)) {
console.log(`${logPrefix} Reading additional env file: ${additionalEnvPath}`);
const content = await Bun.file(additionalEnvPath).text();
const additionalVars = parseEnvFileContent(content, 'preview');
for (const [key, value] of Object.entries(additionalVars)) {
vars[key] = value;
sources[key] = 'envFile';
}
console.log(`${logPrefix} Found ${Object.keys(additionalVars).length} vars in ${envFilePath}`);
} else {
console.log(`${logPrefix} Additional env file not found: ${additionalEnvPath}`);
}
}
console.log(`${logPrefix} Total variables: ${Object.keys(vars).length}`);
return { vars, sources };
} catch (error: any) {
console.error(`${logPrefix} Error:`, error);
return { vars: {}, sources: {}, error: error.message };
} finally {
// Always clean up temp directory
cleanupSshKey(credential as GitCredential | null);
try {
if (existsSync(tempDir)) {
rmSync(tempDir, { recursive: true, force: true });
console.log(`${logPrefix} Cleaned up temp directory`);
}
} catch (cleanupError) {
console.error(`${logPrefix} Failed to cleanup temp directory:`, cleanupError);
}
}
}
+66
View File
@@ -208,6 +208,72 @@ export function translateContainerPathViaMount(containerPath: string): string |
return null;
}
/**
* Get the host path for the Docker socket mount.
* This is needed for sibling containers (e.g., scanners) that need socket access.
*
* When Dockhand runs in Docker with a non-standard socket mount like:
* -v /var/run/user/1000/docker.sock:/var/run/docker.sock
*
* We need to detect the HOST path (/var/run/user/1000/docker.sock) so that
* scanner containers can bind-mount the correct path.
*
* @returns The host path to Docker socket, or '/var/run/docker.sock' as default
*/
export function getHostDockerSocket(): string {
// Priority 1: Explicit environment variable override
if (process.env.HOST_DOCKER_SOCKET) {
console.log(`[HostPath] Using HOST_DOCKER_SOCKET from env: ${process.env.HOST_DOCKER_SOCKET}`);
return process.env.HOST_DOCKER_SOCKET;
}
// Priority 2: Look up from cached mounts (populated by detectHostDataDir on startup)
if (cachedMounts && cachedMounts.length > 0) {
console.log(`[HostPath] Searching ${cachedMounts.length} cached mount(s) for Docker socket`);
// Find mount where destination is docker.sock
const socketMount = cachedMounts.find(m =>
m.destination === '/var/run/docker.sock' ||
m.destination === '/run/docker.sock' ||
m.destination.endsWith('/docker.sock')
);
if (socketMount) {
console.log(`[HostPath] Found Docker socket mount: ${socketMount.source} -> ${socketMount.destination}`);
return socketMount.source;
}
// Log available mounts for debugging
console.log(`[HostPath] No Docker socket mount found. Available mounts:`);
for (const m of cachedMounts) {
console.log(`[HostPath] ${m.source} -> ${m.destination}`);
}
} else {
console.log(`[HostPath] No cached mounts available (not running in Docker or detectHostDataDir not called)`);
}
// Priority 3: Default fallback (works for standard Docker setups)
console.log(`[HostPath] Using default Docker socket: /var/run/docker.sock`);
return '/var/run/docker.sock';
}
/**
* Extract UID from a user-specific Docker socket path.
* User-specific sockets are at /run/user/<uid>/docker.sock
*
* @param socketPath - The host Docker socket path
* @returns The UID as a string (e.g., "1000"), or null if not a user-specific path
*/
export function extractUidFromSocketPath(socketPath: string): string | null {
// Match patterns like /run/user/1000/docker.sock or /var/run/user/1000/docker.sock
const match = socketPath.match(/\/user\/(\d+)\/docker\.sock$/);
if (match) {
console.log(`[HostPath] Extracted UID ${match[1]} from socket path: ${socketPath}`);
return match[1];
}
return null;
}
/**
* Rewrite relative volume paths in a compose file to use absolute host paths.
* This is necessary when Dockhand runs inside Docker with a mounted data volume.
+194 -120
View File
@@ -29,8 +29,14 @@ export interface NotificationPayload {
environmentName?: string;
}
// Result type for functions that can return detailed errors
export interface NotificationResult {
success: boolean;
error?: string;
}
// Send notification via SMTP
async function sendSmtpNotification(config: SmtpConfig, payload: NotificationPayload): Promise<boolean> {
async function sendSmtpNotification(config: SmtpConfig, payload: NotificationPayload): Promise<NotificationResult> {
try {
const transporter = nodemailer.createTransport({
host: config.host,
@@ -67,41 +73,43 @@ async function sendSmtpNotification(config: SmtpConfig, payload: NotificationPay
html
});
return true;
return { success: true };
} catch (error) {
const errorMsg = error instanceof Error ? error.message : String(error);
console.error('[Notifications] SMTP send failed:', errorMsg);
return false;
return { success: false, error: `SMTP error: ${errorMsg}` };
}
}
// Parse Apprise URL and send notification
async function sendAppriseNotification(config: AppriseConfig, payload: NotificationPayload): Promise<boolean> {
let success = true;
async function sendAppriseNotification(config: AppriseConfig, payload: NotificationPayload): Promise<NotificationResult> {
const errors: string[] = [];
for (const url of config.urls) {
try {
const sent = await sendToAppriseUrl(url, payload);
if (!sent) success = false;
const result = await sendToAppriseUrl(url, payload);
if (!result.success && result.error) {
errors.push(result.error);
}
} catch (error) {
const errorMsg = error instanceof Error ? error.message : String(error);
console.error(`[Notifications] Failed to send to ${url}:`, errorMsg);
success = false;
errors.push(`Failed to send: ${errorMsg}`);
}
}
return success;
if (errors.length > 0) {
return { success: false, error: errors.join('; ') };
}
return { success: true };
}
// Send to a single Apprise URL
async function sendToAppriseUrl(url: string, payload: NotificationPayload): Promise<boolean> {
async function sendToAppriseUrl(url: string, payload: NotificationPayload): Promise<NotificationResult> {
try {
// Extract protocol from Apprise URL format (protocol://...)
// Note: Can't use new URL() because custom schemes like 'tgram://' are not valid URLs
const protocolMatch = url.match(/^([a-z]+):\/\//i);
if (!protocolMatch) {
console.error('[Notifications] Invalid Apprise URL format - missing protocol:', url);
return false;
return { success: false, error: 'Invalid Apprise URL format - missing protocol' };
}
const protocol = protocolMatch[1].toLowerCase();
@@ -127,42 +135,48 @@ async function sendToAppriseUrl(url: string, payload: NotificationPayload): Prom
case 'jsons':
return await sendGenericWebhook(url, payload);
default:
console.warn(`[Notifications] Unsupported Apprise protocol: ${protocol}`);
return false;
return { success: false, error: `Unsupported Apprise protocol: ${protocol}` };
}
} catch (error) {
const errorMsg = error instanceof Error ? error.message : String(error);
console.error('[Notifications] Failed to parse Apprise URL:', errorMsg);
return false;
return { success: false, error: `Failed to parse Apprise URL: ${errorMsg}` };
}
}
// Discord webhook
async function sendDiscord(appriseUrl: string, payload: NotificationPayload): Promise<boolean> {
async function sendDiscord(appriseUrl: string, payload: NotificationPayload): Promise<NotificationResult> {
// discord://webhook_id/webhook_token or discords://...
const url = appriseUrl.replace(/^discords?:\/\//, 'https://discord.com/api/webhooks/');
const titleWithEnv = payload.environmentName ? `${payload.title} [${payload.environmentName}]` : payload.title;
const response = await fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
embeds: [{
title: titleWithEnv,
description: payload.message,
color: payload.type === 'error' ? 0xff0000 : payload.type === 'warning' ? 0xffaa00 : payload.type === 'success' ? 0x00ff00 : 0x0099ff,
...(payload.environmentName && {
footer: { text: `Environment: ${payload.environmentName}` }
})
}]
})
});
try {
const response = await fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
embeds: [{
title: titleWithEnv,
description: payload.message,
color: payload.type === 'error' ? 0xff0000 : payload.type === 'warning' ? 0xffaa00 : payload.type === 'success' ? 0x00ff00 : 0x0099ff,
...(payload.environmentName && {
footer: { text: `Environment: ${payload.environmentName}` }
})
}]
})
});
return response.ok;
if (!response.ok) {
const text = await response.text().catch(() => '');
return { success: false, error: `Discord error ${response.status}: ${text || response.statusText}` };
}
return { success: true };
} catch (error) {
return { success: false, error: `Discord connection failed: ${error instanceof Error ? error.message : String(error)}` };
}
}
// Slack webhook
async function sendSlack(appriseUrl: string, payload: NotificationPayload): Promise<boolean> {
async function sendSlack(appriseUrl: string, payload: NotificationPayload): Promise<NotificationResult> {
// slack://token_a/token_b/token_c or webhook URL
let url: string;
if (appriseUrl.includes('hooks.slack.com')) {
@@ -173,24 +187,32 @@ async function sendSlack(appriseUrl: string, payload: NotificationPayload): Prom
}
const envTag = payload.environmentName ? ` \`${payload.environmentName}\`` : '';
const response = await fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
text: `*${payload.title}*${envTag}\n${payload.message}`
})
});
return response.ok;
try {
const response = await fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
text: `*${payload.title}*${envTag}\n${payload.message}`
})
});
if (!response.ok) {
const text = await response.text().catch(() => '');
return { success: false, error: `Slack error ${response.status}: ${text || response.statusText}` };
}
return { success: true };
} catch (error) {
return { success: false, error: `Slack connection failed: ${error instanceof Error ? error.message : String(error)}` };
}
}
// Telegram
async function sendTelegram(appriseUrl: string, payload: NotificationPayload): Promise<boolean> {
async function sendTelegram(appriseUrl: string, payload: NotificationPayload): Promise<NotificationResult> {
// tgram://bot_token/chat_id
const match = appriseUrl.match(/^tgram:\/\/([^/]+)\/(.+)/);
if (!match) {
console.error('[Notifications] Invalid Telegram URL format. Expected: tgram://bot_token/chat_id');
return false;
return { success: false, error: 'Invalid Telegram URL format. Expected: tgram://bot_token/chat_id' };
}
const [, botToken, chatId] = match;
@@ -213,110 +235,162 @@ async function sendTelegram(appriseUrl: string, payload: NotificationPayload): P
});
if (!response.ok) {
const errorData = await response.json().catch(() => ({}));
console.error('[Notifications] Telegram API error:', response.status, errorData);
const errorData = await response.json().catch(() => ({})) as { description?: string };
const errorMsg = errorData.description || response.statusText;
return { success: false, error: `Telegram error ${response.status}: ${errorMsg}` };
}
return response.ok;
return { success: true };
} catch (error) {
const errorMsg = error instanceof Error ? error.message : String(error);
console.error('[Notifications] Telegram send failed:', errorMsg);
return false;
return { success: false, error: `Telegram connection failed: ${error instanceof Error ? error.message : String(error)}` };
}
}
// Gotify
async function sendGotify(appriseUrl: string, payload: NotificationPayload): Promise<boolean> {
async function sendGotify(appriseUrl: string, payload: NotificationPayload): Promise<NotificationResult> {
// gotify://hostname/token or gotifys://hostname/token
const match = appriseUrl.match(/^gotifys?:\/\/([^/]+)\/(.+)/);
if (!match) return false;
if (!match) {
return { success: false, error: 'Invalid Gotify URL format. Expected: gotify://hostname/token' };
}
const [, hostname, token] = match;
const protocol = appriseUrl.startsWith('gotifys') ? 'https' : 'http';
const url = `${protocol}://${hostname}/message?token=${token}`;
const response = await fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
title: payload.title,
message: payload.message,
priority: payload.type === 'error' ? 8 : payload.type === 'warning' ? 5 : 2
})
});
try {
const response = await fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
title: payload.title,
message: payload.message,
priority: payload.type === 'error' ? 8 : payload.type === 'warning' ? 5 : 2
})
});
return response.ok;
if (!response.ok) {
const text = await response.text().catch(() => '');
return { success: false, error: `Gotify error ${response.status}: ${text || response.statusText}` };
}
return { success: true };
} catch (error) {
return { success: false, error: `Gotify connection failed: ${error instanceof Error ? error.message : String(error)}` };
}
}
// ntfy
async function sendNtfy(appriseUrl: string, payload: NotificationPayload): Promise<boolean> {
// ntfy://topic or ntfys://hostname/topic
let url: string;
async function sendNtfy(appriseUrl: string, payload: NotificationPayload): Promise<NotificationResult> {
// Supported formats:
// ntfy://topic (public ntfy.sh)
// ntfy://host/topic (custom server, no auth)
// ntfy://user:pass@host/topic (custom server with auth)
// ntfys:// variants for HTTPS
const isSecure = appriseUrl.startsWith('ntfys');
const path = appriseUrl.replace(/^ntfys?:\/\//, '');
if (path.includes('/')) {
// Custom server
let url: string;
let auth: string | null = null;
// Check for user:pass@host/topic format
const authMatch = path.match(/^([^:]+):([^@]+)@(.+)$/);
if (authMatch) {
const [, user, pass, hostAndTopic] = authMatch;
auth = Buffer.from(`${user}:${pass}`).toString('base64');
url = `${isSecure ? 'https' : 'http'}://${hostAndTopic}`;
} else if (path.includes('/')) {
// Custom server without auth
url = `${isSecure ? 'https' : 'http'}://${path}`;
} else {
// Default ntfy.sh
url = `https://ntfy.sh/${path}`;
}
const response = await fetch(url, {
method: 'POST',
headers: {
'Title': payload.title,
'Priority': payload.type === 'error' ? '5' : payload.type === 'warning' ? '4' : '3',
'Tags': payload.type || 'info'
},
body: payload.message
});
const headers: Record<string, string> = {
'Title': payload.title,
'Priority': payload.type === 'error' ? '5' : payload.type === 'warning' ? '4' : '3',
'Tags': payload.type || 'info'
};
return response.ok;
if (auth) {
headers['Authorization'] = `Basic ${auth}`;
}
try {
const response = await fetch(url, {
method: 'POST',
headers,
body: payload.message
});
if (!response.ok) {
const text = await response.text().catch(() => '');
return { success: false, error: `ntfy error ${response.status}: ${text || response.statusText}` };
}
return { success: true };
} catch (error) {
return { success: false, error: `ntfy connection failed: ${error instanceof Error ? error.message : String(error)}` };
}
}
// Pushover
async function sendPushover(appriseUrl: string, payload: NotificationPayload): Promise<boolean> {
async function sendPushover(appriseUrl: string, payload: NotificationPayload): Promise<NotificationResult> {
// pushover://user_key/api_token
const match = appriseUrl.match(/^pushover:\/\/([^/]+)\/(.+)/);
if (!match) return false;
if (!match) {
return { success: false, error: 'Invalid Pushover URL format. Expected: pushover://user_key/api_token' };
}
const [, userKey, apiToken] = match;
const url = 'https://api.pushover.net/1/messages.json';
const response = await fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
token: apiToken,
user: userKey,
title: payload.title,
message: payload.message,
priority: payload.type === 'error' ? 1 : 0
})
});
try {
const response = await fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
token: apiToken,
user: userKey,
title: payload.title,
message: payload.message,
priority: payload.type === 'error' ? 1 : 0
})
});
return response.ok;
if (!response.ok) {
const text = await response.text().catch(() => '');
return { success: false, error: `Pushover error ${response.status}: ${text || response.statusText}` };
}
return { success: true };
} catch (error) {
return { success: false, error: `Pushover connection failed: ${error instanceof Error ? error.message : String(error)}` };
}
}
// Generic JSON webhook
async function sendGenericWebhook(appriseUrl: string, payload: NotificationPayload): Promise<boolean> {
async function sendGenericWebhook(appriseUrl: string, payload: NotificationPayload): Promise<NotificationResult> {
// json://hostname/path or jsons://hostname/path
const url = appriseUrl.replace(/^jsons?:\/\//, appriseUrl.startsWith('jsons') ? 'https://' : 'http://');
const response = await fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
title: payload.title,
message: payload.message,
type: payload.type || 'info',
timestamp: new Date().toISOString()
})
});
try {
const response = await fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
title: payload.title,
message: payload.message,
type: payload.type || 'info',
timestamp: new Date().toISOString()
})
});
return response.ok;
if (!response.ok) {
const text = await response.text().catch(() => '');
return { success: false, error: `Webhook error ${response.status}: ${text || response.statusText}` };
}
return { success: true };
} catch (error) {
return { success: false, error: `Webhook connection failed: ${error instanceof Error ? error.message : String(error)}` };
}
}
// Send notification to all enabled channels
@@ -325,15 +399,15 @@ export async function sendNotification(payload: NotificationPayload): Promise<{
const results: { name: string; success: boolean }[] = [];
for (const setting of settings) {
let success = false;
let result: NotificationResult = { success: false };
if (setting.type === 'smtp') {
success = await sendSmtpNotification(setting.config as SmtpConfig, payload);
result = await sendSmtpNotification(setting.config as SmtpConfig, payload);
} else if (setting.type === 'apprise') {
success = await sendAppriseNotification(setting.config as AppriseConfig, payload);
result = await sendAppriseNotification(setting.config as AppriseConfig, payload);
}
results.push({ name: setting.name, success });
results.push({ name: setting.name, success: result.success });
}
return {
@@ -343,7 +417,7 @@ export async function sendNotification(payload: NotificationPayload): Promise<{
}
// Test a specific notification setting
export async function testNotification(setting: NotificationSettingData): Promise<boolean> {
export async function testNotification(setting: NotificationSettingData): Promise<NotificationResult> {
const payload: NotificationPayload = {
title: 'Dockhand Test Notification',
message: 'This is a test notification from Dockhand. If you receive this, your notification settings are configured correctly.',
@@ -356,7 +430,7 @@ export async function testNotification(setting: NotificationSettingData): Promis
return await sendAppriseNotification(setting.config as AppriseConfig, payload);
}
return false;
return { success: false, error: 'Unknown notification type' };
}
// Map Docker action to notification event type
@@ -432,13 +506,13 @@ export async function sendEnvironmentNotification(
for (const notif of envNotifications) {
try {
let success = false;
let result: NotificationResult = { success: false };
if (notif.channelType === 'smtp') {
success = await sendSmtpNotification(notif.config as SmtpConfig, enrichedPayload);
result = await sendSmtpNotification(notif.config as SmtpConfig, enrichedPayload);
} else if (notif.channelType === 'apprise') {
success = await sendAppriseNotification(notif.config as AppriseConfig, enrichedPayload);
result = await sendAppriseNotification(notif.config as AppriseConfig, enrichedPayload);
}
if (success) sent++;
if (result.success) sent++;
else allSuccess = false;
} catch (error) {
const errorMsg = error instanceof Error ? error.message : String(error);
@@ -505,13 +579,13 @@ export async function sendEventNotification(
for (const channel of channels) {
try {
let success = false;
let result: NotificationResult = { success: false };
if (channel.channel_type === 'smtp') {
success = await sendSmtpNotification(channel.config as SmtpConfig, enrichedPayload);
result = await sendSmtpNotification(channel.config as SmtpConfig, enrichedPayload);
} else if (channel.channel_type === 'apprise') {
success = await sendAppriseNotification(channel.config as AppriseConfig, enrichedPayload);
result = await sendAppriseNotification(channel.config as AppriseConfig, enrichedPayload);
}
if (success) sent++;
if (result.success) sent++;
else allSuccess = false;
} catch (error) {
const errorMsg = error instanceof Error ? error.message : String(error);
+110 -6
View File
@@ -14,6 +14,9 @@ import {
} from './docker';
import { getEnvironment, getEnvSetting, getSetting } from './db';
import { sendEventNotification } from './notifications';
import { getHostDockerSocket, getHostDataDir, extractUidFromSocketPath } from './host-path';
import { resolve } from 'node:path';
import { mkdir, chown } from 'node:fs/promises';
export type ScannerType = 'none' | 'grype' | 'trivy' | 'both';
@@ -66,6 +69,10 @@ export async function sendVulnerabilityNotifications(
const GRYPE_VOLUME_NAME = 'dockhand-grype-db';
const TRIVY_VOLUME_NAME = 'dockhand-trivy-db';
// Scanner cache directory for rootless Docker (bind mounts instead of volumes)
const DATA_DIR = process.env.DATA_DIR || '/app/data';
const SCANNER_CACHE_DIR = 'scanner-cache';
// Track running scanner instances to detect concurrent scans
const runningScanners = new Map<string, number>(); // key: "grype" or "trivy", value: count
@@ -381,6 +388,43 @@ async function ensureVolume(volumeName: string, envId?: number): Promise<void> {
}
}
/**
* Ensure scanner cache directory exists with correct ownership for rootless Docker.
* Creates the directory in Dockhand's data volume and chowns it to the target UID.
*
* This is needed because Docker volumes are always created with root ownership,
* but rootless Docker scanners run as a non-root user (e.g., UID 1000).
* By using a bind mount from Dockhand's data directory (which Dockhand can chown
* since it runs as root), the scanner can write to its cache.
*
* @param scannerType - 'grype' or 'trivy'
* @param uid - Target UID for ownership (e.g., '1000')
* @returns The HOST path to the cache directory (for bind mounting into scanner)
*/
async function ensureScannerCacheDir(
scannerType: 'grype' | 'trivy',
uid: string
): Promise<string> {
const containerPath = resolve(DATA_DIR, SCANNER_CACHE_DIR, scannerType);
// Create directory if needed (recursive)
await mkdir(containerPath, { recursive: true });
// Chown to the target UID so scanner can write
const uidNum = parseInt(uid, 10);
await chown(containerPath, uidNum, uidNum);
console.log(`[Scanner] Set ownership of ${containerPath} to ${uid}:${uid}`);
// Return the HOST path for bind mounting
const hostDataDir = getHostDataDir();
if (hostDataDir) {
return `${hostDataDir}/${SCANNER_CACHE_DIR}/${scannerType}`;
}
// Fallback: not running in Docker, use container path as-is
return containerPath;
}
// Run scanner in a fresh container with volume-cached database
async function runScannerContainer(
scannerImage: string,
@@ -390,9 +434,7 @@ async function runScannerContainer(
envId?: number,
onOutput?: (line: string) => void
): Promise<string> {
// Ensure database cache volume exists
const volumeName = scannerType === 'grype' ? GRYPE_VOLUME_NAME : TRIVY_VOLUME_NAME;
await ensureVolume(volumeName, envId);
console.log(`[Scanner] Starting ${scannerType} scan for image: ${imageName}, envId: ${envId ?? 'local'}`);
// Check if another scanner of the same type is already running
// If so, use a unique cache subdirectory to avoid lock conflicts
@@ -407,11 +449,59 @@ async function runScannerContainer(
const basePath = scannerType === 'grype' ? '/cache/grype' : '/cache/trivy';
const dbPath = scanId ? `${basePath}${scanId}` : basePath;
// Detect the host Docker socket path based on connection type
// For local socket environments, detect the actual host socket path (handles rootless Docker)
// For remote environments (hawser/direct), scanner runs remotely and uses standard path
const env = envId ? await getEnvironment(envId) : undefined;
const connectionType = env?.connectionType;
let hostSocketPath: string;
let containerUser: string | undefined;
if (!connectionType || connectionType === 'socket') {
// Local socket environment - detect host socket path (handles rootless Docker)
hostSocketPath = getHostDockerSocket();
console.log(`[Scanner] Local socket scan - detected host Docker socket: ${hostSocketPath}`);
// For user-specific Docker sockets, run scanner as that user
// e.g., /run/user/1000/docker.sock -> run as UID 1000
const uid = extractUidFromSocketPath(hostSocketPath);
if (uid) {
containerUser = uid;
console.log(`[Scanner] Rootless Docker detected (UID ${containerUser})`);
}
} else {
// Remote environment (direct/hawser-standard/hawser-edge)
// Scanner runs on remote host, uses remote host's standard Docker socket
hostSocketPath = '/var/run/docker.sock';
console.log(`[Scanner] Remote scan (${connectionType}) - using standard socket path: ${hostSocketPath}`);
}
// Determine cache storage strategy based on environment
// For rootless Docker: use bind mount from data directory with correct ownership
// For standard Docker: use named volume (root-owned is fine when running as root)
let cacheBind: string;
const volumeName = scannerType === 'grype' ? GRYPE_VOLUME_NAME : TRIVY_VOLUME_NAME;
if (containerUser) {
// Rootless Docker: use bind mount from data directory with correct ownership
const hostCachePath = await ensureScannerCacheDir(scannerType, containerUser);
cacheBind = `${hostCachePath}:${basePath}`;
console.log(`[Scanner] Rootless mode - using bind mount: ${cacheBind}`);
} else {
// Standard Docker: use named volume (root-owned is fine when running as root)
await ensureVolume(volumeName, envId);
cacheBind = `${volumeName}:${basePath}`;
console.log(`[Scanner] Standard mode - using volume: ${volumeName}`);
}
const binds = [
'/var/run/docker.sock:/var/run/docker.sock:ro',
`${volumeName}:${basePath}` // Always mount to base path
`${hostSocketPath}:/var/run/docker.sock:ro`,
cacheBind
];
console.log(`[Scanner] Container bind mounts: ${JSON.stringify(binds)}`);
// Environment variables to ensure scanners use the correct cache path
// For concurrent scans, use a unique subdirectory
const envVars = scannerType === 'grype'
@@ -421,7 +511,11 @@ async function runScannerContainer(
if (scanId) {
console.log(`[Scanner] Concurrent scan detected - using unique cache dir: ${dbPath}`);
}
console.log(`[Scanner] Running ${scannerType} with volume ${volumeName} mounted at ${basePath}`);
console.log(`[Scanner] Running ${scannerType} with cache mounted at ${basePath}`);
console.log(`[Scanner] Container command: ${cmd.join(' ')}`);
if (containerUser) {
console.log(`[Scanner] Running scanner container as UID ${containerUser} to match socket owner`);
}
try {
// Run the scanner container
@@ -431,6 +525,7 @@ async function runScannerContainer(
binds,
env: envVars,
name: `dockhand-${scannerType}-${Date.now()}`,
user: containerUser,
envId,
onStderr: (data) => {
// Stream stderr lines for real-time progress output
@@ -443,6 +538,15 @@ async function runScannerContainer(
}
});
console.log(`[Scanner] ${scannerType} container completed, output length: ${output.length}`);
if (output.length === 0) {
console.error(`[Scanner] WARNING: Empty output from ${scannerType} container`);
console.error(`[Scanner] This may indicate the scanner couldn't access Docker socket`);
console.error(`[Scanner] Host socket path used: ${hostSocketPath}`);
} else if (output.length < 100) {
console.log(`[Scanner] ${scannerType} output preview: ${output}`);
}
return output;
} finally {
// Decrement running counter
+77 -3
View File
@@ -24,6 +24,8 @@ import {
getEnvironments,
getEnvUpdateCheckSettings,
getAllEnvUpdateCheckSettings,
getImagePruneSettings,
getAllImagePruneSettings,
getEnvironment,
getEnvironmentTimezone,
getDefaultTimezone
@@ -38,6 +40,7 @@ import {
import { runContainerUpdate } from './tasks/container-update';
import { runGitStackSync } from './tasks/git-stack-sync';
import { runEnvUpdateCheckJob } from './tasks/env-update-check';
import { runImagePrune } from './tasks/image-prune';
import {
runScheduleCleanupJob,
runEventCleanupJob,
@@ -247,7 +250,26 @@ export async function refreshAllSchedules(): Promise<void> {
console.error('[Scheduler] Error loading env update check schedules:', errorMsg);
}
console.log(`[Scheduler] Registered ${containerCount} container schedules, ${gitStackCount} git stack schedules, ${envUpdateCheckCount} env update check schedules`);
// Register image prune schedules
let imagePruneCount = 0;
try {
const pruneConfigs = await getAllImagePruneSettings();
for (const { envId, settings } of pruneConfigs) {
if (settings.enabled && settings.cronExpression) {
const registered = await registerSchedule(
envId,
'image_prune',
envId
);
if (registered) imagePruneCount++;
}
}
} catch (error) {
const errorMsg = error instanceof Error ? error.message : String(error);
console.error('[Scheduler] Error loading image prune schedules:', errorMsg);
}
console.log(`[Scheduler] Registered ${containerCount} container schedules, ${gitStackCount} git stack schedules, ${envUpdateCheckCount} env update check schedules, ${imagePruneCount} image prune schedules`);
}
/**
@@ -256,7 +278,7 @@ export async function refreshAllSchedules(): Promise<void> {
*/
export async function registerSchedule(
scheduleId: number,
type: 'container_update' | 'git_stack_sync' | 'env_update_check',
type: 'container_update' | 'git_stack_sync' | 'env_update_check' | 'image_prune',
environmentId: number | null
): Promise<boolean> {
const key = `${type}-${scheduleId}`;
@@ -290,6 +312,14 @@ export async function registerSchedule(
cronExpression = config.cron;
entityName = `Update: ${env.name}`;
enabled = config.enabled;
} else if (type === 'image_prune') {
const config = await getImagePruneSettings(scheduleId);
if (!config) return false;
const env = await getEnvironment(scheduleId);
if (!env) return false;
cronExpression = config.cronExpression;
entityName = `Prune: ${env.name}`;
enabled = config.enabled;
}
// Don't create job if disabled or no cron expression
@@ -315,6 +345,10 @@ export async function registerSchedule(
const config = await getEnvUpdateCheckSettings(scheduleId);
if (!config || !config.enabled) return;
await runEnvUpdateCheckJob(scheduleId, 'cron');
} else if (type === 'image_prune') {
const config = await getImagePruneSettings(scheduleId);
if (!config || !config.enabled) return;
await runImagePrune(scheduleId, 'cron');
}
});
@@ -334,7 +368,7 @@ export async function registerSchedule(
*/
export function unregisterSchedule(
scheduleId: number,
type: 'container_update' | 'git_stack_sync' | 'env_update_check'
type: 'container_update' | 'git_stack_sync' | 'env_update_check' | 'image_prune'
): void {
const key = `${type}-${scheduleId}`;
const job = activeJobs.get(key);
@@ -407,6 +441,22 @@ export async function refreshSchedulesForEnvironment(environmentId: number): Pro
console.error('[Scheduler] Error refreshing env update check schedule:', errorMsg);
}
// Re-register image prune schedule for this environment
try {
const config = await getImagePruneSettings(environmentId);
if (config && config.enabled && config.cronExpression) {
const registered = await registerSchedule(
environmentId,
'image_prune',
environmentId
);
if (registered) refreshedCount++;
}
} catch (error) {
const errorMsg = error instanceof Error ? error.message : String(error);
console.error('[Scheduler] Error refreshing image prune schedule:', errorMsg);
}
console.log(`[Scheduler] Refreshed ${refreshedCount} schedules for environment ${environmentId}`);
}
@@ -546,6 +596,30 @@ export async function triggerEnvUpdateCheck(environmentId: number): Promise<{ su
}
}
/**
* Manually trigger an image prune for an environment.
*/
export async function triggerImagePrune(environmentId: number): Promise<{ success: boolean; executionId?: number; error?: string }> {
try {
const config = await getImagePruneSettings(environmentId);
if (!config) {
return { success: false, error: 'Image prune settings not found for this environment' };
}
const env = await getEnvironment(environmentId);
if (!env) {
return { success: false, error: 'Environment not found' };
}
// Run in background
runImagePrune(environmentId, 'manual');
return { success: true };
} catch (error: any) {
return { success: false, error: error.message };
}
}
/**
* Manually trigger a system job (schedule cleanup, event cleanup, etc.).
*/
@@ -36,7 +36,8 @@ import {
getImageIdByTag,
removeTempImage,
tagImage,
connectContainerToNetwork
connectContainerToNetwork,
extractContainerOptions
} from '../../docker';
import { getScannerSettings, scanImage, type ScanResult, type VulnerabilitySeverity } from '../../scanner';
import { sendEventNotification } from '../../notifications';
@@ -589,8 +590,8 @@ export async function recreateContainer(
// Get full container config
const inspectData = await inspectContainer(container.id, envId) as any;
const wasRunning = inspectData.State.Running;
const config = inspectData.Config;
const hostConfig = inspectData.HostConfig;
const config = inspectData.Config;
log?.(`Recreating container: ${containerName} (was running: ${wasRunning})`);
log?.(`Preserving all container settings...`);
@@ -605,96 +606,19 @@ export async function recreateContainer(
log?.('Removing old container...');
await removeContainer(container.id, true, envId);
// =============================================================================
// Extract ALL settings from the original container
// =============================================================================
// Extract ALL settings using the shared helper function
const containerOptions = extractContainerOptions(inspectData);
// Port bindings - preserve all host port mappings including HostIp
const ports: { [key: string]: { HostIp?: string; HostPort: string } } = {};
if (hostConfig.PortBindings) {
for (const [containerPort, bindings] of Object.entries(hostConfig.PortBindings)) {
if (bindings && (bindings as any[]).length > 0) {
const binding = (bindings as any[])[0];
ports[containerPort] = {
HostPort: binding.HostPort || ''
};
// Preserve HostIp if specified (e.g., '192.168.0.250:80:80' in compose)
if (binding.HostIp) {
ports[containerPort].HostIp = binding.HostIp;
}
}
}
}
// Volume bindings - preserve ALL volumes including anonymous volumes
// hostConfig.Binds contains named volumes and bind mounts in "source:dest" format
// inspectData.Mounts contains ALL mounts including anonymous volumes with their generated names
const volumeBinds: string[] = [];
const mountedPaths = new Set<string>();
// First, add all entries from hostConfig.Binds (named volumes and bind mounts)
if (hostConfig.Binds && Array.isArray(hostConfig.Binds)) {
for (const bind of hostConfig.Binds) {
volumeBinds.push(bind);
// Track the destination path to avoid duplicates
const parts = bind.split(':');
if (parts.length >= 2) {
mountedPaths.add(parts[1].split(':')[0]); // Handle "src:dest:ro" format
}
}
}
// Then, add anonymous volumes from Mounts that aren't already in Binds
// These have Type: "volume" and a generated Name (hash), but no entry in Binds
const mounts = inspectData.Mounts || [];
for (const mount of mounts) {
if (mount.Type === 'volume' && mount.Name && mount.Destination) {
// Skip if this destination is already covered by Binds
if (!mountedPaths.has(mount.Destination)) {
// Format: "volumeName:destination" or "volumeName:destination:ro"
const bindStr = mount.RW === false
? `${mount.Name}:${mount.Destination}:ro`
: `${mount.Name}:${mount.Destination}`;
volumeBinds.push(bindStr);
log?.(`Preserving anonymous volume: ${mount.Name} -> ${mount.Destination}`);
}
}
}
// Healthcheck configuration
let healthcheck: any = undefined;
if (config.Healthcheck && config.Healthcheck.Test && config.Healthcheck.Test.length > 0) {
// Skip if healthcheck is disabled (NONE)
if (config.Healthcheck.Test[0] !== 'NONE') {
healthcheck = {
test: config.Healthcheck.Test,
interval: config.Healthcheck.Interval,
timeout: config.Healthcheck.Timeout,
retries: config.Healthcheck.Retries,
startPeriod: config.Healthcheck.StartPeriod
};
}
}
// Device mappings
const devices = (hostConfig.Devices || []).map((d: any) => ({
hostPath: d.PathOnHost || '',
containerPath: d.PathInContainer || '',
permissions: d.CgroupPermissions || 'rwm'
})).filter((d: any) => d.hostPath && d.containerPath);
// Ulimits
const ulimits = (hostConfig.Ulimits || []).map((u: any) => ({
name: u.Name,
soft: u.Soft,
hard: u.Hard
}));
// Extract network connections with aliases and static IPs
// Extract additional networks for reconnection (not handled by extractContainerOptions)
// The helper extracts primary network settings, but we need to handle secondary networks separately
const networkSettings = inspectData.NetworkSettings?.Networks || {};
const primaryNetwork = hostConfig.NetworkMode || 'bridge';
const shortContainerId = container.id.substring(0, 12);
// Extract compose labels for alias reconstruction
const composeProject = config.Labels?.['com.docker.compose.project'];
const composeService = config.Labels?.['com.docker.compose.service'];
// Build network info for reconnection (including aliases, IPs, and gateway priority)
interface NetworkInfo {
name: string;
aliases: string[];
@@ -703,68 +627,46 @@ export async function recreateContainer(
gwPriority: number | undefined;
}
// Extract primary network aliases, static IP, and gateway priority (for createContainer)
let primaryNetworkAliases: string[] | undefined;
let primaryNetworkIpv4: string | undefined;
let primaryNetworkIpv6: string | undefined;
let primaryNetworkMacAddress: string | undefined;
let primaryNetworkGwPriority: number | undefined;
const additionalNetworks: NetworkInfo[] = [];
for (const [netName, netConfig] of Object.entries(networkSettings)) {
const netConf = netConfig as any;
// Check if this is the primary network
const isPrimary = netName === primaryNetwork ||
(primaryNetwork === 'bridge' && (netName === 'bridge' || netName === 'default'));
if (isPrimary) {
// Extract primary network's aliases and static IP
// Filter out auto-generated aliases (container name and ID prefix)
// Note: Docker Compose stores aliases in both Aliases and DNSNames,
// but after container recreation Aliases may be null while DNSNames has the values
const allAliases = (netConf.Aliases?.length > 0 ? netConf.Aliases : netConf.DNSNames) || [];
const shortContainerId = container.id.substring(0, 12);
primaryNetworkAliases = allAliases.filter((a: string) =>
a !== containerName &&
a !== container.id &&
a !== shortContainerId
);
if (!primaryNetworkAliases || primaryNetworkAliases.length === 0) {
primaryNetworkAliases = undefined;
// Log primary network info
if (containerOptions.networkAliases?.length) {
log?.(`Primary network aliases: ${containerOptions.networkAliases.join(', ')}`);
}
// Extract static IP from IPAMConfig (user-configured) - don't use auto-assigned IPAddress
primaryNetworkIpv4 = netConf.IPAMConfig?.IPv4Address || undefined;
primaryNetworkIpv6 = netConf.IPAMConfig?.IPv6Address || undefined;
// Extract MAC address (only if explicitly set, not auto-generated)
// Auto-generated MACs start with 02:42, so we preserve all MACs
primaryNetworkMacAddress = netConf.MacAddress || undefined;
// Extract gateway priority (Docker Engine 28+)
// GwPriority determines which network provides the default gateway
primaryNetworkGwPriority = netConf.GwPriority !== undefined && netConf.GwPriority !== 0
? netConf.GwPriority : undefined;
if (primaryNetworkAliases?.length) {
log?.(`Primary network aliases: ${primaryNetworkAliases.join(', ')}`);
if (containerOptions.networkIpv4Address) {
log?.(`Primary network static IPv4: ${containerOptions.networkIpv4Address}`);
}
if (primaryNetworkIpv4) {
log?.(`Primary network static IPv4: ${primaryNetworkIpv4}`);
if (containerOptions.macAddress) {
log?.(`Primary network MAC address: ${containerOptions.macAddress}`);
}
if (primaryNetworkMacAddress) {
log?.(`Primary network MAC address: ${primaryNetworkMacAddress}`);
}
if (primaryNetworkGwPriority !== undefined) {
log?.(`Primary network gateway priority: ${primaryNetworkGwPriority}`);
if (containerOptions.networkGwPriority !== undefined) {
log?.(`Primary network gateway priority: ${containerOptions.networkGwPriority}`);
}
} else {
// Secondary network - add to reconnection list
// Use DNSNames as fallback for aliases (see comment above for primary network)
const secondaryAliases = ((netConf.Aliases?.length > 0 ? netConf.Aliases : netConf.DNSNames) || [])
.filter((a: string) => a !== container.id && a !== shortContainerId);
// For compose containers, ensure service name and project-service aliases on secondary networks
if (composeProject && composeService) {
if (!secondaryAliases.includes(composeService)) {
secondaryAliases.push(composeService);
}
const projectService = `${composeProject}-${composeService}`;
if (!secondaryAliases.includes(projectService)) {
secondaryAliases.push(projectService);
}
}
additionalNetworks.push({
name: netName,
aliases: (netConf.Aliases?.length > 0 ? netConf.Aliases : netConf.DNSNames) || [],
aliases: secondaryAliases,
ipv4Address: netConf.IPAMConfig?.IPv4Address || undefined,
ipv6Address: netConf.IPAMConfig?.IPv6Address || undefined,
gwPriority: netConf.GwPriority !== undefined && netConf.GwPriority !== 0
@@ -778,165 +680,21 @@ export async function recreateContainer(
}
// Log extra hosts if present
if (hostConfig.ExtraHosts?.length > 0) {
log?.(`Extra hosts: ${hostConfig.ExtraHosts.join(', ')}`);
if (containerOptions.extraHosts?.length) {
log?.(`Extra hosts: ${containerOptions.extraHosts.join(', ')}`);
}
// Log device requests if present (GPU, etc.)
if (hostConfig.DeviceRequests?.length > 0) {
for (const dr of hostConfig.DeviceRequests) {
const caps = dr.Capabilities?.flat().join(',') || 'none';
log?.(`Device request: driver=${dr.Driver || 'default'}, count=${dr.Count}, capabilities=[${caps}]`);
if (containerOptions.deviceRequests?.length) {
for (const dr of containerOptions.deviceRequests) {
const caps = dr.capabilities?.flat().join(',') || 'none';
log?.(`Device request: driver=${dr.driver || 'default'}, count=${dr.count}, capabilities=[${caps}]`);
}
}
// Create new container with ALL preserved settings
log?.('Creating new container with preserved settings...');
const newContainer = await createContainer({
name: containerName,
image: config.Image,
// Command and entrypoint
cmd: config.Cmd || undefined,
entrypoint: config.Entrypoint || undefined,
workingDir: config.WorkingDir || undefined,
// Environment and labels
env: config.Env || [],
labels: config.Labels || {},
// Port mappings
ports: Object.keys(ports).length > 0 ? ports : undefined,
// Volume bindings (includes both named and anonymous volumes)
volumeBinds: volumeBinds.length > 0 ? volumeBinds : undefined,
// Restart policy
restartPolicy: hostConfig.RestartPolicy?.Name || 'no',
restartMaxRetries: hostConfig.RestartPolicy?.MaximumRetryCount,
// Network mode and network-specific settings
networkMode: hostConfig.NetworkMode || undefined,
networkAliases: primaryNetworkAliases,
networkIpv4Address: primaryNetworkIpv4,
networkIpv6Address: primaryNetworkIpv6,
networkGwPriority: primaryNetworkGwPriority,
// User and hostname
user: config.User || undefined,
hostname: config.Hostname || undefined,
// Privileged mode
privileged: hostConfig.Privileged || undefined,
// Healthcheck
healthcheck,
// Terminal settings
tty: config.Tty || undefined,
stdinOpen: config.OpenStdin || undefined,
// Memory limits
memory: hostConfig.Memory || undefined,
memoryReservation: hostConfig.MemoryReservation || undefined,
memorySwap: hostConfig.MemorySwap || undefined,
// CPU limits
cpuShares: hostConfig.CpuShares || undefined,
cpuQuota: hostConfig.CpuQuota || undefined,
cpuPeriod: hostConfig.CpuPeriod || undefined,
nanoCpus: hostConfig.NanoCpus || undefined,
// Capabilities
capAdd: hostConfig.CapAdd?.length > 0 ? hostConfig.CapAdd : undefined,
capDrop: hostConfig.CapDrop?.length > 0 ? hostConfig.CapDrop : undefined,
// Devices
devices: devices.length > 0 ? devices : undefined,
// DNS settings
dns: hostConfig.Dns?.length > 0 ? hostConfig.Dns : undefined,
dnsSearch: hostConfig.DnsSearch?.length > 0 ? hostConfig.DnsSearch : undefined,
dnsOptions: hostConfig.DnsOptions?.length > 0 ? hostConfig.DnsOptions : undefined,
// Security options
securityOpt: hostConfig.SecurityOpt?.length > 0 ? hostConfig.SecurityOpt : undefined,
// Ulimits
ulimits: ulimits.length > 0 ? ulimits : undefined,
// Process and memory settings
oomKillDisable: hostConfig.OomKillDisable || undefined,
pidsLimit: hostConfig.PidsLimit || undefined,
shmSize: hostConfig.ShmSize || undefined,
// Tmpfs mounts
tmpfs: hostConfig.Tmpfs && Object.keys(hostConfig.Tmpfs).length > 0 ? hostConfig.Tmpfs : undefined,
// Sysctls
sysctls: hostConfig.Sysctls && Object.keys(hostConfig.Sysctls).length > 0 ? hostConfig.Sysctls : undefined,
// Logging configuration
logDriver: hostConfig.LogConfig?.Type || undefined,
logOptions: hostConfig.LogConfig?.Config && Object.keys(hostConfig.LogConfig.Config).length > 0
? hostConfig.LogConfig.Config : undefined,
// Namespace settings
ipcMode: hostConfig.IpcMode || undefined,
pidMode: hostConfig.PidMode || undefined,
utsMode: hostConfig.UTSMode || undefined,
// Cgroup parent
cgroupParent: hostConfig.CgroupParent || undefined,
// Stop signal and timeout
stopSignal: config.StopSignal || undefined,
stopTimeout: config.StopTimeout || undefined,
// Init process
init: hostConfig.Init === true ? true : undefined,
// MAC address (from primary network settings)
macAddress: primaryNetworkMacAddress,
// Extra hosts (/etc/hosts entries)
extraHosts: hostConfig.ExtraHosts?.length > 0 ? hostConfig.ExtraHosts : undefined,
// Device requests (GPU access, etc.)
deviceRequests: hostConfig.DeviceRequests?.length > 0
? hostConfig.DeviceRequests.map((dr: any) => ({
driver: dr.Driver || undefined,
count: dr.Count,
deviceIDs: dr.DeviceIDs?.length > 0 ? dr.DeviceIDs : undefined,
capabilities: dr.Capabilities?.length > 0 ? dr.Capabilities : undefined,
options: dr.Options && Object.keys(dr.Options).length > 0 ? dr.Options : undefined
}))
: undefined,
// Container runtime (critical for GPU containers using nvidia runtime)
runtime: hostConfig.Runtime && hostConfig.Runtime !== 'runc' ? hostConfig.Runtime : undefined,
// Read-only root filesystem (security hardening)
readonlyRootfs: hostConfig.ReadonlyRootfs === true ? true : undefined,
// CPU pinning
cpusetCpus: hostConfig.CpusetCpus || undefined,
// NUMA memory nodes
cpusetMems: hostConfig.CpusetMems || undefined,
// Additional groups
groupAdd: hostConfig.GroupAdd?.length > 0 ? hostConfig.GroupAdd : undefined,
// Memory swappiness (0-100)
memorySwappiness: hostConfig.MemorySwappiness !== null ? hostConfig.MemorySwappiness : undefined,
// User namespace mode
usernsMode: hostConfig.UsernsMode || undefined,
// Domain name
domainname: config.Domainname || undefined
}, envId);
const newContainer = await createContainer(containerOptions, envId);
// Reconnect to additional networks with aliases, static IPs, and gateway priority (before starting)
if (additionalNetworks.length > 0) {
@@ -0,0 +1,138 @@
/**
* Image Prune Task
*
* Handles scheduled pruning of unused Docker images per environment.
*/
import type { ScheduleTrigger, ImagePruneSettings } from '../../db';
import {
getImagePruneSettings,
setImagePruneSettings,
getEnvironment,
createScheduleExecution,
updateScheduleExecution,
appendScheduleExecutionLog
} from '../../db';
import { pruneImages } from '../../docker';
import { sendEventNotification } from '../../notifications';
/**
* System job ID for image prune (starts at 100 to avoid conflicts with other system jobs)
*/
export const SYSTEM_IMAGE_PRUNE_BASE_ID = 100;
/**
* Execute image prune for an environment.
*/
export async function runImagePrune(
envId: number,
triggeredBy: ScheduleTrigger
): Promise<void> {
const startTime = Date.now();
// Get environment info for logging
const env = await getEnvironment(envId);
if (!env) {
console.error(`[Image Prune] Environment ${envId} not found`);
return;
}
// Get prune settings
const settings = await getImagePruneSettings(envId);
if (!settings) {
console.error(`[Image Prune] No settings found for environment ${envId}`);
return;
}
// Create execution record
const execution = await createScheduleExecution({
scheduleType: 'image_prune',
scheduleId: envId,
environmentId: envId,
entityName: `Image prune: ${env.name}`,
triggeredBy,
status: 'running'
});
await updateScheduleExecution(execution.id, {
startedAt: new Date().toISOString()
});
const log = async (message: string) => {
console.log(`[Image Prune] [${env.name}] ${message}`);
await appendScheduleExecutionLog(execution.id, `[${new Date().toISOString()}] ${message}`);
};
try {
const pruneMode = settings.pruneMode || 'dangling';
const dangling = pruneMode === 'dangling';
await log(`Starting image prune (mode: ${pruneMode})`);
// Execute prune
const result = await pruneImages(dangling, envId);
// Extract space reclaimed and images removed from result
const spaceReclaimed = result?.SpaceReclaimed || 0;
const imagesRemoved = result?.ImagesDeleted?.length || 0;
// Format space for human-readable output
const formatBytes = (bytes: number): string => {
if (bytes === 0) return '0 B';
const k = 1024;
const sizes = ['B', 'KB', 'MB', 'GB', 'TB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return `${parseFloat((bytes / Math.pow(k, i)).toFixed(2))} ${sizes[i]}`;
};
await log(`Prune completed: ${imagesRemoved} images removed, ${formatBytes(spaceReclaimed)} reclaimed`);
// Update settings with last prune info
const updatedSettings: ImagePruneSettings = {
...settings,
lastPruned: new Date().toISOString(),
lastResult: {
spaceReclaimed,
imagesRemoved
}
};
await setImagePruneSettings(envId, updatedSettings);
// Update execution record
await updateScheduleExecution(execution.id, {
status: 'success',
completedAt: new Date().toISOString(),
duration: Date.now() - startTime,
details: {
pruneMode,
spaceReclaimed,
imagesRemoved,
deletedImages: result?.ImagesDeleted?.map((img: any) => img.Deleted || img.Untagged).filter(Boolean)
}
});
// Send success notification
await sendEventNotification('image_prune_success', {
title: 'Image prune completed',
message: `${imagesRemoved} unused images removed, ${formatBytes(spaceReclaimed)} disk space reclaimed`,
type: 'success'
}, envId);
} catch (error: any) {
await log(`Error: ${error.message}`);
await updateScheduleExecution(execution.id, {
status: 'failed',
completedAt: new Date().toISOString(),
duration: Date.now() - startTime,
errorMessage: error.message
});
// Send failure notification
await sendEventNotification('image_prune_failed', {
title: 'Image prune failed',
message: `Failed to prune images: ${error.message}`,
type: 'error'
}, envId);
}
}
+7 -12
View File
@@ -7,6 +7,7 @@
import { readdirSync, existsSync, statSync } from 'node:fs';
import { join, basename, dirname, resolve } from 'node:path';
import yaml from 'js-yaml';
import { getExternalStackPaths, getStackSources, upsertStackSource, type StackSourceType } from './db';
// Compose file patterns to detect (in order of priority - prefer new style first)
@@ -67,23 +68,17 @@ async function isComposeFile(filePath: string): Promise<boolean> {
/**
* Count the number of services defined in a compose file
* Uses simple regex to count top-level keys under 'services:' section
* Parses YAML to reliably count top-level keys under 'services:' section
*/
async function countServices(filePath: string): Promise<number> {
try {
const file = Bun.file(filePath);
const content = await file.text();
// Find the services section and count top-level keys
const servicesMatch = content.match(/^services:\s*\n((?:[ \t]+\S[^\n]*\n?)*)/m) ||
content.match(/\nservices:\s*\n((?:[ \t]+\S[^\n]*\n?)*)/m);
if (!servicesMatch) return 0;
const servicesBlock = servicesMatch[1];
// Count lines that start with exactly 2 spaces followed by a non-space (service names)
const serviceLines = servicesBlock.match(/^ [a-zA-Z0-9_-]+:/gm);
return serviceLines?.length || 0;
const doc = yaml.load(content) as Record<string, unknown> | null;
if (doc?.services && typeof doc.services === 'object') {
return Object.keys(doc.services).length;
}
return 0;
} catch {
return 0;
}
+127 -117
View File
@@ -6,10 +6,9 @@
*/
import { existsSync, mkdirSync, rmSync, readdirSync, cpSync, statSync, unlinkSync, renameSync, readFileSync, writeFileSync } from 'node:fs';
import { join, resolve, dirname } from 'node:path';
import { join, resolve, dirname, basename } from 'node:path';
import {
getEnvironment,
getStackEnvVarsAsRecord,
getSecretEnvVarsAsRecord,
getNonSecretEnvVarsAsRecord,
getStackEnvVars,
@@ -95,7 +94,6 @@ export interface DeployStackOptions {
name: string;
compose: string;
envId?: number | null;
envFileVars?: Record<string, string>;
sourceDir?: string; // Directory to copy all files from (for git stacks)
forceRecreate?: boolean;
composePath?: string; // Custom compose file path (for adopted/imported stacks)
@@ -313,25 +311,6 @@ export async function findStackDir(stackName: string, envId?: number | null): Pr
return null;
}
/**
* List stacks that have compose files stored locally
*/
export function listManagedStacks(): string[] {
const stacksDir = getStacksDir();
if (!existsSync(stacksDir)) {
return [];
}
return readdirSync(stacksDir, { withFileTypes: true })
.filter((dirent) => dirent.isDirectory())
.filter((dirent) => {
// Check all valid compose filenames
const composeNames = ['compose.yaml', 'compose.yml', 'docker-compose.yml', 'docker-compose.yaml'];
return composeNames.some(name => existsSync(join(stacksDir, dirent.name, name)));
})
.map((dirent) => dirent.name);
}
// =============================================================================
// COMPOSE FILE MANAGEMENT
// =============================================================================
@@ -761,6 +740,8 @@ interface ComposeCommandOptions {
composePath?: string;
/** Full path to the env file (for --env-file flag, supports custom names) */
envPath?: string;
/** When true, write non-secret envVars to .env.dockhand override file (git stacks only) */
useOverrideFile?: boolean;
}
/**
@@ -785,7 +766,8 @@ async function executeLocalCompose(
envId?: number | null,
workingDir?: string,
customComposePath?: string,
customEnvPath?: string
customEnvPath?: string,
useOverrideFile?: boolean
): Promise<StackOperationResult> {
const logPrefix = `[Stack:${stackName}]`;
@@ -894,12 +876,29 @@ async function executeLocalCompose(
const useStdin = finalComposeContent !== composeContent;
const args = ['docker', 'compose', '-p', stackName, '-f', useStdin ? '-' : composeFile];
// Add --env-file flag if env file exists
// This makes Docker Compose load the .env file automatically (like Portainer)
// Uses custom path if provided, otherwise defaults to .env in stack directory
const envFilePath = customEnvPath || join(stackDir, '.env');
if (existsSync(envFilePath)) {
args.push('--env-file', envFilePath);
// Always auto-detect .env in compose directory
const defaultEnvPath = join(stackDir, '.env');
if (existsSync(defaultEnvPath)) {
args.push('--env-file', defaultEnvPath);
}
// Add custom env file if configured and different from auto-detected .env
if (customEnvPath && resolve(customEnvPath) !== resolve(defaultEnvPath) && existsSync(customEnvPath)) {
args.push('--env-file', customEnvPath);
}
// For git stacks: write non-secret overrides to .env.dockhand and add as second --env-file
// Docker Compose applies env files in order, so later files override earlier ones.
// This lets the repo's .env provide defaults while our overrides take precedence.
// Secrets are still injected via shell env only (never written to disk).
// Only written when useOverrideFile is true (git stacks). Internal/adopted stacks
// already have their non-secrets in the .env file written by the UI.
if (useOverrideFile && envVars && Object.keys(envVars).length > 0) {
const overrideEnvPath = join(stackDir, '.env.dockhand');
const header = '# Auto-generated by Dockhand. Do not edit - changes will be overwritten on next deploy.\n';
const lines = Object.entries(envVars).map(([k, v]) => `${k}=${v}`);
await Bun.write(overrideEnvPath, header + lines.join('\n') + '\n');
args.push('--env-file', overrideEnvPath);
}
if (useStdin) {
@@ -1199,7 +1198,7 @@ async function executeComposeCommand(
envVars?: Record<string, string>,
secretVars?: Record<string, string>
): Promise<StackOperationResult> {
const { stackName, envId, forceRecreate, removeVolumes, stackFiles, workingDir, composePath, envPath } = options;
const { stackName, envId, forceRecreate, removeVolumes, stackFiles, workingDir, composePath, envPath, useOverrideFile } = options;
// Get environment configuration
const env = envId ? await getEnvironment(envId) : null;
@@ -1219,7 +1218,8 @@ async function executeComposeCommand(
envId,
workingDir,
composePath,
envPath
envPath,
useOverrideFile
);
}
@@ -1263,7 +1263,8 @@ async function executeComposeCommand(
envId,
workingDir,
composePath,
envPath
envPath,
useOverrideFile
);
}
@@ -1282,7 +1283,8 @@ async function executeComposeCommand(
envId,
workingDir,
composePath,
envPath
envPath,
useOverrideFile
);
}
}
@@ -1487,7 +1489,6 @@ async function withContainerFallback(
export interface RequireComposeResult {
success: boolean;
content?: string;
envVars?: Record<string, string>;
secretVars?: Record<string, string>;
needsFileLocation?: boolean;
error?: string;
@@ -1495,19 +1496,18 @@ export interface RequireComposeResult {
stackDir?: string;
/** Full path to the compose file (for imported stacks) */
composePath?: string;
/** Full path to the env file (for --env-file flag) */
envPath?: string;
}
/**
* Get compose file and env vars for stack operations.
* Get compose file and secret vars for stack operations.
*
* Returns:
* - content: The compose file content
* - envVars: Non-secret variables (from .env file, with DB fallback)
* - secretVars: Secret variables (from DB only, for shell injection)
* - envPath: Path to the .env file (Docker Compose reads non-secrets from it)
* - needsFileLocation: true if stack needs user to specify file paths
*
* SECURITY: Secrets are NEVER written to .env files. They are stored in the database
* and injected via shell environment variables at runtime.
*/
async function requireComposeFile(
stackName: string,
@@ -1534,10 +1534,7 @@ async function requireComposeFile(
// These are NEVER written to disk
const secretVars = await getSecretEnvVarsAsRecord(stackName, envId);
// Get non-secret variables from database (for backward compatibility)
const dbNonSecretVars = await getNonSecretEnvVarsAsRecord(stackName, envId);
// Read non-secret vars from .env file
// Determine env file path for --env-file flag
// For stacks with custom composePath (adopted/external), derive envPath from same directory
// For internal stacks, use the default data directory
let envFilePath: string | null = null;
@@ -1560,38 +1557,11 @@ async function requireComposeFile(
envFilePath = join(stackDir, '.env');
}
let fileEnvVars: Record<string, string> = {};
if (envFilePath && existsSync(envFilePath)) {
try {
const content = await Bun.file(envFilePath).text();
for (const line of content.split('\n')) {
const trimmed = line.trim();
if (!trimmed || trimmed.startsWith('#')) continue;
const eqIndex = trimmed.indexOf('=');
if (eqIndex > 0) {
const key = trimmed.substring(0, eqIndex).trim();
let value = trimmed.substring(eqIndex + 1);
if ((value.startsWith('"') && value.endsWith('"')) ||
(value.startsWith("'") && value.endsWith("'"))) {
value = value.slice(1, -1);
}
fileEnvVars[key] = value;
}
}
} catch {
// Ignore file read errors
}
}
// Merge non-secret vars: DB as fallback, file values override
// This ensures external edits to .env are respected during deployment
const envVars = { ...dbNonSecretVars, ...fileEnvVars };
// Docker Compose reads non-secrets from the .env file via --env-file.
// Only secrets need to be injected via shell environment.
return {
success: true,
content: composeResult.content!,
envVars,
secretVars,
stackDir: composeResult.stackDir,
composePath: composeResult.composePath ?? undefined,
@@ -1618,7 +1588,7 @@ export async function startStack(
'up',
{ stackName, envId, workingDir: result.stackDir, composePath: result.composePath, envPath: result.envPath },
result.content!,
result.envVars,
undefined,
result.secretVars
);
}
@@ -1642,7 +1612,7 @@ export async function stopStack(
'stop',
{ stackName, envId, workingDir: result.stackDir, composePath: result.composePath, envPath: result.envPath },
result.content!,
result.envVars,
undefined,
result.secretVars
);
}
@@ -1666,7 +1636,7 @@ export async function restartStack(
'restart',
{ stackName, envId, workingDir: result.stackDir, composePath: result.composePath, envPath: result.envPath },
result.content!,
result.envVars,
undefined,
result.secretVars
);
}
@@ -1691,7 +1661,7 @@ export async function downStack(
'down',
{ stackName, envId, removeVolumes, workingDir: result.stackDir, composePath: result.composePath, envPath: result.envPath },
result.content!,
result.envVars,
undefined,
result.secretVars
);
}
@@ -1707,7 +1677,7 @@ export async function removeStack(
): Promise<StackOperationResult> {
return withStackLock(stackName, async () => {
// Get compose file (may not exist for external stacks)
const composeResult = await getStackComposeFile(stackName);
const composeResult = await getStackComposeFile(stackName, envId);
// Get stack containers BEFORE removing them (for cleanup later)
const stackContainers = await getStackContainers(stackName, envId);
@@ -1804,16 +1774,17 @@ export async function removeStack(
const resolvedStacksDir = resolve(stacksDir);
// Only delete if the directory is within DATA_DIR/stacks/ (files we created)
// AND it looks like a stack directory (contains stackName for safety)
// AND the directory basename matches the stack name exactly (for safety)
if (resolvedCustomDir.startsWith(resolvedStacksDir) &&
customDir.includes(stackName) &&
basename(resolvedCustomDir) === stackName &&
existsSync(customDir)) {
stackDir = customDir;
}
}
// Fall back to default paths (always within DATA_DIR/stacks/)
if (!stackDir) {
// Fall back to default paths ONLY if no custom path was set in DB
// (Don't delete default-path files when an adopted stack has custom path outside DATA_DIR)
if (!stackDir && !stackSource?.composePath) {
const defaultDir = await findStackDir(stackName, envId) || await getStackDir(stackName, envId);
if (existsSync(defaultDir)) {
stackDir = defaultDir;
@@ -1853,14 +1824,14 @@ export async function removeStack(
const gitStack = await getGitStackByName(stackName, envId);
if (gitStack) {
await deleteGitStack(gitStack.id);
deleteGitStackFiles(gitStack.id);
await deleteGitStackFiles(gitStack.id, gitStack.stackName, gitStack.environmentId);
}
// Also cleanup any orphaned git stacks with NULL environment_id for this stack name
if (envId !== undefined && envId !== null) {
const orphanedGitStack = await getGitStackByName(stackName, null);
if (orphanedGitStack) {
await deleteGitStack(orphanedGitStack.id);
deleteGitStackFiles(orphanedGitStack.id);
await deleteGitStackFiles(orphanedGitStack.id, orphanedGitStack.stackName, orphanedGitStack.environmentId);
}
}
} catch (err: any) {
@@ -1890,7 +1861,7 @@ export async function removeStack(
* Uses stack locking to prevent concurrent deployments.
*/
export async function deployStack(options: DeployStackOptions): Promise<StackOperationResult> {
const { name, compose, envId, envFileVars, sourceDir, forceRecreate, composePath, envPath, composeFileName, envFileName } = options;
const { name, compose, envId, sourceDir, forceRecreate, composePath, envPath, composeFileName, envFileName } = options;
const logPrefix = `[Stack:${name}]`;
console.log(`${logPrefix} ========================================`);
@@ -1903,11 +1874,6 @@ export async function deployStack(options: DeployStackOptions): Promise<StackOpe
console.log(`${logPrefix} Custom env path:`, envPath ?? '(none)');
console.log(`${logPrefix} Compose filename:`, composeFileName ?? '(none)');
console.log(`${logPrefix} Env filename:`, envFileName ?? '(none)');
console.log(`${logPrefix} Env file vars provided:`, envFileVars ? Object.keys(envFileVars).length : 0);
if (envFileVars && Object.keys(envFileVars).length > 0) {
console.log(`${logPrefix} Env file var keys:`, Object.keys(envFileVars).join(', '));
console.log(`${logPrefix} Env file vars (masked):`, JSON.stringify(maskSecrets(envFileVars), null, 2));
}
// Validate stack name - Docker Compose requires lowercase alphanumeric, hyphens, underscores
// Must also start with a letter or number
@@ -1943,7 +1909,16 @@ export async function deployStack(options: DeployStackOptions): Promise<StackOpe
if (composeFileName) {
actualComposePath = join(workingDir, composeFileName);
console.log(`${logPrefix} Using compose filename from git config:`, composeFileName);
console.log(`${logPrefix} Actual compose path will be:`, actualComposePath);
} else {
// Detect compose file in source directory
const composeNames = ['docker-compose.yaml', 'docker-compose.yml', 'compose.yaml', 'compose.yml'];
for (const cn of composeNames) {
if (existsSync(join(sourceDir, cn))) {
actualComposePath = join(workingDir, cn);
console.log(`${logPrefix} Detected compose file:`, cn);
break;
}
}
}
// Set actualEnvPath using the provided env filename from git stack config
@@ -1967,31 +1942,38 @@ export async function deployStack(options: DeployStackOptions): Promise<StackOpe
cpSync(sourceDir, workingDir, { recursive: true });
console.log(`${logPrefix} Copied ${sourceDir} -> ${workingDir}`);
} else {
// Internal stack: compose file should already exist (written by saveStackComposeFile)
// Just determine the working directory
workingDir = await getStackDir(name, envId);
console.log(`${logPrefix} Using internal stack directory:`, workingDir);
// Internal stack: check if a custom path exists in DB (adopted/imported stacks)
const source = await getStackSource(name, envId);
if (source?.composePath) {
workingDir = dirname(source.composePath);
actualComposePath = source.composePath;
if (source.envPath) {
actualEnvPath = source.envPath;
}
console.log(`${logPrefix} Using custom path from DB:`, workingDir);
} else {
// Default: compose file should already exist (written by saveStackComposeFile)
workingDir = await getStackDir(name, envId);
console.log(`${logPrefix} Using internal stack directory:`, workingDir);
}
}
console.log(`${logPrefix} Compose content length:`, compose.length, 'chars');
console.log(`${logPrefix} Compose content (full):`);
console.log(compose);
// Fetch stack environment variables from database (these are user overrides)
const dbEnvVars = await getStackEnvVarsAsRecord(name, envId);
console.log(`${logPrefix} DB env vars count:`, Object.keys(dbEnvVars).length);
if (Object.keys(dbEnvVars).length > 0) {
console.log(`${logPrefix} DB env var keys:`, Object.keys(dbEnvVars).join(', '));
console.log(`${logPrefix} DB env vars (masked):`, JSON.stringify(maskSecrets(dbEnvVars), null, 2));
}
// Fetch overrides and secrets from DB
const dbNonSecretVars = await getNonSecretEnvVarsAsRecord(name, envId);
const secretVars = await getSecretEnvVarsAsRecord(name, envId);
console.log(`${logPrefix} DB non-secret override vars:`, Object.keys(dbNonSecretVars).length);
console.log(`${logPrefix} DB secret vars:`, Object.keys(secretVars).length);
// Merge: env file vars as base, database overrides take precedence
const envVars = { ...envFileVars, ...dbEnvVars };
console.log(`${logPrefix} Merged env vars count:`, Object.keys(envVars).length);
if (Object.keys(envVars).length > 0) {
console.log(`${logPrefix} Merged env var keys:`, Object.keys(envVars).join(', '));
console.log(`${logPrefix} Merged env vars (masked):`, JSON.stringify(maskSecrets(envVars), null, 2));
}
// For git stacks (sourceDir provided), use the override file (.env.dockhand)
// to layer editor overrides on top of the repo's .env file.
// Only DB overrides go into .env.dockhand - repo values are already in the repo's env file.
// For internal/adopted stacks, the .env file is already the editor's output,
// so no override file is needed - only pass secrets for shell injection.
const isGitStack = !!sourceDir;
console.log(`${logPrefix} Calling executeComposeCommand...`);
const result = await executeComposeCommand(
@@ -2003,10 +1985,12 @@ export async function deployStack(options: DeployStackOptions): Promise<StackOpe
stackFiles,
workingDir,
composePath: actualComposePath,
envPath: actualEnvPath
envPath: actualEnvPath,
useOverrideFile: isGitStack
},
compose,
envVars
isGitStack ? dbNonSecretVars : undefined,
secretVars
);
console.log(`${logPrefix} ========================================`);
console.log(`${logPrefix} DEPLOY STACK RESULT`);
@@ -2042,7 +2026,7 @@ export async function pullStackImages(
'pull',
{ stackName, envId, workingDir: result.stackDir, composePath: result.composePath, envPath: result.envPath },
result.content!,
result.envVars,
undefined,
result.secretVars
);
}
@@ -2077,9 +2061,22 @@ export async function writeStackEnvFile(
envId?: number | null,
customEnvPath?: string
): Promise<void> {
const envFilePath = customEnvPath
? customEnvPath
: join(await findStackDir(stackName, envId) || await getStackDir(stackName, envId), '.env');
let envFilePath: string;
if (customEnvPath) {
envFilePath = customEnvPath;
} else {
// Check if stack has a custom path in DB
const source = await getStackSource(stackName, envId);
if (source?.envPath) {
envFilePath = source.envPath;
} else if (source?.composePath) {
// Derive env path from custom compose path location
envFilePath = join(dirname(source.composePath), '.env');
} else {
// Fall back to default location
envFilePath = join(await findStackDir(stackName, envId) || await getStackDir(stackName, envId), '.env');
}
}
// Ensure parent directory exists
const dir = dirname(envFilePath);
@@ -2109,9 +2106,22 @@ export async function writeRawStackEnvFile(
envId?: number | null,
customEnvPath?: string
): Promise<void> {
const envFilePath = customEnvPath
? customEnvPath
: join(await findStackDir(stackName, envId) || await getStackDir(stackName, envId), '.env');
let envFilePath: string;
if (customEnvPath) {
envFilePath = customEnvPath;
} else {
// Check if stack has a custom path in DB
const source = await getStackSource(stackName, envId);
if (source?.envPath) {
envFilePath = source.envPath;
} else if (source?.composePath) {
// Derive env path from custom compose path location
envFilePath = join(dirname(source.composePath), '.env');
} else {
// Fall back to default location
envFilePath = join(await findStackDir(stackName, envId) || await getStackDir(stackName, envId), '.env');
}
}
// Ensure parent directory exists
const dir = dirname(envFilePath);
+26 -3
View File
@@ -19,6 +19,7 @@ export interface ThemePreferences {
fontSize: FontSize;
gridFontSize: FontSize;
terminalFont: string;
editorFont: string;
}
const STORAGE_KEY = 'dockhand-theme';
@@ -29,7 +30,8 @@ const defaultPrefs: ThemePreferences = {
font: 'system',
fontSize: 'normal',
gridFontSize: 'normal',
terminalFont: 'system-mono'
terminalFont: 'system-mono',
editorFont: 'system-mono'
};
// Font size scale mapping
@@ -83,9 +85,10 @@ function createThemeStore() {
// Initialize from API (called on mount)
async init(userId?: number) {
try {
// Use profile preferences for authenticated users, public theme endpoint otherwise
const url = userId
? `/api/profile/preferences`
: `/api/settings/general`;
: `/api/settings/theme`;
const res = await fetch(url);
if (res.ok) {
@@ -96,7 +99,8 @@ function createThemeStore() {
font: data.font || data.theme_font || 'system',
fontSize: data.fontSize || data.font_size || 'normal',
gridFontSize: data.gridFontSize || data.grid_font_size || 'normal',
terminalFont: data.terminalFont || data.terminal_font || 'system-mono'
terminalFont: data.terminalFont || data.terminal_font || 'system-mono',
editorFont: data.editorFont || data.editor_font || 'system-mono'
};
set(prefs);
saveToStorage(prefs);
@@ -187,6 +191,9 @@ export function applyTheme(prefs: ThemePreferences) {
// Apply terminal font
applyTerminalFont(prefs.terminalFont);
// Apply editor font
applyEditorFont(prefs.editorFont);
}
// Apply font to document
@@ -237,6 +244,22 @@ function applyTerminalFont(fontId: string) {
document.documentElement.style.setProperty('--font-mono', fontMeta.family);
}
// Apply editor font to document
function applyEditorFont(fontId: string) {
if (typeof document === 'undefined') return;
const fontMeta = getMonospaceFont(fontId);
if (!fontMeta) return;
// Load Google Font if needed
if (fontMeta.googleFont) {
loadGoogleFont(fontMeta);
}
// Set CSS variable
document.documentElement.style.setProperty('--font-editor', fontMeta.family);
}
// Load Google Font dynamically
function loadGoogleFont(font: FontMeta) {
if (!font.googleFont) return;
+10 -1
View File
@@ -105,7 +105,7 @@ export const fonts: FontMeta[] = [
{ id: 'comfortaa', name: 'Comfortaa', family: "'Comfortaa', sans-serif", googleFont: 'Comfortaa:wght@400;500;600;700' }
];
// Monospace fonts for terminal and logs
// Monospace fonts for terminal, logs, and editors
export const monospaceFonts: FontMeta[] = [
// System monospace (no external load)
{ id: 'system-mono', name: 'System Monospace', family: 'ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace' },
@@ -115,6 +115,15 @@ export const monospaceFonts: FontMeta[] = [
{ id: 'fira-code', name: 'Fira Code', family: "'Fira Code', monospace", googleFont: 'Fira+Code:wght@400;500;600;700' },
{ id: 'source-code-pro', name: 'Source Code Pro', family: "'Source Code Pro', monospace", googleFont: 'Source+Code+Pro:wght@400;500;600;700' },
{ id: 'cascadia-code', name: 'Cascadia Code', family: "'Cascadia Code', monospace", googleFont: 'Cascadia+Code:wght@400;500;600;700' },
{ id: 'ibm-plex-mono', name: 'IBM Plex Mono', family: "'IBM Plex Mono', monospace", googleFont: 'IBM+Plex+Mono:wght@400;500;600;700' },
{ id: 'roboto-mono', name: 'Roboto Mono', family: "'Roboto Mono', monospace", googleFont: 'Roboto+Mono:wght@400;500;600;700' },
{ id: 'ubuntu-mono', name: 'Ubuntu Mono', family: "'Ubuntu Mono', monospace", googleFont: 'Ubuntu+Mono:wght@400;700' },
{ id: 'space-mono', name: 'Space Mono', family: "'Space Mono', monospace", googleFont: 'Space+Mono:wght@400;700' },
{ id: 'inconsolata', name: 'Inconsolata', family: "'Inconsolata', monospace", googleFont: 'Inconsolata:wght@400;500;600;700' },
{ id: 'hack', name: 'Hack', family: "'Hack', monospace", googleFont: 'Hack:wght@400;700' },
{ id: 'anonymous-pro', name: 'Anonymous Pro', family: "'Anonymous Pro', monospace", googleFont: 'Anonymous+Pro:wght@400;700' },
{ id: 'dm-mono', name: 'DM Mono', family: "'DM Mono', monospace", googleFont: 'DM+Mono:wght@400;500' },
{ id: 'red-hat-mono', name: 'Red Hat Mono', family: "'Red Hat Mono', monospace", googleFont: 'Red+Hat+Mono:wght@400;500;600;700' },
// Platform-specific (no external load)
{ id: 'menlo', name: 'Menlo', family: 'Menlo, Monaco, monospace' },
+1 -1
View File
@@ -163,7 +163,7 @@ export interface GitRepository {
}
// Grid column configuration types
export type GridId = 'containers' | 'images' | 'imageTags' | 'networks' | 'stacks' | 'volumes' | 'activity' | 'schedules';
export type GridId = 'containers' | 'images' | 'imageTags' | 'networks' | 'stacks' | 'volumes' | 'activity' | 'schedules' | 'audit';
export interface ColumnConfig {
id: string;
+222
View File
@@ -0,0 +1,222 @@
/**
* Utility functions for computing diffs between objects for audit logging
*/
export interface FieldChange {
field: string;
oldValue: any;
newValue: any;
}
export interface AuditDiff {
changes: FieldChange[];
}
/**
* Fields that should never be included in audit diffs (sensitive data)
*/
const SENSITIVE_FIELDS = new Set([
'password',
'sshPrivateKey',
'sshPassphrase',
'tlsKey',
'tlsCert',
'tlsCa',
'hawserToken',
'token',
'secret',
'apiKey'
]);
/**
* Fields that should be shown as masked if changed
*/
const MASKED_FIELDS = new Set([
'password',
'sshPrivateKey',
'sshPassphrase',
'tlsKey',
'hawserToken',
'token',
'secret',
'apiKey'
]);
/**
* Fields that should be skipped entirely (internal timestamps, etc.)
*/
const SKIP_FIELDS = new Set([
'updatedAt',
'createdAt',
'id'
]);
/**
* Compute the diff between two objects for audit logging
* Returns only the fields that have changed
*/
export function computeAuditDiff(
oldObj: Record<string, any> | null | undefined,
newObj: Record<string, any> | null | undefined,
options: {
includeFields?: string[];
excludeFields?: string[];
} = {}
): AuditDiff | null {
if (!oldObj || !newObj) {
return null;
}
const changes: FieldChange[] = [];
const allKeys = new Set([...Object.keys(oldObj), ...Object.keys(newObj)]);
for (const key of allKeys) {
// Skip internal fields
if (SKIP_FIELDS.has(key)) continue;
// Skip if excluded
if (options.excludeFields?.includes(key)) continue;
// Skip if includeFields specified and key not in it
if (options.includeFields && !options.includeFields.includes(key)) continue;
const oldVal = oldObj[key];
const newVal = newObj[key];
// Skip undefined new values (not provided in update)
if (newVal === undefined) continue;
// Check if values are different
if (!isEqual(oldVal, newVal)) {
// Handle sensitive fields - show as masked
if (MASKED_FIELDS.has(key)) {
// Only show change if the masked field actually changed
const oldHasValue = oldVal !== null && oldVal !== undefined && oldVal !== '';
const newHasValue = newVal !== null && newVal !== undefined && newVal !== '';
if (oldHasValue !== newHasValue || (oldHasValue && newHasValue)) {
changes.push({
field: key,
oldValue: oldHasValue ? '••••••••' : null,
newValue: newHasValue ? '••••••••' : null
});
}
} else if (SENSITIVE_FIELDS.has(key)) {
// Skip entirely for other sensitive fields
continue;
} else {
changes.push({
field: key,
oldValue: formatValue(oldVal),
newValue: formatValue(newVal)
});
}
}
}
if (changes.length === 0) {
return null;
}
return { changes };
}
/**
* Deep equality check for values
*/
function isEqual(a: any, b: any): boolean {
// Handle null/undefined
if (a === b) return true;
if (a === null || b === null) return false;
if (a === undefined || b === undefined) return false;
// Handle arrays
if (Array.isArray(a) && Array.isArray(b)) {
if (a.length !== b.length) return false;
return a.every((val, idx) => isEqual(val, b[idx]));
}
// Handle objects
if (typeof a === 'object' && typeof b === 'object') {
const keysA = Object.keys(a);
const keysB = Object.keys(b);
if (keysA.length !== keysB.length) return false;
return keysA.every(key => isEqual(a[key], b[key]));
}
// Primitive comparison
return a === b;
}
/**
* Format a value for display in the diff
*/
function formatValue(val: any): any {
if (val === null || val === undefined) {
return null;
}
// Truncate long strings
if (typeof val === 'string' && val.length > 200) {
return val.substring(0, 200) + '...';
}
// Handle arrays - show count if too many items
if (Array.isArray(val)) {
if (val.length > 10) {
return `[${val.length} items]`;
}
return val.map(formatValue);
}
// Handle objects - show keys if too complex
if (typeof val === 'object') {
const keys = Object.keys(val);
if (keys.length > 10) {
return `{${keys.length} properties}`;
}
const formatted: Record<string, any> = {};
for (const key of keys) {
formatted[key] = formatValue(val[key]);
}
return formatted;
}
return val;
}
/**
* Format field name for display (camelCase to Title Case)
*/
export function formatFieldName(field: string): string {
// Handle special cases
const specialCases: Record<string, string> = {
'tlsCa': 'TLS CA',
'tlsCert': 'TLS certificate',
'tlsKey': 'TLS key',
'tlsSkipVerify': 'Skip TLS verification',
'sshPrivateKey': 'SSH private key',
'sshPassphrase': 'SSH passphrase',
'envVars': 'Environment variables',
'isDefault': 'Default',
'ipAddress': 'IP address',
'authType': 'Auth type',
'eventTypes': 'Event types',
'hawserToken': 'Hawser token',
'connectionType': 'Connection type',
'socketPath': 'Socket path',
'collectActivity': 'Collect activity',
'collectMetrics': 'Collect metrics',
'highlightChanges': 'Highlight changes'
};
if (specialCases[field]) {
return specialCases[field];
}
// Convert camelCase to Title Case with spaces
return field
.replace(/([A-Z])/g, ' $1')
.replace(/^./, str => str.toUpperCase())
.trim();
}
+6 -1
View File
@@ -2,6 +2,7 @@ import { json } from '@sveltejs/kit';
import type { RequestHandler } from '@sveltejs/kit';
import { authorize } from '$lib/server/authorize';
import { getLdapConfigs, createLdapConfig } from '$lib/server/db';
import { auditLdapConfig } from '$lib/server/audit';
// GET /api/auth/ldap - List all LDAP configurations
export const GET: RequestHandler = async ({ cookies }) => {
@@ -31,7 +32,8 @@ export const GET: RequestHandler = async ({ cookies }) => {
};
// POST /api/auth/ldap - Create a new LDAP configuration
export const POST: RequestHandler = async ({ request, cookies }) => {
export const POST: RequestHandler = async (event) => {
const { request, cookies } = event;
const auth = await authorize(cookies);
// Allow access when auth is disabled (setup mode) or when user is admin
@@ -70,6 +72,9 @@ export const POST: RequestHandler = async ({ request, cookies }) => {
tlsCa: data.tlsCa || undefined
});
// Audit log
await auditLdapConfig(event, 'create', config.id, config.name);
return json({
...config,
bindPassword: config.bindPassword ? '********' : undefined
+25 -4
View File
@@ -2,6 +2,8 @@ import { json } from '@sveltejs/kit';
import type { RequestHandler } from '@sveltejs/kit';
import { authorize } from '$lib/server/authorize';
import { getLdapConfig, updateLdapConfig, deleteLdapConfig } from '$lib/server/db';
import { auditLdapConfig } from '$lib/server/audit';
import { computeAuditDiff } from '$lib/utils/diff';
// GET /api/auth/ldap/[id] - Get a specific LDAP configuration
export const GET: RequestHandler = async ({ params, cookies }) => {
@@ -38,7 +40,8 @@ export const GET: RequestHandler = async ({ params, cookies }) => {
};
// PUT /api/auth/ldap/[id] - Update a LDAP configuration
export const PUT: RequestHandler = async ({ params, request, cookies }) => {
export const PUT: RequestHandler = async (event) => {
const { params, request, cookies } = event;
const auth = await authorize(cookies);
// Allow access when auth is disabled (setup mode) or when user is admin
@@ -89,6 +92,14 @@ export const PUT: RequestHandler = async ({ params, request, cookies }) => {
return json({ error: 'Failed to update configuration' }, { status: 500 });
}
// Compute diff for audit (exclude sensitive fields)
const diff = computeAuditDiff(existing, config, {
excludeFields: ['bindPassword', 'tlsCa', 'createdAt', 'updatedAt']
});
// Audit log
await auditLdapConfig(event, 'update', config.id, config.name, diff);
return json({
...config,
bindPassword: config.bindPassword ? '********' : undefined
@@ -100,7 +111,8 @@ export const PUT: RequestHandler = async ({ params, request, cookies }) => {
};
// DELETE /api/auth/ldap/[id] - Delete a LDAP configuration
export const DELETE: RequestHandler = async ({ params, cookies }) => {
export const DELETE: RequestHandler = async (event) => {
const { params, cookies } = event;
const auth = await authorize(cookies);
// Allow access when auth is disabled (setup mode) or when user is admin
@@ -118,11 +130,20 @@ export const DELETE: RequestHandler = async ({ params, cookies }) => {
}
try {
const deleted = await deleteLdapConfig(id);
if (!deleted) {
// Get config before deletion for audit
const config = await getLdapConfig(id);
if (!config) {
return json({ error: 'LDAP configuration not found' }, { status: 404 });
}
const deleted = await deleteLdapConfig(id);
if (!deleted) {
return json({ error: 'Failed to delete LDAP configuration' }, { status: 500 });
}
// Audit log
await auditLdapConfig(event, 'delete', id, config.name);
return json({ success: true });
} catch (error) {
console.error('Failed to delete LDAP config:', error);
+5
View File
@@ -41,6 +41,11 @@ export const POST: RequestHandler = async (event) => {
);
}
// Reject local login attempts when DISABLE_LOCAL_LOGIN is set
if (provider === 'local' && process.env.DISABLE_LOCAL_LOGIN === 'true') {
return json({ error: 'Local login is disabled' }, { status: 403 });
}
// Attempt authentication based on provider
let result: any;
let authProviderType: 'local' | 'ldap' | 'oidc' = 'local';
+6 -1
View File
@@ -6,6 +6,7 @@ import {
createOidcConfig,
type OidcConfig
} from '$lib/server/db';
import { auditOidcProvider } from '$lib/server/audit';
// GET /api/auth/oidc - List all OIDC configurations
export const GET: RequestHandler = async ({ cookies }) => {
@@ -36,7 +37,8 @@ export const GET: RequestHandler = async ({ cookies }) => {
};
// POST /api/auth/oidc - Create new OIDC configuration
export const POST: RequestHandler = async ({ request, cookies }) => {
export const POST: RequestHandler = async (event) => {
const { request, cookies } = event;
const auth = await authorize(cookies);
// When auth is enabled, require authentication and settings:edit permission
@@ -77,6 +79,9 @@ export const POST: RequestHandler = async ({ request, cookies }) => {
roleMappings: data.roleMappings || undefined
});
// Audit log
await auditOidcProvider(event, 'create', config.id, config.name);
return json({
...config,
clientSecret: '********'
+25 -4
View File
@@ -6,6 +6,8 @@ import {
updateOidcConfig,
deleteOidcConfig
} from '$lib/server/db';
import { auditOidcProvider } from '$lib/server/audit';
import { computeAuditDiff } from '$lib/utils/diff';
// GET /api/auth/oidc/[id] - Get specific OIDC configuration
export const GET: RequestHandler = async ({ params, cookies }) => {
@@ -43,7 +45,8 @@ export const GET: RequestHandler = async ({ params, cookies }) => {
};
// PUT /api/auth/oidc/[id] - Update OIDC configuration
export const PUT: RequestHandler = async ({ params, request, cookies }) => {
export const PUT: RequestHandler = async (event) => {
const { params, request, cookies } = event;
const auth = await authorize(cookies);
// When auth is enabled, require authentication and settings:edit permission
@@ -93,6 +96,14 @@ export const PUT: RequestHandler = async ({ params, request, cookies }) => {
return json({ error: 'Failed to update OIDC configuration' }, { status: 500 });
}
// Compute diff for audit (exclude sensitive fields)
const diff = computeAuditDiff(existing, config, {
excludeFields: ['clientSecret', 'createdAt', 'updatedAt']
});
// Audit log
await auditOidcProvider(event, 'update', config.id, config.name, diff);
return json({
...config,
clientSecret: config.clientSecret ? '********' : ''
@@ -104,7 +115,8 @@ export const PUT: RequestHandler = async ({ params, request, cookies }) => {
};
// DELETE /api/auth/oidc/[id] - Delete OIDC configuration
export const DELETE: RequestHandler = async ({ params, cookies }) => {
export const DELETE: RequestHandler = async (event) => {
const { params, cookies } = event;
const auth = await authorize(cookies);
// When auth is enabled, require authentication and settings:edit permission
@@ -123,11 +135,20 @@ export const DELETE: RequestHandler = async ({ params, cookies }) => {
}
try {
const deleted = await deleteOidcConfig(id);
if (!deleted) {
// Get config before deletion for audit
const config = await getOidcConfig(id);
if (!config) {
return json({ error: 'OIDC configuration not found' }, { status: 404 });
}
const deleted = await deleteOidcConfig(id);
if (!deleted) {
return json({ error: 'Failed to delete OIDC configuration' }, { status: 500 });
}
// Audit log
await auditOidcProvider(event, 'delete', id, config.name);
return json({ success: true });
} catch (error) {
console.error('Failed to delete OIDC config:', error);
+8 -3
View File
@@ -21,8 +21,10 @@ export const GET: RequestHandler = async () => {
const providers: { id: string; name: string; type: 'local' | 'ldap' | 'oidc'; initiateUrl?: string }[] = [];
// Local auth is always available when auth is enabled
providers.push({ id: 'local', name: 'Local', type: 'local' });
// Local auth is available unless DISABLE_LOCAL_LOGIN is set
if (process.env.DISABLE_LOCAL_LOGIN !== 'true') {
providers.push({ id: 'local', name: 'Local', type: 'local' });
}
// Add enabled LDAP providers (enterprise only)
for (const config of ldapConfigs) {
@@ -49,6 +51,9 @@ export const GET: RequestHandler = async () => {
});
} catch (error) {
console.error('Failed to get auth providers:', error);
return json({ providers: [{ id: 'local', name: 'Local', type: 'local' }] });
const fallbackProviders = process.env.DISABLE_LOCAL_LOGIN === 'true'
? []
: [{ id: 'local', name: 'Local', type: 'local' }];
return json({ providers: fallbackProviders });
}
};
+6 -1
View File
@@ -2,6 +2,7 @@ import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { getConfigSets, createConfigSet } from '$lib/server/db';
import { authorize } from '$lib/server/authorize';
import { auditConfigSet } from '$lib/server/audit';
export const GET: RequestHandler = async ({ cookies }) => {
const auth = await authorize(cookies);
@@ -18,7 +19,8 @@ export const GET: RequestHandler = async ({ cookies }) => {
}
};
export const POST: RequestHandler = async ({ request, cookies }) => {
export const POST: RequestHandler = async (event) => {
const { request, cookies } = event;
const auth = await authorize(cookies);
if (auth.authEnabled && !await auth.can('configsets', 'create')) {
return json({ error: 'Permission denied' }, { status: 403 });
@@ -42,6 +44,9 @@ export const POST: RequestHandler = async ({ request, cookies }) => {
restartPolicy: body.restartPolicy || 'no'
});
// Audit log
await auditConfigSet(event, 'create', configSet.id, configSet.name);
return json(configSet, { status: 201 });
} catch (error: any) {
console.error('Failed to create config set:', error);
+29 -4
View File
@@ -2,6 +2,8 @@ import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { getConfigSet, updateConfigSet, deleteConfigSet } from '$lib/server/db';
import { authorize } from '$lib/server/authorize';
import { auditConfigSet } from '$lib/server/audit';
import { computeAuditDiff } from '$lib/utils/diff';
export const GET: RequestHandler = async ({ params, cookies }) => {
const auth = await authorize(cookies);
@@ -27,7 +29,8 @@ export const GET: RequestHandler = async ({ params, cookies }) => {
}
};
export const PUT: RequestHandler = async ({ params, request, cookies }) => {
export const PUT: RequestHandler = async (event) => {
const { params, request, cookies } = event;
const auth = await authorize(cookies);
if (auth.authEnabled && !await auth.can('configsets', 'edit')) {
return json({ error: 'Permission denied' }, { status: 403 });
@@ -39,6 +42,12 @@ export const PUT: RequestHandler = async ({ params, request, cookies }) => {
return json({ error: 'Invalid ID' }, { status: 400 });
}
// Get old values before update for diff
const oldConfigSet = await getConfigSet(id);
if (!oldConfigSet) {
return json({ error: 'Config set not found' }, { status: 404 });
}
const body = await request.json();
const configSet = await updateConfigSet(id, {
@@ -56,6 +65,12 @@ export const PUT: RequestHandler = async ({ params, request, cookies }) => {
return json({ error: 'Config set not found' }, { status: 404 });
}
// Compute diff for audit
const diff = computeAuditDiff(oldConfigSet, configSet);
// Audit log
await auditConfigSet(event, 'update', configSet.id, configSet.name, diff);
return json(configSet);
} catch (error: any) {
console.error('Failed to update config set:', error);
@@ -66,7 +81,8 @@ export const PUT: RequestHandler = async ({ params, request, cookies }) => {
}
};
export const DELETE: RequestHandler = async ({ params, cookies }) => {
export const DELETE: RequestHandler = async (event) => {
const { params, cookies } = event;
const auth = await authorize(cookies);
if (auth.authEnabled && !await auth.can('configsets', 'delete')) {
return json({ error: 'Permission denied' }, { status: 403 });
@@ -78,11 +94,20 @@ export const DELETE: RequestHandler = async ({ params, cookies }) => {
return json({ error: 'Invalid ID' }, { status: 400 });
}
const deleted = await deleteConfigSet(id);
if (!deleted) {
// Get config set name before deletion for audit log
const configSet = await getConfigSet(id);
if (!configSet) {
return json({ error: 'Config set not found' }, { status: 404 });
}
const deleted = await deleteConfigSet(id);
if (!deleted) {
return json({ error: 'Failed to delete config set' }, { status: 500 });
}
// Audit log
await auditConfigSet(event, 'delete', id, configSet.name);
return json({ success: true });
} catch (error) {
console.error('Failed to delete config set:', error);
+10 -3
View File
@@ -1,7 +1,8 @@
import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { getEnvironments, getEnvironmentByName, createEnvironment, assignUserRole, getRoleByName, getEnvironmentPublicIps, setEnvironmentPublicIp, getEnvUpdateCheckSettings, getEnvironmentTimezone, type Environment } from '$lib/server/db';
import { getEnvironments, getEnvironmentByName, createEnvironment, assignUserRole, getRoleByName, getEnvironmentPublicIps, setEnvironmentPublicIp, getEnvUpdateCheckSettings, getEnvironmentTimezone, getImagePruneSettings, type Environment } from '$lib/server/db';
import { authorize } from '$lib/server/authorize';
import { auditEnvironment } from '$lib/server/audit';
import { refreshSubprocessEnvironments } from '$lib/server/subprocess-manager';
import { serializeLabels, parseLabels, MAX_LABELS } from '$lib/utils/label-colors';
import { cleanPem } from '$lib/utils/pem';
@@ -36,16 +37,18 @@ export const GET: RequestHandler = async ({ cookies }) => {
}
}
// Parse labels from JSON string to array, add public IPs, update check settings, and timezone
// Parse labels from JSON string to array, add public IPs, update check settings, image prune settings, and timezone
const envWithParsedLabels = await Promise.all(environments.map(async env => {
const updateSettings = updateCheckSettingsMap.get(env.id);
const timezone = await getEnvironmentTimezone(env.id);
const imagePruneSettings = await getImagePruneSettings(env.id);
return {
...env,
labels: parseLabels(env.labels as string | null),
publicIp: publicIps[env.id.toString()] || null,
updateCheckEnabled: updateSettings?.enabled || false,
updateCheckAutoUpdate: updateSettings?.autoUpdate || false,
imagePruneEnabled: imagePruneSettings?.enabled || false,
timezone
};
}));
@@ -57,7 +60,8 @@ export const GET: RequestHandler = async ({ cookies }) => {
}
};
export const POST: RequestHandler = async ({ request, cookies }) => {
export const POST: RequestHandler = async (event) => {
const { request, cookies } = event;
const auth = await authorize(cookies);
if (auth.authEnabled && !await auth.can('environments', 'create')) {
return json({ error: 'Permission denied' }, { status: 403 });
@@ -128,6 +132,9 @@ export const POST: RequestHandler = async ({ request, cookies }) => {
}
}
// Audit log
await auditEnvironment(event, 'create', env.id, env.name);
return json(env);
} catch (error) {
console.error('Failed to create environment:', error);
+36 -4
View File
@@ -1,14 +1,16 @@
import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { getEnvironment, updateEnvironment, deleteEnvironment, getEnvironmentPublicIps, setEnvironmentPublicIp, deleteEnvironmentPublicIp, deleteEnvUpdateCheckSettings, getGitStacksForEnvironmentOnly, deleteGitStack } from '$lib/server/db';
import { getEnvironment, updateEnvironment, deleteEnvironment, getEnvironmentPublicIps, setEnvironmentPublicIp, deleteEnvironmentPublicIp, deleteEnvUpdateCheckSettings, deleteImagePruneSettings, getGitStacksForEnvironmentOnly, deleteGitStack } from '$lib/server/db';
import { clearDockerClientCache } from '$lib/server/docker';
import { deleteGitStackFiles } from '$lib/server/git';
import { authorize } from '$lib/server/authorize';
import { auditEnvironment } from '$lib/server/audit';
import { refreshSubprocessEnvironments } from '$lib/server/subprocess-manager';
import { serializeLabels, parseLabels, MAX_LABELS } from '$lib/utils/label-colors';
import { cleanPem } from '$lib/utils/pem';
import { unregisterSchedule } from '$lib/server/scheduler';
import { closeEdgeConnection } from '$lib/server/hawser';
import { computeAuditDiff } from '$lib/utils/diff';
export const GET: RequestHandler = async ({ params, cookies }) => {
const auth = await authorize(cookies);
@@ -40,7 +42,8 @@ export const GET: RequestHandler = async ({ params, cookies }) => {
}
};
export const PUT: RequestHandler = async ({ params, request, cookies }) => {
export const PUT: RequestHandler = async (event) => {
const { params, request, cookies } = event;
const auth = await authorize(cookies);
if (auth.authEnabled && !await auth.can('environments', 'edit')) {
return json({ error: 'Permission denied' }, { status: 403 });
@@ -48,6 +51,13 @@ export const PUT: RequestHandler = async ({ params, request, cookies }) => {
try {
const id = parseInt(params.id);
// Get old values before update for diff
const oldEnv = await getEnvironment(id);
if (!oldEnv) {
return json({ error: 'Environment not found' }, { status: 404 });
}
const data = await request.json();
// Clear cached Docker client before updating
@@ -95,6 +105,14 @@ export const PUT: RequestHandler = async ({ params, request, cookies }) => {
const publicIps = await getEnvironmentPublicIps();
const publicIp = publicIps[id.toString()] || null;
// Compute diff for audit (exclude sensitive TLS fields)
const diff = computeAuditDiff(oldEnv, env, {
excludeFields: ['tlsCa', 'tlsCert', 'tlsKey', 'hawserToken', 'labels']
});
// Audit log
await auditEnvironment(event, 'update', env.id, env.name, diff);
// Parse labels from JSON string to array
return json({
...env,
@@ -107,7 +125,8 @@ export const PUT: RequestHandler = async ({ params, request, cookies }) => {
}
};
export const DELETE: RequestHandler = async ({ params, cookies }) => {
export const DELETE: RequestHandler = async (event) => {
const { params, cookies } = event;
const auth = await authorize(cookies);
if (auth.authEnabled && !await auth.can('environments', 'delete')) {
return json({ error: 'Permission denied' }, { status: 403 });
@@ -116,6 +135,12 @@ export const DELETE: RequestHandler = async ({ params, cookies }) => {
try {
const id = parseInt(params.id);
// Get environment name before deletion for audit log
const env = await getEnvironment(id);
if (!env) {
return json({ error: 'Environment not found' }, { status: 404 });
}
// Close Edge connection if this is a Hawser Edge environment
// This rejects any pending requests and closes the WebSocket
closeEdgeConnection(id);
@@ -131,7 +156,7 @@ export const DELETE: RequestHandler = async ({ params, cookies }) => {
unregisterSchedule(stack.id, 'git_stack_sync');
}
// Delete git stack files from filesystem
deleteGitStackFiles(stack.id);
await deleteGitStackFiles(stack.id, stack.stackName, stack.environmentId);
// Delete git stack from database
await deleteGitStack(stack.id);
}
@@ -149,9 +174,16 @@ export const DELETE: RequestHandler = async ({ params, cookies }) => {
await deleteEnvUpdateCheckSettings(id);
unregisterSchedule(id, 'env_update_check');
// Clean up image prune settings and unregister schedule
await deleteImagePruneSettings(id);
unregisterSchedule(id, 'image_prune');
// Notify subprocesses to stop collecting from deleted environment
refreshSubprocessEnvironments();
// Audit log
await auditEnvironment(event, 'delete', id, env.name);
return json({ success: true });
} catch (error) {
console.error('Failed to delete environment:', error);
@@ -0,0 +1,121 @@
import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { authorize } from '$lib/server/authorize';
import {
getImagePruneSettings,
setImagePruneSettings,
getEnvironment
} from '$lib/server/db';
import { registerSchedule, unregisterSchedule, triggerImagePrune } from '$lib/server/scheduler';
/**
* Get image prune settings for an environment.
*/
export const GET: RequestHandler = async ({ params, cookies }) => {
const auth = await authorize(cookies);
if (auth.authEnabled && !await auth.can('environments', 'view')) {
return json({ error: 'Permission denied' }, { status: 403 });
}
try {
const id = parseInt(params.id);
// Verify environment exists
const env = await getEnvironment(id);
if (!env) {
return json({ error: 'Environment not found' }, { status: 404 });
}
const settings = await getImagePruneSettings(id);
return json({
settings: settings || {
enabled: false,
cronExpression: '0 3 * * 0', // Default: 3 AM Sunday
pruneMode: 'dangling'
}
});
} catch (error) {
console.error('Failed to get image prune settings:', error);
return json({ error: 'Failed to get image prune settings' }, { status: 500 });
}
};
/**
* Save image prune settings for an environment.
*/
export const POST: RequestHandler = async ({ params, request, cookies }) => {
const auth = await authorize(cookies);
if (auth.authEnabled && !await auth.can('environments', 'edit')) {
return json({ error: 'Permission denied' }, { status: 403 });
}
try {
const id = parseInt(params.id);
// Verify environment exists
const env = await getEnvironment(id);
if (!env) {
return json({ error: 'Environment not found' }, { status: 404 });
}
const data = await request.json();
// Get existing settings to preserve lastPruned and lastResult
const existingSettings = await getImagePruneSettings(id);
const settings = {
enabled: data.enabled ?? false,
cronExpression: data.cronExpression || '0 3 * * 0',
pruneMode: data.pruneMode || 'dangling',
lastPruned: existingSettings?.lastPruned,
lastResult: existingSettings?.lastResult
};
// Save settings to database
await setImagePruneSettings(id, settings);
// Register or unregister schedule based on enabled state
if (settings.enabled) {
await registerSchedule(id, 'image_prune', id);
} else {
unregisterSchedule(id, 'image_prune');
}
return json({ success: true, settings });
} catch (error) {
console.error('Failed to save image prune settings:', error);
return json({ error: 'Failed to save image prune settings' }, { status: 500 });
}
};
/**
* Manually trigger image prune for an environment.
*/
export const PUT: RequestHandler = async ({ params, cookies }) => {
const auth = await authorize(cookies);
if (auth.authEnabled && !await auth.can('environments', 'edit')) {
return json({ error: 'Permission denied' }, { status: 403 });
}
try {
const id = parseInt(params.id);
// Verify environment exists
const env = await getEnvironment(id);
if (!env) {
return json({ error: 'Environment not found' }, { status: 404 });
}
const result = await triggerImagePrune(id);
if (!result.success) {
return json({ error: result.error }, { status: 400 });
}
return json({ success: true });
} catch (error) {
console.error('Failed to trigger image prune:', error);
return json({ error: 'Failed to trigger image prune' }, { status: 500 });
}
};
+6 -1
View File
@@ -6,6 +6,7 @@ import {
type GitAuthType
} from '$lib/server/db';
import { authorize } from '$lib/server/authorize';
import { auditGitCredential } from '$lib/server/audit';
export const GET: RequestHandler = async ({ cookies }) => {
const auth = await authorize(cookies);
@@ -33,7 +34,8 @@ export const GET: RequestHandler = async ({ cookies }) => {
}
};
export const POST: RequestHandler = async ({ request, cookies }) => {
export const POST: RequestHandler = async (event) => {
const { request, cookies } = event;
const auth = await authorize(cookies);
if (auth.authEnabled && !await auth.can('git', 'create')) {
return json({ error: 'Permission denied' }, { status: 403 });
@@ -68,6 +70,9 @@ export const POST: RequestHandler = async ({ request, cookies }) => {
sshPassphrase: data.sshPassphrase
});
// Audit log
await auditGitCredential(event, 'create', credential.id, credential.name);
return json({
id: credential.id,
name: credential.name,
+26 -4
View File
@@ -7,6 +7,8 @@ import {
type GitAuthType
} from '$lib/server/db';
import { authorize } from '$lib/server/authorize';
import { auditGitCredential } from '$lib/server/audit';
import { computeAuditDiff } from '$lib/utils/diff';
export const GET: RequestHandler = async ({ params, cookies }) => {
const auth = await authorize(cookies);
@@ -42,7 +44,8 @@ export const GET: RequestHandler = async ({ params, cookies }) => {
}
};
export const PUT: RequestHandler = async ({ params, request, cookies }) => {
export const PUT: RequestHandler = async (event) => {
const { params, request, cookies } = event;
const auth = await authorize(cookies);
if (auth.authEnabled && !await auth.can('git', 'edit')) {
return json({ error: 'Permission denied' }, { status: 403 });
@@ -78,6 +81,15 @@ export const PUT: RequestHandler = async ({ params, request, cookies }) => {
return json({ error: 'Failed to update credential' }, { status: 500 });
}
// Compute diff for audit (only non-sensitive fields)
const diff = computeAuditDiff(
{ name: existing.name, authType: existing.authType, username: existing.username },
{ name: credential.name, authType: credential.authType, username: credential.username }
);
// Audit log
await auditGitCredential(event, 'update', credential.id, credential.name, diff);
return json({
id: credential.id,
name: credential.name,
@@ -97,7 +109,8 @@ export const PUT: RequestHandler = async ({ params, request, cookies }) => {
}
};
export const DELETE: RequestHandler = async ({ params, cookies }) => {
export const DELETE: RequestHandler = async (event) => {
const { params, cookies } = event;
const auth = await authorize(cookies);
if (auth.authEnabled && !await auth.can('git', 'delete')) {
return json({ error: 'Permission denied' }, { status: 403 });
@@ -109,11 +122,20 @@ export const DELETE: RequestHandler = async ({ params, cookies }) => {
return json({ error: 'Invalid credential ID' }, { status: 400 });
}
const deleted = await deleteGitCredential(id);
if (!deleted) {
// Get credential name before deletion for audit log
const credential = await getGitCredential(id);
if (!credential) {
return json({ error: 'Credential not found' }, { status: 404 });
}
const deleted = await deleteGitCredential(id);
if (!deleted) {
return json({ error: 'Failed to delete credential' }, { status: 500 });
}
// Audit log
await auditGitCredential(event, 'delete', id, credential.name);
return json({ success: true });
} catch (error) {
console.error('Failed to delete git credential:', error);
+92
View File
@@ -0,0 +1,92 @@
import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { getGitRepository, getGitCredential } from '$lib/server/db';
import { previewRepoEnvFiles } from '$lib/server/git';
import { authorize } from '$lib/server/authorize';
/**
* POST /api/git/preview-env
* Clone a git repository to a temp directory and read env files for preview.
* Used when creating a new git stack to populate the env editor.
*
* Body: {
* repositoryId?: number, // Existing repository
* url?: string, // OR new repo URL
* branch?: string, // Branch (default: main)
* credentialId?: number, // Credential for auth
* composePath: string, // Path to compose file
* envFilePath?: string // Optional additional env file
* }
*
* Returns: {
* vars: Record<string, string>, // Merged env variables
* sources: { // Which file each var came from
* [key: string]: '.env' | 'envFile'
* },
* error?: string
* }
*/
export const POST: RequestHandler = async ({ request, cookies }) => {
const auth = await authorize(cookies);
// Basic permission check - must be able to create stacks
if (auth.authEnabled && !auth.isAuthenticated) {
return json({ error: 'Authentication required' }, { status: 401 });
}
try {
const data = await request.json();
if (!data.composePath || typeof data.composePath !== 'string') {
return json({ error: 'Compose path is required' }, { status: 400 });
}
let repoUrl: string;
let branch: string = 'main';
let credentialId: number | null = null;
if (data.repositoryId) {
// Use existing repository
const repo = await getGitRepository(data.repositoryId);
if (!repo) {
return json({ error: 'Repository not found' }, { status: 404 });
}
repoUrl = repo.url;
branch = repo.branch;
credentialId = repo.credentialId;
} else if (data.url) {
// New repository details
repoUrl = data.url;
branch = data.branch || 'main';
credentialId = data.credentialId || null;
} else {
return json({ error: 'Either repositoryId or url is required' }, { status: 400 });
}
// Get credential if specified
let credential = null;
if (credentialId) {
credential = await getGitCredential(credentialId);
}
const result = await previewRepoEnvFiles({
repoUrl,
branch,
credential,
composePath: data.composePath,
envFilePath: data.envFilePath || null
});
if (result.error) {
return json({ vars: {}, sources: {}, error: result.error }, { status: 400 });
}
return json({
vars: result.vars,
sources: result.sources
});
} catch (error: any) {
console.error('Failed to preview env files:', error);
return json({ error: error.message || 'Failed to preview env files' }, { status: 500 });
}
};
+6 -1
View File
@@ -6,6 +6,7 @@ import {
getGitCredentials
} from '$lib/server/db';
import { authorize } from '$lib/server/authorize';
import { auditGitRepository } from '$lib/server/audit';
export const GET: RequestHandler = async ({ url, cookies }) => {
const auth = await authorize(cookies);
@@ -24,7 +25,8 @@ export const GET: RequestHandler = async ({ url, cookies }) => {
}
};
export const POST: RequestHandler = async ({ request, cookies }) => {
export const POST: RequestHandler = async (event) => {
const { request, cookies } = event;
const auth = await authorize(cookies);
if (auth.authEnabled && !await auth.can('git', 'create')) {
return json({ error: 'Permission denied' }, { status: 403 });
@@ -59,6 +61,9 @@ export const POST: RequestHandler = async ({ request, cookies }) => {
credentialId: data.credentialId || null
});
// Audit log
await auditGitRepository(event, 'create', repository.id, repository.name);
return json(repository);
} catch (error: any) {
console.error('Failed to create git repository:', error);
@@ -8,6 +8,8 @@ import {
} from '$lib/server/db';
import { deleteRepositoryFiles } from '$lib/server/git';
import { authorize } from '$lib/server/authorize';
import { auditGitRepository } from '$lib/server/audit';
import { computeAuditDiff } from '$lib/utils/diff';
export const GET: RequestHandler = async ({ params, cookies }) => {
const auth = await authorize(cookies);
@@ -33,7 +35,8 @@ export const GET: RequestHandler = async ({ params, cookies }) => {
}
};
export const PUT: RequestHandler = async ({ params, request, cookies }) => {
export const PUT: RequestHandler = async (event) => {
const { params, request, cookies } = event;
const auth = await authorize(cookies);
if (auth.authEnabled && !await auth.can('git', 'edit')) {
return json({ error: 'Permission denied' }, { status: 403 });
@@ -74,6 +77,12 @@ export const PUT: RequestHandler = async ({ params, request, cookies }) => {
return json({ error: 'Failed to update repository' }, { status: 500 });
}
// Compute diff for audit
const diff = computeAuditDiff(existing, repository);
// Audit log
await auditGitRepository(event, 'update', repository.id, repository.name, diff);
return json(repository);
} catch (error: any) {
console.error('Failed to update git repository:', error);
@@ -84,7 +93,8 @@ export const PUT: RequestHandler = async ({ params, request, cookies }) => {
}
};
export const DELETE: RequestHandler = async ({ params, cookies }) => {
export const DELETE: RequestHandler = async (event) => {
const { params, cookies } = event;
const auth = await authorize(cookies);
if (auth.authEnabled && !await auth.can('git', 'delete')) {
return json({ error: 'Permission denied' }, { status: 403 });
@@ -96,14 +106,23 @@ export const DELETE: RequestHandler = async ({ params, cookies }) => {
return json({ error: 'Invalid repository ID' }, { status: 400 });
}
// Get repository name before deletion for audit log
const repository = await getGitRepository(id);
if (!repository) {
return json({ error: 'Repository not found' }, { status: 404 });
}
// Delete repository files first
deleteRepositoryFiles(id);
const deleted = await deleteGitRepository(id);
if (!deleted) {
return json({ error: 'Repository not found' }, { status: 404 });
return json({ error: 'Failed to delete repository' }, { status: 500 });
}
// Audit log
await auditGitRepository(event, 'delete', id, repository.name);
return json({ success: true });
} catch (error) {
console.error('Failed to delete git repository:', error);
+22 -2
View File
@@ -6,12 +6,14 @@ import {
getGitCredentials,
getGitRepository,
createGitRepository,
upsertStackSource
upsertStackSource,
setStackEnvVars
} from '$lib/server/db';
import { deployGitStack } from '$lib/server/git';
import { authorize } from '$lib/server/authorize';
import { registerSchedule } from '$lib/server/scheduler';
import { secureRandomBytes } from '$lib/server/crypto-fallback';
import { auditGitStack } from '$lib/server/audit';
// Stack name validation: must start with alphanumeric, can contain alphanumeric, hyphens, underscores
const STACK_NAME_REGEX = /^[a-zA-Z0-9][a-zA-Z0-9_-]*$/;
@@ -37,7 +39,8 @@ export const GET: RequestHandler = async ({ url, cookies }) => {
}
};
export const POST: RequestHandler = async ({ request, cookies }) => {
export const POST: RequestHandler = async (event) => {
const { request, cookies } = event;
const auth = await authorize(cookies);
try {
@@ -132,9 +135,26 @@ export const POST: RequestHandler = async ({ request, cookies }) => {
await registerSchedule(gitStack.id, 'git_stack_sync', gitStack.environmentId);
}
// Audit log
await auditGitStack(event, 'create', gitStack.id, gitStack.stackName, gitStack.environmentId);
// Save environment variable overrides before deploying
if (data.envVars && Array.isArray(data.envVars) && data.envVars.length > 0) {
await setStackEnvVars(
trimmedStackName,
data.environmentId || null,
data.envVars.filter((v: any) => v.key?.trim()).map((v: any) => ({
key: v.key.trim(),
value: v.value ?? '',
isSecret: v.isSecret ?? false
}))
);
}
// If deployNow is set, deploy immediately
if (data.deployNow) {
const deployResult = await deployGitStack(gitStack.id);
await auditGitStack(event, 'deploy', gitStack.id, gitStack.stackName, gitStack.environmentId);
return json({
...gitStack,
deployResult: deployResult
+35 -4
View File
@@ -1,9 +1,11 @@
import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { getGitStack, updateGitStack, deleteGitStack, deleteStackSource, updateStackSourceName, updateStackEnvVarsName } from '$lib/server/db';
import { getGitStack, updateGitStack, deleteGitStack, deleteStackSource, updateStackSourceName, updateStackEnvVarsName, setStackEnvVars } from '$lib/server/db';
import { deleteGitStackFiles, deployGitStack } from '$lib/server/git';
import { authorize } from '$lib/server/authorize';
import { registerSchedule, unregisterSchedule } from '$lib/server/scheduler';
import { auditGitStack } from '$lib/server/audit';
import { computeAuditDiff } from '$lib/utils/diff';
// Stack name validation: must start with alphanumeric, can contain alphanumeric, hyphens, underscores
const STACK_NAME_REGEX = /^[a-zA-Z0-9][a-zA-Z0-9_-]*$/;
@@ -30,7 +32,8 @@ export const GET: RequestHandler = async ({ params, cookies }) => {
}
};
export const PUT: RequestHandler = async ({ params, request, cookies }) => {
export const PUT: RequestHandler = async (event) => {
const { params, request, cookies } = event;
const auth = await authorize(cookies);
try {
@@ -84,9 +87,33 @@ export const PUT: RequestHandler = async ({ params, request, cookies }) => {
unregisterSchedule(id, 'git_stack_sync');
}
// Compute diff for audit (exclude sensitive fields)
const diff = computeAuditDiff(existing, updated, {
excludeFields: ['webhookSecret', 'createdAt', 'updatedAt', 'lastSync', 'lastCommit', 'syncStatus', 'syncError']
});
// Audit log
await auditGitStack(event, 'update', updated.id, updated.stackName, updated.environmentId, diff);
// Save environment variable overrides before deploying
if (data.envVars && Array.isArray(data.envVars)) {
const stackName = data.stackName || existing.stackName;
const envId = updated.environmentId ?? null;
await setStackEnvVars(
stackName,
envId,
data.envVars.filter((v: any) => v.key?.trim()).map((v: any) => ({
key: v.key.trim(),
value: v.value ?? '',
isSecret: v.isSecret ?? false
}))
);
}
// If deployNow is set, deploy after saving
if (data.deployNow) {
const deployResult = await deployGitStack(id);
await auditGitStack(event, 'deploy', updated.id, updated.stackName, updated.environmentId);
return json({
...updated,
deployResult
@@ -103,7 +130,8 @@ export const PUT: RequestHandler = async ({ params, request, cookies }) => {
}
};
export const DELETE: RequestHandler = async ({ params, cookies }) => {
export const DELETE: RequestHandler = async (event) => {
const { params, cookies } = event;
const auth = await authorize(cookies);
try {
@@ -122,7 +150,7 @@ export const DELETE: RequestHandler = async ({ params, cookies }) => {
unregisterSchedule(id, 'git_stack_sync');
// Delete git files first
deleteGitStackFiles(id);
await deleteGitStackFiles(id, existing.stackName, existing.environmentId);
// Delete the stack_sources record to free up the stack name
await deleteStackSource(existing.stackName, existing.environmentId);
@@ -130,6 +158,9 @@ export const DELETE: RequestHandler = async ({ params, cookies }) => {
// Delete from database
await deleteGitStack(id);
// Audit log
await auditGitStack(event, 'delete', id, existing.stackName, existing.environmentId);
return json({ success: true });
} catch (error) {
console.error('Failed to delete git stack:', error);
@@ -3,8 +3,10 @@ import type { RequestHandler } from './$types';
import { getGitStack } from '$lib/server/db';
import { deployGitStack } from '$lib/server/git';
import { authorize } from '$lib/server/authorize';
import { auditGitStack } from '$lib/server/audit';
export const POST: RequestHandler = async ({ params, cookies }) => {
export const POST: RequestHandler = async (event) => {
const { params, cookies } = event;
const auth = await authorize(cookies);
try {
@@ -20,6 +22,10 @@ export const POST: RequestHandler = async ({ params, cookies }) => {
}
const result = await deployGitStack(id);
// Audit log
await auditGitStack(event, 'deploy', id, gitStack.stackName, gitStack.environmentId);
return json(result);
} catch (error) {
console.error('Failed to deploy git stack:', error);
+6 -1
View File
@@ -7,6 +7,7 @@ import {
type NotificationEventType
} from '$lib/server/db';
import { authorize } from '$lib/server/authorize';
import { auditNotification } from '$lib/server/audit';
import type { RequestHandler } from './$types';
export const GET: RequestHandler = async ({ cookies }) => {
@@ -32,7 +33,8 @@ export const GET: RequestHandler = async ({ cookies }) => {
}
};
export const POST: RequestHandler = async ({ request, cookies }) => {
export const POST: RequestHandler = async (event) => {
const { request, cookies } = event;
const auth = await authorize(cookies);
if (auth.authEnabled && !await auth.can('notifications', 'create')) {
return json({ error: 'Permission denied' }, { status: 403 });
@@ -73,6 +75,9 @@ export const POST: RequestHandler = async ({ request, cookies }) => {
eventTypes: resolvedEventTypes as NotificationEventType[]
});
// Audit log
await auditNotification(event, 'create', setting.id, setting.name);
return json(setting);
} catch (error: any) {
console.error('Error creating notification setting:', error);
+26 -4
View File
@@ -8,6 +8,8 @@ import {
type NotificationEventType
} from '$lib/server/db';
import { authorize } from '$lib/server/authorize';
import { auditNotification } from '$lib/server/audit';
import { computeAuditDiff } from '$lib/utils/diff';
import type { RequestHandler } from './$types';
export const GET: RequestHandler = async ({ params, cookies }) => {
@@ -43,7 +45,8 @@ export const GET: RequestHandler = async ({ params, cookies }) => {
}
};
export const PUT: RequestHandler = async ({ params, request, cookies }) => {
export const PUT: RequestHandler = async (event) => {
const { params, request, cookies } = event;
const auth = await authorize(cookies);
if (auth.authEnabled && !await auth.can('notifications', 'edit')) {
return json({ error: 'Permission denied' }, { status: 403 });
@@ -94,6 +97,15 @@ export const PUT: RequestHandler = async ({ params, request, cookies }) => {
return json({ error: 'Failed to update notification setting' }, { status: 500 });
}
// Compute diff for audit (exclude config to avoid logging sensitive data)
const diff = computeAuditDiff(
{ name: existing.name, enabled: existing.enabled, eventTypes: existing.eventTypes },
{ name: updated.name, enabled: updated.enabled, eventTypes: updated.eventTypes }
);
// Audit log
await auditNotification(event, 'update', updated.id, updated.name, diff);
// Don't expose passwords in response
const safeSetting = {
...updated,
@@ -110,7 +122,8 @@ export const PUT: RequestHandler = async ({ params, request, cookies }) => {
}
};
export const DELETE: RequestHandler = async ({ params, cookies }) => {
export const DELETE: RequestHandler = async (event) => {
const { params, cookies } = event;
const auth = await authorize(cookies);
if (auth.authEnabled && !await auth.can('notifications', 'delete')) {
return json({ error: 'Permission denied' }, { status: 403 });
@@ -122,11 +135,20 @@ export const DELETE: RequestHandler = async ({ params, cookies }) => {
return json({ error: 'Invalid ID' }, { status: 400 });
}
const deleted = await deleteNotificationSetting(id);
if (!deleted) {
// Get notification name before deletion for audit log
const setting = await getNotificationSetting(id);
if (!setting) {
return json({ error: 'Notification setting not found' }, { status: 404 });
}
const deleted = await deleteNotificationSetting(id);
if (!deleted) {
return json({ error: 'Failed to delete notification setting' }, { status: 500 });
}
// Audit log
await auditNotification(event, 'delete', id, setting.name);
return json({ success: true });
} catch (error) {
console.error('Error deleting notification setting:', error);
+4 -3
View File
@@ -45,11 +45,12 @@ export const POST: RequestHandler = async ({ request, cookies }) => {
updatedAt: new Date().toISOString()
};
const success = await testNotification(setting);
const result = await testNotification(setting);
return json({
success,
message: success ? 'Test notification sent successfully' : 'Failed to send test notification'
success: result.success,
message: result.success ? 'Test notification sent successfully' : undefined,
error: result.error || (result.success ? undefined : 'Failed to send test notification')
});
} catch (error: any) {
console.error('Error testing notification:', error);
@@ -45,7 +45,7 @@ export const PUT: RequestHandler = async ({ request, cookies }) => {
const validTerminalFontIds = monospaceFonts.map(f => f.id);
const validFontSizes = ['xsmall', 'small', 'normal', 'medium', 'large', 'xlarge'];
const updates: { lightTheme?: string; darkTheme?: string; font?: string; fontSize?: string; gridFontSize?: string; terminalFont?: string } = {};
const updates: { lightTheme?: string; darkTheme?: string; font?: string; fontSize?: string; gridFontSize?: string; terminalFont?: string; editorFont?: string } = {};
if (data.lightTheme !== undefined) {
if (!validLightThemeIds.includes(data.lightTheme)) {
@@ -89,6 +89,13 @@ export const PUT: RequestHandler = async ({ request, cookies }) => {
updates.terminalFont = data.terminalFont;
}
if (data.editorFont !== undefined) {
if (!validTerminalFontIds.includes(data.editorFont)) {
return json({ error: 'Invalid editor font' }, { status: 400 });
}
updates.editorFont = data.editorFont;
}
await setUserThemePreferences(currentUser.id, updates);
// Return updated preferences
+12 -1
View File
@@ -1,9 +1,11 @@
import { json } from '@sveltejs/kit';
import { pruneAll } from '$lib/server/docker';
import { authorize } from '$lib/server/authorize';
import { audit } from '$lib/server/audit';
import type { RequestHandler } from './$types';
export const POST: RequestHandler = async ({ url, cookies }) => {
export const POST: RequestHandler = async (event) => {
const { url, cookies } = event;
const auth = await authorize(cookies);
const envId = url.searchParams.get('env');
@@ -16,6 +18,15 @@ export const POST: RequestHandler = async ({ url, cookies }) => {
try {
const result = await pruneAll(envIdNum);
// Audit log - single entry for prune all operation
await audit(event, 'prune', 'settings', {
environmentId: envIdNum,
entityName: 'system',
description: 'Pruned all unused Docker resources',
details: { result }
});
return json({ success: true, result });
} catch (error: any) {
console.error('Error pruning all:', error?.message || error, error?.stack);
+11 -1
View File
@@ -1,9 +1,11 @@
import { json } from '@sveltejs/kit';
import { pruneContainers } from '$lib/server/docker';
import { authorize } from '$lib/server/authorize';
import { audit } from '$lib/server/audit';
import type { RequestHandler } from './$types';
export const POST: RequestHandler = async ({ url, cookies }) => {
export const POST: RequestHandler = async (event) => {
const { url, cookies } = event;
const auth = await authorize(cookies);
const envId = url.searchParams.get('env');
@@ -16,6 +18,14 @@ export const POST: RequestHandler = async ({ url, cookies }) => {
try {
const result = await pruneContainers(envIdNum);
// Audit log
await audit(event, 'prune', 'container', {
environmentId: envIdNum,
description: 'Pruned stopped containers',
details: { result }
});
return json({ success: true, result });
} catch (error) {
console.error('Error pruning containers:', error);
+11 -1
View File
@@ -1,9 +1,11 @@
import { json } from '@sveltejs/kit';
import { pruneImages } from '$lib/server/docker';
import { authorize } from '$lib/server/authorize';
import { audit } from '$lib/server/audit';
import type { RequestHandler } from './$types';
export const POST: RequestHandler = async ({ url, cookies }) => {
export const POST: RequestHandler = async (event) => {
const { url, cookies } = event;
const auth = await authorize(cookies);
const envId = url.searchParams.get('env');
@@ -17,6 +19,14 @@ export const POST: RequestHandler = async ({ url, cookies }) => {
try {
const result = await pruneImages(danglingOnly, envIdNum);
// Audit log
await audit(event, 'prune', 'image', {
environmentId: envIdNum,
description: `Pruned ${danglingOnly ? 'dangling' : 'unused'} images`,
details: { danglingOnly, result }
});
return json({ success: true, result });
} catch (error) {
console.error('Error pruning images:', error);
+11 -1
View File
@@ -1,9 +1,11 @@
import { json } from '@sveltejs/kit';
import { pruneNetworks } from '$lib/server/docker';
import { authorize } from '$lib/server/authorize';
import { audit } from '$lib/server/audit';
import type { RequestHandler } from './$types';
export const POST: RequestHandler = async ({ url, cookies }) => {
export const POST: RequestHandler = async (event) => {
const { url, cookies } = event;
const auth = await authorize(cookies);
const envId = url.searchParams.get('env');
@@ -16,6 +18,14 @@ export const POST: RequestHandler = async ({ url, cookies }) => {
try {
const result = await pruneNetworks(envIdNum);
// Audit log
await audit(event, 'prune', 'network', {
environmentId: envIdNum,
description: 'Pruned unused networks',
details: { result }
});
return json({ success: true, result });
} catch (error) {
console.error('Error pruning networks:', error);
+11 -1
View File
@@ -1,9 +1,11 @@
import { json } from '@sveltejs/kit';
import { pruneVolumes } from '$lib/server/docker';
import { authorize } from '$lib/server/authorize';
import { audit } from '$lib/server/audit';
import type { RequestHandler } from './$types';
export const POST: RequestHandler = async ({ url, cookies }) => {
export const POST: RequestHandler = async (event) => {
const { url, cookies } = event;
const auth = await authorize(cookies);
const envId = url.searchParams.get('env');
@@ -16,6 +18,14 @@ export const POST: RequestHandler = async ({ url, cookies }) => {
try {
const result = await pruneVolumes(envIdNum);
// Audit log
await audit(event, 'prune', 'volume', {
environmentId: envIdNum,
description: 'Pruned unused volumes',
details: { result }
});
return json({ success: true, result });
} catch (error) {
console.error('Error pruning volumes:', error);
+6 -1
View File
@@ -2,6 +2,7 @@ import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { getRegistries, createRegistry, setDefaultRegistry } from '$lib/server/db';
import { authorize } from '$lib/server/authorize';
import { auditRegistry } from '$lib/server/audit';
export const GET: RequestHandler = async ({ cookies }) => {
const auth = await authorize(cookies);
@@ -23,7 +24,8 @@ export const GET: RequestHandler = async ({ cookies }) => {
}
};
export const POST: RequestHandler = async ({ request, cookies }) => {
export const POST: RequestHandler = async (event) => {
const { request, cookies } = event;
const auth = await authorize(cookies);
if (auth.authEnabled && !await auth.can('registries', 'create')) {
return json({ error: 'Permission denied' }, { status: 403 });
@@ -49,6 +51,9 @@ export const POST: RequestHandler = async ({ request, cookies }) => {
await setDefaultRegistry(registry.id);
}
// Audit log
await auditRegistry(event, 'create', registry.id, registry.name);
// Don't expose password in response
const { password, ...safeRegistry } = registry;
return json({ ...safeRegistry, hasCredentials: !!password }, { status: 201 });
+28 -3
View File
@@ -2,6 +2,8 @@ import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { getRegistry, updateRegistry, deleteRegistry, setDefaultRegistry } from '$lib/server/db';
import { authorize } from '$lib/server/authorize';
import { auditRegistry } from '$lib/server/audit';
import { computeAuditDiff } from '$lib/utils/diff';
export const GET: RequestHandler = async ({ params, cookies }) => {
const auth = await authorize(cookies);
@@ -29,7 +31,8 @@ export const GET: RequestHandler = async ({ params, cookies }) => {
}
};
export const PUT: RequestHandler = async ({ params, request, cookies }) => {
export const PUT: RequestHandler = async (event) => {
const { params, request, cookies } = event;
const auth = await authorize(cookies);
if (auth.authEnabled && !await auth.can('registries', 'edit')) {
return json({ error: 'Permission denied' }, { status: 403 });
@@ -41,6 +44,12 @@ export const PUT: RequestHandler = async ({ params, request, cookies }) => {
return json({ error: 'Invalid registry ID' }, { status: 400 });
}
// Get old values before update for diff
const oldRegistry = await getRegistry(id);
if (!oldRegistry) {
return json({ error: 'Registry not found' }, { status: 404 });
}
const data = await request.json();
const registry = await updateRegistry(id, {
name: data.name,
@@ -59,6 +68,12 @@ export const PUT: RequestHandler = async ({ params, request, cookies }) => {
await setDefaultRegistry(id);
}
// Compute diff for audit
const diff = computeAuditDiff(oldRegistry, registry);
// Audit log
await auditRegistry(event, 'update', registry.id, registry.name, diff);
// Don't expose password
const { password, ...safeRegistry } = registry;
return json({ ...safeRegistry, hasCredentials: !!password });
@@ -71,7 +86,8 @@ export const PUT: RequestHandler = async ({ params, request, cookies }) => {
}
};
export const DELETE: RequestHandler = async ({ params, cookies }) => {
export const DELETE: RequestHandler = async (event) => {
const { params, cookies } = event;
const auth = await authorize(cookies);
if (auth.authEnabled && !await auth.can('registries', 'delete')) {
return json({ error: 'Permission denied' }, { status: 403 });
@@ -83,11 +99,20 @@ export const DELETE: RequestHandler = async ({ params, cookies }) => {
return json({ error: 'Invalid registry ID' }, { status: 400 });
}
// Get registry name before deletion for audit log
const registry = await getRegistry(id);
if (!registry) {
return json({ error: 'Registry not found' }, { status: 404 });
}
const deleted = await deleteRegistry(id);
if (!deleted) {
return json({ error: 'Registry not found or cannot be deleted' }, { status: 404 });
return json({ error: 'Registry cannot be deleted' }, { status: 400 });
}
// Audit log
await auditRegistry(event, 'delete', id, registry.name);
return json({ success: true });
} catch (error) {
console.error('Error deleting registry:', error);
+6 -1
View File
@@ -5,6 +5,7 @@ import {
createRole as dbCreateRole
} from '$lib/server/db';
import { authorize } from '$lib/server/authorize';
import { auditRole } from '$lib/server/audit';
// GET /api/roles - List all roles
export const GET: RequestHandler = async ({ cookies }) => {
@@ -26,7 +27,8 @@ export const GET: RequestHandler = async ({ cookies }) => {
};
// POST /api/roles - Create a new role
export const POST: RequestHandler = async ({ request, cookies }) => {
export const POST: RequestHandler = async (event) => {
const { request, cookies } = event;
const auth = await authorize(cookies);
// Check enterprise license
@@ -54,6 +56,9 @@ export const POST: RequestHandler = async ({ request, cookies }) => {
environmentIds: environmentIds ?? null
});
// Audit log
await auditRole(event, 'create', role.id, role.name);
return json(role, { status: 201 });
} catch (error: any) {
console.error('Failed to create role:', error);
+15 -2
View File
@@ -6,6 +6,8 @@ import {
deleteRole as dbDeleteRole
} from '$lib/server/db';
import { authorize } from '$lib/server/authorize';
import { auditRole } from '$lib/server/audit';
import { computeAuditDiff } from '$lib/utils/diff';
// GET /api/roles/[id] - Get a specific role
export const GET: RequestHandler = async ({ params, cookies }) => {
@@ -36,7 +38,8 @@ export const GET: RequestHandler = async ({ params, cookies }) => {
};
// PUT /api/roles/[id] - Update a role
export const PUT: RequestHandler = async ({ params, request, cookies }) => {
export const PUT: RequestHandler = async (event) => {
const { params, request, cookies } = event;
const auth = await authorize(cookies);
// Check enterprise license
@@ -72,6 +75,12 @@ export const PUT: RequestHandler = async ({ params, request, cookies }) => {
return json({ error: 'Failed to update role' }, { status: 500 });
}
// Compute diff for audit
const diff = computeAuditDiff(existingRole, role);
// Audit log
await auditRole(event, 'update', role.id, role.name, diff);
return json(role);
} catch (error: any) {
console.error('Failed to update role:', error);
@@ -83,7 +92,8 @@ export const PUT: RequestHandler = async ({ params, request, cookies }) => {
};
// DELETE /api/roles/[id] - Delete a role
export const DELETE: RequestHandler = async ({ params, cookies }) => {
export const DELETE: RequestHandler = async (event) => {
const { params, cookies } = event;
const auth = await authorize(cookies);
// Check enterprise license
@@ -118,6 +128,9 @@ export const DELETE: RequestHandler = async ({ params, cookies }) => {
return json({ error: 'Failed to delete role' }, { status: 500 });
}
// Audit log
await auditRole(event, 'delete', id, role.name);
return json({ success: true });
} catch (error) {
console.error('Failed to delete role:', error);
+41 -1
View File
@@ -12,6 +12,7 @@ import {
getAllAutoUpdateSettings,
getAllAutoUpdateGitStacks,
getAllEnvUpdateCheckSettings,
getAllImagePruneSettings,
getLastExecutionForSchedule,
getRecentExecutionsForSchedule,
getEnvironment,
@@ -24,7 +25,7 @@ import { getGlobalScannerDefaults, getScannerSettingsWithDefaults } from '$lib/s
export interface ScheduleInfo {
id: number;
type: 'container_update' | 'git_stack_sync' | 'system_cleanup' | 'env_update_check';
type: 'container_update' | 'git_stack_sync' | 'system_cleanup' | 'env_update_check' | 'image_prune';
name: string;
entityName: string;
description?: string;
@@ -164,6 +165,45 @@ export const GET: RequestHandler = async () => {
);
schedules.push(...envUpdateCheckSchedules);
// Get image prune schedules
const imagePruneConfigs = await getAllImagePruneSettings();
const imagePruneSchedules = await Promise.all(
imagePruneConfigs.map(async ({ envId, settings }) => {
const [env, lastExecution, recentExecutions, timezone] = await Promise.all([
getEnvironment(envId),
getLastExecutionForSchedule('image_prune', envId),
getRecentExecutionsForSchedule('image_prune', envId, 5),
getEnvironmentTimezone(envId)
]);
const isEnabled = settings.enabled ?? false;
const nextRun = isEnabled && settings.cronExpression ? getNextRun(settings.cronExpression, timezone) : null;
// Build description based on prune mode
const description = settings.pruneMode === 'all'
? 'Prune all unused images'
: 'Prune dangling images only';
return {
id: envId,
type: 'image_prune' as const,
name: `Prune images: ${env?.name || 'Unknown'}`,
entityName: env?.name || 'Unknown',
description,
environmentId: envId,
environmentName: env?.name ?? null,
enabled: isEnabled,
scheduleType: 'custom',
cronExpression: settings.cronExpression ?? null,
nextRun: nextRun?.toISOString() ?? null,
lastExecution: lastExecution ?? null,
recentExecutions,
isSystem: false,
pruneMode: settings.pruneMode
};
})
);
schedules.push(...imagePruneSchedules);
// Get system schedules
const systemSchedules = await getSystemSchedules();
const sysSchedules = await Promise.all(
@@ -9,7 +9,8 @@ import {
getAutoUpdateSettingById,
deleteAutoUpdateSchedule,
updateGitStack,
deleteEnvUpdateCheckSettings
deleteEnvUpdateCheckSettings,
deleteImagePruneSettings
} from '$lib/server/db';
import { unregisterSchedule } from '$lib/server/scheduler';
@@ -49,6 +50,12 @@ export const DELETE: RequestHandler = async ({ params }) => {
unregisterSchedule(scheduleId, 'env_update_check');
return json({ success: true });
} else if (type === 'image_prune') {
// Delete image prune settings (scheduleId is environmentId)
await deleteImagePruneSettings(scheduleId);
unregisterSchedule(scheduleId, 'image_prune');
return json({ success: true });
} else if (type === 'system_cleanup') {
return json({ error: 'System schedules cannot be removed' }, { status: 400 });
@@ -4,13 +4,13 @@
* POST /api/schedules/[type]/[id]/run - Trigger a manual execution
*
* Path params:
* - type: 'container_update' | 'git_stack_sync' | 'system_cleanup'
* - type: 'container_update' | 'git_stack_sync' | 'system_cleanup' | 'env_update_check' | 'image_prune'
* - id: schedule ID
*/
import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { triggerContainerUpdate, triggerGitStackSync, triggerSystemJob, triggerEnvUpdateCheck } from '$lib/server/scheduler';
import { triggerContainerUpdate, triggerGitStackSync, triggerSystemJob, triggerEnvUpdateCheck, triggerImagePrune } from '$lib/server/scheduler';
export const POST: RequestHandler = async ({ params }) => {
try {
@@ -36,6 +36,9 @@ export const POST: RequestHandler = async ({ params }) => {
case 'env_update_check':
result = await triggerEnvUpdateCheck(scheduleId);
break;
case 'image_prune':
result = await triggerImagePrune(scheduleId);
break;
default:
return json({ error: 'Invalid schedule type' }, { status: 400 });
}
@@ -5,7 +5,7 @@
import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { getAutoUpdateSettingById, updateAutoUpdateSettingById, getGitStack, updateGitStack, getEnvUpdateCheckSettings, setEnvUpdateCheckSettings } from '$lib/server/db';
import { getAutoUpdateSettingById, updateAutoUpdateSettingById, getGitStack, updateGitStack, getEnvUpdateCheckSettings, setEnvUpdateCheckSettings, getImagePruneSettings, setImagePruneSettings } from '$lib/server/db';
import { registerSchedule, unregisterSchedule } from '$lib/server/scheduler';
export const POST: RequestHandler = async ({ params }) => {
@@ -75,6 +75,27 @@ export const POST: RequestHandler = async ({ params }) => {
unregisterSchedule(scheduleId, 'env_update_check');
}
return json({ success: true, enabled: newEnabled });
} else if (type === 'image_prune') {
// scheduleId is environmentId for image prune
const config = await getImagePruneSettings(scheduleId);
if (!config) {
return json({ error: 'Schedule not found' }, { status: 404 });
}
const newEnabled = !config.enabled;
await setImagePruneSettings(scheduleId, {
...config,
enabled: newEnabled
});
// Register or unregister schedule with croner
if (newEnabled && config.cronExpression) {
await registerSchedule(scheduleId, 'image_prune', scheduleId);
} else {
unregisterSchedule(scheduleId, 'image_prune');
}
return json({ success: true, enabled: newEnabled });
} else if (type === 'system_cleanup') {
return json({ error: 'System schedules cannot be paused' }, { status: 400 });
@@ -9,6 +9,7 @@ import {
getAllAutoUpdateSettings,
getAllAutoUpdateGitStacks,
getAllEnvUpdateCheckSettings,
getAllImagePruneSettings,
getLastExecutionForSchedule,
getRecentExecutionsForSchedule,
getEnvironment,
@@ -140,6 +141,45 @@ async function getSchedulesData(): Promise<ScheduleInfo[]> {
);
schedules.push(...envUpdateCheckSchedules);
// Get image prune schedules
const imagePruneConfigs = await getAllImagePruneSettings();
const imagePruneSchedules = await Promise.all(
imagePruneConfigs.map(async ({ envId, settings }) => {
const [env, lastExecution, recentExecutions, timezone] = await Promise.all([
getEnvironment(envId),
getLastExecutionForSchedule('image_prune', envId),
getRecentExecutionsForSchedule('image_prune', envId, 5),
getEnvironmentTimezone(envId)
]);
const isEnabled = settings.enabled ?? false;
const nextRun = isEnabled && settings.cronExpression ? getNextRun(settings.cronExpression, timezone) : null;
// Build description based on prune mode
const description = settings.pruneMode === 'all'
? 'Prune all unused images'
: 'Prune dangling images only';
return {
id: envId,
type: 'image_prune' as const,
name: `Prune images: ${env?.name || 'Unknown'}`,
entityName: env?.name || 'Unknown',
description,
environmentId: envId,
environmentName: env?.name ?? null,
enabled: isEnabled,
scheduleType: 'custom',
cronExpression: settings.cronExpression ?? null,
nextRun: nextRun?.toISOString() ?? null,
lastExecution: lastExecution ?? null,
recentExecutions,
isSystem: false,
pruneMode: settings.pruneMode
};
})
);
schedules.push(...imagePruneSchedules);
// Get system schedules
const systemSchedules = await getSystemSchedules();
const sysSchedules = await Promise.all(
+15 -3
View File
@@ -64,6 +64,7 @@ export interface GeneralSettings {
fontSize: string;
gridFontSize: string;
terminalFont: string;
editorFont: string;
// External stack paths
externalStackPaths: string[];
// Primary stack location
@@ -89,14 +90,16 @@ const DEFAULT_SETTINGS: Omit<GeneralSettings, 'scheduleRetentionDays' | 'eventRe
font: 'system',
fontSize: 'normal',
gridFontSize: 'normal',
terminalFont: 'system-mono'
terminalFont: 'system-mono',
editorFont: 'system-mono'
};
const VALID_LIGHT_THEMES = ['default', 'catppuccin', 'rose-pine', 'nord', 'solarized', 'gruvbox', 'alucard', 'github', 'material', 'atom-one'];
const VALID_DARK_THEMES = ['default', 'catppuccin', 'dracula', 'rose-pine', 'rose-pine-moon', 'tokyo-night', 'nord', 'one-dark', 'gruvbox', 'solarized', 'everforest', 'kanagawa', 'monokai', 'monokai-pro', 'material', 'palenight', 'github'];
const VALID_FONTS = ['system', 'geist', 'inter', 'plus-jakarta', 'dm-sans', 'outfit', 'space-grotesk', 'sofia-sans', 'nunito', 'poppins', 'montserrat', 'raleway', 'manrope', 'roboto', 'open-sans', 'lato', 'source-sans', 'work-sans', 'fira-sans', 'jetbrains-mono', 'fira-code', 'quicksand', 'comfortaa'];
const VALID_FONT_SIZES = ['xsmall', 'small', 'normal', 'medium', 'large', 'xlarge'];
const VALID_TERMINAL_FONTS = ['system-mono', 'jetbrains-mono', 'fira-code', 'source-code-pro', 'cascadia-code', 'menlo', 'consolas', 'sf-mono'];
const VALID_TERMINAL_FONTS = ['system-mono', 'jetbrains-mono', 'fira-code', 'source-code-pro', 'cascadia-code', 'ibm-plex-mono', 'roboto-mono', 'ubuntu-mono', 'space-mono', 'inconsolata', 'hack', 'anonymous-pro', 'dm-mono', 'red-hat-mono', 'menlo', 'consolas', 'sf-mono'];
const VALID_EDITOR_FONTS = VALID_TERMINAL_FONTS;
const VALID_DATE_FORMATS: DateFormat[] = ['MM/DD/YYYY', 'DD/MM/YYYY', 'YYYY-MM-DD', 'DD.MM.YYYY'];
@@ -136,6 +139,7 @@ export const GET: RequestHandler = async ({ cookies }) => {
fontSize,
gridFontSize,
terminalFont,
editorFont,
externalStackPaths,
primaryStackLocation
] = await Promise.all([
@@ -164,6 +168,7 @@ export const GET: RequestHandler = async ({ cookies }) => {
getSetting('theme_font_size'),
getSetting('theme_grid_font_size'),
getSetting('theme_terminal_font'),
getSetting('theme_editor_font'),
getExternalStackPaths(),
getPrimaryStackLocation()
]);
@@ -194,6 +199,7 @@ export const GET: RequestHandler = async ({ cookies }) => {
fontSize: fontSize ?? DEFAULT_SETTINGS.fontSize,
gridFontSize: gridFontSize ?? DEFAULT_SETTINGS.gridFontSize,
terminalFont: terminalFont ?? DEFAULT_SETTINGS.terminalFont,
editorFont: editorFont ?? DEFAULT_SETTINGS.editorFont,
externalStackPaths,
primaryStackLocation
};
@@ -213,7 +219,7 @@ export const POST: RequestHandler = async ({ request, cookies }) => {
try {
const body = await request.json();
const { confirmDestructive, showStoppedContainers, highlightUpdates, timeFormat, dateFormat, downloadFormat, defaultGrypeArgs, defaultTrivyArgs, scheduleRetentionDays, eventRetentionDays, scheduleCleanupCron, eventCleanupCron, scheduleCleanupEnabled, eventCleanupEnabled, logBufferSizeKb, defaultTimezone, eventCollectionMode, eventPollInterval, metricsCollectionInterval, lightTheme, darkTheme, font, fontSize, gridFontSize, terminalFont, externalStackPaths, primaryStackLocation } = body;
const { confirmDestructive, showStoppedContainers, highlightUpdates, timeFormat, dateFormat, downloadFormat, defaultGrypeArgs, defaultTrivyArgs, scheduleRetentionDays, eventRetentionDays, scheduleCleanupCron, eventCleanupCron, scheduleCleanupEnabled, eventCleanupEnabled, logBufferSizeKb, defaultTimezone, eventCollectionMode, eventPollInterval, metricsCollectionInterval, lightTheme, darkTheme, font, fontSize, gridFontSize, terminalFont, editorFont, externalStackPaths, primaryStackLocation } = body;
if (confirmDestructive !== undefined) {
await setSetting('confirm_destructive', confirmDestructive);
@@ -303,6 +309,9 @@ export const POST: RequestHandler = async ({ request, cookies }) => {
if (terminalFont !== undefined && VALID_TERMINAL_FONTS.includes(terminalFont)) {
await setSetting('theme_terminal_font', terminalFont);
}
if (editorFont !== undefined && VALID_EDITOR_FONTS.includes(editorFont)) {
await setSetting('theme_editor_font', editorFont);
}
if (externalStackPaths !== undefined && Array.isArray(externalStackPaths)) {
// Filter to valid non-empty strings
const validPaths = externalStackPaths.filter((p: unknown) => typeof p === 'string' && p.trim());
@@ -345,6 +354,7 @@ export const POST: RequestHandler = async ({ request, cookies }) => {
fontSizeVal,
gridFontSizeVal,
terminalFontVal,
editorFontVal,
externalStackPathsVal,
primaryStackLocationVal
] = await Promise.all([
@@ -373,6 +383,7 @@ export const POST: RequestHandler = async ({ request, cookies }) => {
getSetting('theme_font_size'),
getSetting('theme_grid_font_size'),
getSetting('theme_terminal_font'),
getSetting('theme_editor_font'),
getExternalStackPaths(),
getPrimaryStackLocation()
]);
@@ -403,6 +414,7 @@ export const POST: RequestHandler = async ({ request, cookies }) => {
fontSize: fontSizeVal ?? DEFAULT_SETTINGS.fontSize,
gridFontSize: gridFontSizeVal ?? DEFAULT_SETTINGS.gridFontSize,
terminalFont: terminalFontVal ?? DEFAULT_SETTINGS.terminalFont,
editorFont: editorFontVal ?? DEFAULT_SETTINGS.editorFont,
externalStackPaths: externalStackPathsVal,
primaryStackLocation: primaryStackLocationVal
};
+53
View File
@@ -0,0 +1,53 @@
import { json, type RequestHandler } from '@sveltejs/kit';
import { getSetting } from '$lib/server/db';
/**
* Public endpoint for theme settings - no authentication required.
* Used by the login page to apply the app-level theme before user is authenticated.
*/
const DEFAULT_THEME_SETTINGS = {
lightTheme: 'default',
darkTheme: 'default',
font: 'system',
fontSize: 'normal',
gridFontSize: 'normal',
terminalFont: 'system-mono',
editorFont: 'system-mono'
};
export const GET: RequestHandler = async () => {
try {
const [
lightTheme,
darkTheme,
font,
fontSize,
gridFontSize,
terminalFont,
editorFont
] = await Promise.all([
getSetting('theme_light'),
getSetting('theme_dark'),
getSetting('theme_font'),
getSetting('theme_font_size'),
getSetting('theme_grid_font_size'),
getSetting('theme_terminal_font'),
getSetting('theme_editor_font')
]);
return json({
lightTheme: lightTheme ?? DEFAULT_THEME_SETTINGS.lightTheme,
darkTheme: darkTheme ?? DEFAULT_THEME_SETTINGS.darkTheme,
font: font ?? DEFAULT_THEME_SETTINGS.font,
fontSize: fontSize ?? DEFAULT_THEME_SETTINGS.fontSize,
gridFontSize: gridFontSize ?? DEFAULT_THEME_SETTINGS.gridFontSize,
terminalFont: terminalFont ?? DEFAULT_THEME_SETTINGS.terminalFont,
editorFont: editorFont ?? DEFAULT_THEME_SETTINGS.editorFont
});
} catch (error) {
console.error('Failed to get theme settings:', error);
// Return defaults on error
return json(DEFAULT_THEME_SETTINGS);
}
};
+39 -22
View File
@@ -1,8 +1,9 @@
import { json } from '@sveltejs/kit';
import { listComposeStacks, deployStack, saveStackComposeFile, saveStackEnvVars, writeRawStackEnvFile, saveStackEnvVarsToDb } from '$lib/server/stacks';
import { listComposeStacks, deployStack, saveStackComposeFile, writeStackEnvFile, writeRawStackEnvFile, saveStackEnvVarsToDb } from '$lib/server/stacks';
import { EnvironmentNotFoundError } from '$lib/server/docker';
import { upsertStackSource, getStackSources } from '$lib/server/db';
import { authorize } from '$lib/server/authorize';
import { auditStack } from '$lib/server/audit';
import type { RequestHandler } from './$types';
export const GET: RequestHandler = async ({ url, cookies }) => {
@@ -34,6 +35,14 @@ export const GET: RequestHandler = async ({ url, cookies }) => {
const stackSources = await getStackSources(envIdNum);
const existingNames = new Set(stacks.map((s) => s.name));
// Enrich Docker-discovered stacks with source type from DB
for (const stack of stacks) {
const source = stackSources.find(s => s.stackName === stack.name);
if (source) {
(stack as any).sourceType = source.sourceType;
}
}
for (const source of stackSources) {
// Add stacks from database that aren't already in the Docker list
// This includes internal, git, and external (adopted) stacks that are currently down
@@ -42,8 +51,9 @@ export const GET: RequestHandler = async ({ url, cookies }) => {
name: source.stackName,
containers: [],
containerDetails: [],
status: 'created' as any
});
status: 'created' as any,
sourceType: source.sourceType
} as any);
}
}
@@ -58,7 +68,8 @@ export const GET: RequestHandler = async ({ url, cookies }) => {
}
};
export const POST: RequestHandler = async ({ request, url, cookies }) => {
export const POST: RequestHandler = async (event) => {
const { request, url, cookies } = event;
const auth = await authorize(cookies);
const envId = url.searchParams.get('env');
@@ -97,19 +108,20 @@ export const POST: RequestHandler = async ({ request, url, cookies }) => {
}
// Save environment variables
// - rawEnvContent: non-secret vars with comments → .env file
// - envVars: ALL vars → DB (secrets stored for shell injection, non-secrets for metadata)
// - rawEnvContent → .env file (non-secrets with comments)
// - secrets only → DB (for shell injection at runtime)
if (rawEnvContent) {
// Write raw content to .env file (should NOT contain secrets)
await writeRawStackEnvFile(name, rawEnvContent, envIdNum, envPath || undefined);
}
// Save ALL vars to DB (secrets for shell injection at runtime)
if (envVars && Array.isArray(envVars) && envVars.length > 0) {
await saveStackEnvVarsToDb(name, envVars, envIdNum);
}
// Fallback: if no rawEnvContent, generate .env from non-secret vars
if (!rawEnvContent && envVars && Array.isArray(envVars) && envVars.length > 0) {
await saveStackEnvVars(name, envVars, envIdNum, envPath || undefined);
const secrets = envVars.filter((v: any) => v.isSecret);
if (secrets.length > 0) {
await saveStackEnvVarsToDb(name, secrets, envIdNum);
}
// Fallback: if no rawEnvContent, generate .env from non-secret vars
if (!rawEnvContent) {
await writeStackEnvFile(name, envVars, envIdNum, envPath || undefined);
}
}
// Record the stack as internally created with custom paths if provided
@@ -121,6 +133,9 @@ export const POST: RequestHandler = async ({ request, url, cookies }) => {
envPath: envPath || undefined
});
// Audit log
await auditStack(event, 'create', name, envIdNum);
return json({ success: true, started: false });
}
@@ -132,19 +147,18 @@ export const POST: RequestHandler = async ({ request, url, cookies }) => {
// Save environment variables BEFORE deploying so they're available during start
if (rawEnvContent || (envVars && Array.isArray(envVars) && envVars.length > 0)) {
// - rawEnvContent: non-secret vars with comments → .env file
// - envVars: ALL vars → DB (secrets stored for shell injection, non-secrets for metadata)
if (rawEnvContent) {
// Write raw content to .env file (should NOT contain secrets)
await writeRawStackEnvFile(name, rawEnvContent, envIdNum, envPath || undefined);
}
// Save ALL vars to DB (secrets for shell injection at runtime)
if (envVars && Array.isArray(envVars) && envVars.length > 0) {
await saveStackEnvVarsToDb(name, envVars, envIdNum);
}
// Fallback: if no rawEnvContent, generate .env from non-secret vars
if (!rawEnvContent && envVars && Array.isArray(envVars) && envVars.length > 0) {
await saveStackEnvVars(name, envVars, envIdNum, envPath || undefined);
const secrets = envVars.filter((v: any) => v.isSecret);
if (secrets.length > 0) {
await saveStackEnvVarsToDb(name, secrets, envIdNum);
}
// Fallback: if no rawEnvContent, generate .env from non-secret vars
if (!rawEnvContent) {
await writeStackEnvFile(name, envVars, envIdNum, envPath || undefined);
}
}
}
@@ -170,6 +184,9 @@ export const POST: RequestHandler = async ({ request, url, cookies }) => {
envPath: envPath || undefined
});
// Audit log (create + deploy in one action)
await auditStack(event, 'deploy', name, envIdNum);
return json({ success: true, started: true, output: result.output });
} catch (error: any) {
console.error('Error creating compose stack:', error);
@@ -70,7 +70,6 @@ export const PUT: RequestHandler = async ({ params, request, url, cookies }) =>
if (restart) {
// Deploy with docker compose up -d --force-recreate
// Force recreate ensures env var changes are applied
// Note: deployStack uses requireComposeFile which will use saved paths
// Save paths first if provided
if (pathOptions) {
const saveResult = await saveStackComposeFile(name, content, false, envIdNum, pathOptions);
@@ -78,11 +77,15 @@ export const PUT: RequestHandler = async ({ params, request, url, cookies }) =>
return json({ error: saveResult.error }, { status: 500 });
}
}
// Get authoritative paths from DB/filesystem for deploy
const composeInfo = await getStackComposeFile(name, envIdNum);
result = await deployStack({
name,
compose: content,
envId: envIdNum,
forceRecreate: true
forceRecreate: true,
composePath: composeInfo.composePath || undefined,
envPath: composeInfo.envPath || undefined
});
} else {
// Just save the file without restarting (update operation, not create)
+34 -56
View File
@@ -57,9 +57,8 @@ export const GET: RequestHandler = async ({ params, url, cookies }) => {
try {
const stackName = decodeURIComponent(params.name);
// Get variables from database (masked - secrets show as '***')
const dbVariables = await getStackEnvVars(stackName, envIdNum, true);
const dbByKey = new Map(dbVariables.map(v => [v.key, v]));
// Get secrets from database (masked - values show as '***')
const dbSecrets = await getStackEnvVars(stackName, envIdNum, true);
// Check if this stack has a custom compose path configured
const source = await getStackSource(stackName, envIdNum);
@@ -67,63 +66,49 @@ export const GET: RequestHandler = async ({ params, url, cookies }) => {
// Determine the env file path based on path resolution rules:
// - envPath = '' (empty string) → explicitly no env file
// - envPath = '/path/.env' → use custom path
// - envPath = null with composePath → suggest .env next to compose (but don't auto-load)
// - envPath = null with composePath → .env next to compose
// - envPath = null without composePath → use default location
let envFilePath: string | null = null;
if (source?.envPath === '') {
// Empty string = explicitly no env file
envFilePath = null;
} else if (source?.envPath) {
// Custom env path specified
envFilePath = source.envPath;
} else if (source?.composePath) {
// Custom compose path but no env path - suggest .env next to compose
// For loading, check if it exists (but don't fail if it doesn't)
envFilePath = join(dirname(source.composePath), '.env');
} else {
// Default location - .env in stack directory
const stackDir = await findStackDir(stackName, envIdNum);
if (stackDir) {
envFilePath = join(stackDir, '.env');
}
}
let fileVars: Record<string, string> = {};
if (envFilePath && existsSync(envFilePath)) {
try {
const content = await Bun.file(envFilePath).text();
fileVars = parseEnvFile(content);
} catch (e) {
// Ignore file read errors
}
}
// Merge: DB variables (with secrets masked) + file variables (non-secrets only)
// For non-secrets: file value overrides DB value (user may have edited file)
// For secrets: only DB value exists (masked as '***')
const mergedKeys = new Set([...dbByKey.keys(), ...Object.keys(fileVars)]);
const variables: { key: string; value: string; isSecret: boolean }[] = [];
for (const key of mergedKeys) {
const dbVar = dbByKey.get(key);
const fileValue = fileVars[key];
if (dbVar) {
if (dbVar.isSecret) {
// Secret: use masked value from DB, ignore any file value
variables.push({ key, value: dbVar.value, isSecret: true });
} else if (fileValue !== undefined) {
// Non-secret with file value: file overrides (user may have edited)
variables.push({ key, value: fileValue, isSecret: false });
} else {
// Non-secret only in DB: use DB value
variables.push({ key, value: dbVar.value, isSecret: false });
if (source?.sourceType === 'git') {
// Git stacks: ALL vars (overrides + secrets) come from DB
for (const dbVar of dbSecrets) {
variables.push({ key: dbVar.key, value: dbVar.value, isSecret: dbVar.isSecret });
}
} else {
// Internal/adopted stacks: non-secrets from file, secrets from DB
if (envFilePath && existsSync(envFilePath)) {
try {
const content = await Bun.file(envFilePath).text();
const fileVars = parseEnvFile(content);
for (const [key, value] of Object.entries(fileVars)) {
variables.push({ key, value, isSecret: false });
}
} catch {
// Ignore file read errors
}
}
// Secrets come from the database (never written to file)
for (const secret of dbSecrets) {
if (secret.isSecret) {
variables.push({ key: secret.key, value: secret.value, isSecret: true });
}
} else if (fileValue !== undefined) {
// Variable only in file - add it as non-secret
variables.push({ key, value: fileValue, isSecret: false });
}
}
@@ -136,15 +121,14 @@ export const GET: RequestHandler = async ({ params, url, cookies }) => {
/**
* PUT /api/stacks/[name]/env?env=X
* Set/replace all environment variables for a stack.
* Body: { variables: [{ key, value, isSecret? }] }
* Save secret environment variables for a stack.
* Body: { variables: [{ key, value, isSecret }] }
*
* SECURITY: Secrets are stored ONLY in the database, NEVER written to .env file.
* For secrets, if the value is '***' (the masked placeholder), the original
* secret value from the database is preserved instead of overwriting with '***'.
* Only secrets are stored in the database. Non-secret variables live in the
* .env file (written by PUT /env/raw) and are read directly by Docker Compose.
*
* The .env file only contains non-secret variables (can be edited manually).
* Secrets are injected via shell environment variables at runtime.
* If a secret's value is '***' (masked placeholder), the original value
* from the database is preserved.
*/
export const PUT: RequestHandler = async ({ params, url, cookies, request }) => {
const auth = await authorize(cookies);
@@ -177,14 +161,12 @@ export const PUT: RequestHandler = async ({ params, url, cookies, request }) =>
if (typeof v.value !== 'string') {
return json({ error: `Invalid variable "${v.key}": value must be a string` }, { status: 400 });
}
// Validate key format (env var naming convention)
if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(v.key)) {
return json({ error: `Invalid variable name "${v.key}": must start with a letter or underscore and contain only alphanumeric characters and underscores` }, { status: 400 });
}
}
// Check if any secrets have the masked placeholder '***'
// If so, we need to preserve their original values from the database
// Preserve masked secret values ('***') from the database
const secretsWithMaskedValue = body.variables.filter(
(v: { key: string; value: string; isSecret?: boolean }) =>
v.isSecret && v.value === '***'
@@ -193,16 +175,13 @@ export const PUT: RequestHandler = async ({ params, url, cookies, request }) =>
let variablesToSave = body.variables;
if (secretsWithMaskedValue.length > 0) {
// Get existing variables (unmasked) to preserve secret values
const existingVars = await getStackEnvVars(stackName, envIdNum, false);
const existingByKey = new Map(existingVars.map(v => [v.key, v]));
// Replace masked secrets with their original values
variablesToSave = body.variables.map((v: { key: string; value: string; isSecret?: boolean }) => {
if (v.isSecret && v.value === '***') {
const existing = existingByKey.get(v.key);
if (existing && existing.isSecret) {
// Preserve the original secret value
return { ...v, value: existing.value };
}
}
@@ -210,8 +189,7 @@ export const PUT: RequestHandler = async ({ params, url, cookies, request }) =>
});
}
// Save ALL variables (including secrets) to database
// Note: The .env file is written by PUT /env/raw endpoint, which preserves comments
// Save secrets to database (non-secrets live in the .env file)
await setStackEnvVars(stackName, envIdNum, variablesToSave);
return json({ success: true, count: variablesToSave.length });
@@ -49,6 +49,6 @@ export const GET: RequestHandler = async ({ url }) => {
stackDir,
composePath: `${stackDir}/compose.yaml`,
envPath: `${stackDir}/.env`,
source: location ? 'custom' : 'default'
source: 'default'
});
};
+2 -2
View File
@@ -8,7 +8,7 @@ import {
listNetworks,
getDockerConnectionInfo
} from '$lib/server/docker';
import { listManagedStacks } from '$lib/server/stacks';
import { getStackSources } from '$lib/server/db';
import { isPostgres, isSqlite, getDatabaseSchemaVersion, getPostgresConnectionInfo } from '$lib/server/db/drizzle';
import { hasEnvironments } from '$lib/server/db';
import type { RequestHandler } from './$types';
@@ -149,7 +149,7 @@ export const GET: RequestHandler = async ({ url, cookies }) => {
}
}
const stacks = listManagedStacks();
const stacks = await getStackSources();
const runningContainers = containers.filter(c => c.state === 'running').length;
const stoppedContainers = containers.length - runningContainers;
+6 -1
View File
@@ -11,6 +11,7 @@ import {
} from '$lib/server/db';
import { hashPassword, createUserSession } from '$lib/server/auth';
import { authorize } from '$lib/server/authorize';
import { auditUser } from '$lib/server/audit';
// GET /api/users - List all users
// Free for all - local users are needed for basic auth
@@ -63,7 +64,8 @@ export const GET: RequestHandler = async ({ cookies }) => {
// POST /api/users - Create a new user
// Free for all - local users are needed for basic auth
export const POST: RequestHandler = async ({ request, cookies }) => {
export const POST: RequestHandler = async (event) => {
const { request, cookies } = event;
const auth = await authorize(cookies);
// When auth is enabled and user is logged in, check they can manage users
@@ -116,6 +118,9 @@ export const POST: RequestHandler = async ({ request, cookies }) => {
autoLoggedIn = true;
}
// Audit log
await auditUser(event, 'create', user.id, user.username);
return json({
id: user.id,
username: user.username,
+21 -2
View File
@@ -14,6 +14,8 @@ import {
} from '$lib/server/db';
import { hashPassword } from '$lib/server/auth';
import { authorize } from '$lib/server/authorize';
import { auditUser } from '$lib/server/audit';
import { computeAuditDiff } from '$lib/utils/diff';
// GET /api/users/[id] - Get a specific user
// Free for all - local users are needed for basic auth
@@ -60,7 +62,8 @@ export const GET: RequestHandler = async ({ params, cookies }) => {
// PUT /api/users/[id] - Update a user
// Free for all - local users are needed for basic auth
export const PUT: RequestHandler = async ({ params, request, cookies }) => {
export const PUT: RequestHandler = async (event) => {
const { params, request, cookies } = event;
const auth = await authorize(cookies);
if (!params.id) {
@@ -203,6 +206,15 @@ export const PUT: RequestHandler = async ({ params, request, cookies }) => {
// Compute final isAdmin status
const finalIsAdmin = shouldPromote || (existingUserIsAdmin && !shouldDemote);
// Compute diff for audit (exclude sensitive fields)
const diff = computeAuditDiff(
{ username: existingUser.username, email: existingUser.email, displayName: existingUser.displayName, isActive: existingUser.isActive, isAdmin: existingUserIsAdmin },
{ username: user.username, email: user.email, displayName: user.displayName, isActive: user.isActive, isAdmin: finalIsAdmin }
);
// Audit log
await auditUser(event, 'update', user.id, user.username, diff);
return json({
id: user.id,
username: user.username,
@@ -226,7 +238,8 @@ export const PUT: RequestHandler = async ({ params, request, cookies }) => {
// DELETE /api/users/[id] - Delete a user
// Free for all - local users are needed for basic auth
export const DELETE: RequestHandler = async ({ params, url, cookies }) => {
export const DELETE: RequestHandler = async (event) => {
const { params, url, cookies } = event;
const auth = await authorize(cookies);
// When auth is enabled, check permission (free edition allows all, enterprise checks RBAC)
@@ -279,6 +292,9 @@ export const DELETE: RequestHandler = async ({ params, url, cookies }) => {
// Disable authentication
await updateAuthSettings({ authEnabled: false });
// Audit log
await auditUser(event, 'delete', id, user.username);
return json({ success: true, authDisabled: true });
}
}
@@ -291,6 +307,9 @@ export const DELETE: RequestHandler = async ({ params, url, cookies }) => {
return json({ error: 'Failed to delete user' }, { status: 500 });
}
// Audit log
await auditUser(event, 'delete', id, user.username);
return json({ success: true });
} catch (error) {
console.error('Failed to delete user:', error);
+29 -4
View File
@@ -6,9 +6,12 @@ import {
verifyAndEnableMfa,
disableMfa
} from '$lib/server/auth';
import { auditUser } from '$lib/server/audit';
import { getUser } from '$lib/server/db';
// POST /api/users/[id]/mfa - Setup MFA (generate QR code)
export const POST: RequestHandler = async ({ params, request, cookies }) => {
export const POST: RequestHandler = async (event) => {
const { params, request, cookies } = event;
const currentUser = await validateSession(cookies);
if (!params.id) {
@@ -36,6 +39,15 @@ export const POST: RequestHandler = async ({ params, request, cookies }) => {
return json({ error: 'Invalid MFA code' }, { status: 400 });
}
// Audit log - MFA enabled
const targetUser = await getUser(userId);
if (targetUser) {
await auditUser(event, 'update', userId, targetUser.username, {
mfaEnabled: true,
enabledBy: currentUser?.id === userId ? 'self' : currentUser?.username
});
}
return json({
success: true,
message: 'MFA enabled successfully',
@@ -60,7 +72,8 @@ export const POST: RequestHandler = async ({ params, request, cookies }) => {
};
// DELETE /api/users/[id]/mfa - Disable MFA
export const DELETE: RequestHandler = async ({ params, cookies }) => {
export const DELETE: RequestHandler = async (event) => {
const { params, cookies } = event;
const currentUser = await validateSession(cookies);
if (!params.id) {
@@ -75,11 +88,23 @@ export const DELETE: RequestHandler = async ({ params, cookies }) => {
}
try {
const success = await disableMfa(userId);
if (!success) {
// Get user info before disabling for audit
const targetUser = await getUser(userId);
if (!targetUser) {
return json({ error: 'User not found' }, { status: 404 });
}
const success = await disableMfa(userId);
if (!success) {
return json({ error: 'Failed to disable MFA' }, { status: 500 });
}
// Audit log - MFA disabled
await auditUser(event, 'update', userId, targetUser.username, {
mfaDisabled: true,
disabledBy: currentUser?.id === userId ? 'self' : currentUser?.username
});
return json({ success: true, message: 'MFA disabled successfully' });
} catch (error) {
console.error('MFA disable error:', error);
+27 -3
View File
@@ -6,8 +6,10 @@ import {
getUserRoles,
assignUserRole,
removeUserRole,
getUser
getUser,
getRole
} from '$lib/server/db';
import { auditUser } from '$lib/server/audit';
// GET /api/users/[id]/roles - Get roles assigned to a user
export const GET: RequestHandler = async ({ params, cookies }) => {
@@ -37,7 +39,8 @@ export const GET: RequestHandler = async ({ params, cookies }) => {
};
// POST /api/users/[id]/roles - Assign a role to a user
export const POST: RequestHandler = async ({ params, request, cookies }) => {
export const POST: RequestHandler = async (event) => {
const { params, request, cookies } = event;
// Check enterprise license
if (!(await isEnterprise())) {
return json({ error: 'Enterprise license required' }, { status: 403 });
@@ -66,6 +69,14 @@ export const POST: RequestHandler = async ({ params, request, cookies }) => {
}
const userRole = await assignUserRole(userId, roleId, environmentId);
// Audit log - role assigned
const role = await getRole(roleId);
await auditUser(event, 'update', userId, user.username, {
roleAssigned: role?.name || `Role #${roleId}`,
roleId
});
return json(userRole, { status: 201 });
} catch (error) {
console.error('Failed to assign role:', error);
@@ -74,7 +85,8 @@ export const POST: RequestHandler = async ({ params, request, cookies }) => {
};
// DELETE /api/users/[id]/roles - Remove a role from a user
export const DELETE: RequestHandler = async ({ params, request, cookies }) => {
export const DELETE: RequestHandler = async (event) => {
const { params, request, cookies } = event;
// Check enterprise license
if (!(await isEnterprise())) {
return json({ error: 'Enterprise license required' }, { status: 403 });
@@ -97,11 +109,23 @@ export const DELETE: RequestHandler = async ({ params, request, cookies }) => {
return json({ error: 'Role ID is required' }, { status: 400 });
}
// Get user and role info before deletion for audit
const user = await getUser(userId);
const role = await getRole(roleId);
const deleted = await removeUserRole(userId, roleId, environmentId);
if (!deleted) {
return json({ error: 'Role assignment not found' }, { status: 404 });
}
// Audit log - role removed
if (user) {
await auditUser(event, 'update', userId, user.username, {
roleRemoved: role?.name || `Role #${roleId}`,
roleId
});
}
return json({ success: true });
} catch (error) {
console.error('Failed to remove role:', error);
File diff suppressed because it is too large Load Diff
+7 -1
View File
@@ -98,7 +98,7 @@
return parseFloat((bytes / Math.pow(k, i)).toFixed(decimals)) + sizes[i];
}
type SortField = 'name' | 'image' | 'state' | 'uptime' | 'stack' | 'ip' | 'cpu' | 'memory';
type SortField = 'name' | 'image' | 'state' | 'health' | 'uptime' | 'stack' | 'ip' | 'cpu' | 'memory';
type SortDirection = 'asc' | 'desc';
let containers = $state<ContainerInfo[]>([]);
@@ -701,6 +701,12 @@
cmp = (stateOrder[a.state.toLowerCase() as keyof typeof stateOrder] ?? 4) -
(stateOrder[b.state.toLowerCase() as keyof typeof stateOrder] ?? 4);
break;
case 'health':
const healthOrder: Record<string, number> = { unhealthy: 0, starting: 1, healthy: 2 };
const healthA = a.health ? (healthOrder[a.health] ?? 1) : 3;
const healthB = b.health ? (healthOrder[b.health] ?? 1) : 3;
cmp = healthA - healthB;
break;
case 'uptime':
cmp = parseUptimeToSeconds(a.status) - parseUptimeToSeconds(b.status);
break;
@@ -4,7 +4,7 @@
import * as Tabs from '$lib/components/ui/tabs';
import { Button } from '$lib/components/ui/button';
import { Badge } from '$lib/components/ui/badge';
import { Loader2, Box, Info, Layers, Cpu, MemoryStick, HardDrive, Network, Shield, Settings2, Code, Copy, Check, Activity, Wifi, Pencil, RefreshCw, X, FolderOpen, Moon, Tags, ExternalLink } from 'lucide-svelte';
import { Loader2, Box, Info, Layers, Cpu, MemoryStick, HardDrive, Network, Shield, Settings2, Code, Copy, Check, Activity, Wifi, Pencil, RefreshCw, X, FolderOpen, Moon, Tags, ExternalLink, Gpu } from 'lucide-svelte';
import { Input } from '$lib/components/ui/input';
import { Label } from '$lib/components/ui/label';
import { currentEnvironment, appendEnvParam, environments } from '$lib/stores/environment';
@@ -1193,6 +1193,57 @@
</div>
{/if}
<!-- GPU / Device Requests -->
{#if containerData.HostConfig?.DeviceRequests?.length > 0 || (containerData.HostConfig?.Runtime && containerData.HostConfig.Runtime !== 'runc')}
<div class="space-y-2">
<h3 class="text-sm font-semibold flex items-center gap-2">
<Gpu class="w-4 h-4" />
GPU
</h3>
<div class="grid grid-cols-2 lg:grid-cols-3 gap-3">
{#if containerData.HostConfig?.Runtime}
<div class="p-3 border border-border rounded-lg">
<p class="text-xs text-muted-foreground mb-1">Runtime</p>
<code class="text-sm">{containerData.HostConfig.Runtime}</code>
</div>
{/if}
{#if containerData.HostConfig?.DeviceRequests?.length > 0}
{@const req = containerData.HostConfig.DeviceRequests[0]}
<div class="p-3 border border-border rounded-lg">
<p class="text-xs text-muted-foreground mb-1">Count</p>
<code class="text-sm">{req.Count === -1 ? 'All' : req.Count}</code>
</div>
{#if req.Driver}
<div class="p-3 border border-border rounded-lg">
<p class="text-xs text-muted-foreground mb-1">Driver</p>
<code class="text-sm">{req.Driver}</code>
</div>
{/if}
{#if req.DeviceIDs?.length > 0}
<div class="p-3 border border-border rounded-lg col-span-full">
<p class="text-xs text-muted-foreground mb-1">Device IDs</p>
<div class="flex flex-wrap gap-1.5">
{#each req.DeviceIDs as id}
<Badge variant="secondary" class="text-2xs">{id}</Badge>
{/each}
</div>
</div>
{/if}
{#if req.Capabilities?.length > 0}
<div class="p-3 border border-border rounded-lg col-span-full">
<p class="text-xs text-muted-foreground mb-1">Capabilities</p>
<div class="flex flex-wrap gap-1.5">
{#each req.Capabilities.flat() as cap}
<Badge variant="outline" class="text-2xs bg-violet-50 text-violet-700 dark:bg-violet-900/30 dark:text-violet-400">{cap}</Badge>
{/each}
</div>
</div>
{/if}
{/if}
</div>
</div>
{/if}
<!-- Cgroup -->
<div class="space-y-2">
<h3 class="text-sm font-semibold">Cgroup settings</h3>
@@ -5,7 +5,7 @@
import { Button } from '$lib/components/ui/button';
import { Checkbox } from '$lib/components/ui/checkbox';
import { TogglePill, ToggleGroup } from '$lib/components/ui/toggle-pill';
import { Plus, Trash2, Settings2, RefreshCw, Network, X, Ban, RotateCw, AlertTriangle, PauseCircle, Share2, Server, CircleOff, ChevronDown, ChevronRight, Cpu, Shield, HeartPulse, Wifi, HardDrive, Lock, Loader2, CheckCircle2, Package } from 'lucide-svelte';
import { Plus, Trash2, Settings2, RefreshCw, Network, X, Ban, RotateCw, AlertTriangle, PauseCircle, Share2, Server, CircleOff, ChevronDown, ChevronRight, Cpu, Shield, HeartPulse, Wifi, HardDrive, Lock, Loader2, CheckCircle2, Package, Gpu } from 'lucide-svelte';
import { Badge } from '$lib/components/ui/badge';
import AutoUpdateSettings from './AutoUpdateSettings.svelte';
import type { VulnerabilityCriteria } from '$lib/components/VulnerabilityCriteriaSelector.svelte';
@@ -40,6 +40,8 @@
const commonUlimits = ['nofile', 'nproc', 'core', 'memlock', 'stack', 'cpu', 'fsize', 'locks'];
const commonGpuCapabilities = ['gpu', 'compute', 'utility', 'graphics', 'video', 'display'];
interface ConfigSet {
id: number;
name: string;
@@ -104,6 +106,14 @@
securityOptions: string[];
// Devices
deviceMappings: { hostPath: string; containerPath: string; permissions: string }[];
// GPU settings
gpuEnabled: boolean;
gpuMode: 'all' | 'count' | 'specific';
gpuCount: number;
gpuDeviceIds: string[];
gpuDriver: string;
gpuCapabilities: string[];
runtime: string;
// DNS settings
dnsServers: string[];
dnsSearch: string[];
@@ -166,6 +176,13 @@
capDrop = $bindable(),
securityOptions = $bindable(),
deviceMappings = $bindable(),
gpuEnabled = $bindable(),
gpuMode = $bindable(),
gpuCount = $bindable(),
gpuDeviceIds = $bindable(),
gpuDriver = $bindable(),
gpuCapabilities = $bindable(),
runtime = $bindable(),
dnsServers = $bindable(),
dnsSearch = $bindable(),
dnsOptions = $bindable(),
@@ -187,6 +204,7 @@
let showHealth = $state(false);
let showDns = $state(false);
let showDevices = $state(false);
let showGpu = $state(false);
let showUlimits = $state(false);
// DNS input fields
@@ -197,6 +215,10 @@
// Security options input
let securityOptionInput = $state('');
// GPU device ID input
let gpuDeviceIdInput = $state('');
let customRuntimeInput = $state('');
// Helper functions for form
function addPortMapping() {
portMappings = [...portMappings, { hostPort: '', containerPort: '', protocol: 'tcp' }];
@@ -256,6 +278,27 @@
ulimits = ulimits.filter((_, i) => i !== index);
}
function addGpuDeviceId() {
if (gpuDeviceIdInput.trim() && !gpuDeviceIds.includes(gpuDeviceIdInput.trim())) {
gpuDeviceIds = [...gpuDeviceIds, gpuDeviceIdInput.trim()];
gpuDeviceIdInput = '';
}
}
function removeGpuDeviceId(id: string) {
gpuDeviceIds = gpuDeviceIds.filter(d => d !== id);
}
function addGpuCapability(cap: string) {
if (cap && !gpuCapabilities.includes(cap)) {
gpuCapabilities = [...gpuCapabilities, cap];
}
}
function removeGpuCapability(cap: string) {
gpuCapabilities = gpuCapabilities.filter(c => c !== cap);
}
function addCapability(type: 'add' | 'drop', cap: string) {
if (!cap) return;
const capUpper = cap.toUpperCase();
@@ -1210,6 +1253,146 @@
{/if}
</div>
<!-- GPU Section (Collapsible) -->
<div class="border rounded-lg">
<button
type="button"
onclick={() => showGpu = !showGpu}
class="w-full flex items-center justify-between p-3 hover:bg-muted/50 transition-colors"
>
<div class="flex items-center gap-2">
<Gpu class="w-4 h-4 text-muted-foreground" />
<span class="text-sm font-medium">GPU</span>
{#if gpuEnabled}
<Badge variant="secondary" class="text-2xs">configured</Badge>
{/if}
</div>
{#if showGpu}
<ChevronDown class="w-4 h-4 text-muted-foreground" />
{:else}
<ChevronRight class="w-4 h-4 text-muted-foreground" />
{/if}
</button>
{#if showGpu}
<div class="px-3 pb-3 space-y-3 border-t">
<div class="flex items-center justify-between pt-2">
<Label class="text-xs font-medium">Enable GPU access</Label>
<TogglePill bind:checked={gpuEnabled} />
</div>
<div class="space-y-1.5">
<Label class="text-xs font-medium">Runtime</Label>
<div class="flex gap-2">
<Select.Root type="single" value={runtime === '' ? '' : runtime === 'nvidia' ? 'nvidia' : 'custom'} onValueChange={(v) => {
if (v === '') runtime = '';
else if (v === 'nvidia') runtime = 'nvidia';
else if (v === 'custom') runtime = customRuntimeInput || '';
}}>
<Select.Trigger class="h-9 flex-1">
<span>{runtime === '' ? 'Default (runc)' : runtime === 'nvidia' ? 'NVIDIA' : `Custom: ${runtime}`}</span>
</Select.Trigger>
<Select.Content>
<Select.Item value="" label="Default (runc)" />
<Select.Item value="nvidia" label="NVIDIA" />
<Select.Item value="custom" label="Custom..." />
</Select.Content>
</Select.Root>
{#if runtime !== '' && runtime !== 'nvidia'}
<Input
bind:value={customRuntimeInput}
placeholder="Runtime name"
class="h-9 w-40"
oninput={() => { runtime = customRuntimeInput; }}
/>
{/if}
</div>
</div>
{#if gpuEnabled}
<div class="space-y-1.5">
<Label class="text-xs font-medium">GPU mode</Label>
<ToggleGroup
value={gpuMode}
options={[
{ value: 'all', label: 'All' },
{ value: 'count', label: 'Count' },
{ value: 'specific', label: 'Specific' }
]}
onchange={(v) => { gpuMode = v as 'all' | 'count' | 'specific'; }}
/>
</div>
{#if gpuMode === 'count'}
<div class="space-y-1.5">
<Label class="text-xs font-medium">GPU count</Label>
<Input type="number" bind:value={gpuCount} min="1" placeholder="1" class="h-9 w-24" />
</div>
{/if}
{#if gpuMode === 'specific'}
<div class="space-y-2">
<Label class="text-xs font-medium">Device IDs</Label>
<div class="flex gap-2">
<Input
bind:value={gpuDeviceIdInput}
placeholder="e.g., 0, GPU-xxxx"
class="h-9 flex-1"
onkeydown={(e) => { if (e.key === 'Enter') { e.preventDefault(); addGpuDeviceId(); } }}
/>
<Button type="button" size="sm" variant="outline" onclick={addGpuDeviceId} class="h-9">
<Plus class="w-4 h-4" />
</Button>
</div>
{#if gpuDeviceIds.length > 0}
<div class="flex flex-wrap gap-1.5">
{#each gpuDeviceIds as id}
<Badge variant="secondary" class="text-2xs">
{id}
<button type="button" onclick={() => removeGpuDeviceId(id)} class="ml-1 hover:text-destructive">
<X class="w-3 h-3" />
</button>
</Badge>
{/each}
</div>
{/if}
</div>
{/if}
<div class="space-y-1.5">
<Label class="text-xs font-medium">Driver</Label>
<Input bind:value={gpuDriver} placeholder="nvidia" class="h-9" />
</div>
<div class="space-y-2">
<Label class="text-xs font-medium">Capabilities</Label>
<Select.Root type="single" value="" onValueChange={(v) => { addGpuCapability(v); }}>
<Select.Trigger class="h-9">
<span class="text-muted-foreground">Add capability...</span>
</Select.Trigger>
<Select.Content>
{#each commonGpuCapabilities.filter(c => !gpuCapabilities.includes(c)) as cap}
<Select.Item value={cap} label={cap} />
{/each}
</Select.Content>
</Select.Root>
{#if gpuCapabilities.length > 0}
<div class="flex flex-wrap gap-1.5">
{#each gpuCapabilities as cap}
<Badge variant="outline" class="text-2xs bg-violet-50 text-violet-700 dark:bg-violet-900/30 dark:text-violet-400">
{cap}
<button type="button" onclick={() => removeGpuCapability(cap)} class="ml-1 hover:text-destructive">
<X class="w-3 h-3" />
</button>
</Badge>
{/each}
</div>
{/if}
</div>
{/if}
</div>
{/if}
</div>
<!-- Ulimits Section (Collapsible) -->
<div class="border rounded-lg">
<button
@@ -159,6 +159,15 @@
// Ulimits
let ulimits = $state<{ name: string; soft: string; hard: string }[]>([]);
// GPU settings
let gpuEnabled = $state(false);
let gpuMode = $state<'all' | 'count' | 'specific'>('all');
let gpuCount = $state(1);
let gpuDeviceIds = $state<string[]>([]);
let gpuDriver = $state('');
let gpuCapabilities = $state<string[]>(['gpu']);
let runtime = $state('');
let loading = $state(false);
let errors = $state<{ name?: string; image?: string }>({});
@@ -405,6 +414,23 @@
hard: parseInt(u.hard) || 0
}));
let deviceRequests: any[] | undefined = undefined;
if (gpuEnabled) {
const dr: any = {
capabilities: gpuCapabilities.length > 0 ? [gpuCapabilities] : [['gpu']],
driver: gpuDriver || undefined
};
if (gpuMode === 'all') {
dr.count = -1;
} else if (gpuMode === 'count') {
dr.count = gpuCount || 1;
} else {
dr.count = 0;
dr.deviceIDs = gpuDeviceIds.filter(id => id.trim());
}
deviceRequests = [dr];
}
const payload = {
name: name.trim(),
image: image.trim(),
@@ -430,6 +456,8 @@
capAdd: capAdd.length > 0 ? capAdd : undefined,
capDrop: capDrop.length > 0 ? capDrop : undefined,
devices: devices.length > 0 ? devices : undefined,
deviceRequests,
runtime: runtime || undefined,
dns: dnsServers.length > 0 ? dnsServers : undefined,
dnsSearch: dnsSearch.length > 0 ? dnsSearch : undefined,
dnsOptions: dnsOptions.length > 0 ? dnsOptions : undefined,
@@ -529,6 +557,13 @@
dnsOptions = [];
securityOptions = [];
ulimits = [];
gpuEnabled = false;
gpuMode = 'all';
gpuCount = 1;
gpuDeviceIds = [];
gpuDriver = '';
gpuCapabilities = ['gpu'];
runtime = '';
// Reset pull/scan state
activeTab = skipPullTab ? 'container' : 'pull';
pullStatus = 'idle';
@@ -564,7 +599,7 @@
</script>
<Dialog.Root bind:open onOpenChange={(isOpen) => isOpen && focusFirstInput()}>
<Dialog.Content class="max-w-4xl w-full max-h-[90vh] sm:max-h-[85vh] p-0 flex flex-col overflow-hidden !zoom-in-0 !zoom-out-0" showCloseButton={false}>
<Dialog.Content class="max-w-4xl w-full h-[85vh] p-0 flex flex-col overflow-hidden !zoom-in-0 !zoom-out-0" showCloseButton={false}>
<Dialog.Header class="px-5 py-4 border-b bg-muted/30 shrink-0 sticky top-0 z-10">
<Dialog.Title class="text-base font-semibold">Create new container</Dialog.Title>
<button
@@ -706,6 +741,13 @@
bind:capDrop
bind:securityOptions
bind:deviceMappings
bind:gpuEnabled
bind:gpuMode
bind:gpuCount
bind:gpuDeviceIds
bind:gpuDriver
bind:gpuCapabilities
bind:runtime
bind:dnsServers
bind:dnsSearch
bind:dnsOptions
@@ -153,6 +153,15 @@
// Ulimits
let ulimits = $state<{ name: string; soft: string; hard: string }[]>([]);
// GPU settings
let gpuEnabled = $state(false);
let gpuMode = $state<'all' | 'count' | 'specific'>('all');
let gpuCount = $state(1);
let gpuDeviceIds = $state<string[]>([]);
let gpuDriver = $state('');
let gpuCapabilities = $state<string[]>(['gpu']);
let runtime = $state('');
// Auto-update settings
let autoUpdateEnabled = $state(false);
let autoUpdateCronExpression = $state('0 3 * * *');
@@ -195,12 +204,20 @@
dnsSearch: string[];
dnsOptions: string[];
ulimits: typeof ulimits;
gpuEnabled: boolean;
gpuMode: 'all' | 'count' | 'specific';
gpuCount: number;
gpuDeviceIds: string[];
gpuDriver: string;
gpuCapabilities: string[];
runtime: string;
} | null>(null);
// Compose container detection
let isComposeContainer = $state(false);
let composeStackName = $state('');
let originalAutoUpdate = $state<{
enabled: boolean;
cronExpression: string;
@@ -386,7 +403,7 @@
})
: [{ key: '', value: '' }];
// Parse labels
// Parse labels - filter out com.docker.* labels for UI (they're preserved automatically by updateContainer)
const containerLabels = data.Config.Labels || {};
const labelEntries = Object.entries(containerLabels).filter(
([key]) => !key.startsWith('com.docker.')
@@ -483,6 +500,36 @@
hard: String(u.Hard || 0)
}));
// Parse GPU / Device Requests
const deviceReqs = data.HostConfig?.DeviceRequests || [];
if (deviceReqs.length > 0) {
gpuEnabled = true;
const firstReq = deviceReqs[0];
if (firstReq.DeviceIDs?.length > 0) {
gpuMode = 'specific';
gpuDeviceIds = [...firstReq.DeviceIDs];
} else if (firstReq.Count === -1) {
gpuMode = 'all';
} else {
gpuMode = 'count';
gpuCount = firstReq.Count || 1;
}
gpuCapabilities = firstReq.Capabilities?.length > 0
? firstReq.Capabilities.flat() : ['gpu'];
gpuDriver = firstReq.Driver || '';
} else {
gpuEnabled = false;
gpuMode = 'all';
gpuCount = 1;
gpuDeviceIds = [];
gpuDriver = '';
gpuCapabilities = ['gpu'];
}
// Parse runtime
runtime = (data.HostConfig?.Runtime && data.HostConfig.Runtime !== 'runc')
? data.HostConfig.Runtime : '';
// Fetch available networks and auto-update settings
await fetchNetworks();
await fetchAutoUpdateSettings(name);
@@ -521,7 +568,14 @@
dnsServers: [...dnsServers],
dnsSearch: [...dnsSearch],
dnsOptions: [...dnsOptions],
ulimits: JSON.parse(JSON.stringify(ulimits))
ulimits: JSON.parse(JSON.stringify(ulimits)),
gpuEnabled,
gpuMode,
gpuCount,
gpuDeviceIds: [...gpuDeviceIds],
gpuDriver,
gpuCapabilities: [...gpuCapabilities],
runtime
};
} catch (err) {
error = 'Failed to load container data: ' + String(err);
@@ -601,6 +655,15 @@
const originalUlimits = originalConfig.ulimits.filter(u => u.name && u.soft && u.hard);
if (JSON.stringify(currentUlimits) !== JSON.stringify(originalUlimits)) return true;
// GPU settings
if (gpuEnabled !== originalConfig.gpuEnabled) return true;
if (gpuMode !== originalConfig.gpuMode) return true;
if (gpuCount !== originalConfig.gpuCount) return true;
if (JSON.stringify([...gpuDeviceIds].sort()) !== JSON.stringify([...originalConfig.gpuDeviceIds].sort())) return true;
if (gpuDriver !== originalConfig.gpuDriver) return true;
if (JSON.stringify([...gpuCapabilities].sort()) !== JSON.stringify([...originalConfig.gpuCapabilities].sort())) return true;
if (runtime !== originalConfig.runtime) return true;
return false;
}
@@ -801,6 +864,23 @@
hard: parseInt(u.hard) || 0
}));
let deviceRequests: any[] | undefined = undefined;
if (gpuEnabled) {
const dr: any = {
capabilities: gpuCapabilities.length > 0 ? [gpuCapabilities] : [['gpu']],
driver: gpuDriver || undefined
};
if (gpuMode === 'all') {
dr.count = -1;
} else if (gpuMode === 'count') {
dr.count = gpuCount || 1;
} else {
dr.count = 0;
dr.deviceIDs = gpuDeviceIds.filter(id => id.trim());
}
deviceRequests = [dr];
}
const payload = {
name: name.trim(),
image: image.trim(),
@@ -826,6 +906,8 @@
capAdd: capAdd.length > 0 ? capAdd : undefined,
capDrop: capDrop.length > 0 ? capDrop : undefined,
devices: devices.length > 0 ? devices : undefined,
deviceRequests,
runtime: runtime || undefined,
dns: dnsServers.length > 0 ? dnsServers : undefined,
dnsSearch: dnsSearch.length > 0 ? dnsSearch : undefined,
dnsOptions: dnsOptions.length > 0 ? dnsOptions : undefined,
@@ -997,6 +1079,13 @@
bind:capDrop
bind:securityOptions
bind:deviceMappings
bind:gpuEnabled
bind:gpuMode
bind:gpuCount
bind:gpuDeviceIds
bind:gpuDriver
bind:gpuCapabilities
bind:runtime
bind:dnsServers
bind:dnsSearch
bind:dnsOptions
+20
View File
@@ -18,6 +18,7 @@
import ImageHistoryModal from './ImageHistoryModal.svelte';
import ImageScanModal from './ImageScanModal.svelte';
import PushToRegistryModal from './PushToRegistryModal.svelte';
import ImagePullModal from '$lib/components/ImagePullModal.svelte';
import type { ImageInfo } from '$lib/types';
import { currentEnvironment, environments, appendEnvParam, clearStaleEnvironment } from '$lib/stores/environment';
import CreateContainerModal from '../containers/CreateContainerModal.svelte';
@@ -81,6 +82,9 @@
let showPushModal = $state(false);
let pushingImage = $state<{ id: string; tag: string } | null>(null);
// Pull modal state
let showPullModal = $state(false);
// Run modal state
let showRunModal = $state(false);
let prefilledImage = $state('');
@@ -723,6 +727,12 @@
{/snippet}
</ConfirmPopover>
{/if}
{#if $canAccess('images', 'pull')}
<Button size="sm" variant="default" onclick={() => showPullModal = true}>
<Download class="w-3.5 h-3.5 mr-1.5" />
Pull
</Button>
{/if}
<Button size="sm" variant="outline" onclick={fetchImages}>Refresh</Button>
</div>
</div>
@@ -1069,6 +1079,16 @@
{/if}
</div>
<!-- Pull Image Modal -->
<ImagePullModal
bind:open={showPullModal}
{registries}
{envId}
envHasScanning={scannerEnabled}
showDeleteButton={true}
onComplete={fetchImages}
/>
<!-- Push to Registry Modal -->
{#if pushingImage}
<PushToRegistryModal
+17
View File
@@ -10,6 +10,7 @@
import { authStore } from '$lib/stores/auth';
import { environments } from '$lib/stores/environment';
import * as Alert from '$lib/components/ui/alert';
import { themeStore, applyTheme } from '$lib/stores/theme';
interface AuthProvider {
id: string;
@@ -60,6 +61,22 @@
}
onMount(async () => {
// Set dark mode class based on saved preference or system preference
// This must happen before applyTheme since applyTheme reads the dark class
const savedTheme = localStorage.getItem('theme');
const prefersDark = savedTheme === 'dark' || (!savedTheme && window.matchMedia('(prefers-color-scheme: dark)').matches);
if (prefersDark) {
document.documentElement.classList.add('dark');
} else {
document.documentElement.classList.remove('dark');
}
// Apply theme from localStorage immediately (for flash-free loading)
applyTheme(themeStore.get());
// Initialize theme from app settings (no user yet, so fetches from /api/settings/theme)
themeStore.init();
// Set error from URL if present
if (urlError) {
error = decodeURIComponent(urlError);
+8 -2
View File
@@ -11,7 +11,7 @@
import { Label } from '$lib/components/ui/label';
import { Badge } from '$lib/components/ui/badge';
import CreateContainerModal from '../containers/CreateContainerModal.svelte';
import ImagePullModal from './ImagePullModal.svelte';
import ImagePullModal from '$lib/components/ImagePullModal.svelte';
import CopyToRegistryModal from './CopyToRegistryModal.svelte';
import { canAccess } from '$lib/stores/auth';
import { currentEnvironment, appendEnvParam } from '$lib/stores/environment';
@@ -806,4 +806,10 @@
<CreateContainerModal bind:open={showRunModal} prefilledImage={runImageName} autoPull={true} />
<!-- Pull/Scan Modal -->
<ImagePullModal bind:open={showPullModal} imageName={pullImageName} envHasScanning={envHasScanning} />
<ImagePullModal
bind:open={showPullModal}
imageName={pullImageName}
envId={$currentEnvironment?.id}
envHasScanning={envHasScanning}
showDeleteButton={true}
/>
-230
View File
@@ -1,230 +0,0 @@
<script lang="ts">
import * as Dialog from '$lib/components/ui/dialog';
import { Button } from '$lib/components/ui/button';
import { CheckCircle2, XCircle, Download, ShieldCheck, ShieldAlert, ShieldX, ArrowBigRight } from 'lucide-svelte';
import { currentEnvironment } from '$lib/stores/environment';
import PullTab from '$lib/components/PullTab.svelte';
import ScanTab from '$lib/components/ScanTab.svelte';
import type { ScanResult } from '$lib/components/ScanTab.svelte';
interface Props {
open: boolean;
imageName: string;
envHasScanning?: boolean;
envId?: number | null;
onClose?: () => void;
onComplete?: () => void;
}
let { open = $bindable(), imageName, envHasScanning = false, envId, onClose, onComplete }: Props = $props();
// Component refs
let pullTabRef = $state<PullTab | undefined>();
let scanTabRef = $state<ScanTab | undefined>();
// Tab state
let activeTab = $state<'pull' | 'scan'>('pull');
// Track status from components
let pullStatus = $state<'idle' | 'pulling' | 'complete' | 'error'>('idle');
let scanStatus = $state<'idle' | 'scanning' | 'complete' | 'error'>('idle');
let scanResults = $state<ScanResult[]>([]);
let hasStarted = $state(false);
let pullStarted = $state(false);
let scanStarted = $state(false);
let autoSwitchedToScan = $state(false);
$effect(() => {
if (open && imageName && !hasStarted) {
hasStarted = true;
pullStarted = true;
}
if (!open && hasStarted) {
// Reset when modal closes
hasStarted = false;
pullStarted = false;
scanStarted = false;
pullStatus = 'idle';
scanStatus = 'idle';
scanResults = [];
activeTab = 'pull';
autoSwitchedToScan = false;
pullTabRef?.reset();
scanTabRef?.reset();
}
});
function handlePullComplete() {
pullStatus = 'complete';
if (envHasScanning && !autoSwitchedToScan) {
autoSwitchedToScan = true;
scanStarted = true;
activeTab = 'scan';
setTimeout(() => scanTabRef?.startScan(), 100);
} else {
onComplete?.();
}
}
function handlePullError(_error: string) {
pullStatus = 'error';
}
function handlePullStatusChange(status: 'idle' | 'pulling' | 'complete' | 'error') {
pullStatus = status;
}
function handleScanComplete(results: ScanResult[]) {
scanResults = results;
onComplete?.();
}
function handleScanError(_error: string) {
// Error is handled by ScanTab display
}
function handleScanStatusChange(status: 'idle' | 'scanning' | 'complete' | 'error') {
scanStatus = status;
}
function handleClose() {
if (pullStatus !== 'pulling' && scanStatus !== 'scanning') {
open = false;
onClose?.();
}
}
const totalVulnerabilities = $derived(
scanResults.reduce((total, r) => total + r.vulnerabilities.length, 0)
);
const hasCriticalOrHigh = $derived(
scanResults.some(r => r.summary.critical > 0 || r.summary.high > 0)
);
const isProcessing = $derived(pullStatus === 'pulling' || scanStatus === 'scanning');
const effectiveEnvId = $derived(envId ?? $currentEnvironment?.id ?? null);
const title = $derived(envHasScanning ? 'Pull & scan image' : 'Pull image');
</script>
<Dialog.Root bind:open onOpenChange={handleClose}>
<Dialog.Content class="max-w-4xl h-[85vh] flex flex-col">
<Dialog.Header class="shrink-0 pb-2">
<Dialog.Title class="flex items-center gap-2">
{#if scanStatus === 'complete' && scanResults.length > 0}
{#if hasCriticalOrHigh}
<ShieldX class="w-5 h-5 text-red-500" />
{:else if totalVulnerabilities > 0}
<ShieldAlert class="w-5 h-5 text-yellow-500" />
{:else}
<ShieldCheck class="w-5 h-5 text-green-500" />
{/if}
{:else if pullStatus === 'complete' && !envHasScanning}
<CheckCircle2 class="w-5 h-5 text-green-500" />
{:else if pullStatus === 'error' || scanStatus === 'error'}
<XCircle class="w-5 h-5 text-red-500" />
{:else}
<Download class="w-5 h-5" />
{/if}
{title}
<code class="text-sm font-normal bg-muted px-1.5 py-0.5 rounded ml-1">{imageName}</code>
</Dialog.Title>
</Dialog.Header>
<!-- Tabs -->
{#if envHasScanning}
<div class="flex items-center border-b shrink-0">
<button
class="px-4 py-2 text-sm font-medium border-b-2 transition-colors cursor-pointer {activeTab === 'pull' ? 'border-primary text-foreground' : 'border-transparent text-muted-foreground hover:text-foreground'}"
onclick={() => activeTab = 'pull'}
disabled={isProcessing}
>
<Download class="w-3.5 h-3.5 inline mr-1.5" />
Pull
{#if pullStatus === 'complete'}
<CheckCircle2 class="w-3.5 h-3.5 inline ml-1 text-green-500" />
{:else if pullStatus === 'error'}
<XCircle class="w-3.5 h-3.5 inline ml-1 text-red-500" />
{:else}
<CheckCircle2 class="w-3.5 h-3.5 inline ml-1 invisible" />
{/if}
</button>
<ArrowBigRight class="w-3.5 h-3.5 text-muted-foreground/50 shrink-0" />
<button
class="px-4 py-2 text-sm font-medium border-b-2 transition-colors cursor-pointer {activeTab === 'scan' ? 'border-primary text-foreground' : 'border-transparent text-muted-foreground hover:text-foreground'}"
onclick={() => activeTab = 'scan'}
disabled={isProcessing || pullStatus !== 'complete'}
>
{#if scanStatus === 'complete' && scanResults.length > 0}
{#if hasCriticalOrHigh}
<ShieldX class="w-3.5 h-3.5 inline mr-1.5 text-red-500" />
{:else if totalVulnerabilities > 0}
<ShieldAlert class="w-3.5 h-3.5 inline mr-1.5 text-yellow-500" />
{:else}
<ShieldCheck class="w-3.5 h-3.5 inline mr-1.5 text-green-500" />
{/if}
{:else}
<ShieldCheck class="w-3.5 h-3.5 inline mr-1.5" />
{/if}
Scan
{#if scanStatus === 'complete'}
<CheckCircle2 class="w-3.5 h-3.5 inline ml-1 text-green-500" />
{:else if scanStatus === 'error'}
<XCircle class="w-3.5 h-3.5 inline ml-1 text-red-500" />
{:else}
<CheckCircle2 class="w-3.5 h-3.5 inline ml-1 invisible" />
{/if}
</button>
</div>
{/if}
<div class="flex-1 min-h-0 flex flex-col overflow-hidden py-2">
<!-- Pull Tab -->
<div class="flex flex-col flex-1 min-h-0" class:hidden={activeTab !== 'pull'}>
<PullTab
bind:this={pullTabRef}
{imageName}
envId={effectiveEnvId}
showImageInput={false}
autoStart={pullStarted && pullStatus === 'idle'}
onComplete={handlePullComplete}
onError={handlePullError}
onStatusChange={handlePullStatusChange}
/>
</div>
<!-- Scan Tab -->
{#if envHasScanning}
<div class="flex flex-col flex-1 min-h-0" class:hidden={activeTab !== 'scan'}>
<ScanTab
bind:this={scanTabRef}
{imageName}
envId={effectiveEnvId}
autoStart={scanStarted && scanStatus === 'idle'}
onComplete={handleScanComplete}
onError={handleScanError}
onStatusChange={handleScanStatusChange}
/>
</div>
{/if}
</div>
<Dialog.Footer class="shrink-0 flex justify-end">
<Button
variant={pullStatus === 'complete' || scanStatus === 'complete' ? 'default' : 'secondary'}
onclick={handleClose}
disabled={isProcessing}
>
{#if pullStatus === 'pulling'}
Pulling...
{:else if scanStatus === 'scanning'}
Scanning...
{:else}
Close
{/if}
</Button>
</Dialog.Footer>
</Dialog.Content>
</Dialog.Root>
+12 -2
View File
@@ -142,7 +142,7 @@
interface ScheduleExecution {
id: number;
scheduleType: 'container_update' | 'git_stack_sync' | 'system_cleanup' | 'env_update_check';
scheduleType: 'container_update' | 'git_stack_sync' | 'system_cleanup' | 'env_update_check' | 'image_prune';
scheduleId: number;
environmentId: number | null;
entityName: string;
@@ -161,7 +161,7 @@
interface Schedule {
key: string; // Unique key: type-id
id: number;
type: 'container_update' | 'git_stack_sync' | 'system_cleanup' | 'env_update_check';
type: 'container_update' | 'git_stack_sync' | 'system_cleanup' | 'env_update_check' | 'image_prune';
name: string;
entityName: string;
description?: string;
@@ -921,6 +921,8 @@
Git stack syncs
{:else if filterTypes[0] === 'env_update_check'}
Env update checks
{:else if filterTypes[0] === 'image_prune'}
Image prune
{:else}
System jobs
{/if}
@@ -951,6 +953,10 @@
<CircleFadingArrowUp class="w-4 h-4 mr-2 inline text-green-500/50 drop-shadow-[0_0_3px_rgba(34,197,94,0.3)]" />
Env update checks
</Select.Item>
<Select.Item value="image_prune">
<Trash2 class="w-4 h-4 mr-2 inline text-amber-500 drop-shadow-[0_0_3px_rgba(245,158,11,0.4)]" />
Image prune
</Select.Item>
{#if !hideSystemJobs}
<Select.Item value="system_cleanup">
<Wrench class="w-4 h-4 mr-2 inline text-amber-500 drop-shadow-[0_0_3px_rgba(245,158,11,0.4)]" />
@@ -1131,6 +1137,8 @@
{:else}
<CircleFadingArrowUp class="w-4 h-4 text-green-500 glow-green shrink-0" />
{/if}
{:else if schedule.type === 'image_prune'}
<Trash2 class="w-4 h-4 text-amber-500 glow-amber shrink-0" />
{:else}
<Wrench class="w-4 h-4 text-amber-500 shrink-0" />
{/if}
@@ -1166,6 +1174,8 @@
</span>
{/if}
<span class="truncate">{schedule.description || 'Env update check'}</span>
{:else if schedule.type === 'image_prune'}
<span class="truncate">{schedule.description || 'Prune unused images'}</span>
{:else}
<span class="truncate">{schedule.description || 'System job'}</span>
{/if}
@@ -72,8 +72,11 @@
import { focusFirstInput } from '$lib/utils';
import { authStore, canAccess } from '$lib/stores/auth';
import { licenseStore } from '$lib/stores/license';
import { formatDateTime } from '$lib/stores/settings';
import { getLabelColor, getLabelBgColor, parseLabels, MAX_LABELS } from '$lib/utils/label-colors';
import EventTypesEditor from './EventTypesEditor.svelte';
import UpdatesTab from './tabs/UpdatesTab.svelte';
import ActivityTab from './tabs/ActivityTab.svelte';
// Scanner options for ToggleGroup
const scannerOptions = [
@@ -366,6 +369,14 @@
let updateCheckVulnerabilityCriteria = $state<VulnerabilityCriteria>('never');
let updateCheckLoading = $state(false);
// Image prune settings state
let imagePruneEnabled = $state(false);
let imagePruneCron = $state('0 3 * * 0'); // Default: 3 AM Sunday
let imagePruneMode = $state<'dangling' | 'all'>('dangling');
let imagePruneLastPruned = $state<string | undefined>(undefined);
let imagePruneLastResult = $state<{ spaceReclaimed: number; imagesRemoved: number } | undefined>(undefined);
let imagePruneLoading = $state(false);
// === Validation Functions ===
function isValidHost(host: string): boolean {
if (!host) return false;
@@ -419,10 +430,11 @@
hawserToken = null;
generatedToken = null;
pendingToken = null;
// Load scanner settings, notifications, update check settings, and timezone
// Load scanner settings, notifications, update check settings, image prune settings, and timezone
loadScannerSettings(environment.id);
loadEnvNotifications(environment.id);
loadUpdateCheckSettings(environment.id);
loadImagePruneSettings(environment.id);
loadTimezone(environment.id);
// Load Hawser token if edge mode
if (formConnectionType === 'hawser-edge') {
@@ -461,6 +473,12 @@
updateCheckEnabled = false;
updateCheckCron = '0 4 * * *';
updateCheckAutoUpdate = false;
// Reset image prune settings
imagePruneEnabled = false;
imagePruneCron = '0 3 * * 0';
imagePruneMode = 'dangling';
imagePruneLastPruned = undefined;
imagePruneLastResult = undefined;
// Load default timezone from global settings
loadDefaultTimezone();
}
@@ -664,6 +682,10 @@
if (updateCheckEnabled && newEnv?.id) {
await saveUpdateCheckSettings(newEnv.id);
}
// Save image prune settings if enabled
if (imagePruneEnabled && newEnv?.id) {
await saveImagePruneSettings(newEnv.id);
}
// Save timezone if not default
if (newEnv?.id) {
await saveTimezone(newEnv.id);
@@ -735,6 +757,7 @@
if (response.ok) {
await saveScannerSettings(environment.id);
await saveUpdateCheckSettings(environment.id);
await saveImagePruneSettings(environment.id);
await saveTimezone(environment.id);
toast.success(`Updated environment: ${formName}`);
onSaved();
@@ -897,6 +920,51 @@
}
}
// === Image Prune Settings Functions ===
async function loadImagePruneSettings(envId: number) {
imagePruneLoading = true;
try {
const response = await fetch(`/api/environments/${envId}/image-prune`);
if (response.ok) {
const data = await response.json();
if (data.settings) {
imagePruneEnabled = data.settings.enabled ?? false;
imagePruneCron = data.settings.cronExpression || '0 3 * * 0';
imagePruneMode = data.settings.pruneMode || 'dangling';
imagePruneLastPruned = data.settings.lastPruned;
imagePruneLastResult = data.settings.lastResult;
} else {
// No settings found - use defaults
imagePruneEnabled = false;
imagePruneCron = '0 3 * * 0';
imagePruneMode = 'dangling';
imagePruneLastPruned = undefined;
imagePruneLastResult = undefined;
}
}
} catch (error) {
console.error('Failed to load image prune settings:', error);
} finally {
imagePruneLoading = false;
}
}
async function saveImagePruneSettings(envId: number) {
try {
await fetch(`/api/environments/${envId}/image-prune`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
enabled: imagePruneEnabled,
cronExpression: imagePruneCron,
pruneMode: imagePruneMode
})
});
} catch (error) {
console.error('Failed to save image prune settings:', error);
}
}
async function removeGrype(envId?: number) {
removingGrype = true;
try {
@@ -1929,117 +1997,30 @@
<!-- Updates Tab -->
<Tabs.Content value="updates" class="space-y-4 mt-0 h-full">
<div class="space-y-4">
<div class="text-sm font-medium">
Scheduled update check
</div>
<p class="text-xs text-muted-foreground">
Periodically check all containers in this environment for available image updates.
</p>
{#if updateCheckLoading}
<div class="flex items-center justify-center py-4">
<RefreshCw class="w-5 h-5 animate-spin text-muted-foreground" />
</div>
{:else}
<div class="flex items-start gap-2">
<CircleFadingArrowUp class="w-4 h-4 text-green-500 glow-green mt-0.5 shrink-0" />
<div class="flex-1">
<Label>Enable scheduled update check</Label>
<p class="text-xs text-muted-foreground">Automatically check for container updates on a schedule</p>
</div>
<TogglePill bind:checked={updateCheckEnabled} />
</div>
{#if updateCheckEnabled}
<div class="flex items-start gap-2">
<div class="w-4 shrink-0"></div>
<div class="flex-1 space-y-2">
<Label>Schedule</Label>
<CronEditor value={updateCheckCron} onchange={(cron) => updateCheckCron = cron} />
</div>
</div>
<div class="flex items-start gap-2">
<CircleArrowUp class="w-4 h-4 text-muted-foreground mt-0.5 shrink-0" />
<div class="flex-1">
<Label>Automatically update containers</Label>
<p class="text-xs text-muted-foreground">
When enabled, containers will be updated automatically when new images are found.
When disabled, only sends notifications about available updates.
</p>
</div>
<TogglePill bind:checked={updateCheckAutoUpdate} />
</div>
{#if updateCheckAutoUpdate && scannerEnabled}
<div class="flex items-start gap-2">
<div class="w-4 shrink-0"></div>
<div class="flex-1">
<Label>Block updates with vulnerabilities</Label>
<p class="text-xs text-muted-foreground">
Block auto-updates if the new image has vulnerabilities exceeding this criteria
</p>
</div>
<VulnerabilityCriteriaSelector
bind:value={updateCheckVulnerabilityCriteria}
class="w-[200px]"
/>
</div>
{/if}
<div class="text-xs text-muted-foreground bg-muted/50 rounded-md p-2 flex items-start gap-2">
<Info class="w-3 h-3 mt-0.5 shrink-0" />
{#if updateCheckAutoUpdate}
{#if scannerEnabled && updateCheckVulnerabilityCriteria !== 'never'}
<span>New images are pulled to a temporary tag, scanned, then deployed if they pass the vulnerability check. Blocked images are deleted automatically.</span>
{:else}
<span>Containers will be updated automatically when new images are available.</span>
{/if}
{:else}
<span>You'll receive notifications when updates are available. Containers won't be modified.</span>
{/if}
</div>
{/if}
{/if}
</div>
<!-- Timezone selector -->
<div class="space-y-2">
<Label>Timezone</Label>
<TimezoneSelector
bind:value={formTimezone}
id="edit-env-timezone"
/>
<p class="text-xs text-muted-foreground">
Used for scheduling auto-updates and git syncs
</p>
</div>
<UpdatesTab
updateCheckLoading={updateCheckLoading}
bind:updateCheckEnabled={updateCheckEnabled}
bind:updateCheckCron={updateCheckCron}
bind:updateCheckAutoUpdate={updateCheckAutoUpdate}
bind:updateCheckVulnerabilityCriteria={updateCheckVulnerabilityCriteria}
scannerEnabled={scannerEnabled}
imagePruneLoading={imagePruneLoading}
bind:imagePruneEnabled={imagePruneEnabled}
bind:imagePruneCron={imagePruneCron}
bind:imagePruneMode={imagePruneMode}
imagePruneLastPruned={imagePruneLastPruned}
imagePruneLastResult={imagePruneLastResult}
bind:timezone={formTimezone}
/>
</Tabs.Content>
<!-- Activity Tab -->
<Tabs.Content value="activity" class="space-y-4 mt-0 h-full">
<div class="flex items-start gap-3">
<div class="flex-1">
<Label>Collect container activity</Label>
<p class="text-xs text-muted-foreground">Track container events (start, stop, restart, etc.) from this environment in real-time</p>
</div>
<TogglePill bind:checked={formCollectActivity} />
</div>
<div class="flex items-start gap-3">
<div class="flex-1">
<Label>Collect system metrics</Label>
<p class="text-xs text-muted-foreground">Collect CPU and memory usage statistics from this environment</p>
</div>
<TogglePill bind:checked={formCollectMetrics} />
</div>
<div class="flex items-start gap-3">
<div class="flex-1">
<Label>Highlight value changes</Label>
<p class="text-xs text-muted-foreground">Show amber glow when container values change in the containers list</p>
</div>
<TogglePill bind:checked={formHighlightChanges} />
</div>
<ActivityTab
bind:collectActivity={formCollectActivity}
bind:collectMetrics={formCollectMetrics}
bind:highlightChanges={formHighlightChanges}
/>
</Tabs.Content>
<!-- Security Tab -->
@@ -63,6 +63,7 @@
updatedAt: string;
updateCheckEnabled?: boolean;
updateCheckAutoUpdate?: boolean;
imagePruneEnabled?: boolean;
timezone?: string;
hawserVersion?: string;
}
@@ -479,7 +480,12 @@
<Cpu class="w-4 h-4 text-sky-400 glow-sky" />
</span>
{/if}
{#if !env.updateCheckEnabled && !hasScannerEnabled && !env.collectActivity && !env.collectMetrics}
{#if env.imagePruneEnabled}
<span title="Automatic image pruning enabled">
<Trash2 class="w-4 h-4 text-amber-500 glow-amber" />
</span>
{/if}
{#if !env.updateCheckEnabled && !hasScannerEnabled && !env.collectActivity && !env.collectMetrics && !env.imagePruneEnabled}
<span class="text-muted-foreground text-xs">—</span>
{/if}
</div>
@@ -0,0 +1,38 @@
<script lang="ts">
import { Label } from '$lib/components/ui/label';
import { TogglePill } from '$lib/components/ui/toggle-pill';
interface Props {
collectActivity: boolean;
collectMetrics: boolean;
highlightChanges: boolean;
}
let {
collectActivity = $bindable(),
collectMetrics = $bindable(),
highlightChanges = $bindable()
}: Props = $props();
</script>
<div class="flex items-start gap-3">
<div class="flex-1">
<Label>Collect container activity</Label>
<p class="text-xs text-muted-foreground">Track container events (start, stop, restart, etc.) from this environment in real-time</p>
</div>
<TogglePill bind:checked={collectActivity} />
</div>
<div class="flex items-start gap-3">
<div class="flex-1">
<Label>Collect system metrics</Label>
<p class="text-xs text-muted-foreground">Collect CPU and memory usage statistics from this environment</p>
</div>
<TogglePill bind:checked={collectMetrics} />
</div>
<div class="flex items-start gap-3">
<div class="flex-1">
<Label>Highlight value changes</Label>
<p class="text-xs text-muted-foreground">Show amber glow when container values change in the containers list</p>
</div>
<TogglePill bind:checked={highlightChanges} />
</div>
@@ -0,0 +1,219 @@
<script lang="ts">
import { Label } from '$lib/components/ui/label';
import * as Select from '$lib/components/ui/select';
import { TogglePill } from '$lib/components/ui/toggle-pill';
import CronEditor from '$lib/components/cron-editor.svelte';
import TimezoneSelector from '$lib/components/TimezoneSelector.svelte';
import VulnerabilityCriteriaSelector, { type VulnerabilityCriteria } from '$lib/components/VulnerabilityCriteriaSelector.svelte';
import { CircleFadingArrowUp, CircleArrowUp, RefreshCw, Info, Trash2 } from 'lucide-svelte';
import { formatDateTime } from '$lib/stores/settings';
interface Props {
// Update check settings
updateCheckLoading: boolean;
updateCheckEnabled: boolean;
updateCheckCron: string;
updateCheckAutoUpdate: boolean;
updateCheckVulnerabilityCriteria: VulnerabilityCriteria;
scannerEnabled: boolean;
// Image prune settings
imagePruneLoading: boolean;
imagePruneEnabled: boolean;
imagePruneCron: string;
imagePruneMode: 'dangling' | 'all';
imagePruneLastPruned?: string;
imagePruneLastResult?: { spaceReclaimed: number; imagesRemoved: number };
// Timezone
timezone: string;
}
let {
updateCheckLoading,
updateCheckEnabled = $bindable(),
updateCheckCron = $bindable(),
updateCheckAutoUpdate = $bindable(),
updateCheckVulnerabilityCriteria = $bindable(),
scannerEnabled,
imagePruneLoading,
imagePruneEnabled = $bindable(),
imagePruneCron = $bindable(),
imagePruneMode = $bindable(),
imagePruneLastPruned,
imagePruneLastResult,
timezone = $bindable()
}: Props = $props();
// Format bytes to human-readable string
function formatBytes(bytes: number): string {
if (bytes === 0) return '0 B';
const k = 1024;
const sizes = ['B', 'KB', 'MB', 'GB', 'TB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return `${parseFloat((bytes / Math.pow(k, i)).toFixed(2))} ${sizes[i]}`;
}
</script>
<!-- Scheduled Update Check Section -->
<div class="space-y-4">
<div class="text-sm font-medium">
Scheduled update check
</div>
<p class="text-xs text-muted-foreground">
Periodically check all containers in this environment for available image updates.
</p>
{#if updateCheckLoading}
<div class="flex items-center justify-center py-4">
<RefreshCw class="w-5 h-5 animate-spin text-muted-foreground" />
</div>
{:else}
<div class="flex items-start gap-2">
<CircleFadingArrowUp class="w-4 h-4 text-green-500 glow-green mt-0.5 shrink-0" />
<div class="flex-1">
<Label>Enable scheduled update check</Label>
<p class="text-xs text-muted-foreground">Automatically check for container updates on a schedule</p>
</div>
<TogglePill bind:checked={updateCheckEnabled} />
</div>
{#if updateCheckEnabled}
<div class="flex items-start gap-2">
<div class="w-4 shrink-0"></div>
<div class="flex-1 space-y-2">
<Label>Schedule</Label>
<CronEditor value={updateCheckCron} onchange={(cron) => updateCheckCron = cron} />
</div>
</div>
<div class="flex items-start gap-2">
<CircleArrowUp class="w-4 h-4 text-muted-foreground mt-0.5 shrink-0" />
<div class="flex-1">
<Label>Automatically update containers</Label>
<p class="text-xs text-muted-foreground">
When enabled, containers will be updated automatically when new images are found.
When disabled, only sends notifications about available updates.
</p>
</div>
<TogglePill bind:checked={updateCheckAutoUpdate} />
</div>
{#if updateCheckAutoUpdate && scannerEnabled}
<div class="flex items-start gap-2">
<div class="w-4 shrink-0"></div>
<div class="flex-1">
<Label>Block updates with vulnerabilities</Label>
<p class="text-xs text-muted-foreground">
Block auto-updates if the new image has vulnerabilities exceeding this criteria
</p>
</div>
<VulnerabilityCriteriaSelector
bind:value={updateCheckVulnerabilityCriteria}
class="w-[200px]"
/>
</div>
{/if}
<div class="text-xs text-muted-foreground bg-muted/50 rounded-md p-2 flex items-start gap-2">
<Info class="w-3 h-3 mt-0.5 shrink-0" />
{#if updateCheckAutoUpdate}
{#if scannerEnabled && updateCheckVulnerabilityCriteria !== 'never'}
<span>New images are pulled to a temporary tag, scanned, then deployed if they pass the vulnerability check. Blocked images are deleted automatically.</span>
{:else}
<span>Containers will be updated automatically when new images are available.</span>
{/if}
{:else}
<span>You'll receive notifications when updates are available. Containers won't be modified.</span>
{/if}
</div>
{/if}
{/if}
</div>
<!-- Image Pruning Section -->
<div class="space-y-4 pt-4 border-t">
<div class="text-sm font-medium">
Automatic image pruning
</div>
<p class="text-xs text-muted-foreground">
Automatically remove unused Docker images on a schedule to free up disk space.
</p>
{#if imagePruneLoading}
<div class="flex items-center justify-center py-4">
<RefreshCw class="w-5 h-5 animate-spin text-muted-foreground" />
</div>
{:else}
<div class="flex items-start gap-2">
<Trash2 class="w-4 h-4 text-amber-500 glow-amber mt-0.5 shrink-0" />
<div class="flex-1">
<Label>Enable automatic image pruning</Label>
<p class="text-xs text-muted-foreground">Automatically remove unused images on a schedule</p>
</div>
<TogglePill bind:checked={imagePruneEnabled} />
</div>
{#if imagePruneEnabled}
<div class="flex items-start gap-2">
<div class="w-4 shrink-0"></div>
<div class="flex-1 space-y-2">
<Label>Schedule</Label>
<CronEditor value={imagePruneCron} onchange={(cron) => imagePruneCron = cron} />
</div>
</div>
<div class="flex items-start gap-2">
<div class="w-4 shrink-0"></div>
<div class="flex-1 space-y-2">
<Label>Prune mode</Label>
<Select.Root type="single" bind:value={imagePruneMode}>
<Select.Trigger class="w-full">
{imagePruneMode === 'dangling' ? 'Dangling images only' : 'All unused images'}
</Select.Trigger>
<Select.Content>
<Select.Item value="dangling">Dangling images only</Select.Item>
<Select.Item value="all">All unused images</Select.Item>
</Select.Content>
</Select.Root>
<p class="text-xs text-muted-foreground">
{#if imagePruneMode === 'dangling'}
Only removes untagged image layers (safest option)
{:else}
Removes all images not used by any container (more aggressive)
{/if}
</p>
</div>
</div>
{#if imagePruneLastPruned}
<div class="flex items-start gap-2">
<div class="w-4 shrink-0"></div>
<div class="flex-1">
<p class="text-xs text-muted-foreground">
Last pruned: {formatDateTime(imagePruneLastPruned)}
{#if imagePruneLastResult}
- {imagePruneLastResult.imagesRemoved} images removed, {formatBytes(imagePruneLastResult.spaceReclaimed)} reclaimed
{/if}
</p>
</div>
</div>
{/if}
<div class="text-xs text-muted-foreground bg-muted/50 rounded-md p-2 flex items-start gap-2">
<Info class="w-3 h-3 mt-0.5 shrink-0" />
<span>Images in use by running or stopped containers will never be removed.</span>
</div>
{/if}
{/if}
</div>
<!-- Timezone selector -->
<div class="space-y-2">
<Label>Timezone</Label>
<TimezoneSelector
bind:value={timezone}
id="edit-env-timezone"
/>
<p class="text-xs text-muted-foreground">
Used for scheduling auto-updates, git syncs, and image pruning
</p>
</div>
+27 -27
View File
@@ -128,20 +128,22 @@
<Card.Title class="text-sm font-medium flex items-center gap-2">
<Eye class="w-4 h-4" />
Appearance
{#if !$authStore.authEnabled}
<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">
Theme and font settings are global when authentication is disabled. When auth is enabled, users can customize their appearance in their profile.
</Tooltip.Content>
</Tooltip.Portal>
</Tooltip.Root>
</Tooltip.Provider>
{/if}
<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>
@@ -225,20 +227,18 @@
<p class="text-xs text-muted-foreground">How dates are displayed throughout the app</p>
</div>
</div>
<!-- Right column: Theme settings (only when auth disabled) -->
{#if !$authStore.authEnabled}
<div class="space-y-4">
<ThemeSelector />
</div>
{:else}
<div class="text-xs text-muted-foreground flex items-start gap-1.5">
<HelpCircle class="w-3.5 h-3.5 shrink-0 mt-0.5" />
<div>
<p>Appearance settings (theme, fonts) are personal when auth is enabled.</p>
<a href="/profile" class="text-primary hover:underline">Configure in your profile</a>
<!-- 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>
</div>
{/if}
{/if}
</div>
</div>
</Card.Content>
</Card.Root>
+58 -23
View File
@@ -243,6 +243,7 @@
{ value: 'running', label: 'Running', icon: Play, color: 'text-emerald-500' },
{ value: 'partial', label: 'Partial', icon: CircleDashed, color: 'text-amber-500' },
{ value: 'stopped', label: 'Stopped', icon: Square, color: 'text-rose-500' },
{ value: 'created', label: 'Created', icon: CircleDashed, color: 'text-slate-500' },
{ value: 'not deployed', label: 'Not deployed', icon: Rocket, color: 'text-violet-500' }
];
@@ -335,13 +336,13 @@
const query = searchQuery.toLowerCase();
result = result.filter(stack =>
stack.name.toLowerCase().includes(query) ||
stack.status.toLowerCase().includes(query)
getDisplayStatus(stack).toLowerCase().includes(query)
);
}
// Filter by status
// Filter by status (uses display status so git "created" matches "not deployed")
if (statusFilter.length > 0) {
result = result.filter(stack => statusFilter.includes(stack.status.toLowerCase()));
result = result.filter(stack => statusFilter.includes(getDisplayStatus(stack).toLowerCase()));
}
// Sort
@@ -355,7 +356,7 @@
cmp = a.containers.length - b.containers.length;
break;
case 'status':
cmp = a.status.localeCompare(b.status);
cmp = getDisplayStatus(a).localeCompare(getDisplayStatus(b));
break;
case 'cpu':
const cpuA = getStackStats(a)?.cpuPercent ?? -1;
@@ -391,7 +392,7 @@
// Count by status for selected stacks
const selectedRunning = $derived(selectedInFilter.filter(s => s.status === 'running' || s.status === 'partial' || s.status === 'restarting'));
const selectedStopped = $derived(selectedInFilter.filter(s => s.status === 'stopped' || s.status === 'not deployed'));
const selectedStopped = $derived(selectedInFilter.filter(s => s.status === 'stopped' || s.status === 'not deployed' || s.status === 'created'));
function toggleSelectAll() {
if (allFilteredSelected) {
@@ -655,6 +656,13 @@
return stackSources[stackName] || { sourceType: 'external' };
}
function getDisplayStatus(stack: ComposeStackInfo): string {
if (stack.status === 'created' && getStackSource(stack.name).sourceType === 'git') {
return 'not deployed';
}
return stack.status;
}
async function openGitModal(gitStack?: any) {
editingGitStack = gitStack || null;
// Fetch repositories and credentials before opening modal
@@ -679,8 +687,14 @@
try {
const response = await fetch(appendEnvParam(`/api/stacks/${encodeURIComponent(name)}/start`, envId), { method: 'POST' });
if (!response.ok) {
const data = await response.json();
const errorMsg = data.error || 'Failed to start stack';
const rawText = await response.text();
let errorMsg = 'Failed to start stack';
try {
const data = JSON.parse(rawText);
errorMsg = data.error || errorMsg;
} catch {
errorMsg = rawText || errorMsg;
}
showErrorDialog(`Failed to start ${name}`, errorMsg);
return;
}
@@ -701,8 +715,14 @@
try {
const response = await fetch(appendEnvParam(`/api/stacks/${encodeURIComponent(name)}/stop`, envId), { method: 'POST' });
if (!response.ok) {
const data = await response.json();
const errorMsg = data.error || 'Failed to stop stack';
const rawText = await response.text();
let errorMsg = 'Failed to stop stack';
try {
const data = JSON.parse(rawText);
errorMsg = data.error || errorMsg;
} catch {
errorMsg = rawText || errorMsg;
}
showErrorDialog(`Failed to stop ${name}`, errorMsg);
return;
}
@@ -723,8 +743,14 @@
try {
const response = await fetch(appendEnvParam(`/api/stacks/${encodeURIComponent(name)}/restart`, envId), { method: 'POST' });
if (!response.ok) {
const data = await response.json();
const errorMsg = data.error || 'Failed to restart stack';
const rawText = await response.text();
let errorMsg = 'Failed to restart stack';
try {
const data = JSON.parse(rawText);
errorMsg = data.error || errorMsg;
} catch {
errorMsg = rawText || errorMsg;
}
showErrorDialog(`Failed to restart ${name}`, errorMsg);
return;
}
@@ -817,6 +843,8 @@
return `${base} bg-red-200 dark:bg-red-800 text-red-900 dark:text-red-100`;
case 'partial':
return `${base} bg-amber-200 dark:bg-amber-800 text-amber-900 dark:text-amber-100`;
case 'created':
return `${base} bg-slate-200 dark:bg-slate-700 text-slate-900 dark:text-slate-100`;
case 'not deployed':
return `${base} bg-violet-200 dark:bg-violet-800 text-violet-900 dark:text-violet-100`;
default:
@@ -1342,13 +1370,19 @@
Internal
</span>
{:else}
<span
class="inline-flex items-center justify-center gap-1 text-xs px-1.5 py-0.5 rounded-sm bg-gray-100 text-gray-800 dark:bg-gray-800 dark:text-gray-200 shadow-sm min-w-[5.5rem]"
title="File location unknown"
>
<ExternalLink class="w-3 h-3" />
Untracked
</span>
<Tooltip.Root>
<Tooltip.Trigger>
<span
class="inline-flex items-center justify-center gap-1 text-xs px-1.5 py-0.5 rounded-sm bg-gray-100 text-gray-800 dark:bg-gray-800 dark:text-gray-200 shadow-sm min-w-[5.5rem]"
>
<ExternalLink class="w-3 h-3" />
Untracked
</span>
</Tooltip.Trigger>
<Tooltip.Content class="whitespace-nowrap">
Compose file location unknown. Click the stack name or edit button to locate it.
</Tooltip.Content>
</Tooltip.Root>
{/if}
{:else if column.id === 'location'}
{#if source.composePath}
@@ -1364,7 +1398,7 @@
</Tooltip.Content>
</Tooltip.Root>
{:else}
<span class="text-xs text-muted-foreground/50">—</span>
<span class="text-xs text-muted-foreground/50 italic">Not set</span>
{/if}
{:else if column.id === 'containers'}
<div class="flex items-center gap-1">
@@ -1465,10 +1499,11 @@
{getStackVolumeCount(stack) || '-'}
</span>
{:else if column.id === 'status'}
{@const StatusIcon = getStackStatusIcon(stack.status)}
<span class={getStatusClasses(stack.status)}>
{@const displayStatus = getDisplayStatus(stack)}
{@const StatusIcon = getStackStatusIcon(displayStatus)}
<span class={getStatusClasses(displayStatus)}>
<StatusIcon class="w-3 h-3" />
{stack.status}
{displayStatus}
</span>
{:else if column.id === 'actions'}
<div class="relative flex gap-1 justify-end">
@@ -1481,7 +1516,7 @@
</button>
</div>
{/if}
{#if stack.status === 'not deployed' && source.gitStack}
{#if (stack.status === 'not deployed' || stack.status === 'created') && source.gitStack}
<button
type="button"
onclick={() => openGitModal(source.gitStack)}
+144 -99
View File
@@ -6,7 +6,7 @@
import { Label } from '$lib/components/ui/label';
import { Input } from '$lib/components/ui/input';
import { TogglePill } from '$lib/components/ui/toggle-pill';
import { Loader2, GitBranch, RefreshCw, Webhook, Rocket, RefreshCcw, Copy, Check, FolderGit2, Github, Key, KeyRound, Lock, FileText, HelpCircle, GripVertical, X } from 'lucide-svelte';
import { Loader2, GitBranch, RefreshCw, Webhook, Rocket, RefreshCcw, Copy, Check, FolderGit2, Github, Key, KeyRound, Lock, FileText, HelpCircle, GripVertical, X, Download } from 'lucide-svelte';
import * as Tooltip from '$lib/components/ui/tooltip';
import CronEditor from '$lib/components/cron-editor.svelte';
import StackEnvVarsPanel from '$lib/components/StackEnvVarsPanel.svelte';
@@ -102,25 +102,13 @@
let fileEnvVars = $state<Record<string, string>>({});
let loadingFileVars = $state(false);
let existingSecretKeys = $state<Set<string>>(new Set());
let populatingEnvVars = $state(false);
// Resizable split panel state
let splitRatio = $state(60); // percentage for form panel
let isDraggingSplit = $state(false);
let containerRef: HTMLDivElement | null = $state(null);
// Derived state for merged variables and sources
const envVarSources = $derived<Record<string, 'file' | 'override'>>(() => {
const sources: Record<string, 'file' | 'override'> = {};
// File vars
for (const key of Object.keys(fileEnvVars)) {
sources[key] = 'file';
}
// Overrides take precedence
for (const v of envVars.filter(v => v.key)) {
sources[v.key] = 'override';
}
return sources;
});
// Track which gitStack was initialized to avoid repeated resets
let lastInitializedStackId = $state<number | null | undefined>(undefined);
@@ -263,6 +251,79 @@
}
}
async function populateEnvVars() {
// Validate we have repository info
if (formRepoMode === 'existing' && !formRepositoryId) {
toast.error('Please select a repository first');
return;
}
if (formRepoMode === 'new' && !formNewRepoUrl.trim()) {
toast.error('Please enter a repository URL first');
return;
}
populatingEnvVars = true;
try {
const body: Record<string, any> = {
composePath: formComposePath || 'compose.yaml',
envFilePath: formEnvFilePath || null
};
if (formRepoMode === 'existing') {
body.repositoryId = formRepositoryId;
} else {
body.url = formNewRepoUrl;
body.branch = formNewRepoBranch || 'main';
body.credentialId = formNewRepoCredentialId;
}
const response = await fetch('/api/git/preview-env', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body)
});
const data = await response.json();
if (!response.ok) {
toast.error('Failed to load env variables', {
description: data.error || 'Unknown error'
});
return;
}
const vars = data.vars as Record<string, string>;
const count = Object.keys(vars).length;
if (count === 0) {
toast.info('No environment variables found', {
description: 'No .env files found in the repository. You can still add variables manually.'
});
return;
}
// Convert to EnvVar array - preserve existing user entries that aren't in repo
const existingUserVars = envVars.filter(v => v.key.trim() && !(v.key in vars));
const newVars: EnvVar[] = Object.entries(vars).map(([key, value]) => ({
key,
value,
isSecret: false
}));
envVars = [...newVars, ...existingUserVars];
fileEnvVars = vars;
toast.success(`Loaded ${count} variable${count === 1 ? '' : 's'}`, {
description: 'You can now customize values before deploying'
});
} catch (e) {
console.error('Failed to populate env vars:', e);
toast.error('Failed to load env variables');
} finally {
populatingEnvVars = false;
}
}
function resetForm() {
// Clear state BEFORE async loads to avoid race conditions
formError = '';
@@ -344,6 +405,15 @@
formError = '';
try {
// Only save vars that are actual overrides (differ from file) or new (not in file)
// This ensures file updates from git are picked up on next sync
const overrideVars = envVars.filter(v => {
if (!v.key.trim()) return false;
const fileValue = fileEnvVars[v.key];
// Save if: not in file (new var), value differs from file, or is a secret
return fileValue === undefined || v.value !== fileValue || v.isSecret;
});
let body: any = {
stackName: formStackName,
composePath: formComposePath || 'compose.yaml',
@@ -353,7 +423,12 @@
autoUpdateCron: formAutoUpdateCron,
webhookEnabled: formWebhookEnabled,
webhookSecret: formWebhookEnabled ? formWebhookSecret : null,
deployNow: deployAfterSave
deployNow: deployAfterSave,
envVars: overrideVars.map(v => ({
key: v.key.trim(),
value: v.value,
isSecret: v.isSecret
}))
};
if (formRepoMode === 'existing') {
@@ -394,31 +469,6 @@
return;
}
// Save environment variable overrides (always save to ensure DB is in sync)
// This handles both adding new vars and clearing all vars
const definedVars = envVars.filter(v => v.key.trim());
try {
const envResponse = await fetch(
`/api/stacks/${encodeURIComponent(formStackName)}/env${environmentId ? `?env=${environmentId}` : ''}`,
{
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
variables: definedVars.map(v => ({
key: v.key.trim(),
value: v.value,
isSecret: v.isSecret
}))
})
}
);
if (!envResponse.ok) {
console.error('Failed to save environment variables');
}
} catch (e) {
console.error('Failed to save environment variables:', e);
}
onSaved();
onClose();
} catch (error) {
@@ -433,17 +483,26 @@
if (formRepoMode === 'existing' && formRepositoryId && !gitStack && !formStackNameUserModified) {
const repo = repositories.find(r => r.id === formRepositoryId);
if (repo) {
// Normalize repo name: lowercase, spaces/underscores to hyphens, strip invalid chars
const normalizedName = repo.name
.toLowerCase()
.replace(/[\s_]+/g, '-')
.replace(/[^a-z0-9-]/g, '')
.replace(/-+/g, '-')
.replace(/^-|-$/g, '');
// Extract compose filename without extension for stack name
const composeName = formComposePath
.replace(/^.*\//, '') // Remove directory path
.replace(/\.(yml|yaml)$/i, '') // Remove extension
.replace(/^docker-compose\.?/, ''); // Remove docker-compose prefix
.replace(/^docker-compose\.?/, '') // Remove docker-compose prefix
.replace(/^compose$/, ''); // Remove plain "compose"
// Combine repo name with compose name if it's not the default
if (composeName && composeName !== 'docker-compose') {
formStackName = `${repo.name}-${composeName}`;
formStackName = `${normalizedName}-${composeName}`;
} else {
formStackName = repo.name;
formStackName = normalizedName;
}
}
}
@@ -669,71 +728,26 @@
<!-- Additional env file for variable substitution -->
<div class="space-y-2">
<div class="flex items-center gap-1.5">
<Label for="env-file-path">Additional .env file (optional)</Label>
<Label for="env-file-path">Additional env file (optional)</Label>
<Tooltip.Root>
<Tooltip.Trigger>
<HelpCircle class="w-3.5 h-3.5 text-muted-foreground cursor-help" />
</Tooltip.Trigger>
<Tooltip.Content>
<div class="w-80">
<p class="text-xs">All files in the git repository are cloned automatically, including any files referenced by <code class="bg-muted px-1 rounded">env_file:</code> directives.</p>
<p class="text-xs mt-2">Only use this field for additional environment variables to be passed to Docker Compose.</p>
<p class="text-xs mt-2">The contents will be parsed and passed as shell environment variables.</p>
<p class="text-xs">A <code class="bg-muted px-1 rounded">.env</code> file in the compose directory is always loaded automatically, if present.</p>
<p class="text-xs mt-2">Use this field for an additional env file with a non-standard name (e.g. <code class="bg-muted px-1 rounded">.env.production</code>). Its values override the default <code class="bg-muted px-1 rounded">.env</code>.</p>
<p class="text-xs mt-2">Overrides from the environment variables editor on the right always take highest precedence.</p>
</div>
</Tooltip.Content>
</Tooltip.Root>
</div>
{#if gitStack && envFiles.length > 0}
<!-- Dropdown selector for existing stacks with discovered .env files -->
<Select.Root
type="single"
value={formEnvFilePath ?? 'none'}
onValueChange={(v) => {
formEnvFilePath = v === 'none' ? null : v;
if (formEnvFilePath) {
loadEnvFileContents(formEnvFilePath);
} else {
fileEnvVars = {};
}
}}
>
<Select.Trigger class="w-full">
{#if loadingEnvFiles}
<Loader2 class="w-4 h-4 mr-2 animate-spin" />
Loading...
{:else if formEnvFilePath}
<FileText class="w-4 h-4 mr-2 text-muted-foreground" />
{formEnvFilePath}
{:else}
<FileText class="w-4 h-4 mr-2 text-muted-foreground" />
None
{/if}
</Select.Trigger>
<Select.Content>
<Select.Item value="none">
<span class="text-muted-foreground">None</span>
</Select.Item>
{#each envFiles as file}
<Select.Item value={file}>
<span class="flex items-center gap-2">
<FileText class="w-4 h-4 text-muted-foreground" />
{file}
</span>
</Select.Item>
{/each}
</Select.Content>
</Select.Root>
{:else}
<!-- Text input for new stacks or when no .env files discovered -->
<Input
id="env-file-path"
bind:value={formEnvFilePath}
placeholder=".env"
placeholder=""
/>
{/if}
<p class="text-xs text-muted-foreground">
For variable substitution from a file outside the compose directory.
</p>
<p class="text-xs text-muted-foreground">Additional env file to pass to Docker Compose</p>
</div>
<!-- Auto-update section -->
@@ -877,12 +891,43 @@
<div class="flex-1 min-w-0 flex flex-col overflow-hidden bg-zinc-50 dark:bg-zinc-800/50">
<StackEnvVarsPanel
bind:variables={envVars}
showSource={!!formEnvFilePath && gitStack !== null}
sources={envVarSources}
placeholder={{ key: 'OVERRIDE_VAR', value: 'override value' }}
infoText="These environment variables are optional. If a .env file is specified in the repository, these values will be merged with the file values. Variables defined here take precedence over .env file values."
placeholder={{ key: 'MY_VAR', value: 'value' }}
infoText="Override variables from your repository env files. Non-secrets are saved to <code class='bg-muted px-1 rounded'>.env.dockhand</code> in the stack directory. Secrets are stored in the database and injected via shell environment at deploy time."
existingSecretKeys={gitStack !== null ? existingSecretKeys : new Set()}
/>
>
{#snippet headerActions()}
{#if !gitStack}
<div class="flex items-center gap-0.5">
<Button
type="button"
size="sm"
variant="ghost"
onclick={populateEnvVars}
disabled={populatingEnvVars || (formRepoMode === 'existing' && !formRepositoryId) || (formRepoMode === 'new' && !formNewRepoUrl.trim())}
class="h-6 text-xs px-2"
>
{#if populatingEnvVars}
<Loader2 class="w-3.5 h-3.5 mr-1 animate-spin" />
Loading...
{:else}
<Download class="w-3.5 h-3.5 mr-1" />
Populate
{/if}
</Button>
<Tooltip.Root>
<Tooltip.Trigger>
<HelpCircle class="w-3.5 h-3.5 text-muted-foreground cursor-help" />
</Tooltip.Trigger>
<Tooltip.Content>
<div class="w-64">
<p class="text-xs">Clone the repository and load environment variables from the <code class="bg-muted px-1 rounded">.env</code> file (in compose directory) and additional env file (if specified), so you can see what you can override.</p>
</div>
</Tooltip.Content>
</Tooltip.Root>
</div>
{/if}
{/snippet}
</StackEnvVarsPanel>
</div>
</div>
+8 -5
View File
@@ -6,6 +6,7 @@
import { Import, Loader2, Play, Info } from 'lucide-svelte';
import FilesystemBrowser, { type FileEntry } from './FilesystemBrowser.svelte';
import CodeEditor from '$lib/components/CodeEditor.svelte';
import yaml from 'js-yaml';
import { toast } from 'svelte-sonner';
import { currentEnvironment, environments } from '$lib/stores/environment';
import { getIconComponent } from '$lib/utils/icons';
@@ -83,11 +84,13 @@
const data = await res.json();
previewContent = data.content || '';
// Count services in the compose file
const serviceMatches = previewContent?.match(/^services:\s*\n((?:\s{2,}\w+:.*\n?)+)/m);
if (serviceMatches) {
const servicesBlock = serviceMatches[1];
const serviceNames = servicesBlock.match(/^\s{2}\w+:/gm);
previewServiceCount = serviceNames?.length || 0;
try {
const doc = yaml.load(previewContent) as Record<string, unknown> | null;
if (doc?.services && typeof doc.services === 'object') {
previewServiceCount = Object.keys(doc.services).length;
}
} catch {
previewServiceCount = 0;
}
}
} catch (e) {
+70 -74
View File
@@ -87,6 +87,7 @@
// Base directory when user browsed to a directory (without stack name yet)
let browsedBaseDirectory = $state<string | null>(null);
// UI state
let composePathCopied = $state(false);
let envPathCopied = $state(false);
@@ -123,9 +124,8 @@
if (!workingComposePath) return undefined;
switch (pathSource) {
case 'browsed':
return 'Custom location';
case 'custom':
return 'Using saved location';
return 'Custom location';
case 'default':
return 'Using default location';
default:
@@ -398,9 +398,8 @@
}
// In CREATE mode, we only want the content - don't store external paths
// Files will be saved to internal stack directory
// Files will be saved to the directory containing the selected compose file
if (mode === 'create') {
pathSource = 'browsed';
showFileBrowser = false;
// Load compose file content when selecting a file (not directory)
@@ -409,9 +408,14 @@
const dir = finalPath.replace(/\/[^/]+$/, '');
const potentialEnvPath = `${dir}/.env`;
await loadFilesFromLocalFilesystem(finalPath, potentialEnvPath);
// Don't set workingComposePath/workingEnvPath - use internal defaults
workingComposePath = '';
workingEnvPath = '';
// Use the selected file's path directly
workingComposePath = finalPath;
workingEnvPath = `${dir}/.env`;
browsedBaseDirectory = null;
// 'custom' prevents the path effect from overriding (it only acts on 'browsed')
pathSource = 'custom';
} else {
pathSource = 'browsed';
}
isDirty = true;
return;
@@ -480,12 +484,13 @@
}
}
// In CREATE mode, don't store external path - content will be saved to internal directory
// In EDIT mode, store the path for the file location
if (mode !== 'create') {
// Store the selected path:
// - Always in EDIT mode
// - In CREATE mode when user selected a custom compose location OR explicitly selected an env file
if (mode !== 'create' || pathSource === 'custom' || pathSource === 'browsed' || !isDirectory) {
workingEnvPath = finalPath;
}
// If CREATE mode, workingEnvPath stays empty - will use internal default
// Otherwise CREATE mode with internal location uses default via suggestedEnvPath
isDirty = true;
}
@@ -1063,32 +1068,32 @@ services:
throw new Error(rawEnvError.error || 'Failed to save environment file');
}
// Save ALL vars to DB (includes secrets with real values)
const definedVars = prepared.variables;
if (definedVars.length > 0 || hadExistingDbVars) {
// Save only secrets to DB (non-secrets are in the .env file written above)
const secretVars = prepared.variables.filter(v => v.isSecret);
if (secretVars.length > 0 || hadExistingDbVars) {
const envResponse = await fetch(
appendEnvParam(`/api/stacks/${encodeURIComponent(stackName)}/env`, envId),
{
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
variables: definedVars.map(v => ({
variables: secretVars.map(v => ({
key: v.key.trim(),
value: v.value,
isSecret: v.isSecret
isSecret: true
}))
})
}
);
if (!envResponse.ok) {
// Log but don't fail - DB stores secret metadata
console.warn('Failed to save environment variable metadata to database');
// Log but don't fail - DB stores secret values
console.warn('Failed to save secret variables to database');
}
hadExistingDbVars = definedVars.length > 0;
hadExistingDbVars = secretVars.length > 0;
existingSecretKeys = new Set(
definedVars.filter(v => v.isSecret && v.key.trim()).map(v => v.key.trim())
secretVars.filter(v => v.key.trim()).map(v => v.key.trim())
);
}
@@ -1220,6 +1225,33 @@ services:
return () => clearTimeout(timeout);
});
// Pre-fetched default base directory for create mode (fetched once on open/env change)
let defaultStackDir = $state<string | null>(null);
async function fetchDefaultBasePath(envId: number | null, location: string | null) {
const params = new URLSearchParams({ name: '__placeholder__' });
if (envId) params.set('env', String(envId));
if (location) params.set('location', location);
try {
const r = await fetch(`/api/stacks/default-path?${params}`);
if (r.ok) {
const data = await r.json();
// Extract base dir by removing the placeholder name
defaultStackDir = data.stackDir.replace('/__placeholder__', '');
}
} catch {
// Ignore fetch errors
}
}
// Fetch default base path when modal opens or environment changes
$effect(() => {
if (!open || mode !== 'create') return;
const envId = $currentEnvironment?.id ?? null;
const location = $appSettings.primaryStackLocation;
fetchDefaultBasePath(envId, location);
});
// Auto-update default paths when stack name changes in create mode
// This unified effect handles both default paths and browsed directory paths
$effect(() => {
@@ -1227,17 +1259,21 @@ services:
const name = newStackName.trim();
// Case 1: No name entered yet - clear paths
// User selected a specific file - paths are locked, don't touch them
if (pathSource === 'custom') return;
// No name entered yet - clear paths but preserve browsed state
if (!name) {
workingComposePath = '';
workingEnvPath = '';
autoComputedComposePath = '';
pathSource = null;
if (!browsedBaseDirectory) {
pathSource = null;
}
return;
}
// Case 2: User has browsed and selected a directory - use that as base
// Keep updating as user types (don't clear browsedBaseDirectory!)
// User browsed and selected a directory - build path from that base
if (browsedBaseDirectory) {
workingComposePath = `${browsedBaseDirectory}/${name}/compose.yaml`;
workingEnvPath = `${browsedBaseDirectory}/${name}/.env`;
@@ -1245,54 +1281,14 @@ services:
return;
}
// Case 3: User already has a browsed path set (from previous name entry)
// Update the stack name portion in the existing path
if (pathSource === 'browsed' && workingComposePath) {
// Extract base directory from existing path and rebuild with new name
// Path format: {baseDir}/{stackName}/compose.yaml
const pathParts = workingComposePath.split('/');
pathParts.pop(); // remove 'compose.yaml'
pathParts.pop(); // remove old stack name
const baseDir = pathParts.join('/');
if (baseDir) {
workingComposePath = `${baseDir}/${name}/compose.yaml`;
workingEnvPath = `${baseDir}/${name}/.env`;
}
return;
// Use pre-fetched default base directory
if (defaultStackDir) {
const dir = `${defaultStackDir}/${name}`;
autoComputedComposePath = `${dir}/compose.yaml`;
workingComposePath = `${dir}/compose.yaml`;
workingEnvPath = `${dir}/.env`;
pathSource = 'default';
}
// Case 4: Default path from settings/API
const location = $appSettings.primaryStackLocation;
const envId = $currentEnvironment?.id ?? null;
const fetchDefaultPath = async () => {
try {
const params = new URLSearchParams({ name });
if (envId) params.set('env', String(envId));
if (location) {
params.set('location', location);
}
const response = await fetch(`/api/stacks/default-path?${params}`);
if (response.ok) {
const data = await response.json();
// Check if user has customized before updating auto-computed
// Compare current working path against OLD auto path (before we update it)
const userHasCustomized = workingComposePath !== '' &&
workingComposePath !== autoComputedComposePath;
// Track the auto-computed path
autoComputedComposePath = data.composePath;
// Only update working paths if user hasn't customized
if (!userHasCustomized) {
workingComposePath = data.composePath;
workingEnvPath = data.envPath;
pathSource = data.source || 'default';
}
}
} catch (e) {
console.error('Failed to fetch default path:', e);
}
};
fetchDefaultPath();
});
</script>
@@ -1433,7 +1429,7 @@ services:
<AlertCircle class="w-4 h-4 shrink-0 text-amber-500 mt-0.5" />
<div class="flex-1 min-w-0">
<p class="text-sm text-zinc-600 dark:text-zinc-400 mb-2">
<span class="font-medium text-amber-800 dark:text-amber-300">Untracked stack.</span> Select the compose file location to start managing this stack with Dockhand.
<span class="font-medium text-amber-800 dark:text-amber-300">Untracked stack</span> — this stack is running in Docker but Dockhand doesn't know where its compose file is stored on disk. Browse to locate the file to start editing and managing it.
</p>
{#if stackContainers.length > 0}
<div class="text-xs text-zinc-500 dark:text-zinc-400">
@@ -1621,10 +1617,10 @@ services:
<Button class="w-36" onclick={() => handleSave(true)} disabled={saving || loading || (needsFileLocation && !workingComposePath.trim())}>
{#if saving && savingWithRestart}
<Loader2 class="w-4 h-4 mr-2 animate-spin" />
Restarting...
Deploying...
{:else}
<Play class="w-4 h-4 mr-2" />
Save & restart
Save & redeploy
{/if}
</Button>
{/if}
+351 -53
View File
@@ -8,13 +8,29 @@
import { Label } from '$lib/components/ui/label';
import { Input } from '$lib/components/ui/input';
import { Button } from '$lib/components/ui/button';
import { Plus, Trash2, HardDrive, Database, Server } from 'lucide-svelte';
import { TogglePill } from '$lib/components/ui/toggle-pill';
import { Plus, Trash2, HardDrive, Database, Server, ChevronDown } from 'lucide-svelte';
const VOLUME_DRIVERS = [
{ value: 'local', label: 'Local', description: 'Default local driver', icon: HardDrive },
{ value: 'nfs', label: 'NFS', description: 'Network file system', icon: Server },
{ value: 'cifs', label: 'CIFS', description: 'Windows/SMB shares', icon: Database }
];
const SMB_VERSIONS = [
{ value: '2.0', label: 'SMB 2.0' },
{ value: '2.1', label: 'SMB 2.1' },
{ value: '3.0', label: 'SMB 3.0' },
{ value: '3.1.1', label: 'SMB 3.1.1' }
];
const NFS_VERSIONS = [
{ value: '3', label: 'NFSv3' },
{ value: '4', label: 'NFSv4' },
{ value: '4.1', label: 'NFSv4.1' },
{ value: '4.2', label: 'NFSv4.2' }
];
import { currentEnvironment, appendEnvParam } from '$lib/stores/environment';
import { focusFirstInput } from '$lib/utils';
@@ -32,9 +48,28 @@
let driverOpts = $state<KeyValue[]>([]);
let labels = $state<KeyValue[]>([]);
// CIFS fields
let cifsServer = $state('');
let cifsShare = $state('');
let cifsUsername = $state('');
let cifsPassword = $state('');
let cifsVersion = $state('3.0');
let cifsDomain = $state('');
// NFS fields
let nfsServer = $state('');
let nfsPath = $state('');
let nfsVersion = $state('4');
let nfsSoft = $state(true);
let nfsNolock = $state(true);
let nfsReadOnly = $state(false);
// Additional options visibility
let showAdditionalOpts = $state(false);
let creating = $state(false);
let error = $state('');
let errors = $state<{ name?: string }>({});
let errors = $state<{ name?: string; server?: string; share?: string; path?: string }>({});
function addDriverOpt() {
driverOpts = [...driverOpts, { key: '', value: '' }];
@@ -57,6 +92,19 @@
driver = 'local';
driverOpts = [];
labels = [];
cifsServer = '';
cifsShare = '';
cifsUsername = '';
cifsPassword = '';
cifsVersion = '3.0';
cifsDomain = '';
nfsServer = '';
nfsPath = '';
nfsVersion = '4';
nfsSoft = true;
nfsNolock = true;
nfsReadOnly = false;
showAdditionalOpts = false;
error = '';
errors = {};
}
@@ -66,22 +114,62 @@
if (!name.trim()) {
errors.name = 'Volume name is required';
return;
}
// Validate driver-specific required fields
if (driver === 'cifs') {
if (!cifsServer.trim()) errors.server = 'Server is required';
if (!cifsShare.trim()) errors.share = 'Share path is required';
} else if (driver === 'nfs') {
if (!nfsServer.trim()) errors.server = 'Server is required';
if (!nfsPath.trim()) errors.path = 'Export path is required';
}
if (Object.keys(errors).length > 0) return;
creating = true;
error = '';
try {
const envId = $currentEnvironment?.id ?? null;
// Convert key-value arrays to objects
// Build driverOpts based on driver type
const driverOptsObj: Record<string, string> = {};
driverOpts.forEach(({ key, value }) => {
if (key && value) {
driverOptsObj[key] = value;
}
});
if (driver === 'cifs') {
driverOptsObj.type = 'cifs';
const share = cifsShare.trim().replace(/^\/+/, '');
driverOptsObj.device = `//${cifsServer.trim()}/${share}`;
const opts = [`addr=${cifsServer.trim()}`, `username=${cifsUsername}`, `password=${cifsPassword}`, `vers=${cifsVersion}`];
if (cifsDomain.trim()) opts.push(`domain=${cifsDomain.trim()}`);
// Append additional options
driverOpts.forEach(({ key, value }) => {
if (key && value) opts.push(`${key}=${value}`);
else if (key) opts.push(key);
});
driverOptsObj.o = opts.join(',');
} else if (driver === 'nfs') {
driverOptsObj.type = 'nfs';
const path = nfsPath.trim().startsWith('/') ? nfsPath.trim() : `/${nfsPath.trim()}`;
driverOptsObj.device = `:${path}`;
const opts = [`addr=${nfsServer.trim()}`, `nfsvers=${nfsVersion}`];
if (nfsSoft) opts.push('soft');
if (nfsNolock) opts.push('nolock');
if (nfsReadOnly) opts.push('ro');
// Append additional options
driverOpts.forEach(({ key, value }) => {
if (key && value) opts.push(`${key}=${value}`);
else if (key) opts.push(key);
});
driverOptsObj.o = opts.join(',');
} else {
// Local driver - use generic key-value pairs
driverOpts.forEach(({ key, value }) => {
if (key && value) {
driverOptsObj[key] = value;
}
});
}
const labelsObj: Record<string, string> = {};
labels.forEach(({ key, value }) => {
@@ -95,7 +183,7 @@
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
name: name.trim(),
driver,
driver: driver === 'nfs' || driver === 'cifs' ? 'local' : driver,
driverOpts: driverOptsObj,
labels: labelsObj
})
@@ -186,53 +274,263 @@
</p>
</div>
<!-- Driver Options -->
<div class="space-y-2">
<div class="flex items-center justify-between">
<Label>Driver options</Label>
<Button
type="button"
size="sm"
variant="outline"
onclick={addDriverOpt}
disabled={creating}
>
<Plus class="w-3 h-3 mr-1" />
Add option
</Button>
</div>
{#if driverOpts.length > 0}
<!-- Driver-specific fields -->
{#if driver === 'cifs'}
<!-- CIFS fields -->
<div class="grid grid-cols-2 gap-4">
<div class="space-y-2">
{#each driverOpts as opt, i}
<div class="flex gap-2">
<Input
bind:value={opt.key}
placeholder="Key"
disabled={creating}
class="flex-1"
/>
<Input
bind:value={opt.value}
placeholder="Value"
disabled={creating}
class="flex-1"
/>
<Button
type="button"
size="icon"
variant="ghost"
onclick={() => removeDriverOpt(i)}
disabled={creating}
>
<Trash2 class="w-4 h-4" />
<Label for="cifs-server">Server / IP *</Label>
<Input
id="cifs-server"
bind:value={cifsServer}
placeholder="192.168.1.100"
disabled={creating}
class={errors.server ? 'border-destructive focus-visible:ring-destructive' : ''}
oninput={() => errors.server = undefined}
/>
{#if errors.server}
<p class="text-xs text-destructive">{errors.server}</p>
{/if}
</div>
<div class="space-y-2">
<Label for="cifs-share">Share path *</Label>
<Input
id="cifs-share"
bind:value={cifsShare}
placeholder="shared/folder"
disabled={creating}
class={errors.share ? 'border-destructive focus-visible:ring-destructive' : ''}
oninput={() => errors.share = undefined}
/>
{#if errors.share}
<p class="text-xs text-destructive">{errors.share}</p>
{/if}
</div>
</div>
<div class="grid grid-cols-2 gap-4">
<div class="space-y-2">
<Label for="cifs-username">Username</Label>
<Input
id="cifs-username"
bind:value={cifsUsername}
placeholder="user"
disabled={creating}
/>
</div>
<div class="space-y-2">
<Label for="cifs-password">Password</Label>
<Input
id="cifs-password"
type="password"
bind:value={cifsPassword}
placeholder="••••••••"
disabled={creating}
/>
</div>
</div>
<div class="grid grid-cols-2 gap-4">
<div class="space-y-2">
<Label for="cifs-version">SMB version</Label>
<Select.Root type="single" bind:value={cifsVersion} disabled={creating}>
<Select.Trigger class="w-full h-9">
{SMB_VERSIONS.find(v => v.value === cifsVersion)?.label ?? 'Select version'}
</Select.Trigger>
<Select.Content>
{#each SMB_VERSIONS as v}
<Select.Item value={v.value} label={v.label}>{v.label}</Select.Item>
{/each}
</Select.Content>
</Select.Root>
</div>
<div class="space-y-2">
<Label for="cifs-domain">Domain</Label>
<Input
id="cifs-domain"
bind:value={cifsDomain}
placeholder="WORKGROUP"
disabled={creating}
/>
<p class="text-xs text-muted-foreground">Optional AD/workgroup domain</p>
</div>
</div>
<!-- Additional options (collapsible) -->
<div class="space-y-2">
<button
type="button"
class="flex items-center gap-1 text-sm text-muted-foreground hover:text-foreground transition-colors"
onclick={() => showAdditionalOpts = !showAdditionalOpts}
>
<ChevronDown class="w-3.5 h-3.5 transition-transform {showAdditionalOpts ? 'rotate-180' : ''}" />
Additional options
</button>
{#if showAdditionalOpts}
<div class="space-y-2 pl-1">
<div class="flex items-center justify-end">
<Button type="button" size="sm" variant="outline" onclick={addDriverOpt} disabled={creating}>
<Plus class="w-3 h-3 mr-1" />
Add option
</Button>
</div>
{/each}
{#if driverOpts.length > 0}
{#each driverOpts as opt, i}
<div class="flex gap-2">
<Input bind:value={opt.key} placeholder="Key" disabled={creating} class="flex-1" />
<Input bind:value={opt.value} placeholder="Value (optional)" disabled={creating} class="flex-1" />
<Button type="button" size="icon" variant="ghost" onclick={() => removeDriverOpt(i)} disabled={creating}>
<Trash2 class="w-4 h-4" />
</Button>
</div>
{/each}
{:else}
<p class="text-xs text-muted-foreground">Extra mount options appended to the mount string</p>
{/if}
</div>
{/if}
</div>
{:else if driver === 'nfs'}
<!-- NFS fields -->
<div class="grid grid-cols-2 gap-4">
<div class="space-y-2">
<Label for="nfs-server">Server / IP *</Label>
<Input
id="nfs-server"
bind:value={nfsServer}
placeholder="192.168.1.100"
disabled={creating}
class={errors.server ? 'border-destructive focus-visible:ring-destructive' : ''}
oninput={() => errors.server = undefined}
/>
{#if errors.server}
<p class="text-xs text-destructive">{errors.server}</p>
{/if}
</div>
{:else}
<p class="text-xs text-muted-foreground">No driver options configured</p>
{/if}
</div>
<div class="space-y-2">
<Label for="nfs-path">Export path *</Label>
<Input
id="nfs-path"
bind:value={nfsPath}
placeholder="/exports/data"
disabled={creating}
class={errors.path ? 'border-destructive focus-visible:ring-destructive' : ''}
oninput={() => errors.path = undefined}
/>
{#if errors.path}
<p class="text-xs text-destructive">{errors.path}</p>
{/if}
</div>
</div>
<div class="space-y-2">
<Label for="nfs-version">NFS version</Label>
<Select.Root type="single" bind:value={nfsVersion} disabled={creating}>
<Select.Trigger class="w-full max-w-[200px] h-9">
{NFS_VERSIONS.find(v => v.value === nfsVersion)?.label ?? 'Select version'}
</Select.Trigger>
<Select.Content>
{#each NFS_VERSIONS as v}
<Select.Item value={v.value} label={v.label}>{v.label}</Select.Item>
{/each}
</Select.Content>
</Select.Root>
</div>
<div class="flex items-center gap-6">
<div class="flex items-center gap-2">
<TogglePill bind:checked={nfsSoft} onLabel="Soft" offLabel="Hard" />
<span class="text-xs text-muted-foreground">mount</span>
</div>
<div class="flex items-center gap-2">
<TogglePill bind:checked={nfsNolock} />
<span class="text-xs text-muted-foreground">No lock</span>
</div>
<div class="flex items-center gap-2">
<TogglePill bind:checked={nfsReadOnly} />
<span class="text-xs text-muted-foreground">Read-only</span>
</div>
</div>
<!-- Additional options (collapsible) -->
<div class="space-y-2">
<button
type="button"
class="flex items-center gap-1 text-sm text-muted-foreground hover:text-foreground transition-colors"
onclick={() => showAdditionalOpts = !showAdditionalOpts}
>
<ChevronDown class="w-3.5 h-3.5 transition-transform {showAdditionalOpts ? 'rotate-180' : ''}" />
Additional options
</button>
{#if showAdditionalOpts}
<div class="space-y-2 pl-1">
<div class="flex items-center justify-end">
<Button type="button" size="sm" variant="outline" onclick={addDriverOpt} disabled={creating}>
<Plus class="w-3 h-3 mr-1" />
Add option
</Button>
</div>
{#if driverOpts.length > 0}
{#each driverOpts as opt, i}
<div class="flex gap-2">
<Input bind:value={opt.key} placeholder="Key" disabled={creating} class="flex-1" />
<Input bind:value={opt.value} placeholder="Value (optional)" disabled={creating} class="flex-1" />
<Button type="button" size="icon" variant="ghost" onclick={() => removeDriverOpt(i)} disabled={creating}>
<Trash2 class="w-4 h-4" />
</Button>
</div>
{/each}
{:else}
<p class="text-xs text-muted-foreground">Extra mount options appended to the mount string</p>
{/if}
</div>
{/if}
</div>
{:else}
<!-- Local driver - generic key-value options -->
<div class="space-y-2">
<div class="flex items-center justify-between">
<Label>Driver options</Label>
<Button
type="button"
size="sm"
variant="outline"
onclick={addDriverOpt}
disabled={creating}
>
<Plus class="w-3 h-3 mr-1" />
Add option
</Button>
</div>
{#if driverOpts.length > 0}
<div class="space-y-2">
{#each driverOpts as opt, i}
<div class="flex gap-2">
<Input
bind:value={opt.key}
placeholder="Key"
disabled={creating}
class="flex-1"
/>
<Input
bind:value={opt.value}
placeholder="Value"
disabled={creating}
class="flex-1"
/>
<Button
type="button"
size="icon"
variant="ghost"
onclick={() => removeDriverOpt(i)}
disabled={creating}
>
<Trash2 class="w-4 h-4" />
</Button>
</div>
{/each}
</div>
{:else}
<p class="text-xs text-muted-foreground">No driver options configured</p>
{/if}
</div>
{/if}
<!-- Labels -->
<div class="space-y-2">