Compare commits

...

8 Commits

Author SHA1 Message Date
jarek 50bc746660 v1.0.23 2026-04-03 13:53:12 +02:00
jarek 81c03b5dc5 v1.0.23 2026-04-03 11:51:42 +02:00
jarek 4a7c971cf8 shims for the baseline build 2026-03-26 08:23:12 +01:00
jarek faa2b9d571 v1.0.22 2026-03-21 14:46:17 +01:00
jarek 2ca41703f2 v1.0.22 2026-03-21 14:29:30 +01:00
Jarek Krochmalski c19d73c509 Update SECURITY.md 2026-03-15 09:37:22 +01:00
Jarek Krochmalski 7e869b582a Create SECURITY.md 2026-03-15 06:13:45 +01:00
Dennis Braun d0e5edcc98 fix: propagate DOCKER_API_VERSION to updater sidecar
The dockhand-updater image ships Docker CLI 29.2.1 (API 1.53), which
fails on hosts running older Docker daemons (e.g. Synology DSM with
Docker 24.0.2 / API 1.43). Every docker command in update.sh returns
"client version 1.53 is too new".

Query the daemon's API version via /version and pass it as
DOCKER_API_VERSION to the updater container env. If the env var is
already set on the main container, forward that instead.

Fixes #759
2026-03-13 18:38:02 +01:00
139 changed files with 9424 additions and 668 deletions
+10 -6
View File
@@ -75,18 +75,22 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
&& rm -rf /var/lib/apt/lists/* \
&& cp "$(dpkg -L libnss-wrapper | grep 'libnss_wrapper\.so$')" /usr/local/lib/libnss_wrapper.so
# Copy package files and install dependencies
# Copy package files and install dependencies (--ignore-scripts blocks malicious postinstall hooks)
COPY package.json package-lock.json ./
RUN npm ci
RUN npm ci --ignore-scripts \
&& npm rebuild better-sqlite3 argon2
# Copy source code and build
COPY . .
RUN npm run build
# Production dependencies only (rebuilds native addons like better-sqlite3)
RUN rm -rf node_modules \
&& npm ci --omit=dev \
&& rm -rf node_modules/@types
# Production dependencies only
# Preserve better-sqlite3 native addon (no prebuilds exist for Node 24 ABI 137)
RUN cp -r node_modules/better-sqlite3/build /tmp/better-sqlite3-build \
&& rm -rf node_modules \
&& npm ci --omit=dev --ignore-scripts \
&& cp -r /tmp/better-sqlite3-build node_modules/better-sqlite3/build \
&& rm -rf node_modules/@types /tmp/better-sqlite3-build
# Build Go collector
FROM --platform=$BUILDPLATFORM golang:1.25.8 AS go-builder
+27
View File
@@ -0,0 +1,27 @@
## How to Report a Security Flaw
Keeping Dockhand secure is a **top** priority. We highly value community contributions that help protect our users.
> [!IMPORTANT]
> If you discover a security vulnerability, please do not create a public GitHub issue - this can expose users to risk before a fix is available.
> If you find a security vulnerability, we ask that you keep it private and avoid opening a public issue on GitHub.
> Instead, please email us directly at [[security@dockhand.pro](mailto:security@dockhand.pro)]. This inbox has the highest priority.
## Details to Include
To help us track down and resolve the bug as efficiently as possible, please provide the following information in your email:
- A clear explanation of the flaw
- A step-by-step guide on how to reproduce the issue
- The specific Dockhand versions and host environments where the bug is present
- Any ideas you have for a patch or temporary workaround
## Our take
Once you submit a report, we promise to:
- Confirm receipt of your message within a couple of hours
- Swiftly investigate and verify the vulnerability
- Roll out a secure patch as quickly as possible
- Keep you updated throughout the entire patching process
We deeply appreciate your commitment to responsible disclosure and your help in keeping the Dockhand ecosystem safe.
+1 -1
View File
@@ -1 +1 @@
v1.0.21
v1.0.23
+21 -4
View File
@@ -10,10 +10,12 @@ PGID=${PGID:-1001}
export BODY_SIZE_LIMIT=${BODY_SIZE_LIMIT:-2G}
# Default command (--expose-gc allows forced GC from /api/debug/memory?gc=true)
# Custom CA: set NODE_EXTRA_CA_CERTS=/path/to/ca.crt (appends to built-in CAs)
# Enterprise (system CA store): set NODE_OPTIONS="--use-openssl-ca"
if [ "$MEMORY_MONITOR" = "true" ]; then
DEFAULT_CMD="node --use-openssl-ca --dns-result-order=ipv4first --no-network-family-autoselection --expose-gc /app/server.js"
DEFAULT_CMD="node --dns-result-order=ipv4first --no-network-family-autoselection --expose-gc /app/server.js"
else
DEFAULT_CMD="node --use-openssl-ca --dns-result-order=ipv4first --no-network-family-autoselection /app/server.js"
DEFAULT_CMD="node --dns-result-order=ipv4first --no-network-family-autoselection /app/server.js"
fi
# === Detect if running as root ===
@@ -100,14 +102,29 @@ else
fi
fi
chown -R "$RUN_USER":"$RUN_USER" /app/data 2>/dev/null || true
# === Directory Ownership ===
# Only chown Dockhand's own subdirectories, not the entire /app/data tree.
# Recursive chown on /app/data breaks stack volumes mounted with relative paths
# (e.g. ./postgresql:/var/lib/postgresql) that need different ownership (#719).
DATA_DIR="${DATA_DIR:-/app/data}"
chown "$RUN_USER":"$RUN_USER" "$DATA_DIR" 2>/dev/null || true
for subdir in db stacks git-repos tmp icons snapshots scanner-cache; do
if [ -d "$DATA_DIR/$subdir" ]; then
chown -R "$RUN_USER":"$RUN_USER" "$DATA_DIR/$subdir" 2>/dev/null || true
fi
done
if [ "$RUN_USER" = "dockhand" ]; then
chown -R dockhand:dockhand /home/dockhand 2>/dev/null || true
fi
if [ -n "$DATA_DIR" ] && [ "$DATA_DIR" != "/app/data" ] && [ "$DATA_DIR" != "./data" ]; then
mkdir -p "$DATA_DIR"
chown -R "$RUN_USER":"$RUN_USER" "$DATA_DIR" 2>/dev/null || true
chown "$RUN_USER":"$RUN_USER" "$DATA_DIR" 2>/dev/null || true
for subdir in db stacks git-repos tmp icons snapshots scanner-cache; do
if [ -d "$DATA_DIR/$subdir" ]; then
chown -R "$RUN_USER":"$RUN_USER" "$DATA_DIR/$subdir" 2>/dev/null || true
fi
done
fi
fi
+16 -2
View File
@@ -113,14 +113,28 @@ else
fi
# === Directory Ownership ===
chown -R "$RUN_USER":"$RUN_USER" /app/data 2>/dev/null || true
# Only chown Dockhand's own subdirectories, not the entire /app/data tree.
# Recursive chown on /app/data breaks stack volumes mounted with relative paths
# (e.g. ./postgresql:/var/lib/postgresql) that need different ownership (#719).
DATA_DIR="${DATA_DIR:-/app/data}"
chown "$RUN_USER":"$RUN_USER" "$DATA_DIR" 2>/dev/null || true
for subdir in db stacks git-repos tmp icons snapshots scanner-cache; do
if [ -d "$DATA_DIR/$subdir" ]; then
chown -R "$RUN_USER":"$RUN_USER" "$DATA_DIR/$subdir" 2>/dev/null || true
fi
done
if [ "$RUN_USER" = "dockhand" ]; then
chown -R dockhand:dockhand /home/dockhand 2>/dev/null || true
fi
if [ -n "$DATA_DIR" ] && [ "$DATA_DIR" != "/app/data" ] && [ "$DATA_DIR" != "./data" ]; then
mkdir -p "$DATA_DIR"
chown -R "$RUN_USER":"$RUN_USER" "$DATA_DIR" 2>/dev/null || true
chown "$RUN_USER":"$RUN_USER" "$DATA_DIR" 2>/dev/null || true
for subdir in db stacks git-repos tmp icons snapshots scanner-cache; do
if [ -d "$DATA_DIR/$subdir" ]; then
chown -R "$RUN_USER":"$RUN_USER" "$DATA_DIR/$subdir" 2>/dev/null || true
fi
done
fi
fi
@@ -0,0 +1,3 @@
ALTER TABLE "git_stacks" ADD COLUMN "build_on_deploy" boolean DEFAULT false;--> statement-breakpoint
ALTER TABLE "git_stacks" ADD COLUMN "repull_images" boolean DEFAULT false;--> statement-breakpoint
ALTER TABLE "git_stacks" ADD COLUMN "force_redeploy" boolean DEFAULT false;
File diff suppressed because it is too large Load Diff
+7
View File
@@ -29,6 +29,13 @@
"when": 1767687362730,
"tag": "0003_add_stack_paths",
"breakpoints": true
},
{
"idx": 4,
"version": "7",
"when": 1774155653752,
"tag": "0004_add_git_stack_deploy_options",
"breakpoints": true
}
]
}
@@ -0,0 +1,3 @@
ALTER TABLE `git_stacks` ADD `build_on_deploy` integer DEFAULT false;--> statement-breakpoint
ALTER TABLE `git_stacks` ADD `repull_images` integer DEFAULT false;--> statement-breakpoint
ALTER TABLE `git_stacks` ADD `force_redeploy` integer DEFAULT false;
File diff suppressed because it is too large Load Diff
+7
View File
@@ -29,6 +29,13 @@
"when": 1767689000000,
"tag": "0003_add_stack_paths",
"breakpoints": true
},
{
"idx": 4,
"version": "6",
"when": 1774155653752,
"tag": "0004_add_git_stack_deploy_options",
"breakpoints": true
}
]
}
+14 -15
View File
@@ -1,7 +1,7 @@
{
"name": "dockhand",
"private": true,
"version": "1.0.21",
"version": "1.0.23",
"type": "module",
"scripts": {
"dev": "npx vite dev",
@@ -70,25 +70,25 @@
"@codemirror/theme-one-dark": "6.1.3",
"@codemirror/view": "6.39.11",
"@lezer/highlight": "1.2.3",
"@lucide/lab": "^0.1.2",
"@lucide/lab": "0.1.2",
"ansi_up": "6.0.6",
"argon2": "^0.41.1",
"better-sqlite3": "^11.7.0",
"codemirror": "6.0.2",
"argon2": "0.41.1",
"better-sqlite3": "11.7.0",
"croner": "9.1.0",
"cronstrue": "3.9.0",
"devalue": "5.6.4",
"drizzle-orm": "0.45.1",
"js-yaml": "^4.1.1",
"ldapts": "^8.1.3",
"nodemailer": "^7.0.12",
"otpauth": "^9.4.1",
"fast-xml-parser": "5.5.8",
"js-yaml": "4.1.1",
"ldapts": "8.1.3",
"nodemailer": "8.0.4",
"otpauth": "9.4.1",
"postgres": "3.4.8",
"qrcode": "^1.5.4",
"svelte-dnd-action": "0.9.69",
"qrcode": "1.5.4",
"rollup": "4.60.0",
"svelte-sonner": "1.0.7",
"undici": "7.22.0",
"ws": "^8.18.0"
"undici": "7.24.5",
"ws": "8.18.0"
},
"devDependencies": {
"@internationalized/date": "^3.10.1",
@@ -102,7 +102,7 @@
"@types/better-sqlite3": "^7.6.12",
"@types/js-yaml": "^4.0.9",
"@types/node": "^22.10.0",
"@types/nodemailer": "7.0.5",
"@types/nodemailer": "7.0.11",
"@types/qrcode": "^1.5.6",
"@types/ws": "^8.5.13",
"@xterm/addon-fit": "^0.11.0",
@@ -122,7 +122,6 @@
"svelte": "5.53.5",
"svelte-check": "^4.3.5",
"svelte-easy-crop": "^5.0.0",
"svelte-virtual-scroll-list": "^1.3.0",
"tailwind-merge": "^3.4.0",
"tailwind-variants": "^3.2.2",
"tailwindcss": "^4.1.18",
+2
View File
@@ -455,3 +455,5 @@ function handleHawserConnection(ws, connId, remoteIp) {
server.listen(PORT, HOST, () => {
console.log(`Listening on http://${HOST}:${PORT}/ with WebSocket`);
});
+53
View File
@@ -0,0 +1,53 @@
/*
* getrandom() shim for old kernels (< 3.17) that lack the syscall.
*
* musl libc calls getrandom() which returns ENOSYS on kernel 3.10.x
* (e.g. Synology DS1513+). This shim intercepts the call and falls
* back to /dev/urandom, which is cryptographically secure after boot
* and is the same entropy source getrandom() reads from on modern kernels.
*
* Usage: LD_PRELOAD=/usr/lib/libgetrandom-shim.so <command>
*/
#define _GNU_SOURCE
#include <errno.h>
#include <fcntl.h>
#include <sys/syscall.h>
#include <unistd.h>
#ifndef SYS_getrandom
# ifdef __x86_64__
# define SYS_getrandom 318
# elif defined(__aarch64__)
# define SYS_getrandom 278
# else
# error "Unsupported architecture"
# endif
#endif
ssize_t getrandom(void *buf, size_t buflen, unsigned int flags) {
/* Try the real syscall first */
long ret = syscall(SYS_getrandom, buf, buflen, flags);
if (ret >= 0 || errno != ENOSYS)
return (ssize_t)ret;
/* Kernel too old — fall back to /dev/urandom */
int fd = open("/dev/urandom", O_RDONLY | O_CLOEXEC);
if (fd < 0)
return -1;
ssize_t total = 0;
while ((size_t)total < buflen) {
ssize_t n = read(fd, (char *)buf + total, buflen - (size_t)total);
if (n <= 0) {
if (n < 0 && errno == EINTR)
continue;
close(fd);
return -1;
}
total += n;
}
close(fd);
return total;
}
+32 -12
View File
@@ -8,9 +8,26 @@
imageUrl: string;
onCancel: () => void;
onSave: (dataUrl: string) => void;
cropShape?: 'round' | 'rect';
outputSize?: number;
outputFormat?: 'image/jpeg' | 'image/webp';
outputQuality?: number;
title?: string;
saveLabel?: string;
}
let { show, imageUrl, onCancel, onSave }: Props = $props();
let {
show,
imageUrl,
onCancel,
onSave,
cropShape = 'round',
outputSize = 256,
outputFormat = 'image/jpeg',
outputQuality = 0.9,
title = 'Crop avatar',
saveLabel = 'Save avatar'
}: Props = $props();
// Cropper state
let crop = $state({ x: 0, y: 0 });
@@ -144,9 +161,9 @@
return;
}
// Set canvas size to output size (256x256 for avatar)
canvas.width = 256;
canvas.height = 256;
// Set canvas size to output size
canvas.width = outputSize;
canvas.height = outputSize;
// Ensure we use a square crop area to avoid stretching
// Center the square within the original crop area
@@ -163,12 +180,12 @@
size,
0,
0,
256,
256
outputSize,
outputSize
);
// Convert to data URL
const dataUrl = canvas.toDataURL('image/jpeg', 0.9);
const dataUrl = canvas.toDataURL(outputFormat, outputQuality);
resolve(dataUrl);
};
@@ -204,16 +221,18 @@
handleCancel();
}
}
</script>
<svelte:window onkeydown={handleKeydown} />
{#if show && imageUrl}
<div class="fixed inset-0 bg-black/80 z-50 flex items-center justify-center p-4">
<div class="fixed inset-0 bg-black/80 z-[200] flex items-center justify-center p-4">
<div class="bg-background rounded-lg w-full max-w-2xl max-h-[90vh] flex flex-col shadow-2xl">
<!-- Header -->
<div class="p-4 border-b">
<h3 class="text-lg font-semibold">Crop avatar</h3>
<h3 class="text-lg font-semibold">{title}</h3>
<p class="text-sm text-muted-foreground mt-1">
Drag to reposition. Use the slider to zoom.
</p>
@@ -226,7 +245,8 @@
bind:crop
bind:zoom
aspect={1}
cropShape="round"
minZoom={0.5}
cropShape={cropShape}
showGrid={false}
on:cropcomplete={onCropComplete}
on:mediaLoaded={onMediaLoaded}
@@ -239,7 +259,7 @@
<ZoomOut class="w-5 h-5 text-muted-foreground shrink-0" />
<input
type="range"
min="1"
min="0.5"
max="3"
step="0.1"
bind:value={zoom}
@@ -266,7 +286,7 @@
disabled={saving || !imageLoaded}
>
<Check class="w-4 h-4" />
{saving ? 'Uploading...' : !imageLoaded ? 'Loading...' : 'Save avatar'}
{saving ? 'Uploading...' : !imageLoaded ? 'Loading...' : saveLabel}
</Button>
</div>
</div>
+23
View File
@@ -0,0 +1,23 @@
<script lang="ts">
import { getIconComponent, isCustomIcon } from '$lib/utils/icons';
import type { Component } from 'svelte';
interface Props {
icon: string;
envId: number;
class?: string;
cacheBust?: string | number;
}
let { icon, envId, class: className = 'w-4 h-4', cacheBust }: Props = $props();
const isCustom = $derived(isCustomIcon(icon));
const LucideIcon = $derived(!isCustom ? getIconComponent(icon) : null) as Component | null;
const imgSrc = $derived(isCustom ? `/api/environments/${envId}/icon${cacheBust ? `?v=${cacheBust}` : ''}` : '');
</script>
{#if isCustom}
<img src={imgSrc} alt="" class="{className} rounded-full object-cover" />
{:else if LucideIcon}
<LucideIcon class={className} />
{/if}
+9 -7
View File
@@ -43,15 +43,17 @@
let selectedEditorFont = $state('system-mono');
onMount(async () => {
// Load monospace fonts for dropdown previews
// Load bundled monospace fonts for dropdown previews
const fontsToLoad = monospaceFonts.filter(f => f.googleFont);
if (fontsToLoad.length > 0) {
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);
let loaded = 0;
for (const font of fontsToLoad) {
const link = document.createElement('link');
link.rel = 'stylesheet';
link.href = `/fonts/${font.id}/font.css`;
link.onload = () => { if (++loaded >= fontsToLoad.length) monoFontsLoaded = true; };
document.head.appendChild(link);
}
} else {
monoFontsLoaded = true;
}
+16 -1
View File
@@ -29,7 +29,22 @@
'Europe/Kyiv': 'Europe/Kiev',
'Asia/Ho_Chi_Minh': 'Asia/Saigon',
'America/Nuuk': 'America/Godthab',
'Pacific/Kanton': 'Pacific/Enderbury'
'Pacific/Kanton': 'Pacific/Enderbury',
'Asia/Kolkata': 'Asia/Calcutta',
'Asia/Kathmandu': 'Asia/Katmandu',
'Asia/Yangon': 'Asia/Rangoon',
'Asia/Kashgar': 'Asia/Urumqi',
'Atlantic/Faroe': 'Atlantic/Faeroe',
'Europe/Uzhgorod': 'Europe/Kiev',
'Europe/Zaporozhye': 'Europe/Kiev',
'America/Atikokan': 'America/Coral_Harbour',
'America/Argentina/Buenos_Aires': 'America/Buenos_Aires',
'America/Argentina/Catamarca': 'America/Catamarca',
'America/Argentina/Cordoba': 'America/Cordoba',
'America/Argentina/Jujuy': 'America/Jujuy',
'America/Argentina/Mendoza': 'America/Mendoza',
'Pacific/Pohnpei': 'Pacific/Ponape',
'Pacific/Chuuk': 'Pacific/Truk'
};
// Reverse map: canonical → modern alias names (for display hints)
+61 -10
View File
@@ -1,11 +1,11 @@
<script lang="ts">
import { onMount } from 'svelte';
import { Cpu, MemoryStick, Box, Globe, ChevronDown, Check, HardDrive, Clock, Wifi, WifiOff, Route, UndoDot, Icon, AlertCircle, Loader2 } from 'lucide-svelte';
import { Cpu, MemoryStick, Box, Globe, ChevronDown, Check, HardDrive, Clock, Wifi, WifiOff, Route, UndoDot, Icon, AlertCircle, Loader2, Search, X } from 'lucide-svelte';
import { whale } from '@lucide/lab';
import { Button } from '$lib/components/ui/button';
import { currentEnvironment, environments, type Environment } from '$lib/stores/environment';
import { sseConnected } from '$lib/stores/events';
import { getIconComponent } from '$lib/utils/icons';
import EnvironmentIcon from '$lib/components/EnvironmentIcon.svelte';
import { toast } from 'svelte-sonner';
import { themeStore, type FontSize } from '$lib/stores/theme';
import { getTimeFormat } from '$lib/stores/settings';
@@ -77,6 +77,8 @@
let diskUsageLoading = $state(false);
let envAbortController: AbortController | null = null; // Aborts ALL requests when switching envs
let showDropdown = $state(false);
let searchTerm = $state('');
let searchInputRef = $state<HTMLInputElement | null>(null);
let currentEnvId = $state<number | null>(null);
let lastUpdated = $state<Date>(new Date());
let isConnected = $state(false);
@@ -94,6 +96,22 @@
// Reactive environment list from store
let envList = $derived($environments);
const showSearch = $derived(envList.length > 8);
const filteredEnvList = $derived(
searchTerm.trim()
? envList.filter((e: Environment) => e.name.toLowerCase().includes(searchTerm.toLowerCase()))
: envList
);
// Clear search and focus when dropdown opens/closes
$effect(() => {
if (showDropdown && showSearch) {
// Use tick to wait for DOM render
setTimeout(() => searchInputRef?.focus(), 0);
} else {
searchTerm = '';
}
});
sseConnected.subscribe(v => isConnected = v);
@@ -349,14 +367,12 @@
class="flex items-center gap-1.5 -ml-1 px-1 py-1 rounded-md hover:bg-muted transition-colors cursor-pointer"
>
{#if hostInfo?.environment && Number(hostInfo.environment.id) === Number(currentEnvId)}
{@const EnvIcon = getIconComponent(hostInfo.environment.icon || 'globe')}
<EnvIcon class="{iconSizeLargeClass()} text-primary" />
<EnvironmentIcon icon={hostInfo.environment.icon || 'globe'} envId={hostInfo.environment.id} class="{iconSizeLargeClass()} text-primary" />
<span class="font-medium text-foreground">{hostInfo.environment.name}</span>
{:else if currentEnvId && envList.length > 0}
{@const currentEnv = envList.find(e => Number(e.id) === Number(currentEnvId))}
{#if currentEnv}
{@const EnvIcon = getIconComponent(currentEnv.icon || 'globe')}
<EnvIcon class="{iconSizeLargeClass()} text-primary" />
<EnvironmentIcon icon={currentEnv.icon || 'globe'} envId={currentEnv.id} class="{iconSizeLargeClass()} text-primary" />
<span class="font-medium text-foreground">{currentEnv.name}</span>
{:else}
<Globe class="{iconSizeLargeClass()} text-muted-foreground" />
@@ -371,9 +387,40 @@
{#if showDropdown && envList.length > 0}
<div class="absolute top-full left-0 mt-1 min-w-56 w-max max-w-80 bg-popover border rounded-md shadow-lg z-50">
<div class="py-1">
{#each envList as env (env.id)}
{@const EnvIcon = getIconComponent(env.icon || 'globe')}
{#if showSearch}
<div class="sticky top-0 bg-popover border-b px-2 py-1.5">
<div class="relative">
<Search class="absolute left-2 top-1/2 -translate-y-1/2 w-3.5 h-3.5 text-muted-foreground" />
<input
bind:this={searchInputRef}
bind:value={searchTerm}
type="text"
placeholder="Search environments..."
class="w-full pl-7 pr-7 py-1 text-sm bg-transparent border rounded focus:outline-none focus:ring-1 focus:ring-ring"
onclick={(e) => e.stopPropagation()}
onkeydown={(e) => {
if (e.key === 'Escape') {
if (searchTerm) {
searchTerm = '';
} else {
showDropdown = false;
}
}
}}
/>
{#if searchTerm}
<button
class="absolute right-1.5 top-1/2 -translate-y-1/2 p-0.5 rounded hover:bg-muted"
onclick={(e) => { e.stopPropagation(); searchTerm = ''; searchInputRef?.focus(); }}
>
<X class="w-3 h-3 text-muted-foreground" />
</button>
{/if}
</div>
</div>
{/if}
<div class="py-1 max-h-[calc(100vh-8rem)] overflow-y-auto">
{#each filteredEnvList as env (env.id)}
{@const isOffline = offlineEnvIds.has(env.id)}
{@const isSwitching = switchingEnvId === env.id}
<button
@@ -387,7 +434,7 @@
{:else if isOffline}
<WifiOff class="{iconSizeLargeClass()} text-destructive shrink-0" />
{:else}
<EnvIcon class="{iconSizeLargeClass()} text-muted-foreground shrink-0" />
<EnvironmentIcon icon={env.icon || 'globe'} envId={env.id} class="{iconSizeLargeClass()} text-muted-foreground shrink-0" />
{/if}
<span class="flex-1 whitespace-nowrap" class:text-muted-foreground={isOffline}>{env.name}</span>
{#if isOffline && !isSwitching}
@@ -396,6 +443,10 @@
<Check class="{iconSizeLargeClass()} text-primary shrink-0" />
{/if}
</button>
{:else}
<div class="px-3 py-2 text-sm text-muted-foreground">
No matching environments
</div>
{/each}
</div>
</div>
+36 -20
View File
@@ -1,44 +1,60 @@
<script lang="ts">
import { Button } from '$lib/components/ui/button';
import { Sun, Moon } from 'lucide-svelte';
import { onMount } from 'svelte';
import { Sun, Moon, Monitor } from 'lucide-svelte';
import { onMount, onDestroy } from 'svelte';
import { onDarkModeChange } from '$lib/stores/theme';
let isDark = $state(false);
type ThemeMode = 'light' | 'dark' | 'system';
let mode = $state<ThemeMode>('system');
let mediaQuery: MediaQueryList | null = null;
onMount(() => {
// Check for saved preference or system preference
const saved = localStorage.getItem('theme');
if (saved) {
isDark = saved === 'dark';
} else {
isDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
}
updateTheme();
const saved = localStorage.getItem('theme') as ThemeMode | null;
mode = saved === 'light' || saved === 'dark' || saved === 'system' ? saved : 'system';
mediaQuery = window.matchMedia('(prefers-color-scheme: dark)');
mediaQuery.addEventListener('change', onSystemChange);
applyMode();
});
function updateTheme() {
onDestroy(() => {
mediaQuery?.removeEventListener('change', onSystemChange);
});
function onSystemChange() {
if (mode === 'system') {
applyMode();
}
}
function applyMode() {
const isDark = mode === 'dark' || (mode === 'system' && !!mediaQuery?.matches);
if (isDark) {
document.documentElement.classList.add('dark');
} else {
document.documentElement.classList.remove('dark');
}
localStorage.setItem('theme', isDark ? 'dark' : 'light');
// Apply the correct theme colors for the new mode
onDarkModeChange();
}
function toggleTheme() {
isDark = !isDark;
updateTheme();
function cycleTheme() {
const order: ThemeMode[] = ['light', 'dark', 'system'];
mode = order[(order.indexOf(mode) + 1) % order.length];
localStorage.setItem('theme', mode);
applyMode();
}
</script>
<Button variant="ghost" size="icon" onclick={toggleTheme} class="h-9 w-9">
{#if isDark}
<Button variant="ghost" size="icon" onclick={cycleTheme} class="h-9 w-9" title={mode === 'system' ? 'Theme: system' : mode === 'dark' ? 'Theme: dark' : 'Theme: light'}>
{#if mode === 'dark'}
<Moon class="h-4 w-4" />
{:else if mode === 'light'}
<Sun class="h-4 w-4" />
{:else}
<Moon class="h-4 w-4" />
<Monitor class="h-4 w-4" />
{/if}
<span class="sr-only">Toggle theme</span>
</Button>
+19 -1
View File
@@ -118,6 +118,23 @@ export const scheduleColumns: ColumnConfig[] = [
{ id: 'actions', label: '', fixed: 'end', width: 100, resizable: false }
];
// Environment grid columns (dashboard list view)
export const environmentColumns: ColumnConfig[] = [
{ id: 'status', label: '', width: 36, resizable: false },
{ id: 'name', label: 'Environment', sortable: true, sortField: 'name', width: 180, minWidth: 100, grow: true },
{ id: 'connection', label: 'Connection', sortable: true, sortField: 'connection', width: 110, minWidth: 80 },
{ id: 'host', label: 'Host', sortable: true, sortField: 'host', width: 150, minWidth: 80 },
{ id: 'containers', label: 'Containers', sortable: true, sortField: 'containers', width: 100, minWidth: 70 },
{ id: 'updates', label: 'Updates', sortable: true, sortField: 'updates', width: 75, minWidth: 55 },
{ id: 'cpu', label: 'CPU', sortable: true, sortField: 'cpu', width: 110, minWidth: 80 },
{ id: 'memory', label: 'Memory', sortable: true, sortField: 'memory', width: 110, minWidth: 80 },
{ id: 'images', label: 'Images', sortable: true, sortField: 'images', width: 65, minWidth: 50 },
{ id: 'volumes', label: 'Volumes', sortable: true, sortField: 'volumes', width: 70, minWidth: 50 },
{ id: 'stacks', label: 'Stacks', sortable: true, sortField: 'stacks', width: 85, minWidth: 65 },
{ id: 'events', label: 'Events', sortable: true, sortField: 'events', width: 65, minWidth: 50 },
{ id: 'labels', label: 'Labels', width: 150, minWidth: 80 }
];
// Map of grid ID to column definitions
export const gridColumnConfigs: Record<GridId, ColumnConfig[]> = {
containers: containerColumns,
@@ -128,7 +145,8 @@ export const gridColumnConfigs: Record<GridId, ColumnConfig[]> = {
volumes: volumeColumns,
activity: activityColumns,
schedules: scheduleColumns,
audit: auditColumns
audit: auditColumns,
environments: environmentColumns
};
// Get configurable columns (not fixed)
+41
View File
@@ -1,4 +1,45 @@
[
{
"version": "1.0.23",
"date": "2026-04-03",
"changes": [
{ "type": "feature", "text": "theme toggle with system option — auto-follows OS light/dark preference (#803)" },
{ "type": "feature", "text": "custom user option for terminal shell sessions, persisted per container (#830)" },
{ "type": "feature", "text": "redeploy button for internal stacks with pull/build/force-recreate options (#152)" },
{ "type": "feature", "text": "build, re-pull images and force redeployment options for git stacks (#792, #472)" },
{ "type": "fix", "text": "allow underscores in hostname validation (#790)" },
{ "type": "fix", "text": "HTTPS git repos with self-signed CA certificates fail to clone/pull (#842)" },
{ "type": "fix", "text": "stack restart fails for containers using network_mode: service:<container> — added recreate option (#844)" },
{ "type": "fix", "text": "git stack sync deletes data in relative volume paths (#831)" },
{ "type": "fix", "text": "batch update skips Hawser containers (#485)" },
{ "type": "fix", "text": "registry delete fails for multi-arch/OCI manifest images" },
{ "type": "fix", "text": "scanner cache cleanup to prevent volume bloat (#808)" },
{ "type": "fix", "text": "negotiate Docker API version for scanner/updater sidecar containers (#759)" },
{ "type": "fix", "text": "scan vulnerability counts mismatch with displayed list (#705)" }
],
"imageTag": "fnsys/dockhand:v1.0.23"
},
{
"version": "1.0.22",
"date": "2026-03-21",
"changes": [
{ "type": "feature", "text": "dashboard list view with inline search and connection filters (#740)" },
{ "type": "feature", "text": "custom environment icon (#754)" },
{ "type": "feature", "text": "show +N indicator for containers with multiple IP addresses (#644)" },
{ "type": "feature", "text": "bundle all fonts locally for privacy and offline use (#734)" },
{ "type": "fix", "text": "respect PROXY settings when checking for container updates" },
{ "type": "fix", "text": "git stacks force-redeploy after a failed sync (#693)" },
{ "type": "fix", "text": "What's New modal shown before login, exposing version info (#717)" },
{ "type": "fix", "text": "git repository files not removed from disk on delete (#671)" },
{ "type": "fix", "text": "recursive chown at startup breaks stack volumes with different ownership (#719)" },
{ "type": "fix", "text": "missing notification event toggles for container healthy, image prune events (#659)" },
{ "type": "fix", "text": "container disappears when edit fails (e.g. invalid memory/swap) (#736)" },
{ "type": "fix", "text": "regression: network container count always shows 0 (#761)" },
{ "type": "fix", "text": "Grype/Trivy scan containers don't inherit proxy env vars (#780)" },
{ "type": "fix", "text": "pin vulnerability scanner images to specific versions not :latest" }
],
"imageTag": "fnsys/dockhand:v1.0.22"
},
{
"version": "1.0.21",
"date": "2026-03-13",
+14 -2
View File
@@ -275,6 +275,12 @@
"license": "MIT",
"repository": "https://github.com/chalk/ansi-styles"
},
{
"name": "ansi_up",
"version": "6.0.6",
"license": "MIT",
"repository": "https://github.com/drudru/ansi_up"
},
{
"name": "argon2",
"version": "0.41.1",
@@ -289,7 +295,7 @@
},
{
"name": "aria-query",
"version": "5.3.2",
"version": "5.3.1",
"license": "Apache-2.0",
"repository": "https://github.com/A11yance/aria-query"
},
@@ -425,6 +431,12 @@
"license": "MIT",
"repository": "https://github.com/sveltejs/devalue"
},
{
"name": "devalue",
"version": "5.6.4",
"license": "MIT",
"repository": "https://github.com/sveltejs/devalue"
},
{
"name": "dijkstrajs",
"version": "1.0.3",
@@ -781,7 +793,7 @@
},
{
"name": "svelte",
"version": "5.53.1",
"version": "5.53.5",
"license": "MIT",
"repository": "https://github.com/sveltejs/svelte"
},
+71 -5
View File
@@ -819,6 +819,13 @@ export const ENVIRONMENT_NOTIFICATION_EVENTS = NOTIFICATION_EVENT_TYPES.filter(e
export type NotificationEventType = typeof NOTIFICATION_EVENT_TYPES[number]['id'];
const environmentEventIds = new Set(ENVIRONMENT_NOTIFICATION_EVENTS.map(e => e.id));
/** Strip system-scoped events (e.g. license_expiring) from environment notification records */
function filterEnvironmentEventTypes(eventTypes: string[]): string[] {
return eventTypes.filter(id => environmentEventIds.has(id));
}
export interface NotificationSettingData {
id: number;
type: 'smtp' | 'apprise';
@@ -982,7 +989,7 @@ export async function getEnvironmentNotifications(environmentId: number): Promis
return rows.map((row: any) => ({
...row,
eventTypes: row.eventTypes ? JSON.parse(row.eventTypes) : NOTIFICATION_EVENT_TYPES.map(e => e.id)
eventTypes: filterEnvironmentEventTypes(row.eventTypes ? JSON.parse(row.eventTypes) : ENVIRONMENT_NOTIFICATION_EVENTS.map(e => e.id))
})) as EnvironmentNotificationData[];
}
@@ -1009,7 +1016,7 @@ export async function getEnvironmentNotification(environmentId: number, notifica
if (!rows[0]) return null;
return {
...rows[0],
eventTypes: rows[0].eventTypes ? JSON.parse(rows[0].eventTypes) : NOTIFICATION_EVENT_TYPES.map(e => e.id)
eventTypes: filterEnvironmentEventTypes(rows[0].eventTypes ? JSON.parse(rows[0].eventTypes) : ENVIRONMENT_NOTIFICATION_EVENTS.map(e => e.id))
} as EnvironmentNotificationData;
}
@@ -1019,7 +1026,7 @@ export async function createEnvironmentNotification(data: {
enabled?: boolean;
eventTypes?: NotificationEventType[];
}): Promise<EnvironmentNotificationData> {
const eventTypes = data.eventTypes || NOTIFICATION_EVENT_TYPES.map(e => e.id);
const eventTypes = data.eventTypes || ENVIRONMENT_NOTIFICATION_EVENTS.map(e => e.id);
await db.insert(environmentNotifications).values({
environmentId: data.environmentId,
notificationId: data.notificationId,
@@ -1087,7 +1094,7 @@ export async function getEnabledEnvironmentNotifications(
return rows
.map(row => ({
...row,
eventTypes: row.eventTypes ? JSON.parse(row.eventTypes) : NOTIFICATION_EVENT_TYPES.map(e => e.id),
eventTypes: filterEnvironmentEventTypes(row.eventTypes ? JSON.parse(row.eventTypes) : ENVIRONMENT_NOTIFICATION_EVENTS.map(e => e.id)),
config: decryptNotificationConfig(row.channelType ?? 'apprise', row.config)
}))
.filter(row => !eventType || row.eventTypes.includes(eventType)) as (EnvironmentNotificationData & { config: any })[];
@@ -2019,6 +2026,14 @@ export async function updateGitRepository(id: number, data: Partial<GitRepositor
return getGitRepository(id);
}
export async function getGitStacksByRepositoryId(repositoryId: number): Promise<Array<{ id: number; stackName: string; environmentId: number | null }>> {
return db.select({
id: gitStacks.id,
stackName: gitStacks.stackName,
environmentId: gitStacks.environmentId
}).from(gitStacks).where(eq(gitStacks.repositoryId, repositoryId));
}
export async function deleteGitRepository(id: number): Promise<boolean> {
await db.delete(gitRepositories).where(eq(gitRepositories.id, id));
return true;
@@ -2040,6 +2055,9 @@ export interface GitStackData {
autoUpdateCron: string;
webhookEnabled: boolean;
webhookSecret: string | null;
buildOnDeploy: boolean;
repullImages: boolean;
forceRedeploy: boolean;
lastSync: string | null;
lastCommit: string | null;
syncStatus: GitSyncStatus;
@@ -2129,6 +2147,9 @@ export async function getGitStacks(environmentId?: number): Promise<GitStackWith
autoUpdateCron: row.autoUpdateCron,
webhookEnabled: row.webhookEnabled,
webhookSecret: row.webhookSecret,
buildOnDeploy: row.buildOnDeploy ?? false,
repullImages: row.repullImages ?? false,
forceRedeploy: row.forceRedeploy ?? false,
lastSync: row.lastSync,
lastCommit: row.lastCommit,
syncStatus: row.syncStatus,
@@ -2159,6 +2180,9 @@ export async function getGitStacksForEnvironmentOnly(environmentId: number): Pro
autoUpdateCron: gitStacks.autoUpdateCron,
webhookEnabled: gitStacks.webhookEnabled,
webhookSecret: gitStacks.webhookSecret,
buildOnDeploy: gitStacks.buildOnDeploy,
repullImages: gitStacks.repullImages,
forceRedeploy: gitStacks.forceRedeploy,
lastSync: gitStacks.lastSync,
lastCommit: gitStacks.lastCommit,
syncStatus: gitStacks.syncStatus,
@@ -2187,6 +2211,9 @@ export async function getGitStacksForEnvironmentOnly(environmentId: number): Pro
autoUpdateCron: row.autoUpdateCron,
webhookEnabled: row.webhookEnabled,
webhookSecret: row.webhookSecret,
buildOnDeploy: row.buildOnDeploy ?? false,
repullImages: row.repullImages ?? false,
forceRedeploy: row.forceRedeploy ?? false,
lastSync: row.lastSync,
lastCommit: row.lastCommit,
syncStatus: row.syncStatus,
@@ -2216,6 +2243,9 @@ export async function getGitStack(id: number): Promise<GitStackWithRepo | null>
autoUpdateCron: gitStacks.autoUpdateCron,
webhookEnabled: gitStacks.webhookEnabled,
webhookSecret: gitStacks.webhookSecret,
buildOnDeploy: gitStacks.buildOnDeploy,
repullImages: gitStacks.repullImages,
forceRedeploy: gitStacks.forceRedeploy,
lastSync: gitStacks.lastSync,
lastCommit: gitStacks.lastCommit,
syncStatus: gitStacks.syncStatus,
@@ -2245,6 +2275,9 @@ export async function getGitStack(id: number): Promise<GitStackWithRepo | null>
autoUpdateCron: row.autoUpdateCron,
webhookEnabled: row.webhookEnabled,
webhookSecret: row.webhookSecret,
buildOnDeploy: row.buildOnDeploy ?? false,
repullImages: row.repullImages ?? false,
forceRedeploy: row.forceRedeploy ?? false,
lastSync: row.lastSync,
lastCommit: row.lastCommit,
syncStatus: row.syncStatus,
@@ -2274,6 +2307,9 @@ export async function getGitStackByName(stackName: string, environmentId?: numbe
autoUpdateCron: gitStacks.autoUpdateCron,
webhookEnabled: gitStacks.webhookEnabled,
webhookSecret: gitStacks.webhookSecret,
buildOnDeploy: gitStacks.buildOnDeploy,
repullImages: gitStacks.repullImages,
forceRedeploy: gitStacks.forceRedeploy,
lastSync: gitStacks.lastSync,
lastCommit: gitStacks.lastCommit,
syncStatus: gitStacks.syncStatus,
@@ -2308,6 +2344,9 @@ export async function getGitStackByName(stackName: string, environmentId?: numbe
autoUpdateCron: row.autoUpdateCron,
webhookEnabled: row.webhookEnabled,
webhookSecret: row.webhookSecret,
buildOnDeploy: row.buildOnDeploy ?? false,
repullImages: row.repullImages ?? false,
forceRedeploy: row.forceRedeploy ?? false,
lastSync: row.lastSync,
lastCommit: row.lastCommit,
syncStatus: row.syncStatus,
@@ -2337,6 +2376,9 @@ export async function getGitStackByWebhookSecret(secret: string): Promise<GitSta
autoUpdateCron: gitStacks.autoUpdateCron,
webhookEnabled: gitStacks.webhookEnabled,
webhookSecret: gitStacks.webhookSecret,
buildOnDeploy: gitStacks.buildOnDeploy,
repullImages: gitStacks.repullImages,
forceRedeploy: gitStacks.forceRedeploy,
lastSync: gitStacks.lastSync,
lastCommit: gitStacks.lastCommit,
syncStatus: gitStacks.syncStatus,
@@ -2366,6 +2408,9 @@ export async function getGitStackByWebhookSecret(secret: string): Promise<GitSta
autoUpdateCron: row.autoUpdateCron,
webhookEnabled: row.webhookEnabled,
webhookSecret: row.webhookSecret,
buildOnDeploy: row.buildOnDeploy ?? false,
repullImages: row.repullImages ?? false,
forceRedeploy: row.forceRedeploy ?? false,
lastSync: row.lastSync,
lastCommit: row.lastCommit,
syncStatus: row.syncStatus,
@@ -2393,6 +2438,9 @@ export async function createGitStack(data: {
autoUpdateCron?: string;
webhookEnabled?: boolean;
webhookSecret?: string | null;
buildOnDeploy?: boolean;
repullImages?: boolean;
forceRedeploy?: boolean;
}): Promise<GitStackWithRepo> {
const result = await db.insert(gitStacks).values({
stackName: data.stackName,
@@ -2404,7 +2452,10 @@ export async function createGitStack(data: {
autoUpdateSchedule: data.autoUpdateSchedule || 'daily',
autoUpdateCron: data.autoUpdateCron || '0 3 * * *',
webhookEnabled: data.webhookEnabled || false,
webhookSecret: data.webhookSecret || null
webhookSecret: data.webhookSecret || null,
buildOnDeploy: data.buildOnDeploy ?? false,
repullImages: data.repullImages ?? false,
forceRedeploy: data.forceRedeploy ?? false
}).returning();
return getGitStack(result[0].id) as Promise<GitStackWithRepo>;
}
@@ -2421,6 +2472,9 @@ export async function updateGitStack(id: number, data: Partial<GitStackData>): P
if (data.autoUpdateCron !== undefined) updateData.autoUpdateCron = data.autoUpdateCron;
if (data.webhookEnabled !== undefined) updateData.webhookEnabled = data.webhookEnabled;
if (data.webhookSecret !== undefined) updateData.webhookSecret = data.webhookSecret;
if (data.buildOnDeploy !== undefined) updateData.buildOnDeploy = data.buildOnDeploy;
if (data.repullImages !== undefined) updateData.repullImages = data.repullImages;
if (data.forceRedeploy !== undefined) updateData.forceRedeploy = data.forceRedeploy;
if (data.lastSync !== undefined) updateData.lastSync = data.lastSync;
if (data.lastCommit !== undefined) updateData.lastCommit = data.lastCommit;
if (data.syncStatus !== undefined) updateData.syncStatus = data.syncStatus;
@@ -2455,6 +2509,9 @@ export async function getEnabledAutoUpdateGitStacks(): Promise<GitStackWithRepo[
autoUpdateCron: gitStacks.autoUpdateCron,
webhookEnabled: gitStacks.webhookEnabled,
webhookSecret: gitStacks.webhookSecret,
buildOnDeploy: gitStacks.buildOnDeploy,
repullImages: gitStacks.repullImages,
forceRedeploy: gitStacks.forceRedeploy,
lastSync: gitStacks.lastSync,
lastCommit: gitStacks.lastCommit,
syncStatus: gitStacks.syncStatus,
@@ -2482,6 +2539,9 @@ export async function getEnabledAutoUpdateGitStacks(): Promise<GitStackWithRepo[
autoUpdateCron: row.autoUpdateCron,
webhookEnabled: row.webhookEnabled,
webhookSecret: row.webhookSecret,
buildOnDeploy: row.buildOnDeploy ?? false,
repullImages: row.repullImages ?? false,
forceRedeploy: row.forceRedeploy ?? false,
lastSync: row.lastSync,
lastCommit: row.lastCommit,
syncStatus: row.syncStatus,
@@ -2510,6 +2570,9 @@ export async function getAllAutoUpdateGitStacks(): Promise<GitStackWithRepo[]> {
autoUpdateCron: gitStacks.autoUpdateCron,
webhookEnabled: gitStacks.webhookEnabled,
webhookSecret: gitStacks.webhookSecret,
buildOnDeploy: gitStacks.buildOnDeploy,
repullImages: gitStacks.repullImages,
forceRedeploy: gitStacks.forceRedeploy,
lastSync: gitStacks.lastSync,
lastCommit: gitStacks.lastCommit,
syncStatus: gitStacks.syncStatus,
@@ -2536,6 +2599,9 @@ export async function getAllAutoUpdateGitStacks(): Promise<GitStackWithRepo[]> {
autoUpdateCron: row.autoUpdateCron,
webhookEnabled: row.webhookEnabled,
webhookSecret: row.webhookSecret,
buildOnDeploy: row.buildOnDeploy ?? false,
repullImages: row.repullImages ?? false,
forceRedeploy: row.forceRedeploy ?? false,
lastSync: row.lastSync,
lastCommit: row.lastCommit,
syncStatus: row.syncStatus,
+3
View File
@@ -315,6 +315,9 @@ export const gitStacks = sqliteTable('git_stacks', {
autoUpdateCron: text('auto_update_cron').default('0 3 * * *'),
webhookEnabled: integer('webhook_enabled', { mode: 'boolean' }).default(false),
webhookSecret: text('webhook_secret'),
buildOnDeploy: integer('build_on_deploy', { mode: 'boolean' }).default(false),
repullImages: integer('repull_images', { mode: 'boolean' }).default(false),
forceRedeploy: integer('force_redeploy', { mode: 'boolean' }).default(false),
lastSync: text('last_sync'),
lastCommit: text('last_commit'),
syncStatus: text('sync_status').default('pending'),
+3
View File
@@ -318,6 +318,9 @@ export const gitStacks = pgTable('git_stacks', {
autoUpdateCron: text('auto_update_cron').default('0 3 * * *'),
webhookEnabled: boolean('webhook_enabled').default(false),
webhookSecret: text('webhook_secret'),
buildOnDeploy: boolean('build_on_deploy').default(false),
repullImages: boolean('repull_images').default(false),
forceRedeploy: boolean('force_redeploy').default(false),
lastSync: timestamp('last_sync', { mode: 'string' }),
lastCommit: text('last_commit'),
syncStatus: text('sync_status').default('pending'),
+41 -29
View File
@@ -1,4 +1,4 @@
import { setGlobalDispatcher, Agent } from 'undici';
import { setGlobalDispatcher, Agent, EnvHttpProxyAgent } from 'undici';
import dns from 'node:dns';
import net from 'node:net';
@@ -60,32 +60,44 @@ function lookupWithCache(hostname: string): Promise<{ address: string; family: n
return promise;
}
setGlobalDispatcher(
new Agent({
connect: {
// Undici default is 10s. Increase to 30s for NAS environments with slow NAT/firewalls (#676).
timeout: 30_000,
lookup(hostname: string, opts: any, cb: any) {
if (typeof opts === 'function') {
cb = opts;
opts = {};
}
// IP addresses / localhost → no DNS needed
if (net.isIP(hostname) || hostname === 'localhost') {
return origLookup(hostname, opts, cb);
}
lookupWithCache(hostname)
.then(({ address, family }) => {
if (opts.all) {
cb(null, [{ address, family }]);
} else {
cb(null, address, family);
}
})
.catch((err) => cb(err));
}
// Shared connect options for DNS lookup
const connectOptions = {
// Undici default is 10s. Increase to 30s for NAS environments with slow NAT/firewalls (#676).
timeout: 30_000,
lookup(hostname: string, opts: any, cb: any) {
if (typeof opts === 'function') {
cb = opts;
opts = {};
}
})
);
// IP addresses / localhost → no DNS needed
if (net.isIP(hostname) || hostname === 'localhost') {
return origLookup(hostname, opts, cb);
}
lookupWithCache(hostname)
.then(({ address, family }) => {
if (opts.all) {
cb(null, [{ address, family }]);
} else {
cb(null, address, family);
}
})
.catch((err) => cb(err));
}
};
// Use EnvHttpProxyAgent when HTTP(S)_PROXY env vars are set, otherwise plain Agent.
// Node.js fetch/undici does NOT respect proxy env vars by default — EnvHttpProxyAgent
// reads HTTP_PROXY, HTTPS_PROXY, and NO_PROXY automatically.
const hasProxy = process.env.HTTP_PROXY || process.env.HTTPS_PROXY ||
process.env.http_proxy || process.env.https_proxy;
if (hasProxy) {
const proxyUrl = process.env.HTTPS_PROXY || process.env.https_proxy ||
process.env.HTTP_PROXY || process.env.http_proxy;
console.log(`[DNS] HTTP proxy detected (${proxyUrl}), using EnvHttpProxyAgent`);
setGlobalDispatcher(new EnvHttpProxyAgent({ connect: connectOptions }));
} else {
setGlobalDispatcher(new Agent({ connect: connectOptions }));
}
+20
View File
@@ -0,0 +1,20 @@
import { json } from '@sveltejs/kit';
/**
* Checks if a value contains path traversal or injection characters.
* Rejects: .., /, \, null bytes, % (catches double-encoding).
*/
function containsPathTraversal(value: string): boolean {
return value.includes('..') || value.includes('/') || value.includes('\\') || value.includes('\0') || value.includes('%');
}
/**
* Validates a Docker resource ID/name from URL params.
* Returns a 400 Response if invalid, null if valid.
*/
export function validateDockerIdParam(id: string, resourceType = 'resource'): Response | null {
if (!id || containsPathTraversal(id)) {
return json({ error: `Invalid ${resourceType} ID` }, { status: 400 });
}
return null;
}
+162 -20
View File
@@ -819,6 +819,11 @@ export async function dockerFetch(
options: DockerFetchOptions = {},
envId?: number | null
): Promise<Response> {
// Guard against path traversal — legitimate Docker API paths never contain '..'
if (path.includes('..')) {
throw new Error('Invalid Docker API path');
}
const startTime = Date.now();
const config = await getDockerConfig(envId);
const { streaming, ...fetchOptions } = options;
@@ -977,7 +982,7 @@ export async function dockerFetch(
/**
* Make a JSON request to Docker API
*/
async function dockerJsonRequest<T>(
export async function dockerJsonRequest<T>(
path: string,
options: RequestInit = {},
envId?: number | null
@@ -1789,6 +1794,40 @@ export async function recreateContainerFromInspect(
HostConfig: hostConfig
};
// 4a. Update image-embedded labels to match the new image.
// Docker's create API uses exactly the labels you pass, ignoring the new image's
// embedded labels. We inspect both old and new images to distinguish image-origin
// labels from user-set labels, then merge accordingly.
try {
const [oldImageInspect, newImageInspect] = await Promise.all([
inspectImage(config.Image, envId),
inspectImage(newImage, envId)
]);
const oldImageLabels: Record<string, string> = (oldImageInspect as any)?.Config?.Labels || {};
const newImageLabels: Record<string, string> = (newImageInspect as any)?.Config?.Labels || {};
const containerLabels: Record<string, string> = createConfig.Labels || {};
const mergedLabels: Record<string, string> = {};
// Keep user-set labels (not present in old image)
for (const [k, v] of Object.entries(containerLabels)) {
if (!(k in oldImageLabels)) {
mergedLabels[k] = v;
}
}
// Add all new image labels (overrides old image labels)
for (const [k, v] of Object.entries(newImageLabels)) {
mergedLabels[k] = v;
}
createConfig.Labels = mergedLabels;
log?.(`Updated image labels: ${Object.keys(newImageLabels).length} from new image, ${Object.keys(mergedLabels).length} total`);
} catch (e) {
log?.(`Warning: could not update image labels: ${e}`);
// Fall through with old labels — non-fatal
}
// Strip default MemorySwappiness — Podman + cgroupv2 rejects it.
// Docker returns -1, Podman returns 0 when unset.
const swappiness = createConfig.HostConfig?.MemorySwappiness;
@@ -2269,6 +2308,14 @@ export function extractContainerOptions(inspectData: any): CreateContainerOption
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;
const name = oldContainerInfo.Name?.replace(/^\//, '') || '';
const oldContainerId = oldContainerInfo.Id;
const networks: Record<string, any> = oldContainerInfo.NetworkSettings?.Networks || {};
const hostConfig = oldContainerInfo.HostConfig || {};
const networkMode = hostConfig.NetworkMode || '';
const isSharedNetwork = networkMode.startsWith('container:') ||
networkMode.startsWith('service:') ||
networkMode === 'host' || networkMode === 'none';
// Extract ALL existing container options
const existingOptions = extractContainerOptions(oldContainerInfo);
@@ -2285,18 +2332,81 @@ export async function updateContainer(id: string, options: Partial<CreateContain
}
};
// 1. Stop old container
if (wasRunning) {
await stopContainer(id, envId);
}
await removeContainer(id, true, envId);
// 2. Rename old container to free the name (instead of removing — allows rollback)
await dockerFetch(
`/containers/${oldContainerId}/rename?name=${encodeURIComponent(name + '-old')}`,
{ method: 'POST' },
envId
).then(async r => { if (!r.ok) throw new Error('Failed to rename old container'); await drainResponse(r); });
const newContainer = await createContainer(mergedOptions, envId);
if (startAfterUpdate || wasRunning) {
await newContainer.start();
// 3. Disconnect networks from old container to free static IPs
if (!isSharedNetwork) {
for (const [, netConfig] of Object.entries(networks)) {
const nc = netConfig as any;
if (nc.NetworkID) {
await disconnectContainerFromNetwork(nc.NetworkID, oldContainerId, true, envId).catch(() => {});
}
}
}
// Rollback helper: restore old container on failure
const rollback = async () => {
try {
// Rename back
await dockerFetch(
`/containers/${oldContainerId}/rename?name=${encodeURIComponent(name)}`,
{ method: 'POST' },
envId
).then(r => drainResponse(r)).catch(() => {});
// Reconnect networks
if (!isSharedNetwork) {
for (const [, netConfig] of Object.entries(networks)) {
const nc = netConfig as any;
if (nc.NetworkID) {
await connectContainerToNetworkRaw(nc.NetworkID, oldContainerId, nc, envId).catch(() => {});
}
}
}
// Restart if it was running
if (wasRunning) {
await startContainer(oldContainerId, envId).catch(() => {});
}
} catch {
// Rollback is best-effort
}
};
// 4. Create new container
let newContainer;
try {
newContainer = await createContainer(mergedOptions, envId);
} catch (createError) {
await rollback();
throw createError;
}
// 5. Start if needed
if (startAfterUpdate || wasRunning) {
try {
await newContainer.start();
} catch (startError) {
// Remove failed new container and restore old one
await removeContainer(newContainer.id, true, envId).catch(() => {});
await rollback();
throw startError;
}
}
// 6. Remove old container (success path only)
await removeContainer(oldContainerId, true, envId).catch(() => {});
return newContainer;
}
@@ -3134,6 +3244,22 @@ export async function getDockerVersion(envId?: number | null) {
return dockerJsonRequest('/version', {}, envId);
}
/**
* Get the Docker daemon's API version string for a given environment.
* Used to pin DOCKER_API_VERSION when spawning sidecar containers (scanner, updater)
* whose bundled Docker CLI may be newer than the host daemon supports.
* Returns null if the version cannot be determined.
* Requires an envId local Docker (no environment) must query /version directly.
*/
export async function getNegotiatedApiVersion(envId: number): Promise<string | null> {
try {
const versionInfo = await getDockerVersion(envId);
return versionInfo?.ApiVersion || null;
} catch {
return null;
}
}
/**
* Lightweight ping check for Docker daemon availability.
* Uses /_ping endpoint which returns "OK" as plain text with minimal overhead.
@@ -3317,7 +3443,29 @@ export interface NetworkInfo {
}
export async function listNetworks(envId?: number | null): Promise<NetworkInfo[]> {
const networks = await dockerJsonRequest<any[]>('/networks', {}, envId);
const [networks, containers] = await Promise.all([
dockerJsonRequest<any[]>('/networks', {}, envId),
dockerJsonRequest<any[]>('/containers/json?all=true', {}, envId)
]);
// Build map of networkId -> container info from container network settings
const networkContainers = new Map<string, Record<string, { name: string; ipv4Address: string }>>();
for (const container of containers) {
const nets = container.NetworkSettings?.Networks;
if (!nets) continue;
const containerName = (container.Names?.[0] || '').replace(/^\//, '');
for (const [, netInfo] of Object.entries(nets as Record<string, any>)) {
const netId = netInfo.NetworkID;
if (!netId) continue;
if (!networkContainers.has(netId)) {
networkContainers.set(netId, {});
}
networkContainers.get(netId)![container.Id] = {
name: containerName,
ipv4Address: netInfo.IPAddress || ''
};
}
}
return networks.map((network: any) => ({
id: network.Id,
@@ -3335,13 +3483,7 @@ export async function listNetworks(envId?: number | null): Promise<NetworkInfo[]
auxAddress: cfg.AuxAddress || cfg.auxAddress
}))
},
containers: Object.entries(network.Containers || {}).reduce((acc: any, [id, data]: [string, any]) => {
acc[id] = {
name: data.Name,
ipv4Address: data.IPv4Address
};
return acc;
}, {})
containers: networkContainers.get(network.Id) || {}
}));
}
@@ -3970,8 +4112,9 @@ export async function runContainerWithStreaming(options: {
// Container has exited. Now fetch stdout reliably (no race condition).
const stdout = await fetchContainerStdout(containerId, config, options.envId);
// If stdout is empty and exit code is non-zero, fetch stderr for debugging
// If stdout is empty and exit code is non-zero, fetch stderr and throw
if (stdout.length === 0 && exitCode !== 0) {
let stderrText = '';
try {
const stderrResponse = await dockerFetch(
`/containers/${containerId}/logs?stdout=false&stderr=true&follow=false`,
@@ -3980,13 +4123,12 @@ export async function runContainerWithStreaming(options: {
);
const stderrBuffer = Buffer.from(await stderrResponse.arrayBuffer());
const stderrOutput = demuxDockerStream(stderrBuffer, { separateStreams: true });
const stderrText = typeof stderrOutput === 'string' ? stderrOutput : stderrOutput.stderr;
if (stderrText) {
console.error(`[runContainerWithStreaming] Container stderr: ${stderrText.substring(0, 1000)}`);
}
stderrText = typeof stderrOutput === 'string' ? stderrOutput : stderrOutput.stderr;
} catch {
// Ignore stderr fetch errors
}
const detail = stderrText ? stderrText.substring(0, 1000) : 'no stderr output';
throw new Error(`Container exited with code ${exitCode}: ${detail}`);
}
return stdout;
@@ -4761,7 +4903,7 @@ function getVolumeCacheKey(volumeName: string, envId?: number | null): string {
/**
* Ensure the volume helper image (busybox) is available, pulling if necessary
*/
async function ensureVolumeHelperImage(envId?: number | null): Promise<void> {
export async function ensureVolumeHelperImage(envId?: number | null): Promise<void> {
// Check if image exists
const response = await dockerFetch(`/images/${encodeURIComponent(VOLUME_HELPER_IMAGE)}/json`, {}, envId);
+36
View File
@@ -0,0 +1,36 @@
import { resolve } from 'path';
import { existsSync, mkdirSync, writeFileSync, unlinkSync, readFileSync } from 'fs';
function getIconsDir(): string {
const dataDir = process.env.DATA_DIR || './data';
const dir = resolve(dataDir, 'icons');
if (!existsSync(dir)) {
mkdirSync(dir, { recursive: true });
}
return dir;
}
export function saveEnvironmentIcon(envId: number, base64Data: string): void {
const dir = getIconsDir();
// Strip data URL prefix if present
const base64 = base64Data.replace(/^data:image\/\w+;base64,/, '');
const buffer = Buffer.from(base64, 'base64');
writeFileSync(resolve(dir, `env-${envId}.webp`), buffer);
}
export function deleteEnvironmentIcon(envId: number): void {
const dir = getIconsDir();
const path = resolve(dir, `env-${envId}.webp`);
if (existsSync(path)) {
unlinkSync(path);
}
}
export function getEnvironmentIconBuffer(envId: number): Buffer | null {
const dir = getIconsDir();
const path = resolve(dir, `env-${envId}.webp`);
if (!existsSync(path)) {
return null;
}
return readFileSync(path);
}
+28 -10
View File
@@ -88,7 +88,7 @@ let _nssWrapperNeeded = false;
async function ensurePasswdEntry(env: GitEnv): Promise<void> {
if (_nssWrapperChecked) {
if (_nssWrapperNeeded) {
env.LD_PRELOAD = NSS_WRAPPER_LIB;
env.LD_PRELOAD = env.LD_PRELOAD ? `${env.LD_PRELOAD}:${NSS_WRAPPER_LIB}` : NSS_WRAPPER_LIB;
env.NSS_WRAPPER_PASSWD = TMP_PASSWD;
env.NSS_WRAPPER_GROUP = TMP_GROUP;
}
@@ -136,7 +136,7 @@ async function ensurePasswdEntry(env: GitEnv): Promise<void> {
}
_nssWrapperNeeded = true;
env.LD_PRELOAD = NSS_WRAPPER_LIB;
env.LD_PRELOAD = env.LD_PRELOAD ? `${env.LD_PRELOAD}:${NSS_WRAPPER_LIB}` : NSS_WRAPPER_LIB;
env.NSS_WRAPPER_PASSWD = TMP_PASSWD;
env.NSS_WRAPPER_GROUP = TMP_GROUP;
console.log(`[git] Created temp passwd for UID ${uid} with libnss_wrapper`);
@@ -153,6 +153,11 @@ async function buildGitEnv(credential: GitCredential | null): Promise<GitEnv> {
SSH_AUTH_SOCK: ''
};
// Pass custom CA certificate to git CLI (NODE_EXTRA_CA_CERTS only affects Node.js)
if (process.env.NODE_EXTRA_CA_CERTS) {
env.GIT_SSL_CAINFO = process.env.NODE_EXTRA_CA_CERTS;
}
// Ensure current UID is resolvable for SSH/git operations
await ensurePasswdEntry(env);
@@ -733,7 +738,8 @@ export async function syncGitStack(stackId: number): Promise<SyncResult> {
// Always re-clone to ensure clean state (handles branch/URL/credential changes, force pushes, etc.)
// Blobless clones fetch all commits (for git diff) but download blobs on-demand
const previousCommit = await getPreviousCommit(repoPath, env);
// Fall back to DB lastCommit when repo dir was deleted by a previous failed sync (#693)
const previousCommit = await getPreviousCommit(repoPath, env) ?? gitStack.lastCommit ?? null;
if (existsSync(repoPath)) {
console.log(`${logPrefix} Removing existing clone for fresh sync...`);
rmSync(repoPath, { recursive: true, force: true });
@@ -762,7 +768,8 @@ export async function syncGitStack(stackId: number): Promise<SyncResult> {
// Check if commit changed
const newCommitResult = await execGit(['rev-parse', 'HEAD'], repoPath, env);
const newCommit = newCommitResult.stdout.trim();
const commitChanged = previousCommit !== newCommit;
// Normalize to 7-char short hash for comparison (DB stores 7-char, git returns 40-char)
const commitChanged = previousCommit?.substring(0, 7) !== newCommit.substring(0, 7);
console.log(`${logPrefix} Previous commit: ${previousCommit || '(none)'}, new commit: ${newCommit.substring(0, 7)}, commit changed: ${commitChanged}`);
// Check if any files in the compose file's directory have changed
@@ -930,8 +937,10 @@ export async function deployGitStack(stackId: number, options?: { force?: boolea
// Check if there are changes - skip redeploy if no changes and not forced
// Note: For new stacks (first deploy), syncResult.updated will be true
if (!force && !syncResult.updated) {
console.log(`${logPrefix} No changes detected and force=false, skipping redeploy`);
// forceRedeploy setting overrides the skip logic for webhooks/scheduled syncs
const shouldDeploy = force || gitStack.forceRedeploy || syncResult.updated;
if (!shouldDeploy) {
console.log(`${logPrefix} No changes detected and force=false, forceRedeploy=false, skipping redeploy`);
return {
success: true,
output: 'No changes detected, skipping redeploy',
@@ -941,6 +950,9 @@ export async function deployGitStack(stackId: number, options?: { force?: boolea
const forceRecreate = syncResult.updated;
console.log(`${logPrefix} Will force recreate:`, forceRecreate, `(updated=${syncResult.updated})`);
console.log(`${logPrefix} Build on deploy:`, gitStack.buildOnDeploy);
console.log(`${logPrefix} Re-pull images:`, gitStack.repullImages);
console.log(`${logPrefix} Force redeploy setting:`, gitStack.forceRedeploy);
// Deploy using unified function - handles both new and existing stacks
// Uses `docker compose up -d --remove-orphans` which only recreates changed services
@@ -958,7 +970,9 @@ export async function deployGitStack(stackId: number, options?: { force?: boolea
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)
forceRecreate
forceRecreate,
build: gitStack.buildOnDeploy,
pullPolicy: gitStack.repullImages ? 'always' : undefined
});
console.log(`${logPrefix} ----------------------------------------`);
@@ -1101,7 +1115,8 @@ export async function deployGitStackWithProgress(
// Always re-clone to ensure clean state (handles branch/URL/credential changes, force pushes, etc.)
// Shallow clones are fast so this is acceptable
const previousCommit = await getPreviousCommit(repoPath, env);
// Fall back to DB lastCommit when repo dir was deleted by a previous failed sync (#693)
const previousCommit = await getPreviousCommit(repoPath, env) ?? gitStack.lastCommit ?? null;
// Step 2: Cloning
onProgress({ status: 'cloning', message: 'Cloning repository...', step: 2, totalSteps });
@@ -1130,7 +1145,8 @@ export async function deployGitStackWithProgress(
// Check if commit changed
const newCommitResult = await execGit(['rev-parse', 'HEAD'], repoPath, env);
const newCommit = newCommitResult.stdout.trim();
const commitChanged = previousCommit !== newCommit;
// Normalize to 7-char short hash for comparison (DB stores 7-char, git returns 40-char)
const commitChanged = previousCommit?.substring(0, 7) !== newCommit.substring(0, 7);
// Check if any files in the compose file's directory have changed
// (for consistency with syncGitStack, though this function always deploys)
@@ -1210,7 +1226,9 @@ export async function deployGitStackWithProgress(
envId: gitStack.environmentId,
sourceDir: composeDir, // Copy entire directory from git repo
composeFileName: basename(gitStack.composePath), // Use original compose filename from repo
envFileName // Env file relative to compose dir (for --env-file flag, optional)
envFileName, // Env file relative to compose dir (for --env-file flag, optional)
build: gitStack.buildOnDeploy,
pullPolicy: gitStack.repullImages ? 'always' : undefined
});
if (result.success) {
+12 -38
View File
@@ -42,47 +42,21 @@ export interface NotificationResult {
error?: string;
}
// SMTP transporter cache — reuses connections instead of creating a new TLS pool per notification.
const transporterCache = new Map<string, { transporter: ReturnType<typeof nodemailer.createTransport>; lastUsed: number }>();
function getOrCreateTransporter(config: SmtpConfig): ReturnType<typeof nodemailer.createTransport> {
const key = `${config.host}:${config.port}:${config.secure}:${config.username || ''}`;
const cached = transporterCache.get(key);
if (cached) {
cached.lastUsed = Date.now();
return cached.transporter;
}
const transporter = nodemailer.createTransport({
host: config.host,
port: config.port,
secure: config.secure,
auth: config.username ? {
user: config.username,
pass: config.password
} : undefined,
tls: config.skipTlsVerify ? {
rejectUnauthorized: false
} : undefined
});
transporterCache.set(key, { transporter, lastUsed: Date.now() });
return transporter;
}
// Clean up idle transporters every 10 minutes
setInterval(() => {
const now = Date.now();
for (const [key, entry] of transporterCache) {
if (now - entry.lastUsed > 10 * 60 * 1000) {
entry.transporter.close();
transporterCache.delete(key);
}
}
}, 10 * 60 * 1000);
// Send notification via SMTP
async function sendSmtpNotification(config: SmtpConfig, payload: NotificationPayload): Promise<NotificationResult> {
try {
const transporter = getOrCreateTransporter(config);
const transporter = nodemailer.createTransport({
host: config.host,
port: config.port,
secure: config.secure,
auth: config.username ? {
user: config.username,
pass: config.password
} : undefined,
tls: config.skipTlsVerify ? {
rejectUnauthorized: false
} : undefined
});
const envBadge = payload.environmentName
? `<span style="display: inline-block; background: #3b82f6; color: white; padding: 2px 8px; border-radius: 4px; font-size: 12px; margin-left: 8px;">${payload.environmentName}</span>`
+108 -27
View File
@@ -11,13 +11,14 @@ import {
runContainer,
runContainerWithStreaming,
inspectImage,
checkImageUpdateAvailable
checkImageUpdateAvailable,
getNegotiatedApiVersion
} from './docker';
import { getEnvironment, getEnvSetting, getSetting } from './db';
import { sendEventNotification } from './notifications';
import { getHostDockerSocket, getHostDataDir, extractUidFromSocketPath, getOwnDockerHost, getOwnNetworkMode } from './host-path';
import { resolve } from 'node:path';
import { mkdir, chown } from 'node:fs/promises';
import { mkdir, chown, rm } from 'node:fs/promises';
export type ScannerType = 'none' | 'grype' | 'trivy' | 'both';
@@ -108,6 +109,10 @@ const inProgressScans = new Map<string, Promise<string>>();
export const DEFAULT_GRYPE_ARGS = '-o json -v {image}';
export const DEFAULT_TRIVY_ARGS = 'image --format json {image}';
// Pinned scanner images — avoid :latest after the March 2026 Trivy supply chain attack
export const DEFAULT_GRYPE_IMAGE = 'anchore/grype:v0.110.0';
export const DEFAULT_TRIVY_IMAGE = 'aquasec/trivy:0.69.3';
export interface VulnerabilitySeverity {
critical: number;
high: number;
@@ -150,28 +155,36 @@ export interface ScanProgress {
output?: string; // Line of scanner output
}
// Get global default scanner CLI args from general settings (or fallback to hardcoded defaults)
// Get global default scanner CLI args and images from general settings (or fallback to hardcoded defaults)
export async function getGlobalScannerDefaults(): Promise<{
grypeArgs: string;
trivyArgs: string;
grypeImage: string;
trivyImage: string;
}> {
const [grypeArgs, trivyArgs] = await Promise.all([
const [grypeArgs, trivyArgs, grypeImage, trivyImage] = await Promise.all([
getSetting('default_grype_args'),
getSetting('default_trivy_args')
getSetting('default_trivy_args'),
getSetting('default_grype_image'),
getSetting('default_trivy_image')
]);
return {
grypeArgs: grypeArgs ?? DEFAULT_GRYPE_ARGS,
trivyArgs: trivyArgs ?? DEFAULT_TRIVY_ARGS
trivyArgs: trivyArgs ?? DEFAULT_TRIVY_ARGS,
grypeImage: grypeImage ?? DEFAULT_GRYPE_IMAGE,
trivyImage: trivyImage ?? DEFAULT_TRIVY_IMAGE
};
}
// Get scanner settings (scanner type is per-environment, CLI args are global)
// Get scanner settings (scanner type is per-environment, CLI args and images are global)
export async function getScannerSettings(envId?: number): Promise<{
scanner: ScannerType;
grypeArgs: string;
trivyArgs: string;
grypeImage: string;
trivyImage: string;
}> {
// CLI args are always global - no need for per-env settings
// CLI args and images are always global - no need for per-env settings
const [globalDefaults, scanner] = await Promise.all([
getGlobalScannerDefaults(),
getEnvSetting('vulnerability_scanner', envId)
@@ -180,25 +193,31 @@ export async function getScannerSettings(envId?: number): Promise<{
return {
scanner: scanner || 'none',
grypeArgs: globalDefaults.grypeArgs,
trivyArgs: globalDefaults.trivyArgs
trivyArgs: globalDefaults.trivyArgs,
grypeImage: globalDefaults.grypeImage,
trivyImage: globalDefaults.trivyImage
};
}
// Optimized version that accepts pre-cached global defaults (avoids redundant DB calls)
// Only looks up scanner type per-environment since CLI args are global
// Only looks up scanner type per-environment since CLI args and images are global
export async function getScannerSettingsWithDefaults(
envId: number | undefined,
globalDefaults: { grypeArgs: string; trivyArgs: string }
globalDefaults: { grypeArgs: string; trivyArgs: string; grypeImage: string; trivyImage: string }
): Promise<{
scanner: ScannerType;
grypeArgs: string;
trivyArgs: string;
grypeImage: string;
trivyImage: string;
}> {
const scanner = await getEnvSetting('vulnerability_scanner', envId) || 'none';
return {
scanner,
grypeArgs: globalDefaults.grypeArgs,
trivyArgs: globalDefaults.trivyArgs
trivyArgs: globalDefaults.trivyArgs,
grypeImage: globalDefaults.grypeImage,
trivyImage: globalDefaults.trivyImage
};
}
@@ -668,6 +687,28 @@ async function runScannerContainerCore(
? [`GRYPE_DB_CACHE_DIR=${dbPath}`]
: [`TRIVY_CACHE_DIR=${dbPath}`];
// Pin Docker API version so scanner's bundled Docker client doesn't request
// a version newer than the host daemon supports (e.g. grype ships client v1.53
// but the host may only support up to v1.43).
const apiVersion = await getNegotiatedApiVersion(envId);
if (apiVersion) {
envVars.push(`DOCKER_API_VERSION=${apiVersion}`);
console.log(`[Scanner] Using negotiated Docker API version: ${apiVersion}`);
}
// Propagate proxy env vars so scanners can reach the internet in proxied environments
const proxyVarNames = [
'HTTP_PROXY', 'http_proxy',
'HTTPS_PROXY', 'https_proxy',
'NO_PROXY', 'no_proxy',
'ALL_PROXY', 'all_proxy',
];
for (const name of proxyVarNames) {
if (process.env[name]) {
envVars.push(`${name}=${process.env[name]}`);
}
}
// In TCP mode, pass DOCKER_HOST so scanner connects to Docker via TCP
if (scannerDockerHost) {
envVars.push(`DOCKER_HOST=${scannerDockerHost}`);
@@ -697,11 +738,7 @@ async function runScannerContainerCore(
});
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`);
console.error(`[Scanner] Docker access: ${scannerDockerHost ? `TCP ${scannerDockerHost}` : `socket ${hostSocketPath}`}`);
} else if (output.length < 100) {
if (output.length < 100) {
console.log(`[Scanner] ${scannerType} output preview: ${output}`);
}
@@ -715,8 +752,7 @@ export async function scanWithGrype(
onProgress?: (progress: ScanProgress) => void
): Promise<ScanResult> {
const startTime = Date.now();
const scannerImage = 'anchore/grype:latest';
const { grypeArgs } = await getScannerSettings(envId);
const { grypeArgs, grypeImage: scannerImage } = await getScannerSettings(envId);
onProgress?.({
stage: 'checking',
@@ -813,8 +849,7 @@ export async function scanWithTrivy(
onProgress?: (progress: ScanProgress) => void
): Promise<ScanResult> {
const startTime = Date.now();
const scannerImage = 'aquasec/trivy:latest';
const { trivyArgs } = await getScannerSettings(envId);
const { trivyArgs, trivyImage: scannerImage } = await getScannerSettings(envId);
onProgress?.({
stage: 'checking',
@@ -978,9 +1013,10 @@ export async function checkScannerAvailability(envId?: number): Promise<{
grype: boolean;
trivy: boolean;
}> {
const defaults = await getGlobalScannerDefaults();
const [grypeAvailable, trivyAvailable] = await Promise.all([
isScannerImageAvailable('anchore/grype', envId),
isScannerImageAvailable('aquasec/trivy', envId)
isScannerImageAvailable(defaults.grypeImage, envId),
isScannerImageAvailable(defaults.trivyImage, envId)
]);
return {
@@ -995,12 +1031,14 @@ async function getScannerVersion(
envId?: number
): Promise<string | null> {
try {
const scannerImage = scannerType === 'grype' ? 'anchore/grype:latest' : 'aquasec/trivy:latest';
const defaults = await getGlobalScannerDefaults();
const scannerImage = scannerType === 'grype' ? defaults.grypeImage : defaults.trivyImage;
// Check if image exists first
// Check if image exists first — match by repo name prefix, not exact tag
const imageRepo = scannerType === 'grype' ? 'anchore/grype' : 'aquasec/trivy';
const images = await listImages(envId);
const hasImage = images.some((img) =>
img.tags?.some((tag: string) => tag === scannerImage)
img.tags?.some((tag: string) => tag.startsWith(imageRepo + ':'))
);
if (!hasImage) return null;
@@ -1062,10 +1100,11 @@ export async function checkScannerUpdates(envId?: number): Promise<{
};
try {
const defaults = await getGlobalScannerDefaults();
const images = await listImages(envId);
// Check both scanners
for (const [scanner, imageName] of [['grype', 'anchore/grype:latest'], ['trivy', 'aquasec/trivy:latest']] as const) {
for (const [scanner, imageName] of [['grype', defaults.grypeImage], ['trivy', defaults.trivyImage]] as const) {
try {
// Find local image
const localImage = images.find((img) =>
@@ -1108,3 +1147,45 @@ export async function cleanupScannerVolumes(envId?: number): Promise<void> {
console.error('[Scanner] Failed to cleanup scanner volumes:', errorMsg);
}
}
/**
* Clean up all scanner cache storage (volumes + bind mount directories).
* Handles both standard Docker (named volumes) and rootless Docker (bind mounts).
* Next scan after cleanup will re-download a fresh vulnerability database (~200MB).
*/
export async function cleanupScannerCache(envId?: number): Promise<{ volumes: string[]; dirs: string[] }> {
const removedVolumes: string[] = [];
const removedDirs: string[] = [];
// 1. Remove named volumes (standard Docker mode)
for (const volumeName of [GRYPE_VOLUME_NAME, TRIVY_VOLUME_NAME]) {
try {
await removeVolume(volumeName, true, envId);
removedVolumes.push(volumeName);
const envSuffix = envId ? ` (env ${envId})` : '';
console.log(`[Scanner] Removed volume: ${volumeName}${envSuffix}`);
} catch {
// Volume might not exist, ignore
}
}
// 2. Remove bind mount cache directories (rootless Docker mode, local only)
if (!envId) {
for (const scannerType of ['grype', 'trivy'] as const) {
const cachePath = resolve(DATA_DIR, SCANNER_CACHE_DIR, scannerType);
try {
await rm(cachePath, { recursive: true, force: true });
removedDirs.push(cachePath);
console.log(`[Scanner] Removed cache directory: ${cachePath}`);
} catch {
// Directory might not exist, ignore
}
}
}
if (removedVolumes.length > 0 || removedDirs.length > 0) {
console.log(`[Scanner] Cache cleanup complete: ${removedVolumes.length} volumes, ${removedDirs.length} directories removed`);
}
return { volumes: removedVolumes, dirs: removedDirs };
}
+62 -1
View File
@@ -45,9 +45,11 @@ import {
runScheduleCleanupJob,
runEventCleanupJob,
runVolumeHelperCleanupJob,
runScannerCacheCleanupJob,
SYSTEM_SCHEDULE_CLEANUP_ID,
SYSTEM_EVENT_CLEANUP_ID,
SYSTEM_VOLUME_HELPER_CLEANUP_ID
SYSTEM_VOLUME_HELPER_CLEANUP_ID,
SYSTEM_SCANNER_CLEANUP_ID
} from './tasks/system-cleanup';
// Store all active cron jobs
@@ -57,6 +59,7 @@ const activeJobs: Map<string, Cron> = new Map();
let cleanupJob: Cron | null = null;
let eventCleanupJob: Cron | null = null;
let volumeHelperCleanupJob: Cron | null = null;
let scannerCacheCleanupJob: Cron | null = null;
// Scheduler state
let isRunning = false;
@@ -131,10 +134,35 @@ export async function startScheduler(): Promise<void> {
await runVolumeHelperCleanupJob('cron', volumeCleanupFns);
});
// Scanner cache cleanup runs weekly (Sunday 3am) to prevent DB volume bloat
const scannerCleanupFn = async () => {
const { cleanupScannerCache } = await import('../scanner');
const envs = await getEnvironments();
// Clean local cache (volumes + bind mount dirs)
const localResult = await cleanupScannerCache();
// Clean remote environment volumes
for (const env of envs) {
try {
const envResult = await cleanupScannerCache(env.id);
localResult.volumes.push(...envResult.volumes);
} catch (error) {
const msg = error instanceof Error ? error.message : String(error);
console.log(`[Scanner] Skipping cache cleanup for env "${env.name}" (id=${env.id}): ${msg}`);
}
}
return localResult;
};
scannerCacheCleanupJob = new Cron('0 3 * * 0', { timezone: defaultTimezone, legacyMode: false }, async () => {
await runScannerCacheCleanupJob('cron', scannerCleanupFn);
});
console.log(`[Scheduler] System schedule cleanup: ${scheduleCleanupCron} [${defaultTimezone}]`);
console.log(`[Scheduler] System event cleanup: ${eventCleanupCron} [${defaultTimezone}]`);
console.log(`[Scheduler] Volume helper cleanup: every 30 minutes [${defaultTimezone}]`);
console.log(`[Scheduler] Scanner cache cleanup: weekly (Sunday 3am) [${defaultTimezone}]`);
// Register all dynamic schedules from database
await refreshAllSchedules();
@@ -164,6 +192,10 @@ export function stopScheduler(): void {
volumeHelperCleanupJob.stop();
volumeHelperCleanupJob = null;
}
if (scannerCacheCleanupJob) {
scannerCacheCleanupJob.stop();
scannerCacheCleanupJob = null;
}
// Stop all dynamic jobs
for (const [key, job] of activeJobs.entries()) {
@@ -487,6 +519,9 @@ export async function refreshSystemJobs(): Promise<void> {
if (volumeHelperCleanupJob) {
volumeHelperCleanupJob.stop();
}
if (scannerCacheCleanupJob) {
scannerCacheCleanupJob.stop();
}
// Re-create with new timezone
cleanupJob = new Cron(scheduleCleanupCron, { timezone: defaultTimezone, legacyMode: false }, async () => {
@@ -501,9 +536,18 @@ export async function refreshSystemJobs(): Promise<void> {
await runVolumeHelperCleanupJob('cron', volumeCleanupFns);
});
const scannerCleanupFn = async () => {
const { cleanupScannerCache } = await import('../scanner');
return cleanupScannerCache();
};
scannerCacheCleanupJob = new Cron('0 3 * * 0', { timezone: defaultTimezone, legacyMode: false }, async () => {
await runScannerCacheCleanupJob('cron', scannerCleanupFn);
});
console.log(`[Scheduler] System schedule cleanup: ${scheduleCleanupCron} [${defaultTimezone}]`);
console.log(`[Scheduler] System event cleanup: ${eventCleanupCron} [${defaultTimezone}]`);
console.log(`[Scheduler] Volume helper cleanup: every 30 minutes [${defaultTimezone}]`);
console.log(`[Scheduler] Scanner cache cleanup: weekly (Sunday 3am) [${defaultTimezone}]`);
}
// =============================================================================
@@ -637,6 +681,13 @@ export async function triggerSystemJob(jobId: string): Promise<{ success: boolea
cleanupExpiredVolumeHelpers
});
return { success: true };
} else if (jobId === String(SYSTEM_SCANNER_CLEANUP_ID) || jobId === 'scanner-cache-cleanup') {
const scannerCleanupFn = async () => {
const { cleanupScannerCache } = await import('../scanner');
return cleanupScannerCache();
};
runScannerCacheCleanupJob('manual', scannerCleanupFn);
return { success: true };
} else {
return { success: false, error: 'Unknown system job ID' };
}
@@ -694,6 +745,16 @@ export async function getSystemSchedules(): Promise<SystemScheduleInfo[]> {
nextRun: getNextRun('*/30 * * * *')?.toISOString() ?? null,
isSystem: true,
enabled: true
},
{
id: SYSTEM_SCANNER_CLEANUP_ID,
type: 'system_cleanup' as const,
name: 'Scanner cache cleanup',
description: 'Removes scanner vulnerability database cache to reclaim disk space',
cronExpression: '0 3 * * 0',
nextRun: getNextRun('0 3 * * 0')?.toISOString() ?? null,
isSystem: true,
enabled: true
}
];
}
@@ -20,6 +20,7 @@ import {
export const SYSTEM_SCHEDULE_CLEANUP_ID = 1;
export const SYSTEM_EVENT_CLEANUP_ID = 2;
export const SYSTEM_VOLUME_HELPER_CLEANUP_ID = 3;
export const SYSTEM_SCANNER_CLEANUP_ID = 4;
/**
* Execute schedule execution cleanup job.
@@ -200,3 +201,66 @@ export async function runVolumeHelperCleanupJob(
});
}
}
/**
* Execute scanner cache cleanup job.
* Removes scanner database volumes and bind mount directories to reclaim disk space.
*/
export async function runScannerCacheCleanupJob(
triggeredBy: ScheduleTrigger = 'cron',
cleanupFn?: () => Promise<{ volumes: string[]; dirs: string[] }>
): Promise<void> {
const startTime = Date.now();
const execution = await createScheduleExecution({
scheduleType: 'system_cleanup',
scheduleId: SYSTEM_SCANNER_CLEANUP_ID,
environmentId: null,
entityName: 'Scanner cache cleanup',
triggeredBy,
status: 'running'
});
await updateScheduleExecution(execution.id, {
startedAt: new Date().toISOString()
});
const log = async (message: string) => {
console.log(`[Scanner Cache Cleanup] ${message}`);
await appendScheduleExecutionLog(execution.id, `[${new Date().toISOString()}] ${message}`);
};
try {
await log('Starting scanner cache cleanup');
let result: { volumes: string[]; dirs: string[] };
if (cleanupFn) {
result = await cleanupFn();
} else {
const { cleanupScannerCache } = await import('../../scanner');
result = await cleanupScannerCache();
}
if (result.volumes.length > 0) {
await log(`Removed volumes: ${result.volumes.join(', ')}`);
}
if (result.dirs.length > 0) {
await log(`Removed directories: ${result.dirs.join(', ')}`);
}
await log(`Cleanup complete: ${result.volumes.length} volumes, ${result.dirs.length} directories removed`);
await updateScheduleExecution(execution.id, {
status: 'success',
completedAt: new Date().toISOString(),
duration: Date.now() - startTime,
details: { removedVolumes: result.volumes, removedDirs: result.dirs }
});
} 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
});
}
}
+89
View File
@@ -0,0 +1,89 @@
/**
* Pure SSE parsing utilities no server dependencies.
* Can be safely imported in unit tests and client code.
*/
/**
* Check if the client prefers JSON over SSE.
* Returns true when Accept header includes application/json but NOT text/event-stream.
*/
export function prefersJSON(request?: Request): boolean {
const accept = request?.headers.get('accept') || '';
return accept.includes('application/json') && !accept.includes('text/event-stream');
}
/**
* Wrap an SSE Response for JSON-preferring clients.
*
* Consumes the SSE stream using proper event framing (blank-line delimited,
* multi-line data joined with \n, CRLF stripped). Returns the `result` event
* data as a JSON response, or a fallback if no result event was emitted.
*
* Usage:
* if (prefersJSON(request)) return sseToJSON(buildSSEResponse());
* return buildSSEResponse();
*/
export async function sseToJSON(sseResponse: Response): Promise<Response> {
const reader = sseResponse.body!.getReader();
const decoder = new TextDecoder();
let buffer = '';
let eventType = '';
let dataLines: string[] = [];
let resultData: unknown = null;
const dispatch = () => {
const data = dataLines.join('\n');
const type = eventType || 'message';
eventType = '';
dataLines = [];
if (type === 'result' && data) {
try {
resultData = JSON.parse(data);
} catch {
// keep previous resultData
}
}
};
const parseLine = (rawLine: string) => {
const line = rawLine.endsWith('\r') ? rawLine.slice(0, -1) : rawLine;
if (line.startsWith(':')) return;
if (line === '') { dispatch(); return; }
const colon = line.indexOf(':');
const field = colon === -1 ? line : line.slice(0, colon);
let val = colon === -1 ? '' : line.slice(colon + 1);
if (val.startsWith(' ')) val = val.slice(1);
if (field === 'event') eventType = val || 'message';
else if (field === 'data') dataLines.push(val);
};
try {
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split('\n');
buffer = lines.pop() || '';
for (const line of lines) parseLine(line);
}
// Flush remaining bytes and process trailing content
buffer += decoder.decode();
if (buffer) {
for (const line of buffer.split('\n')) parseLine(line);
}
// Final dispatch for servers missing trailing blank line
if (dataLines.length > 0) dispatch();
} catch {
// stream error, return what we have
} finally {
reader.releaseLock();
}
const body = resultData ?? { success: false, error: 'No result' };
return new Response(JSON.stringify(body), {
headers: { 'content-type': 'application/json' }
});
}
+3 -84
View File
@@ -1,90 +1,9 @@
import { json } from '@sveltejs/kit';
import { createJob, appendLine, completeJob, failJob } from '$lib/server/jobs';
import { prefersJSON } from '$lib/server/sse-parser';
/**
* Check if the client prefers JSON over SSE.
* Returns true when Accept header includes application/json but NOT text/event-stream.
*/
export function prefersJSON(request?: Request): boolean {
const accept = request?.headers.get('accept') || '';
return accept.includes('application/json') && !accept.includes('text/event-stream');
}
/**
* Wrap an SSE Response for JSON-preferring clients.
*
* Consumes the SSE stream using proper event framing (blank-line delimited,
* multi-line data joined with \n, CRLF stripped). Returns the `result` event
* data as a JSON response, or a fallback if no result event was emitted.
*
* Usage:
* if (prefersJSON(request)) return sseToJSON(buildSSEResponse());
* return buildSSEResponse();
*/
export async function sseToJSON(sseResponse: Response): Promise<Response> {
const reader = sseResponse.body!.getReader();
const decoder = new TextDecoder();
let buffer = '';
let eventType = '';
let dataLines: string[] = [];
let resultData: unknown = null;
const dispatch = () => {
const data = dataLines.join('\n');
const type = eventType || 'message';
eventType = '';
dataLines = [];
if (type === 'result' && data) {
try {
resultData = JSON.parse(data);
} catch {
// keep previous resultData
}
}
};
const parseLine = (rawLine: string) => {
const line = rawLine.endsWith('\r') ? rawLine.slice(0, -1) : rawLine;
if (line.startsWith(':')) return;
if (line === '') { dispatch(); return; }
const colon = line.indexOf(':');
const field = colon === -1 ? line : line.slice(0, colon);
let val = colon === -1 ? '' : line.slice(colon + 1);
if (val.startsWith(' ')) val = val.slice(1);
if (field === 'event') eventType = val || 'message';
else if (field === 'data') dataLines.push(val);
};
try {
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split('\n');
buffer = lines.pop() || '';
for (const line of lines) parseLine(line);
}
// Flush remaining bytes and process trailing content
buffer += decoder.decode();
if (buffer) {
for (const line of buffer.split('\n')) parseLine(line);
}
// Final dispatch for servers missing trailing blank line
if (dataLines.length > 0) dispatch();
} catch {
// stream error, return what we have
} finally {
reader.releaseLock();
}
const body = resultData ?? { success: false, error: 'No result' };
return new Response(JSON.stringify(body), {
headers: { 'Content-Type': 'application/json' }
});
}
// Re-export pure parsing utilities (no server deps) for backward compat
export { prefersJSON, sseToJSON } from '$lib/server/sse-parser';
/**
* Job-based response for long-running operations.
+70 -29
View File
@@ -58,6 +58,8 @@ export interface StackOperationResult {
success: boolean;
output?: string;
error?: string;
/** The docker compose command that was executed (for debugging/testing) */
command?: string;
}
/**
@@ -99,6 +101,8 @@ export interface DeployStackOptions {
envId?: number | null;
sourceDir?: string; // Directory to copy all files from (for git stacks)
forceRecreate?: boolean;
build?: boolean; // Build images before starting (--build)
pullPolicy?: string; // Pull policy: 'always' | 'missing' | 'never'
composePath?: string; // Custom compose file path (for adopted/imported stacks)
envPath?: string; // Custom env file path (for adopted/imported stacks)
composeFileName?: string; // Compose filename to use (e.g., "docker-compose.yaml") for git stacks
@@ -788,6 +792,8 @@ interface ComposeCommandOptions {
stackName: string;
envId?: number | null;
forceRecreate?: boolean;
build?: boolean; // Build images before starting (--build)
pullPolicy?: string; // Pull policy: 'always' | 'missing' | 'never'
removeVolumes?: boolean;
stackFiles?: Record<string, string>; // All files to send to Hawser
/** Working directory for compose execution (for imported stacks) */
@@ -848,7 +854,9 @@ async function executeLocalCompose(
customComposePath?: string,
customEnvPath?: string,
useOverrideFile?: boolean,
serviceName?: string
serviceName?: string,
build?: boolean,
pullPolicy?: string
): Promise<StackOperationResult> {
const logPrefix = `[Stack:${stackName}]`;
@@ -1040,6 +1048,8 @@ async function executeLocalCompose(
case 'up':
args.push('up', '-d', '--remove-orphans');
if (forceRecreate) args.push('--force-recreate');
if (build) args.push('--build');
if (pullPolicy) args.push('--pull', pullPolicy);
// If targeting a specific service, only update that service
if (serviceName) {
args.push(serviceName);
@@ -1067,11 +1077,13 @@ async function executeLocalCompose(
break;
}
const commandStr = args.join(' ');
console.log(`${logPrefix} ----------------------------------------`);
console.log(`${logPrefix} EXECUTE LOCAL COMPOSE`);
console.log(`${logPrefix} ----------------------------------------`);
console.log(`${logPrefix} Operation:`, operation);
console.log(`${logPrefix} Command:`, args.join(' '));
console.log(`${logPrefix} Command:`, commandStr);
console.log(`${logPrefix} Working directory:`, stackDir);
console.log(`${logPrefix} Compose file:`, composeFile);
console.log(`${logPrefix} DOCKER_HOST:`, dockerHost || '(local socket)');
@@ -1141,20 +1153,23 @@ async function executeLocalCompose(
return {
success: false,
output: stdout,
error: `docker compose ${operation} timed out after ${COMPOSE_TIMEOUT_MS / 1000} seconds`
error: `docker compose ${operation} timed out after ${COMPOSE_TIMEOUT_MS / 1000} seconds`,
command: commandStr
};
}
if (code === 0) {
return {
success: true,
output: stdout || stderr || `Stack "${stackName}" ${operation} completed successfully`
output: stdout || stderr || `Stack "${stackName}" ${operation} completed successfully`,
command: commandStr
};
} else {
return {
success: false,
output: stdout,
error: stderr || `docker compose ${operation} exited with code ${code}`
error: stderr || `docker compose ${operation} exited with code ${code}`,
command: commandStr
};
}
} finally {
@@ -1165,7 +1180,8 @@ async function executeLocalCompose(
return {
success: false,
output: '',
error: `Failed to run docker compose ${operation}: ${err.message}`
error: `Failed to run docker compose ${operation}: ${err.message}`,
command: commandStr
};
} finally {
// Cleanup temp override file from host path translation
@@ -1207,7 +1223,9 @@ async function executeComposeViaHawser(
removeVolumes?: boolean,
stackFiles?: Record<string, string>,
serviceName?: string,
composeFileName?: string
composeFileName?: string,
build?: boolean,
pullPolicy?: string
): Promise<StackOperationResult> {
const logPrefix = `[Stack:${stackName}]`;
// Import dockerFetch dynamically to avoid circular dependency
@@ -1280,6 +1298,8 @@ async function executeComposeViaHawser(
files, // Files including .env (secrets NOT in .env file)
forceRecreate: forceRecreate || false,
removeVolumes: removeVolumes || false,
build: build || false,
pullPolicy: pullPolicy || '',
registries, // Registry credentials for docker login
serviceName // Target specific service only (with --no-deps)
});
@@ -1347,7 +1367,7 @@ async function executeComposeCommand(
envVars?: Record<string, string>,
secretVars?: Record<string, string>
): Promise<StackOperationResult> {
const { stackName, envId, forceRecreate, removeVolumes, stackFiles, workingDir, composePath, envPath, useOverrideFile, serviceName, composeFileName } = options;
const { stackName, envId, forceRecreate, build, pullPolicy, removeVolumes, stackFiles, workingDir, composePath, envPath, useOverrideFile, serviceName, composeFileName } = options;
// Get environment configuration
const env = envId ? await getEnvironment(envId) : null;
@@ -1369,7 +1389,9 @@ async function executeComposeCommand(
composePath,
envPath,
useOverrideFile,
serviceName
serviceName,
build,
pullPolicy
);
}
@@ -1431,7 +1453,9 @@ async function executeComposeCommand(
removeVolumes,
hawserStackFiles,
serviceName,
composeFileName
composeFileName,
build,
pullPolicy
);
}
@@ -1462,7 +1486,9 @@ async function executeComposeCommand(
composePath,
envPath,
useOverrideFile,
serviceName
serviceName,
build,
pullPolicy
);
}
@@ -1483,7 +1509,9 @@ async function executeComposeCommand(
composePath,
envPath,
useOverrideFile,
serviceName
serviceName,
build,
pullPolicy
);
}
}
@@ -1747,7 +1775,7 @@ export interface RequireComposeResult {
* - envPath: Path to the .env file (Docker Compose reads non-secrets from it)
* - needsFileLocation: true if stack needs user to specify file paths
*/
async function requireComposeFile(
export async function requireComposeFile(
stackName: string,
envId?: number | null,
composeConfigPath?: string
@@ -1877,12 +1905,19 @@ export async function stopStack(
}
/**
* Restart a stack using docker compose restart
* Falls back to individual container restart for stacks without compose files
* Restart a stack using docker compose restart or stop+up (recreate mode).
*
* mode='restart' (default): Uses 'docker compose restart' fast, in-place restart
* that preserves container IDs but won't fix stale network_mode references.
* mode='recreate': Uses 'docker compose stop' then 'docker compose up -d'
* recreates containers, fixing network_mode: service:<container> dependencies.
*
* Falls back to individual container restart for stacks without compose files.
*/
export async function restartStack(
stackName: string,
envId?: number | null
envId?: number | null,
mode: 'restart' | 'recreate' = 'restart'
): Promise<StackOperationResult> {
const result = await requireComposeFile(stackName, envId);
@@ -1891,13 +1926,17 @@ export async function restartStack(
return withContainerFallback(stackName, envId, 'restart');
}
const composeResult = await executeComposeCommand(
'restart',
{ stackName, envId, workingDir: result.stackDir, composePath: result.composePath, envPath: result.envPath },
result.content!,
result.nonSecretVars,
result.secretVars
);
const opts: ComposeCommandOptions = { stackName, envId, workingDir: result.stackDir, composePath: result.composePath, envPath: result.envPath };
let composeResult: StackOperationResult;
if (mode === 'recreate') {
// Stop first, then bring up with --force-recreate to ensure new container IDs
await executeComposeCommand('stop', opts, result.content!, result.nonSecretVars, result.secretVars);
composeResult = await executeComposeCommand('up', { ...opts, forceRecreate: true }, result.content!, result.nonSecretVars, result.secretVars);
} else {
composeResult = await executeComposeCommand('restart', opts, result.content!, result.nonSecretVars, result.secretVars);
}
// Restart any dynamically-spawned child containers not in the compose file
await cleanupOrphanStackContainers(stackName, envId, 'restart');
@@ -2133,7 +2172,7 @@ export async function removeStack(
* Uses stack locking to prevent concurrent deployments.
*/
export async function deployStack(options: DeployStackOptions): Promise<StackOperationResult> {
const { name, compose, envId, sourceDir, forceRecreate, composePath, envPath, composeFileName, envFileName } = options;
const { name, compose, envId, sourceDir, forceRecreate, build, pullPolicy, composePath, envPath, composeFileName, envFileName } = options;
const logPrefix = `[Stack:${name}]`;
console.log(`${logPrefix} ========================================`);
@@ -2206,12 +2245,12 @@ export async function deployStack(options: DeployStackOptions): Promise<StackOpe
console.log(`${logPrefix} Read ${Object.keys(stackFiles).length} files from source directory`);
console.log(`${logPrefix} Files:`, Object.keys(stackFiles).join(', '));
// Copy source to stack directory
// Copy git source files to stack directory (overlay, not replace).
// Do NOT rmSync first — relative volume mounts (e.g., ./data) live here
// and would be destroyed, causing data loss (#831).
console.log(`${logPrefix} Copying source directory to stack directory...`);
if (existsSync(workingDir)) {
rmSync(workingDir, { recursive: true, force: true });
}
cpSync(sourceDir, workingDir, { recursive: true });
mkdirSync(workingDir, { recursive: true });
cpSync(sourceDir, workingDir, { recursive: true, force: true });
console.log(`${logPrefix} Copied ${sourceDir} -> ${workingDir}`);
} else {
// Internal stack: check if a custom path exists in DB (adopted/imported stacks)
@@ -2275,6 +2314,8 @@ export async function deployStack(options: DeployStackOptions): Promise<StackOpe
stackName: name,
envId,
forceRecreate,
build,
pullPolicy,
stackFiles,
workingDir,
composePath: actualComposePath,
+31 -5
View File
@@ -13,10 +13,14 @@ export interface GridItem {
export interface DashboardPreferences {
gridLayout: GridItem[];
locked: boolean;
viewMode: 'grid' | 'list';
}
const defaultPreferences: DashboardPreferences = {
gridLayout: []
gridLayout: [],
locked: false,
viewMode: 'grid'
};
// Environment info from API
@@ -147,16 +151,20 @@ function createDashboardStore() {
const data = await response.json();
// Handle migration from old format
if (data.gridLayout && Array.isArray(data.gridLayout)) {
set({ gridLayout: data.gridLayout });
set({
gridLayout: data.gridLayout,
locked: data.locked ?? false,
viewMode: data.viewMode ?? 'grid'
});
} else {
set({ gridLayout: [] });
set(defaultPreferences);
}
} else {
set({ gridLayout: [] });
set(defaultPreferences);
}
} catch (error) {
console.error('Failed to load dashboard preferences:', error);
set({ gridLayout: [] });
set(defaultPreferences);
} finally {
// Always mark as initialized so saves can proceed
initialized = true;
@@ -206,6 +214,24 @@ function createDashboardStore() {
return newPrefs;
});
},
setLocked: (locked: boolean) => {
update(prefs => {
const newPrefs = { ...prefs, locked };
if (initialized) {
scheduleSave(newPrefs);
}
return newPrefs;
});
},
setViewMode: (viewMode: 'grid' | 'list') => {
update(prefs => {
const newPrefs = { ...prefs, viewMode };
if (initialized) {
scheduleSave(newPrefs);
}
return newPrefs;
});
},
reset: () => {
initialized = false;
set(defaultPreferences);
+52 -3
View File
@@ -27,8 +27,11 @@ export interface AppSettings {
eventPollInterval: number;
metricsCollectionInterval: number;
compactPorts: boolean;
formatLogTimestamps: boolean;
externalStackPaths: string[];
primaryStackLocation: string | null;
defaultGrypeImage: string;
defaultTrivyImage: string;
}
const DEFAULT_SETTINGS: AppSettings = {
@@ -52,8 +55,11 @@ const DEFAULT_SETTINGS: AppSettings = {
eventPollInterval: 60000,
metricsCollectionInterval: 30000,
compactPorts: false,
formatLogTimestamps: false,
externalStackPaths: [],
primaryStackLocation: null
primaryStackLocation: null,
defaultGrypeImage: 'anchore/grype:v0.110.0',
defaultTrivyImage: 'aquasec/trivy:0.69.3'
};
// Create a writable store for app settings
@@ -91,8 +97,11 @@ function createSettingsStore() {
eventPollInterval: settings.eventPollInterval ?? DEFAULT_SETTINGS.eventPollInterval,
metricsCollectionInterval: settings.metricsCollectionInterval ?? DEFAULT_SETTINGS.metricsCollectionInterval,
compactPorts: settings.compactPorts ?? DEFAULT_SETTINGS.compactPorts,
formatLogTimestamps: settings.formatLogTimestamps ?? DEFAULT_SETTINGS.formatLogTimestamps,
externalStackPaths: settings.externalStackPaths ?? DEFAULT_SETTINGS.externalStackPaths,
primaryStackLocation: settings.primaryStackLocation ?? DEFAULT_SETTINGS.primaryStackLocation
primaryStackLocation: settings.primaryStackLocation ?? DEFAULT_SETTINGS.primaryStackLocation,
defaultGrypeImage: settings.defaultGrypeImage ?? DEFAULT_SETTINGS.defaultGrypeImage,
defaultTrivyImage: settings.defaultTrivyImage ?? DEFAULT_SETTINGS.defaultTrivyImage
});
}
} catch {
@@ -133,8 +142,11 @@ function createSettingsStore() {
eventPollInterval: updatedSettings.eventPollInterval ?? DEFAULT_SETTINGS.eventPollInterval,
metricsCollectionInterval: updatedSettings.metricsCollectionInterval ?? DEFAULT_SETTINGS.metricsCollectionInterval,
compactPorts: updatedSettings.compactPorts ?? DEFAULT_SETTINGS.compactPorts,
formatLogTimestamps: updatedSettings.formatLogTimestamps ?? DEFAULT_SETTINGS.formatLogTimestamps,
externalStackPaths: updatedSettings.externalStackPaths ?? DEFAULT_SETTINGS.externalStackPaths,
primaryStackLocation: updatedSettings.primaryStackLocation ?? DEFAULT_SETTINGS.primaryStackLocation
primaryStackLocation: updatedSettings.primaryStackLocation ?? DEFAULT_SETTINGS.primaryStackLocation,
defaultGrypeImage: updatedSettings.defaultGrypeImage ?? DEFAULT_SETTINGS.defaultGrypeImage,
defaultTrivyImage: updatedSettings.defaultTrivyImage ?? DEFAULT_SETTINGS.defaultTrivyImage
});
}
} catch (error) {
@@ -301,6 +313,13 @@ function createSettingsStore() {
return newSettings;
});
},
setFormatLogTimestamps: (value: boolean) => {
update((current) => {
const newSettings = { ...current, formatLogTimestamps: value };
saveSettings({ formatLogTimestamps: value });
return newSettings;
});
},
setExternalStackPaths: (value: string[]) => {
update((current) => {
const newSettings = { ...current, externalStackPaths: value };
@@ -315,6 +334,20 @@ function createSettingsStore() {
return newSettings;
});
},
setDefaultGrypeImage: (value: string) => {
update((current) => {
const newSettings = { ...current, defaultGrypeImage: value };
saveSettings({ defaultGrypeImage: value });
return newSettings;
});
},
setDefaultTrivyImage: (value: string) => {
update((current) => {
const newSettings = { ...current, defaultTrivyImage: value };
saveSettings({ defaultTrivyImage: value });
return newSettings;
});
},
// Manual refresh from database
refresh: loadSettings
};
@@ -430,3 +463,19 @@ export function getTimeFormat(): TimeFormat {
export function getDateFormat(): DateFormat {
return cachedDateFormat;
}
// Regex matching ISO 8601 timestamps at the start of log lines (after optional container prefix)
// Matches: 2026-01-12T07:47:44.449821093Z or 2026-01-12T07:47:44Z
const ISO_TIMESTAMP_RE = /(\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2})(?:\.\d+)?Z/g;
/**
* Replace ISO 8601 timestamps in log text with formatted local timestamps.
* Uses the user's configured date/time format settings.
*/
export function formatLogTimestamps(text: string): string {
return text.replace(ISO_TIMESTAMP_RE, (_match, dateTimePart) => {
const d = new Date(_match);
if (isNaN(d.getTime())) return _match;
return `${formatDatePart(d)} ${formatTimePart(d, true)}`;
});
}
+2 -2
View File
@@ -264,7 +264,7 @@ function applyEditorFont(fontId: string) {
document.documentElement.style.setProperty('--font-editor', fontMeta.family);
}
// Load Google Font dynamically
// Load bundled font CSS (fonts are bundled in static/fonts/)
function loadGoogleFont(font: FontMeta) {
if (!font.googleFont) return;
@@ -274,7 +274,7 @@ function loadGoogleFont(font: FontMeta) {
const link = document.createElement('link');
link.id = linkId;
link.rel = 'stylesheet';
link.href = `https://fonts.googleapis.com/css2?family=${font.googleFont}&display=swap`;
link.href = `/fonts/${font.id}/font.css`;
document.head.appendChild(link);
}
-1
View File
@@ -120,7 +120,6 @@ export const monospaceFonts: FontMeta[] = [
{ 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' },
+2 -2
View File
@@ -28,7 +28,7 @@ export interface ContainerInfo {
rw: boolean;
}>;
networkMode: string;
networks: string[];
networks: Record<string, { ipAddress: string }>;
/**
* Identifies system containers (Dockhand, Hawser) that cannot be updated from within Dockhand.
* - 'dockhand': The Dockhand container itself
@@ -164,7 +164,7 @@ export interface GitRepository {
}
// Grid column configuration types
export type GridId = 'containers' | 'images' | 'imageTags' | 'networks' | 'stacks' | 'volumes' | 'activity' | 'schedules' | 'audit';
export type GridId = 'containers' | 'images' | 'imageTags' | 'networks' | 'stacks' | 'volumes' | 'activity' | 'schedules' | 'audit' | 'environments';
export interface ColumnConfig {
id: string;
+1
View File
@@ -23,6 +23,7 @@ export async function copyToClipboard(text: string): Promise<boolean> {
document.body.appendChild(textarea);
textarea.focus();
textarea.select();
textarea.setSelectionRange(0, textarea.value.length);
const ok = document.execCommand('copy');
document.body.removeChild(textarea);
if (ok) return true;
+4
View File
@@ -43,4 +43,8 @@ export function getIconComponent(iconName: string): ComponentType {
return iconMap[iconName] || Globe;
}
export function isCustomIcon(icon: string | null | undefined): boolean {
return !!icon && icon.startsWith('custom:');
}
export { iconMap };
+77
View File
@@ -19,6 +19,83 @@ export const USER_OPTIONS = [
{ value: '', label: 'Container default' }
];
const TERMINAL_USER_STORAGE_KEY = 'dockhand-terminal-users';
const CUSTOM_USERS_STORAGE_KEY = 'dockhand-custom-users';
/** Get saved user for a container from localStorage */
export function getSavedUser(containerId: string): string | null {
if (typeof window === 'undefined') return null;
try {
const stored = localStorage.getItem(TERMINAL_USER_STORAGE_KEY);
if (stored) {
const map = JSON.parse(stored) as Record<string, string>;
return map[containerId] ?? null;
}
} catch { /* ignore */ }
return null;
}
/** Save user choice for a container to localStorage */
export function saveUserForContainer(containerId: string, user: string) {
if (typeof window === 'undefined') return;
try {
const stored = localStorage.getItem(TERMINAL_USER_STORAGE_KEY);
const map = stored ? JSON.parse(stored) as Record<string, string> : {};
if (user === 'root') {
delete map[containerId];
} else {
map[containerId] = user;
}
localStorage.setItem(TERMINAL_USER_STORAGE_KEY, JSON.stringify(map));
} catch { /* ignore */ }
// Also track custom users globally
const isPreset = USER_OPTIONS.some(o => o.value === user);
if (!isPreset && user) {
addCustomUser(user);
}
}
/** Get all custom users ever used */
export function getCustomUsers(): string[] {
if (typeof window === 'undefined') return [];
try {
const stored = localStorage.getItem(CUSTOM_USERS_STORAGE_KEY);
return stored ? JSON.parse(stored) : [];
} catch { return []; }
}
/** Add a custom user to the global list */
function addCustomUser(user: string) {
if (typeof window === 'undefined') return;
try {
const users = getCustomUsers();
if (!users.includes(user)) {
users.push(user);
localStorage.setItem(CUSTOM_USERS_STORAGE_KEY, JSON.stringify(users));
}
} catch { /* ignore */ }
}
/** Remove a custom user from the global list and clear per-container references */
export function removeCustomUser(user: string) {
if (typeof window === 'undefined') return;
try {
const users = getCustomUsers().filter(u => u !== user);
localStorage.setItem(CUSTOM_USERS_STORAGE_KEY, JSON.stringify(users));
// Clear per-container entries that reference this user
const stored = localStorage.getItem(TERMINAL_USER_STORAGE_KEY);
if (stored) {
const map = JSON.parse(stored) as Record<string, string>;
for (const [id, u] of Object.entries(map)) {
if (u === user) delete map[id];
}
localStorage.setItem(TERMINAL_USER_STORAGE_KEY, JSON.stringify(map));
}
} catch { /* ignore */ }
}
export interface ShellDetectionResult {
shells: string[];
defaultShell: string | null;
+9 -3
View File
@@ -82,9 +82,6 @@
// Check auth status
authStore.check();
// Check What's New popup
checkWhatsNew();
return () => {
disconnectSSE();
};
@@ -113,6 +110,15 @@
localStorage.setItem('dockhand-whats-new-version', currentVersion);
}
}
// Show What's New only after auth resolves — avoids leaking version info on login page (#717)
let whatsNewChecked = false;
$effect(() => {
if (!whatsNewChecked && !$authStore.loading && (!$authStore.authEnabled || $authStore.authenticated)) {
whatsNewChecked = true;
checkWhatsNew();
}
});
</script>
<svelte:head>
+131 -4
View File
@@ -5,7 +5,7 @@
<script lang="ts">
import { onMount, onDestroy } from 'svelte';
import { browser } from '$app/environment';
import { RefreshCw, LayoutGrid, Loader2, Server, Tags, Square, RectangleVertical, Rows3, LayoutTemplate, Maximize2, Plus } from 'lucide-svelte';
import { RefreshCw, LayoutGrid, Loader2, Server, Tags, Square, RectangleVertical, Rows3, LayoutTemplate, Maximize2, Plus, Lock, LockOpen, List, Search, Plug, Route, UndoDot } from 'lucide-svelte';
import { toast } from 'svelte-sonner';
import PageHeader from '$lib/components/PageHeader.svelte';
import * as DropdownMenu from '$lib/components/ui/dropdown-menu';
@@ -14,11 +14,14 @@
import EnvironmentTile from './dashboard/EnvironmentTile.svelte';
import EnvironmentTileSkeleton from './dashboard/EnvironmentTileSkeleton.svelte';
import DraggableGrid, { type GridItemLayout } from './dashboard/DraggableGrid.svelte';
import EnvironmentListView from './dashboard/EnvironmentListView.svelte';
import { dashboardPreferences, dashboardData, GRID_COLS, GRID_ROW_HEIGHT, type TileItem } from '$lib/stores/dashboard';
import { currentEnvironment, environments } from '$lib/stores/environment';
import { IsMobile } from '$lib/hooks/is-mobile.svelte';
import type { EnvironmentStats } from './api/dashboard/stats/+server';
import { getLabelColor, getLabelBgColor } from '$lib/utils/label-colors';
import { Input } from '$lib/components/ui/input';
import MultiSelectFilter from '$lib/components/MultiSelectFilter.svelte';
const LABEL_FILTER_STORAGE_KEY = 'dockhand-dashboard-label-filter';
@@ -52,6 +55,42 @@
const mobileWatcher = new IsMobile();
const isMobile = $derived.by(() => mobileWatcher.current);
// Dashboard lock and view mode from preferences
let locked = $state(false);
let viewMode = $state<'grid' | 'list'>('grid');
// List view filter state
let listSearchQuery = $state('');
let listConnectionFilter = $state<string[]>([]);
const connectionOptions = [
{ value: 'socket', label: 'Socket' },
{ value: 'direct', label: 'Direct', icon: Plug },
{ value: 'hawser-standard', label: 'Standard', icon: Route },
{ value: 'hawser-edge', label: 'Edge', icon: UndoDot }
];
// Count of list-filtered results (for header display)
const listFilteredCount = $derived.by(() => {
let result = filteredTiles;
if (listConnectionFilter.length > 0) {
result = result.filter(t => {
const type = t.stats?.connectionType || 'socket';
return listConnectionFilter.includes(type);
});
}
const q = listSearchQuery.trim().toLowerCase();
if (q) {
result = result.filter(t => {
const s = t.stats;
if (!s) return false;
return s.name.toLowerCase().includes(q) ||
s.host?.toLowerCase().includes(q) ||
s.labels?.some(l => l.toLowerCase().includes(q));
});
}
return result.length;
});
// Subscribe to environments store's loaded flag for quick "loaded" detection
// When loaded, immediately create skeleton tiles so the UI shows something useful
// The SSE stream will then update these tiles with real stats
@@ -166,6 +205,17 @@
}
});
// Filter tiles for list view based on selected labels
const filteredTiles = $derived.by(() => {
if (filterLabels.length === 0) {
return tiles;
}
return tiles.filter(t => {
const tileLabels = t.stats?.labels || [];
return tileLabels.some(label => filterLabels.includes(label));
});
});
// Filter grid items based on selected labels
const filteredGridItems = $derived.by(() => {
if (filterLabels.length === 0) {
@@ -217,6 +267,8 @@
// Subscribe to preferences store to load saved layout
const unsubscribePrefs = dashboardPreferences.subscribe(prefs => {
locked = prefs.locked;
viewMode = prefs.viewMode;
if (prefs.gridLayout.length > 0 && tiles.length > 0 && !prefsLoaded) {
// Apply saved layout
gridItems = prefs.gridLayout.map(item => ({
@@ -614,8 +666,27 @@
}
}
function toggleLocked() {
locked = !locked;
dashboardPreferences.setLocked(locked);
}
function switchToListView() {
viewMode = 'list';
dashboardPreferences.setViewMode('list');
// Remove focus from trigger button
if (document.activeElement instanceof HTMLElement) {
document.activeElement.blur();
}
}
// Apply autolayout - arrange all tiles with specified dimensions
function applyAutoLayout(width: number, height: number) {
// Switch to grid view when selecting a grid layout
if (viewMode !== 'grid') {
viewMode = 'grid';
dashboardPreferences.setViewMode('grid');
}
const tileIds = tiles.map(t => t.id);
const newGridItems: GridItemLayout[] = [];
@@ -928,7 +999,7 @@
<!-- Header -->
<div class="shrink-0 flex flex-wrap justify-between items-center gap-3 min-h-8">
<div class="flex items-center gap-4">
<PageHeader icon={LayoutGrid} title="Environments" />
<PageHeader icon={LayoutGrid} title="Environments" count={tiles.length} />
<!-- Label filter toggles (only show if there are labels) -->
{#if allLabels.length > 0}
@@ -960,6 +1031,33 @@
</div>
<div class="flex items-center gap-1">
<!-- List view filters (search + connection type) -->
{#if viewMode === 'list'}
<div class="flex items-center gap-2 mr-2">
<div class="relative">
<Search class="absolute left-2 top-1/2 -translate-y-1/2 w-3.5 h-3.5 text-muted-foreground" />
<Input
type="text"
placeholder="Search environments..."
bind:value={listSearchQuery}
onkeydown={(e) => e.key === 'Escape' && (listSearchQuery = '')}
class="pl-8 h-8 w-52 text-sm"
/>
</div>
<MultiSelectFilter
bind:value={listConnectionFilter}
options={connectionOptions}
placeholder="All connections"
pluralLabel="connections"
width="w-48"
defaultIcon={Plug}
/>
{#if listSearchQuery || listConnectionFilter.length > 0}
<span class="text-xs text-muted-foreground whitespace-nowrap">{listFilteredCount} of {filteredTiles.length}</span>
{/if}
</div>
{/if}
<!-- Add environment button -->
<button
onclick={() => goto('/settings?tab=environments&new=true')}
@@ -969,6 +1067,21 @@
<Plus class="w-4 h-4" />
</button>
<!-- Lock toggle (only in grid view) -->
{#if viewMode === 'grid'}
<button
onclick={toggleLocked}
class="p-1.5 rounded hover:bg-muted transition-colors"
title={locked ? 'Unlock tiles' : 'Lock tiles'}
>
{#if locked}
<Lock class="w-4 h-4 text-primary" />
{:else}
<LockOpen class="w-4 h-4" />
{/if}
</button>
{/if}
<!-- Autolayout dropdown -->
<DropdownMenu.Root>
<DropdownMenu.Trigger>
@@ -976,7 +1089,7 @@
<button
{...props}
class="p-1.5 rounded hover:bg-muted transition-colors"
title="Auto-layout tiles"
title="Layout options"
>
<LayoutTemplate class="w-4 h-4" />
</button>
@@ -999,6 +1112,11 @@
<Maximize2 class="w-4 h-4" />
<span>Full</span>
</DropdownMenu.Item>
<DropdownMenu.Separator />
<DropdownMenu.Item onclick={switchToListView} class="flex items-center gap-2 cursor-pointer">
<List class="w-4 h-4" />
<span>List</span>
</DropdownMenu.Item>
</DropdownMenu.Content>
</DropdownMenu.Root>
@@ -1032,8 +1150,16 @@
Go to Settings
</Button>
</div>
{:else if viewMode === 'list'}
<!-- List view -->
<EnvironmentListView
tiles={filteredTiles}
searchQuery={listSearchQuery}
connectionFilter={listConnectionFilter}
onrowclick={handleTileClick}
/>
{:else if filteredGridItems.length === 0}
<!-- Filter shows no results -->
<!-- Filter shows no results (grid view) -->
<div class="flex flex-col items-center justify-center h-64 text-muted-foreground">
<div class="w-16 h-16 mb-4 rounded-2xl border-2 border-dashed border-muted-foreground/30 flex items-center justify-center">
<Tags class="w-8 h-8 opacity-40" />
@@ -1086,6 +1212,7 @@
maxW={2}
minH={1}
maxH={4}
{locked}
onchange={handleGridChange}
onitemclick={handleTileClick}
>
+8 -7
View File
@@ -36,7 +36,7 @@
} from 'lucide-svelte';
import PageHeader from '$lib/components/PageHeader.svelte';
import { currentEnvironment, environments as environmentsStore } from '$lib/stores/environment';
import { getIconComponent } from '$lib/utils/icons';
import EnvironmentIcon from '$lib/components/EnvironmentIcon.svelte';
import { canAccess } from '$lib/stores/auth';
import ConfirmPopover from '$lib/components/ConfirmPopover.svelte';
import { toast } from 'svelte-sonner';
@@ -683,14 +683,17 @@
<!-- Environment filter -->
{#if environments.length > 0}
{@const selectedEnv = environments.find(e => e.id === filterEnvironmentId)}
{@const SelectedEnvIcon = selectedEnv ? getIconComponent(selectedEnv.icon || 'globe') : Server}
<Select.Root
type="single"
value={filterEnvironmentId !== null ? String(filterEnvironmentId) : undefined}
onValueChange={(v) => filterEnvironmentId = v ? parseInt(v) : null}
>
<Select.Trigger size="sm" class="w-44 text-sm">
<SelectedEnvIcon class="w-3.5 h-3.5 mr-1.5 text-muted-foreground shrink-0" />
{#if selectedEnv}
<EnvironmentIcon icon={selectedEnv.icon || 'globe'} envId={selectedEnv.id} class="w-3.5 h-3.5 mr-1.5 text-muted-foreground shrink-0" />
{:else}
<Server class="w-3.5 h-3.5 mr-1.5 text-muted-foreground shrink-0" />
{/if}
<span class="truncate">
{#if filterEnvironmentId === null}
Environment
@@ -705,9 +708,8 @@
All environments
</Select.Item>
{#each environments as env}
{@const EnvIcon = getIconComponent(env.icon || 'globe')}
<Select.Item value={String(env.id)}>
<EnvIcon class="w-4 h-4 mr-2 text-muted-foreground" />
<EnvironmentIcon icon={env.icon || 'globe'} envId={env.id} class="w-4 h-4 mr-2 text-muted-foreground" />
{env.name}
</Select.Item>
{/each}
@@ -814,9 +816,8 @@
<span class="font-mono text-xs whitespace-nowrap">{formatTimestamp(event.timestamp)}</span>
{:else if column.id === 'environment'}
{#if event.environmentName}
{@const EventEnvIcon = getIconComponent(event.environmentIcon || 'globe')}
<div class="flex items-center gap-1 text-xs">
<EventEnvIcon class="w-3 h-3 text-muted-foreground shrink-0" />
<EnvironmentIcon icon={event.environmentIcon || 'globe'} envId={event.environmentId || 0} class="w-3 h-3 text-muted-foreground shrink-0" />
<span class="truncate">{event.environmentName}</span>
</div>
{:else}
@@ -6,7 +6,7 @@ import { getOidcConfig } from '$lib/server/db';
// GET /api/auth/oidc/[id]/initiate - Start OIDC authentication flow
export const GET: RequestHandler = async ({ params, url }) => {
// Check if auth is enabled
if (!isAuthEnabled()) {
if (!await isAuthEnabled()) {
return json({ error: 'Authentication is not enabled' }, { status: 400 });
}
@@ -45,7 +45,7 @@ export const GET: RequestHandler = async ({ params, url }) => {
// POST /api/auth/oidc/[id]/initiate - Get authorization URL without redirect
export const POST: RequestHandler = async ({ params, request }) => {
// Check if auth is enabled
if (!isAuthEnabled()) {
if (!await isAuthEnabled()) {
return json({ error: 'Authentication is not enabled' }, { status: 400 });
}
@@ -6,7 +6,7 @@ import { validateSession, testOidcConnection, isAuthEnabled } from '$lib/server/
export const POST: RequestHandler = async ({ params, cookies }) => {
// When auth is disabled, allow access (for initial setup)
// When auth is enabled, require admin
if (isAuthEnabled()) {
if (await isAuthEnabled()) {
const user = await validateSession(cookies);
if (!user || !user.isAdmin) {
return json({ error: 'Admin access required' }, { status: 403 });
+1 -1
View File
@@ -7,7 +7,7 @@ import { auditAuth } from '$lib/server/audit';
export const GET: RequestHandler = async (event) => {
const { url, cookies } = event;
// Check if auth is enabled
if (!isAuthEnabled()) {
if (!await isAuthEnabled()) {
throw redirect(302, '/login?error=auth_disabled');
}
@@ -8,9 +8,13 @@ import { deleteAutoUpdateSchedule, getAutoUpdateSetting, removePendingContainerU
import { authorize } from '$lib/server/authorize';
import { auditContainer } from '$lib/server/audit';
import { unregisterSchedule } from '$lib/server/scheduler';
import { validateDockerIdParam } from '$lib/server/docker-validation';
import type { RequestHandler } from './$types';
export const GET: RequestHandler = async ({ params, url, cookies }) => {
const invalid = validateDockerIdParam(params.id, 'container');
if (invalid) return invalid;
const auth = await authorize(cookies);
const envId = url.searchParams.get('env');
@@ -41,6 +45,9 @@ export const GET: RequestHandler = async ({ params, url, cookies }) => {
export const DELETE: RequestHandler = async (event) => {
const { params, url, cookies } = event;
const invalid = validateDockerIdParam(params.id, 'container');
if (invalid) return invalid;
const auth = await authorize(cookies);
const force = url.searchParams.get('force') === 'true';
@@ -9,8 +9,12 @@ import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { createExec, getDockerConnectionInfo } from '$lib/server/docker';
import { authorize } from '$lib/server/authorize';
import { validateDockerIdParam } from '$lib/server/docker-validation';
export const POST: RequestHandler = async ({ params, request, cookies, url }) => {
const invalid = validateDockerIdParam(params.id, 'container');
if (invalid) return invalid;
const auth = await authorize(cookies);
if (auth.authEnabled && !auth.isAuthenticated) {
return json({ error: 'Unauthorized' }, { status: 401 });
@@ -1,9 +1,13 @@
import { json } from '@sveltejs/kit';
import { listContainerDirectory } from '$lib/server/docker';
import { authorize } from '$lib/server/authorize';
import { validateDockerIdParam } from '$lib/server/docker-validation';
import type { RequestHandler } from './$types';
export const GET: RequestHandler = async ({ params, url, cookies }) => {
const invalid = validateDockerIdParam(params.id, 'container');
if (invalid) return invalid;
const auth = await authorize(cookies);
const path = url.searchParams.get('path') || '/';
@@ -1,9 +1,13 @@
import { json } from '@sveltejs/kit';
import { chmodContainerPath } from '$lib/server/docker';
import { authorize } from '$lib/server/authorize';
import { validateDockerIdParam } from '$lib/server/docker-validation';
import type { RequestHandler } from './$types';
export const POST: RequestHandler = async ({ params, url, cookies, request }) => {
const invalid = validateDockerIdParam(params.id, 'container');
if (invalid) return invalid;
const auth = await authorize(cookies);
const envId = url.searchParams.get('env');
@@ -1,12 +1,16 @@
import { json } from '@sveltejs/kit';
import { readContainerFile, writeContainerFile } from '$lib/server/docker';
import { authorize } from '$lib/server/authorize';
import { validateDockerIdParam } from '$lib/server/docker-validation';
import type { RequestHandler } from './$types';
// Max file size for reading (1MB)
const MAX_FILE_SIZE = 1024 * 1024;
export const GET: RequestHandler = async ({ params, url, cookies }) => {
const invalid = validateDockerIdParam(params.id, 'container');
if (invalid) return invalid;
const auth = await authorize(cookies);
const path = url.searchParams.get('path');
@@ -60,6 +64,9 @@ export const GET: RequestHandler = async ({ params, url, cookies }) => {
};
export const PUT: RequestHandler = async ({ params, url, cookies, request }) => {
const invalid = validateDockerIdParam(params.id, 'container');
if (invalid) return invalid;
const auth = await authorize(cookies);
const path = url.searchParams.get('path');
@@ -1,9 +1,13 @@
import { json } from '@sveltejs/kit';
import { createContainerFile, createContainerDirectory } from '$lib/server/docker';
import { authorize } from '$lib/server/authorize';
import { validateDockerIdParam } from '$lib/server/docker-validation';
import type { RequestHandler } from './$types';
export const POST: RequestHandler = async ({ params, url, cookies, request }) => {
const invalid = validateDockerIdParam(params.id, 'container');
if (invalid) return invalid;
const auth = await authorize(cookies);
const envId = url.searchParams.get('env');
@@ -1,9 +1,13 @@
import { json } from '@sveltejs/kit';
import { deleteContainerPath } from '$lib/server/docker';
import { authorize } from '$lib/server/authorize';
import { validateDockerIdParam } from '$lib/server/docker-validation';
import type { RequestHandler } from './$types';
export const DELETE: RequestHandler = async ({ params, url, cookies }) => {
const invalid = validateDockerIdParam(params.id, 'container');
if (invalid) return invalid;
const auth = await authorize(cookies);
const path = url.searchParams.get('path');
@@ -1,9 +1,13 @@
import { gzipSync } from 'node:zlib';
import { getContainerArchive, statContainerPath } from '$lib/server/docker';
import { authorize } from '$lib/server/authorize';
import { validateDockerIdParam } from '$lib/server/docker-validation';
import type { RequestHandler } from './$types';
export const GET: RequestHandler = async ({ params, url, cookies }) => {
const invalid = validateDockerIdParam(params.id, 'container');
if (invalid) return invalid;
const auth = await authorize(cookies);
const path = url.searchParams.get('path');
@@ -1,9 +1,13 @@
import { json } from '@sveltejs/kit';
import { renameContainerPath } from '$lib/server/docker';
import { authorize } from '$lib/server/authorize';
import { validateDockerIdParam } from '$lib/server/docker-validation';
import type { RequestHandler } from './$types';
export const POST: RequestHandler = async ({ params, url, cookies, request }) => {
const invalid = validateDockerIdParam(params.id, 'container');
if (invalid) return invalid;
const auth = await authorize(cookies);
const envId = url.searchParams.get('env');
@@ -1,6 +1,7 @@
import { json } from '@sveltejs/kit';
import { putContainerArchive } from '$lib/server/docker';
import { authorize } from '$lib/server/authorize';
import { validateDockerIdParam } from '$lib/server/docker-validation';
import type { RequestHandler } from './$types';
/**
@@ -84,6 +85,9 @@ function createTarArchive(filename: string, content: Uint8Array): Uint8Array {
}
export const POST: RequestHandler = async ({ params, url, request, cookies }) => {
const invalid = validateDockerIdParam(params.id, 'container');
if (invalid) return invalid;
const auth = await authorize(cookies);
const path = url.searchParams.get('path');
@@ -2,8 +2,12 @@ import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { inspectContainer } from '$lib/server/docker';
import { authorize } from '$lib/server/authorize';
import { validateDockerIdParam } from '$lib/server/docker-validation';
export const GET: RequestHandler = async ({ params, url, cookies }) => {
const invalid = validateDockerIdParam(params.id, 'container');
if (invalid) return invalid;
const auth = await authorize(cookies);
const envId = url.searchParams.get('env');
@@ -1,9 +1,13 @@
import { json } from '@sveltejs/kit';
import { getContainerLogs } from '$lib/server/docker';
import { authorize } from '$lib/server/authorize';
import { validateDockerIdParam } from '$lib/server/docker-validation';
import type { RequestHandler } from './$types';
export const GET: RequestHandler = async ({ params, url, cookies }) => {
const invalid = validateDockerIdParam(params.id, 'container');
if (invalid) return invalid;
const auth = await authorize(cookies);
const tail = parseInt(url.searchParams.get('tail') || '100');
@@ -1,6 +1,7 @@
import type { RequestHandler } from './$types';
import { authorize } from '$lib/server/authorize';
import { getEnvironment } from '$lib/server/db';
import { validateDockerIdParam } from '$lib/server/docker-validation';
import { unixSocketRequest, unixSocketStreamRequest, httpsAgentRequest } from '$lib/server/docker';
import type { DockerClientConfig as BaseDockerClientConfig } from '$lib/server/docker';
import { sendEdgeRequest, sendEdgeStreamRequest, isEdgeConnected } from '$lib/server/hawser';
@@ -254,6 +255,9 @@ async function handleEdgeLogsStream(containerId: string, tail: string, environme
}
export const GET: RequestHandler = async ({ params, url, cookies }) => {
const invalid = validateDockerIdParam(params.id, 'container');
if (invalid) return invalid;
const auth = await authorize(cookies);
const containerId = params.id;
@@ -3,9 +3,13 @@ import type { RequestHandler } from './$types';
import { pauseContainer, inspectContainer } from '$lib/server/docker';
import { authorize } from '$lib/server/authorize';
import { auditContainer } from '$lib/server/audit';
import { validateDockerIdParam } from '$lib/server/docker-validation';
export const POST: RequestHandler = async (event) => {
const { params, url, cookies } = event;
const invalid = validateDockerIdParam(params.id, 'container');
if (invalid) return invalid;
const auth = await authorize(cookies);
const envId = url.searchParams.get('env');
@@ -3,10 +3,14 @@ import { renameContainer, inspectContainer } from '$lib/server/docker';
import { renameAutoUpdateSchedule } from '$lib/server/db';
import { authorize } from '$lib/server/authorize';
import { auditContainer } from '$lib/server/audit';
import { validateDockerIdParam } from '$lib/server/docker-validation';
import type { RequestHandler } from './$types';
export const POST: RequestHandler = async (event) => {
const { params, request, url, cookies } = event;
const invalid = validateDockerIdParam(params.id, 'container');
if (invalid) return invalid;
const auth = await authorize(cookies);
const envId = url.searchParams.get('env');
@@ -2,10 +2,14 @@ import { json } from '@sveltejs/kit';
import { restartContainer, inspectContainer } from '$lib/server/docker';
import { authorize } from '$lib/server/authorize';
import { auditContainer } from '$lib/server/audit';
import { validateDockerIdParam } from '$lib/server/docker-validation';
import type { RequestHandler } from './$types';
export const POST: RequestHandler = async (event) => {
const { params, url, cookies } = event;
const invalid = validateDockerIdParam(params.id, 'container');
if (invalid) return invalid;
const auth = await authorize(cookies);
const envId = url.searchParams.get('env');
@@ -1,6 +1,7 @@
import { json } from '@sveltejs/kit';
import { execInContainer } from '$lib/server/docker';
import { authorize } from '$lib/server/authorize';
import { validateDockerIdParam } from '$lib/server/docker-validation';
import type { RequestHandler } from './$types';
// Shell paths to check
@@ -12,6 +13,9 @@ const SHELLS_TO_CHECK = [
];
export const GET: RequestHandler = async ({ params, url, cookies }) => {
const invalid = validateDockerIdParam(params.id, 'container');
if (invalid) return invalid;
const auth = await authorize(cookies);
const envId = url.searchParams.get('env');
@@ -2,10 +2,14 @@ import { json } from '@sveltejs/kit';
import { startContainer, inspectContainer } from '$lib/server/docker';
import { authorize } from '$lib/server/authorize';
import { auditContainer } from '$lib/server/audit';
import { validateDockerIdParam } from '$lib/server/docker-validation';
import type { RequestHandler } from './$types';
export const POST: RequestHandler = async (event) => {
const { params, url, cookies } = event;
const invalid = validateDockerIdParam(params.id, 'container');
if (invalid) return invalid;
const auth = await authorize(cookies);
const envId = url.searchParams.get('env');
@@ -3,6 +3,7 @@ import type { RequestHandler } from './$types';
import { getContainerStats, EnvironmentNotFoundError } from '$lib/server/docker';
import { authorize } from '$lib/server/authorize';
import { hasEnvironments } from '$lib/server/db';
import { validateDockerIdParam } from '$lib/server/docker-validation';
function calculateCpuPercent(stats: any): number {
const cpuDelta = stats.cpu_stats.cpu_usage.total_usage - stats.precpu_stats.cpu_usage.total_usage;
@@ -70,6 +71,9 @@ function calculateMemoryUsage(memoryStats: any): { usage: number; raw: number; c
}
export const GET: RequestHandler = async ({ params, url, cookies }) => {
const invalid = validateDockerIdParam(params.id, 'container');
if (invalid) return invalid;
const auth = await authorize(cookies);
const envId = url.searchParams.get('env');
@@ -2,10 +2,14 @@ import { json } from '@sveltejs/kit';
import { stopContainer, inspectContainer } from '$lib/server/docker';
import { authorize } from '$lib/server/authorize';
import { auditContainer } from '$lib/server/audit';
import { validateDockerIdParam } from '$lib/server/docker-validation';
import type { RequestHandler } from './$types';
export const POST: RequestHandler = async (event) => {
const { params, url, cookies } = event;
const invalid = validateDockerIdParam(params.id, 'container');
if (invalid) return invalid;
const auth = await authorize(cookies);
const envId = url.searchParams.get('env');
@@ -2,6 +2,7 @@ import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { execInContainer, getContainerTop } from '$lib/server/docker';
import { authorize } from '$lib/server/authorize';
import { validateDockerIdParam } from '$lib/server/docker-validation';
function parsePsOutput(output: string): { Titles: string[]; Processes: string[][] } | null {
const lines = output.trim().split('\n').filter(line => line.trim());
@@ -28,6 +29,9 @@ function parsePsOutput(output: string): { Titles: string[]; Processes: string[][
}
export const GET: RequestHandler = async ({ params, url, cookies }) => {
const invalid = validateDockerIdParam(params.id, 'container');
if (invalid) return invalid;
const auth = await authorize(cookies);
const envId = url.searchParams.get('env');
@@ -3,9 +3,13 @@ import type { RequestHandler } from './$types';
import { unpauseContainer, inspectContainer } from '$lib/server/docker';
import { authorize } from '$lib/server/authorize';
import { auditContainer } from '$lib/server/audit';
import { validateDockerIdParam } from '$lib/server/docker-validation';
export const POST: RequestHandler = async (event) => {
const { params, url, cookies } = event;
const invalid = validateDockerIdParam(params.id, 'container');
if (invalid) return invalid;
const auth = await authorize(cookies);
const envId = url.searchParams.get('env');
@@ -3,10 +3,14 @@ import { pullImage, updateContainer, type CreateContainerOptions } from '$lib/se
import { authorize } from '$lib/server/authorize';
import { auditContainer } from '$lib/server/audit';
import { removePendingContainerUpdate } from '$lib/server/db';
import { validateDockerIdParam } from '$lib/server/docker-validation';
import type { RequestHandler } from './$types';
export const POST: RequestHandler = async (event) => {
const { params, request, url, cookies } = event;
const invalid = validateDockerIdParam(params.id, 'container');
if (invalid) return invalid;
const auth = await authorize(cookies);
const envId = url.searchParams.get('env');
@@ -14,7 +14,7 @@ import {
import { auditContainer } from '$lib/server/audit';
import { getScannerSettings, scanImage } from '$lib/server/scanner';
import { saveVulnerabilityScan, removePendingContainerUpdate, type VulnerabilityCriteria } from '$lib/server/db';
import { parseImageNameAndTag, shouldBlockUpdate, combineScanSummaries, isDockhandContainer } from '$lib/server/scheduler/tasks/update-utils';
import { parseImageNameAndTag, shouldBlockUpdate, combineScanSummaries, isSystemContainer } from '$lib/server/scheduler/tasks/update-utils';
import { recreateContainer } from '$lib/server/scheduler/tasks/container-update';
import { createJob, appendLine, completeJob, failJob } from '$lib/server/jobs';
@@ -156,8 +156,9 @@ export const POST: RequestHandler = async (event) => {
const imageName = config.Image;
const currentImageId = inspectData.Image;
// Skip Dockhand container - cannot update itself
if (isDockhandContainer(imageName)) {
// Skip system containers (Dockhand, Hawser)
const systemType = isSystemContainer(imageName);
if (systemType) {
sendData({
type: 'progress',
containerId,
@@ -166,7 +167,7 @@ export const POST: RequestHandler = async (event) => {
current: i + 1,
total: containerIds.length,
success: true,
message: `Skipping ${containerName} - cannot update Dockhand itself`
message: `Skipping ${containerName} - cannot update ${systemType} container`
});
skippedCount++;
continue;
@@ -273,7 +274,6 @@ export const POST: RequestHandler = async (event) => {
let scanBlocked = false;
let blockReason = '';
let finalScanResult: ScanResult | undefined;
let individualScannerResults: ScannerResult[] = [];
try {
@@ -290,17 +290,7 @@ export const POST: RequestHandler = async (event) => {
});
if (scanResults.length > 0) {
const scanSummary = combineScanSummaries(scanResults);
finalScanResult = {
critical: scanSummary.critical,
high: scanSummary.high,
medium: scanSummary.medium,
low: scanSummary.low,
negligible: scanSummary.negligible,
unknown: scanSummary.unknown
};
// Build individual scanner results
// Build individual scanner results (used by frontend)
individualScannerResults = scanResults.map(result => ({
scanner: result.scanner as 'grype' | 'trivy',
critical: result.summary.critical,
@@ -333,17 +323,20 @@ export const POST: RequestHandler = async (event) => {
} catch { /* ignore save errors */ }
}
// Check if blocked
const { blocked, reason } = shouldBlockUpdate(vulnerabilityCriteria, scanSummary, undefined);
// Check if blocked (combineScanSummaries uses Math.max for security check)
const combinedForBlockCheck = combineScanSummaries(scanResults);
const { blocked, reason } = shouldBlockUpdate(vulnerabilityCriteria, combinedForBlockCheck, undefined);
if (blocked) {
scanBlocked = true;
blockReason = reason;
}
}
// Collect vulnerabilities from all scanners (cap at 100)
// Collect vulnerabilities from all scanners (sort by severity, cap at 100)
const severityOrder: Record<string, number> = { critical: 0, high: 1, medium: 2, low: 3, negligible: 4, unknown: 5 };
const vulnerabilities = scanResults
.flatMap(r => r.vulnerabilities || [])
.sort((a, b) => (severityOrder[a.severity] ?? 9) - (severityOrder[b.severity] ?? 9))
.slice(0, 100)
.map(v => ({
id: v.id,
@@ -355,15 +348,21 @@ export const POST: RequestHandler = async (event) => {
scanner: v.scanner
}));
// Derive combined totals from the displayed (sliced) array so summary matches the table
const totalCritical = vulnerabilities.filter(v => v.severity === 'critical').length;
const totalHigh = vulnerabilities.filter(v => v.severity === 'high').length;
const totalMedium = vulnerabilities.filter(v => v.severity === 'medium').length;
const totalLow = vulnerabilities.filter(v => v.severity === 'low').length;
const hasVulns = totalCritical + totalHigh + totalMedium + totalLow > 0;
sendData({
type: 'scan_complete',
containerId,
containerName,
scanResult: finalScanResult,
scannerResults: individualScannerResults.length > 0 ? individualScannerResults : undefined,
vulnerabilities: vulnerabilities.length > 0 ? vulnerabilities : undefined,
message: finalScanResult
? `Scan complete: ${finalScanResult.critical} critical, ${finalScanResult.high} high, ${finalScanResult.medium} medium, ${finalScanResult.low} low`
message: hasVulns
? `Scan complete: ${totalCritical} critical, ${totalHigh} high, ${totalMedium} medium, ${totalLow} low`
: 'Scan complete: no vulnerabilities found'
});
@@ -398,7 +397,6 @@ export const POST: RequestHandler = async (event) => {
current: i + 1,
total: containerIds.length,
success: false,
scanResult: finalScanResult,
scannerResults: individualScannerResults.length > 0 ? individualScannerResults : undefined,
blockReason,
message: `Update blocked: ${blockReason}`
@@ -102,7 +102,7 @@ export const POST: RequestHandler = async ({ url, cookies, request }) => {
}
await Promise.all(Array.from({ length: Math.min(CONCURRENCY, containers.length) }, () => runNext()));
const updatesFound = results.filter(r => r.hasUpdate).length;
const updatesFound = results.filter(r => r.hasUpdate && !r.systemContainer).length;
// Save containers with updates to the database for persistence
if (envIdNum) {
+62 -25
View File
@@ -1,26 +1,59 @@
import { json, type RequestHandler } from '@sveltejs/kit';
import { getDashboardPreferences, saveDashboardPreferences } from '$lib/server/db';
import { getUserPreference, setUserPreference } from '$lib/server/db';
import { authorize } from '$lib/server/authorize';
// Store all dashboard prefs as a single JSON blob to avoid the chained .where() bug
// in getUserPreference/setUserPreference (chained .where() replaces instead of ANDing)
const DASHBOARD_PREFS_KEY = 'dashboard_prefs';
interface StoredDashboardPrefs {
gridLayout: any[];
locked: boolean;
viewMode: 'grid' | 'list';
}
async function getPrefs(userId: number | null): Promise<StoredDashboardPrefs> {
const stored = await getUserPreference<StoredDashboardPrefs>({
userId,
environmentId: null,
key: DASHBOARD_PREFS_KEY
});
if (stored && typeof stored === 'object' && Array.isArray(stored.gridLayout)) {
return {
gridLayout: stored.gridLayout,
locked: stored.locked ?? false,
viewMode: stored.viewMode ?? 'grid'
};
}
// Migration: try reading from old dashboard_layout key
const oldLayout = await getUserPreference<any[]>({
userId,
environmentId: null,
key: 'dashboard_layout'
});
return {
gridLayout: Array.isArray(oldLayout) ? oldLayout : [],
locked: false,
viewMode: 'grid'
};
}
async function savePrefs(userId: number | null, prefs: StoredDashboardPrefs): Promise<void> {
await setUserPreference(
{ userId, environmentId: null, key: DASHBOARD_PREFS_KEY },
prefs
);
}
export const GET: RequestHandler = async ({ cookies }) => {
const auth = await authorize(cookies);
try {
// Get user-specific preferences, or fall back to global preferences
const userId = auth.user?.id ?? null;
const prefs = await getDashboardPreferences(userId);
// If no preferences exist, return empty gridLayout
if (!prefs) {
return json({
id: 0,
userId: null,
gridLayout: [],
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString()
});
}
const prefs = await getPrefs(userId);
return json(prefs);
} catch (error) {
console.error('Failed to get dashboard preferences:', error);
@@ -33,19 +66,23 @@ export const POST: RequestHandler = async ({ request, cookies }) => {
try {
const body = await request.json();
const { gridLayout } = body;
const userId = auth.user?.id ?? null;
if (!gridLayout || !Array.isArray(gridLayout)) {
return json({ error: 'gridLayout is required and must be an array' }, { status: 400 });
// Load current prefs and merge changes
const current = await getPrefs(userId);
if (body.gridLayout && Array.isArray(body.gridLayout)) {
current.gridLayout = body.gridLayout;
}
if (body.locked !== undefined) {
current.locked = body.locked;
}
if (body.viewMode !== undefined) {
current.viewMode = body.viewMode;
}
const userId = auth.user?.id ?? null;
const prefs = await saveDashboardPreferences({
userId,
gridLayout
});
return json(prefs);
await savePrefs(userId, current);
return json(current);
} catch (error) {
console.error('Failed to save dashboard preferences:', error);
return json({ error: 'Failed to save dashboard preferences' }, { status: 500 });
+8 -2
View File
@@ -1,17 +1,23 @@
import { json, type RequestHandler } from '@sveltejs/kit';
import dependencies from '$lib/data/dependencies.json';
import { DEFAULT_GRYPE_IMAGE, DEFAULT_TRIVY_IMAGE } from '$lib/server/scanner';
// Extract version tag from image string (e.g., "anchore/grype:v0.110.0" -> "v0.110.0")
function imageTag(image: string): string {
return image.split(':')[1] || 'latest';
}
// External tools used by Dockhand (Docker images)
const externalTools = [
{
name: 'anchore/grype',
version: 'latest',
version: imageTag(DEFAULT_GRYPE_IMAGE),
license: 'Apache-2.0',
repository: 'https://github.com/anchore/grype'
},
{
name: 'aquasec/trivy',
version: 'latest',
version: imageTag(DEFAULT_TRIVY_IMAGE),
license: 'Apache-2.0',
repository: 'https://github.com/aquasecurity/trivy'
}
@@ -11,6 +11,7 @@ import { cleanPem } from '$lib/utils/pem';
import { unregisterSchedule } from '$lib/server/scheduler';
import { closeEdgeConnection } from '$lib/server/hawser';
import { computeAuditDiff } from '$lib/utils/diff';
import { deleteEnvironmentIcon } from '$lib/server/env-icons';
export const GET: RequestHandler = async ({ params, cookies }) => {
const auth = await authorize(cookies);
@@ -167,6 +168,9 @@ export const DELETE: RequestHandler = async (event) => {
return json({ error: 'Cannot delete this environment' }, { status: 400 });
}
// Clean up custom icon file if exists
deleteEnvironmentIcon(id);
// Clean up public IP entry for this environment
await deleteEnvironmentPublicIp(id);
@@ -0,0 +1,68 @@
import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { getEnvironment, updateEnvironment } from '$lib/server/db';
import { authorize } from '$lib/server/authorize';
import { saveEnvironmentIcon, deleteEnvironmentIcon, getEnvironmentIconBuffer } from '$lib/server/env-icons';
export const GET: RequestHandler = async ({ params }) => {
const id = parseInt(params.id);
const buffer = getEnvironmentIconBuffer(id);
if (!buffer) {
return json({ error: 'No custom icon' }, { status: 404 });
}
return new Response(new Uint8Array(buffer), {
headers: {
'Content-Type': 'image/webp',
'Cache-Control': 'public, max-age=3600'
}
});
};
export const POST: RequestHandler = async ({ params, request, cookies }) => {
const auth = await authorize(cookies);
if (auth.authEnabled && !await auth.can('environments', 'edit')) {
return json({ error: 'Permission denied' }, { status: 403 });
}
const id = parseInt(params.id);
const env = await getEnvironment(id);
if (!env) {
return json({ error: 'Environment not found' }, { status: 404 });
}
const data = await request.json();
if (!data.image || typeof data.image !== 'string') {
return json({ error: 'Missing image data' }, { status: 400 });
}
// Validate size (~200KB base64 limit)
if (data.image.length > 300_000) {
return json({ error: 'Image too large' }, { status: 400 });
}
saveEnvironmentIcon(id, data.image);
const iconValue = `custom:env-${id}.webp`;
await updateEnvironment(id, { icon: iconValue });
return json({ success: true, icon: iconValue });
};
export const DELETE: RequestHandler = async ({ params, cookies }) => {
const auth = await authorize(cookies);
if (auth.authEnabled && !await auth.can('environments', 'edit')) {
return json({ error: 'Permission denied' }, { status: 403 });
}
const id = parseInt(params.id);
const env = await getEnvironment(id);
if (!env) {
return json({ error: 'Environment not found' }, { status: 404 });
}
deleteEnvironmentIcon(id);
await updateEnvironment(id, { icon: 'globe' });
return json({ success: true, icon: 'globe' });
};
@@ -13,7 +13,23 @@ const TIMEZONE_ALIASES: Record<string, string> = {
'Europe/Kyiv': 'Europe/Kiev',
'Asia/Ho_Chi_Minh': 'Asia/Saigon',
'America/Nuuk': 'America/Godthab',
'Pacific/Kanton': 'Pacific/Enderbury'
'Pacific/Kanton': 'Pacific/Enderbury',
// Modern IANA names that Node.js ICU maps to legacy names
'Asia/Kolkata': 'Asia/Calcutta',
'Asia/Kathmandu': 'Asia/Katmandu',
'Asia/Yangon': 'Asia/Rangoon',
'Asia/Kashgar': 'Asia/Urumqi',
'Atlantic/Faroe': 'Atlantic/Faeroe',
'Europe/Uzhgorod': 'Europe/Kiev',
'Europe/Zaporozhye': 'Europe/Kiev',
'America/Atikokan': 'America/Coral_Harbour',
'America/Argentina/Buenos_Aires': 'America/Buenos_Aires',
'America/Argentina/Catamarca': 'America/Catamarca',
'America/Argentina/Cordoba': 'America/Cordoba',
'America/Argentina/Jujuy': 'America/Jujuy',
'America/Argentina/Mendoza': 'America/Mendoza',
'Pacific/Pohnpei': 'Pacific/Ponape',
'Pacific/Chuuk': 'Pacific/Truk'
};
function normalizeTimezone(tz: string): string {
@@ -4,9 +4,10 @@ import {
getGitRepository,
updateGitRepository,
deleteGitRepository,
getGitCredentials
getGitCredentials,
getGitStacksByRepositoryId
} from '$lib/server/db';
import { deleteRepositoryFiles } from '$lib/server/git';
import { deleteRepositoryFiles, deleteGitStackFiles } from '$lib/server/git';
import { authorize } from '$lib/server/authorize';
import { auditGitRepository } from '$lib/server/audit';
import { computeAuditDiff } from '$lib/utils/diff';
@@ -112,7 +113,13 @@ export const DELETE: RequestHandler = async (event) => {
return json({ error: 'Repository not found' }, { status: 404 });
}
// Delete repository files first
// Delete git stack clone directories before cascade deletes the DB rows
const stacks = await getGitStacksByRepositoryId(id);
for (const stack of stacks) {
await deleteGitStackFiles(stack.id, stack.stackName, stack.environmentId);
}
// Delete repository clone directory
deleteRepositoryFiles(id);
const deleted = await deleteGitRepository(id);
+4 -1
View File
@@ -119,7 +119,10 @@ export const POST: RequestHandler = async (event) => {
autoUpdateSchedule: data.autoUpdateSchedule || 'daily',
autoUpdateCron: data.autoUpdateCron || '0 3 * * *',
webhookEnabled: data.webhookEnabled || false,
webhookSecret: webhookSecret
webhookSecret: webhookSecret,
buildOnDeploy: data.buildOnDeploy ?? false,
repullImages: data.repullImages ?? false,
forceRedeploy: data.forceRedeploy ?? false
});
// Create stack_sources entry so the stack appears in the list immediately
+4 -1
View File
@@ -72,7 +72,10 @@ export const PUT: RequestHandler = async (event) => {
autoUpdateSchedule: data.autoUpdateSchedule,
autoUpdateCron: data.autoUpdateCron,
webhookEnabled: data.webhookEnabled,
webhookSecret: data.webhookSecret
webhookSecret: data.webhookSecret,
buildOnDeploy: data.buildOnDeploy,
repullImages: data.repullImages,
forceRedeploy: data.forceRedeploy
});
// If stack name changed, update related records
@@ -17,10 +17,10 @@ function verifySignature(payload: string, signature: string | null, secret: stri
.createHmac('sha256', secret)
.update(payload)
.digest('hex');
return crypto.timingSafeEqual(
Buffer.from(signature),
Buffer.from(expectedSignature)
);
const sigBuf = Buffer.from(signature);
const expectedBuf = Buffer.from(expectedSignature);
if (sigBuf.length !== expectedBuf.length) return false;
return crypto.timingSafeEqual(sigBuf, expectedBuf);
}
// GitLab uses X-Gitlab-Token which should match exactly
+4 -4
View File
@@ -17,10 +17,10 @@ function verifySignature(payload: string, signature: string | null, secret: stri
.createHmac('sha256', secret)
.update(payload)
.digest('hex');
return crypto.timingSafeEqual(
Buffer.from(signature),
Buffer.from(expectedSignature)
);
const sigBuf = Buffer.from(signature);
const expectedBuf = Buffer.from(expectedSignature);
if (sigBuf.length !== expectedBuf.length) return false;
return crypto.timingSafeEqual(sigBuf, expectedBuf);
}
// GitLab uses X-Gitlab-Token which should match exactly
+4
View File
@@ -2,10 +2,14 @@ import { json } from '@sveltejs/kit';
import { removeImage, inspectImage } from '$lib/server/docker';
import { authorize } from '$lib/server/authorize';
import { auditImage } from '$lib/server/audit';
import { validateDockerIdParam } from '$lib/server/docker-validation';
import type { RequestHandler } from './$types';
export const DELETE: RequestHandler = async (event) => {
const { params, url, cookies } = event;
const invalid = validateDockerIdParam(params.id, 'image');
if (invalid) return invalid;
const auth = await authorize(cookies);
const force = url.searchParams.get('force') === 'true';
@@ -3,9 +3,13 @@ import { exportImage, inspectImage } from '$lib/server/docker';
import { authorize } from '$lib/server/authorize';
import { createGzip } from 'zlib';
import { Readable } from 'stream';
import { validateDockerIdParam } from '$lib/server/docker-validation';
import type { RequestHandler } from './$types';
export const GET: RequestHandler = async ({ params, url, cookies }) => {
const invalid = validateDockerIdParam(params.id, 'image');
if (invalid) return invalid;
const auth = await authorize(cookies);
const envId = url.searchParams.get('env');
@@ -2,8 +2,12 @@ import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { getImageHistory } from '$lib/server/docker';
import { authorize } from '$lib/server/authorize';
import { validateDockerIdParam } from '$lib/server/docker-validation';
export const GET: RequestHandler = async ({ params, url, cookies }) => {
const invalid = validateDockerIdParam(params.id, 'image');
if (invalid) return invalid;
const auth = await authorize(cookies);
const envId = url.searchParams.get('env');
@@ -1,9 +1,13 @@
import { json } from '@sveltejs/kit';
import { tagImage } from '$lib/server/docker';
import { authorize } from '$lib/server/authorize';
import { validateDockerIdParam } from '$lib/server/docker-validation';
import type { RequestHandler } from './$types';
export const POST: RequestHandler = async ({ params, request, url, cookies }) => {
const invalid = validateDockerIdParam(params.id, 'image');
if (invalid) return invalid;
const auth = await authorize(cookies);
const envId = url.searchParams.get('env');
+3 -1
View File
@@ -10,7 +10,9 @@ export const GET: RequestHandler = async ({ url }) => {
// Return as plain text if requested
if (url.searchParams.get('format') === 'text') {
return text(content);
return text(content, {
headers: { 'content-type': 'text/plain; charset=utf-8' }
});
}
return json({ content });
+3 -1
View File
@@ -10,7 +10,9 @@ export const GET: RequestHandler = async ({ url }) => {
// Return as plain text if requested
if (url.searchParams.get('format') === 'text') {
return text(content);
return text(content, {
headers: { 'content-type': 'text/plain; charset=utf-8' }
});
}
return json({ content });
+7
View File
@@ -3,8 +3,12 @@ import type { RequestHandler } from './$types';
import { removeNetwork, inspectNetwork } from '$lib/server/docker';
import { authorize } from '$lib/server/authorize';
import { auditNetwork } from '$lib/server/audit';
import { validateDockerIdParam } from '$lib/server/docker-validation';
export const GET: RequestHandler = async ({ params, url, cookies }) => {
const invalid = validateDockerIdParam(params.id, 'network');
if (invalid) return invalid;
const auth = await authorize(cookies);
const envId = url.searchParams.get('env');
@@ -32,6 +36,9 @@ export const GET: RequestHandler = async ({ params, url, cookies }) => {
export const DELETE: RequestHandler = async (event) => {
const { params, url, cookies } = event;
const invalid = validateDockerIdParam(params.id, 'network');
if (invalid) return invalid;
const auth = await authorize(cookies);
const envId = url.searchParams.get('env');
@@ -3,9 +3,13 @@ import type { RequestHandler } from './$types';
import { connectContainerToNetwork, inspectNetwork } from '$lib/server/docker';
import { authorize } from '$lib/server/authorize';
import { auditNetwork } from '$lib/server/audit';
import { validateDockerIdParam } from '$lib/server/docker-validation';
export const POST: RequestHandler = async (event) => {
const { params, url, request, cookies } = event;
const invalid = validateDockerIdParam(params.id, 'network');
if (invalid) return invalid;
const auth = await authorize(cookies);
const envId = url.searchParams.get('env');
@@ -25,6 +29,9 @@ export const POST: RequestHandler = async (event) => {
return json({ error: 'Container ID is required' }, { status: 400 });
}
const invalidContainer = validateDockerIdParam(containerId, 'container');
if (invalidContainer) return invalidContainer;
// Get network name for audit
let networkName = params.id;
try {
@@ -3,9 +3,13 @@ import type { RequestHandler } from './$types';
import { disconnectContainerFromNetwork, inspectNetwork } from '$lib/server/docker';
import { authorize } from '$lib/server/authorize';
import { auditNetwork } from '$lib/server/audit';
import { validateDockerIdParam } from '$lib/server/docker-validation';
export const POST: RequestHandler = async (event) => {
const { params, url, request, cookies } = event;
const invalid = validateDockerIdParam(params.id, 'network');
if (invalid) return invalid;
const auth = await authorize(cookies);
const envId = url.searchParams.get('env');
@@ -25,6 +29,9 @@ export const POST: RequestHandler = async (event) => {
return json({ error: 'Container ID is required' }, { status: 400 });
}
const invalidContainer = validateDockerIdParam(containerId, 'container');
if (invalidContainer) return invalidContainer;
// Get network name for audit
let networkName = params.id;
try {
@@ -2,8 +2,12 @@ import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { inspectNetwork } from '$lib/server/docker';
import { authorize } from '$lib/server/authorize';
import { validateDockerIdParam } from '$lib/server/docker-validation';
export const GET: RequestHandler = async ({ params, url, cookies }) => {
const invalid = validateDockerIdParam(params.id, 'network');
if (invalid) return invalid;
const auth = await authorize(cookies);
const envId = url.searchParams.get('env');
+30 -24
View File
@@ -10,6 +10,14 @@ function isDockerHub(url: string): boolean {
lower.includes('registry.hub.docker.com');
}
// Manifest types in priority order: single-platform first, then multi-arch
const MANIFEST_TYPES = [
'application/vnd.docker.distribution.manifest.v2+json',
'application/vnd.oci.image.manifest.v1+json',
'application/vnd.docker.distribution.manifest.list.v2+json',
'application/vnd.oci.image.index.v1+json'
];
export const DELETE: RequestHandler = async ({ url }) => {
try {
const registryId = url.searchParams.get('registry');
@@ -39,43 +47,41 @@ export const DELETE: RequestHandler = async ({ url }) => {
}
const { baseUrl, authHeader } = await getRegistryAuth(registry, `repository:${imageName}:pull,push,delete`);
// Note: orgPath is not used here because imageName already contains the full repo path
const headers: HeadersInit = {
'Accept': 'application/vnd.docker.distribution.manifest.v2+json'
};
if (authHeader) {
headers['Authorization'] = authHeader;
}
// Step 1: Get the manifest digest
// Step 1: Resolve manifest digest. Try each type individually because
// the registry may only serve certain types, and DELETE requires the
// Accept header to match the stored manifest type.
const manifestUrl = `${baseUrl}/v2/${imageName}/manifests/${tag}`;
const headResponse = await fetch(manifestUrl, {
method: 'HEAD',
headers
});
let digest: string | null = null;
let matchedType: string | null = null;
if (!headResponse.ok) {
for (const mediaType of MANIFEST_TYPES) {
const headers: HeadersInit = { 'Accept': mediaType };
if (authHeader) headers['Authorization'] = authHeader;
const headResponse = await fetch(manifestUrl, { method: 'HEAD', headers });
if (headResponse.ok) {
digest = headResponse.headers.get('Docker-Content-Digest');
matchedType = mediaType;
break;
}
if (headResponse.status === 401) {
return json({ error: 'Authentication failed' }, { status: 401 });
}
if (headResponse.status === 404) {
return json({ error: 'Image or tag not found' }, { status: 404 });
}
return json({ error: `Failed to get manifest: ${headResponse.status}` }, { status: headResponse.status });
}
const digest = headResponse.headers.get('Docker-Content-Digest');
if (!digest) {
return json({ error: 'Could not get image digest. Registry may not support deletion.' }, { status: 400 });
if (!digest || !matchedType) {
return json({ error: 'Image or tag not found' }, { status: 404 });
}
// Step 2: Delete the manifest by digest
// Step 2: Delete the manifest by digest using the matched type
const deleteHeaders: HeadersInit = { 'Accept': matchedType };
if (authHeader) deleteHeaders['Authorization'] = authHeader;
const deleteUrl = `${baseUrl}/v2/${imageName}/manifests/${digest}`;
const deleteResponse = await fetch(deleteUrl, {
method: 'DELETE',
headers
headers: deleteHeaders
});
if (!deleteResponse.ok) {
+21
View File
@@ -371,6 +371,27 @@ export const POST: RequestHandler = async ({ request, cookies }) => {
...networkEnvVars
];
// Pin Docker API version so the updater's bundled Docker CLI
// doesn't request a version newer than the host daemon supports
// (e.g. Synology DSM with Docker 24.x / API 1.43)
if (process.env.DOCKER_API_VERSION) {
updaterEnv.push(`DOCKER_API_VERSION=${process.env.DOCKER_API_VERSION}`);
console.log(`[SelfUpdate] Forwarding explicit DOCKER_API_VERSION: ${process.env.DOCKER_API_VERSION}`);
} else {
try {
const versionResp = await localDockerFetch('/version');
if (versionResp.ok) {
const versionInfo = await versionResp.json() as { ApiVersion?: string };
if (versionInfo.ApiVersion) {
updaterEnv.push(`DOCKER_API_VERSION=${versionInfo.ApiVersion}`);
console.log(`[SelfUpdate] Using negotiated Docker API version: ${versionInfo.ApiVersion}`);
}
}
} catch {
console.warn('[SelfUpdate] Could not detect Docker API version, updater will negotiate on its own');
}
}
// Configure updater's Docker access based on connection type
const tcpHost = getDockerTcpHost();
const updaterHostConfig: Record<string, unknown> = { AutoRemove: true };
@@ -35,6 +35,7 @@ export const GET: RequestHandler = async ({ cookies }) => {
const containerId = getOwnContainerId();
if (!containerId) {
console.log('[SelfUpdate] Not running in Docker, skipping update check');
return json({
updateAvailable: false,
error: 'Not running in Docker'
@@ -45,6 +46,7 @@ export const GET: RequestHandler = async ({ cookies }) => {
// Inspect own container to get current image info
const inspectResponse = await localDockerFetch(`/containers/${containerId}/json`);
if (!inspectResponse.ok) {
console.log(`[SelfUpdate] Failed to inspect container ${containerId.substring(0, 12)}: ${inspectResponse.status}`);
return json({
updateAvailable: false,
error: 'Failed to inspect own container'
@@ -61,7 +63,10 @@ export const GET: RequestHandler = async ({ cookies }) => {
const currentImageId = inspectData.Image || '';
const containerName = inspectData.Name?.replace(/^\//, '') || '';
console.log(`[SelfUpdate] Container: ${containerId.substring(0, 12)}, image: ${currentImage}, tag: ${currentImage.split(':').pop() || 'latest'}`);
if (!currentImage) {
console.log('[SelfUpdate] Could not determine current image from inspect data');
return json({
updateAvailable: false,
error: 'Could not determine current image'
@@ -73,6 +78,7 @@ export const GET: RequestHandler = async ({ cookies }) => {
// Digest-based images (e.g. image@sha256:...) can't be checked for updates
if (currentImage.includes('@sha256:')) {
console.log('[SelfUpdate] Image pinned by digest, cannot check for updates');
return json({
updateAvailable: false,
currentImage,
@@ -94,6 +100,7 @@ export const GET: RequestHandler = async ({ cookies }) => {
// Version-based check: compare against latest released version from changelog
const currentTagVersion = versionMatch[1];
const suffix = versionMatch[2] || ''; // '-baseline' or ''
console.log(`[SelfUpdate] Version-based check: current=${currentTagVersion}${suffix}`);
try {
const changelogResponse = await fetch(
@@ -102,6 +109,7 @@ export const GET: RequestHandler = async ({ cookies }) => {
);
if (!changelogResponse.ok) {
console.log(`[SelfUpdate] Failed to fetch changelog from GitHub: ${changelogResponse.status}`);
return json({
updateAvailable: false,
currentImage,
@@ -122,6 +130,7 @@ export const GET: RequestHandler = async ({ cookies }) => {
const latestRelease = changelog.find(entry => !entry.comingSoon);
if (!latestRelease) {
console.log('[SelfUpdate] No released version found in changelog');
return json({
updateAvailable: false,
currentImage,
@@ -133,12 +142,14 @@ export const GET: RequestHandler = async ({ cookies }) => {
const latestVersion = latestRelease.version;
const hasNewer = compareVersions(latestVersion, currentTagVersion) > 0;
console.log(`[SelfUpdate] Latest changelog version: ${latestVersion}, current: ${currentTagVersion}, hasNewer: ${hasNewer}`);
if (hasNewer) {
// Build new image tag preserving registry prefix and suffix
const newTag = `v${latestVersion.replace(/^v/, '')}${suffix}`;
const newImage = `${imageWithoutTag}:${newTag}`;
console.log(`[SelfUpdate] Update available: ${currentImage}${newImage}`);
return json({
updateAvailable: true,
currentImage,
@@ -149,6 +160,7 @@ export const GET: RequestHandler = async ({ cookies }) => {
});
}
console.log(`[SelfUpdate] Up to date (version ${currentTagVersion})`);
return json({
updateAvailable: false,
currentImage,
@@ -156,6 +168,7 @@ export const GET: RequestHandler = async ({ cookies }) => {
isComposeManaged
});
} catch (err) {
console.log(`[SelfUpdate] Version check failed: ${err}`);
return json({
updateAvailable: false,
currentImage,
@@ -167,10 +180,12 @@ export const GET: RequestHandler = async ({ cookies }) => {
}
// Digest-based check for mutable tags (:latest, :baseline, etc.)
console.log(`[SelfUpdate] Digest-based check for mutable tag: ${tag}`);
// Inspect image via local Docker socket to get RepoDigests
const imageResponse = await localDockerFetch(`/images/${encodeURIComponent(currentImageId)}/json`);
if (!imageResponse.ok) {
console.log(`[SelfUpdate] Failed to inspect image ${currentImageId}: ${imageResponse.status}`);
return json({
updateAvailable: false,
currentImage,
@@ -192,6 +207,7 @@ export const GET: RequestHandler = async ({ cookies }) => {
.filter(Boolean) as string[];
if (localDigests.length === 0) {
console.log('[SelfUpdate] No RepoDigests found — local/untagged image, cannot check registry');
return json({
updateAvailable: false,
currentImage,
@@ -202,9 +218,12 @@ export const GET: RequestHandler = async ({ cookies }) => {
});
}
console.log(`[SelfUpdate] Local digests: ${localDigests.map(d => d.substring(0, 19)).join(', ')}`);
// Query registry for latest digest
const registryDigest = await getRegistryManifestDigest(currentImage);
if (!registryDigest) {
console.log(`[SelfUpdate] Could not query registry for ${currentImage}`);
return json({
updateAvailable: false,
currentImage,
@@ -216,6 +235,7 @@ export const GET: RequestHandler = async ({ cookies }) => {
}
const hasUpdate = !localDigests.includes(registryDigest);
console.log(`[SelfUpdate] Registry digest: ${registryDigest.substring(0, 19)}, match: ${!hasUpdate}, updateAvailable: ${hasUpdate}`);
return json({
updateAvailable: hasUpdate,
@@ -227,6 +247,7 @@ export const GET: RequestHandler = async ({ cookies }) => {
isComposeManaged
});
} catch (err) {
console.log(`[SelfUpdate] Check failed with error: ${err}`);
return json({
updateAvailable: false,
error: 'Check failed: ' + String(err)

Some files were not shown because too many files have changed in this diff Show More