1.0.13
This commit is contained in:
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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': {
|
||||
|
||||
@@ -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}
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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,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
@@ -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
@@ -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
@@ -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
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,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: '********'
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,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);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
};
|
||||
@@ -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
@@ -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'
|
||||
});
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
+381
-484
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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)}
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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">
|
||||
|
||||
Reference in New Issue
Block a user