1.0.13
This commit is contained in:
1 parent
a88d3d5788
commit
8f3a7eb435
96 files changed
+5235
-1870
No files matched your search
@@ -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();
|
||||
}
|
||||
Reference in new issue
Block a user