Compare commits

...
3 Commits
Author SHA1 Message Date
jarek c718ac9a84 1.0.37 2026-07-11 15:31:52 +02:00
jarek 685132ff89 1.0.36 2026-06-27 15:15:54 +02:00
jarek ce12d7ae9f 1.0.35 2026-06-19 08:21:22 +02:00
144 changed files with 6664 additions and 1085 deletions
+15 -2
View File
@@ -37,7 +37,7 @@ RUN APKO_ARCH=$([ "$TARGETARCH" = "arm64" ] && echo "aarch64" || echo "x86_64")
" - busybox" \
" - tzdata" \
" - docker-cli" \
" - docker-compose=5.1.4-r5" \
" - docker-compose=5.2.0-r0" \
" - docker-cli-buildx" \
" - sqlite" \
" - postgresql-client" \
@@ -163,10 +163,23 @@ RUN chmod +x ./scripts/*.sh ./scripts/**/*.sh 2>/dev/null || true
RUN mkdir -p /home/dockhand/.dockhand/stacks /app/data \
&& chown dockhand:dockhand /app/data /home/dockhand /home/dockhand/.dockhand /home/dockhand/.dockhand/stacks
# OCI image annotations (#1217) — lets tooling map this image back to its
# source repo (image registry name differs from GitHub repo name).
LABEL org.opencontainers.image.source="https://github.com/Finsys/dockhand" \
org.opencontainers.image.url="https://dockhand.pro" \
org.opencontainers.image.title="Dockhand" \
org.opencontainers.image.description="Docker management" \
org.opencontainers.image.vendor="Finsys" \
org.opencontainers.image.licenses="BUSL-1.1"
EXPOSE 3000
HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \
CMD curl -f http://localhost:${PORT:-3000}/ || exit 1
CMD if [ "$(echo "${HTTPS_MODE:-off}" | tr '[:upper:]' '[:lower:]')" = "on" ]; then \
curl -fsk https://localhost:${PORT:-3000}/ || exit 1; \
else \
curl -fs http://localhost:${PORT:-3000}/ || exit 1; \
fi
ENTRYPOINT ["/sbin/tini", "--", "/usr/local/bin/docker-entrypoint.sh"]
CMD []
+1 -1
View File
@@ -1 +1 @@
v1.0.34
v1.0.37
+44 -19
View File
@@ -131,10 +131,44 @@ type environment struct {
baseURL string
cancel context.CancelFunc
ctx context.Context
statusMu sync.Mutex // guards online + statusReported (written from metrics + events goroutines)
online bool
statusReported bool // true after first env_status message sent
}
// markOnline records the env as online and reports whether this is a transition
// that should be sent as an env_status message. Atomic under statusMu so the
// metrics and events goroutines can't race on the bool pair.
func (e *environment) markOnline() bool {
e.statusMu.Lock()
defer e.statusMu.Unlock()
if !e.online || !e.statusReported {
e.online = true
e.statusReported = true
return true
}
return false
}
// markOffline is the offline counterpart of markOnline.
func (e *environment) markOffline() bool {
e.statusMu.Lock()
defer e.statusMu.Unlock()
if e.online || !e.statusReported {
e.online = false
e.statusReported = true
return true
}
return false
}
// forceOffline unconditionally marks the env offline (caller always reports).
func (e *environment) forceOffline() {
e.statusMu.Lock()
defer e.statusMu.Unlock()
e.online = false
}
// closeTransports releases idle connections held by the environment's HTTP transports.
// Must be called when an environment is removed or reconfigured to prevent connection pool leaks.
func (e *environment) closeTransports() {
@@ -277,6 +311,9 @@ func buildClients(cfg *EnvConfig) (client *http.Client, streamClient *http.Clien
}
func buildTLSConfig(cfg *EnvConfig) (*tls.Config, error) {
if cfg.SkipVerify {
fmt.Fprintf(os.Stderr, "[collector] WARNING: TLS verification disabled for %s — this is insecure\n", cfg.Host)
}
tlsCfg := &tls.Config{
InsecureSkipVerify: cfg.SkipVerify,
ServerName: cfg.Host, // Explicit SNI for IP-based hosts
@@ -385,17 +422,13 @@ func (m *manager) runMetrics(env *environment) {
func (m *manager) collectMetrics(env *environment) {
if err := env.ping(env.ctx); err != nil {
if env.online || !env.statusReported {
env.online = false
env.statusReported = true
if env.markOffline() {
m.send(OutMessage{Type: "env_status", EnvID: env.id, Online: boolPtr(false), Error: "Docker not reachable: " + err.Error()})
}
return
}
if !env.online || !env.statusReported {
env.online = true
env.statusReported = true
if env.markOnline() {
m.send(OutMessage{Type: "env_status", EnvID: env.id, Online: boolPtr(true)})
}
@@ -585,9 +618,7 @@ func (m *manager) runEvents(env *environment) {
// Stream mode
if err := env.ping(env.ctx); err != nil {
if env.online || !env.statusReported {
env.online = false
env.statusReported = true
if env.markOffline() {
m.send(OutMessage{Type: "env_status", EnvID: env.id, Online: boolPtr(false), Error: "Docker not reachable: " + err.Error()})
}
if !waitOrCancel(reconnectDelay) {
@@ -597,9 +628,7 @@ func (m *manager) runEvents(env *environment) {
continue
}
if !env.online || !env.statusReported {
env.online = true
env.statusReported = true
if env.markOnline() {
m.send(OutMessage{Type: "env_status", EnvID: env.id, Online: boolPtr(true)})
}
reconnectDelay = 5 * time.Second
@@ -610,7 +639,7 @@ func (m *manager) runEvents(env *environment) {
if env.ctx.Err() != nil {
return
}
env.online = false
env.forceOffline()
m.send(OutMessage{Type: "env_status", EnvID: env.id, Online: boolPtr(false), Error: err.Error()})
if !waitOrCancel(reconnectDelay) {
return
@@ -700,17 +729,13 @@ func (m *manager) runEvents(env *environment) {
func (m *manager) pollEvents(env *environment) {
if err := env.ping(env.ctx); err != nil {
if env.online || !env.statusReported {
env.online = false
env.statusReported = true
if env.markOffline() {
m.send(OutMessage{Type: "env_status", EnvID: env.id, Online: boolPtr(false), Error: "Docker not reachable: " + err.Error()})
}
return
}
if !env.online || !env.statusReported {
env.online = true
env.statusReported = true
if env.markOnline() {
m.send(OutMessage{Type: "env_status", EnvID: env.id, Online: boolPtr(true)})
}
+9 -5
View File
@@ -1,7 +1,7 @@
{
"name": "dockhand",
"private": true,
"version": "1.0.34",
"version": "1.0.37",
"type": "module",
"scripts": {
"dev": "npx vite dev",
@@ -80,15 +80,16 @@
"devalue": "5.8.1",
"drizzle-orm": "0.45.2",
"fast-xml-parser": "5.7.3",
"js-yaml": "4.1.1",
"js-yaml": "4.2.0",
"ldapts": "8.1.3",
"nodemailer": "8.0.9",
"nodemailer": "9.0.1",
"otpauth": "9.4.1",
"postgres": "3.4.8",
"prom-client": "15.1.3",
"qrcode": "1.5.4",
"rollup": "4.60.0",
"svelte-sonner": "1.0.7",
"undici": "7.24.5",
"undici": "7.28.0",
"ws": "8.21.0"
},
"devDependencies": {
@@ -103,12 +104,15 @@
"@types/better-sqlite3": "^7.6.12",
"@types/js-yaml": "^4.0.9",
"@types/node": "^22.10.0",
"@types/nodemailer": "7.0.11",
"@types/nodemailer": "8.0.1",
"@types/qrcode": "^1.5.6",
"@types/sarif": "^2.1.7",
"@types/ws": "^8.5.13",
"@xterm/addon-fit": "^0.11.0",
"@xterm/addon-web-links": "^0.12.0",
"@xterm/xterm": "^6.0.0",
"ajv": "^8.20.0",
"ajv-draft-04": "^1.0.0",
"autoprefixer": "^10.4.23",
"bits-ui": "2.15.4",
"clsx": "^2.1.1",
+23 -4
View File
@@ -17,14 +17,19 @@ import { readFileSync } from 'node:fs';
import { WebSocketServer } from 'ws';
import { handler } from './build/handler.js';
// Patch console to prepend ISO timestamps
// Patch console to prepend an ISO timestamp and a log level (#1166), e.g.
// 2026-06-11T12:34:56.789Z INFO ...
// 2026-06-11T12:34:56.789Z WARN ...
// 2026-06-11T12:34:56.789Z ERROR ...
const _log = console.log;
const _error = console.error;
const _warn = console.warn;
const _info = console.info;
const ts = () => new Date().toISOString();
console.log = (...args) => _log(ts(), ...args);
console.error = (...args) => _error(ts(), ...args);
console.warn = (...args) => _warn(ts(), ...args);
console.log = (...args) => _log(ts(), 'INFO ', ...args);
console.info = (...args) => _info(ts(), 'INFO ', ...args);
console.warn = (...args) => _warn(ts(), 'WARN ', ...args);
console.error = (...args) => _error(ts(), 'ERROR', ...args);
const PORT = parseInt(process.env.PORT || '3000', 10);
const HOST = process.env.HOST || '0.0.0.0';
@@ -408,6 +413,13 @@ async function handleTerminalConnection(ws, url, connId) {
ws.on('close', () => {
wsConnections.delete(connId);
});
// Without an 'error' listener, an emitted socket error (abrupt disconnect,
// ECONNRESET) is re-thrown as an uncaught exception and crashes the process.
ws.on('error', (err) => {
console.error('[Terminal WS] Connection error:', err.message);
wsConnections.delete(connId);
});
}
/**
@@ -480,6 +492,13 @@ function handleEdgeExec(ws, connId, containerId, shell, user, environmentId) {
wsConnections.delete(connId);
});
// An unhandled 'error' event would crash the process; log and clean up.
ws.on('error', (err) => {
console.error('[Edge exec WS] Connection error:', err.message);
edgeExecSessions.delete(execId);
wsConnections.delete(connId);
});
wsConnections.set(connId, { ws });
}
+45 -7
View File
@@ -58,6 +58,38 @@ html {
font-size: calc(12px * var(--grid-font-size-scale, 1)) !important;
}
/* Grid action icons (#1072) — size is independent of grid font size.
* Driven by --action-icon-size set from Settings > General. Default 12px
* matches the historical Tailwind w-3 size; users can bump up for high-DPI. */
.grid-action-icon {
width: var(--action-icon-size, 12px) !important;
height: var(--action-icon-size, 12px) !important;
}
/* Colored action icons (#1072) — opt-in via Settings > General.
* When html.colored-actions is set, semantic .grid-action-* classes paint
* their icons. Default (no class) leaves them muted-foreground gray. */
html.colored-actions .grid-action-start { color: rgb(22 163 74); }
html.colored-actions.dark .grid-action-start { color: rgb(74 222 128); }
html.colored-actions .grid-action-stop { color: rgb(217 119 6); }
html.colored-actions.dark .grid-action-stop { color: rgb(251 191 36); }
html.colored-actions .grid-action-pause { color: rgb(217 119 6); }
html.colored-actions.dark .grid-action-pause { color: rgb(251 191 36); }
html.colored-actions .grid-action-restart { color: rgb(37 99 235); }
html.colored-actions.dark .grid-action-restart { color: rgb(96 165 250); }
html.colored-actions .grid-action-delete { color: rgb(220 38 38); }
html.colored-actions.dark .grid-action-delete { color: rgb(248 113 113); }
html.colored-actions .grid-action-edit { color: rgb(37 99 235); }
html.colored-actions.dark .grid-action-edit { color: rgb(96 165 250); }
html.colored-actions .grid-action-info { color: rgb(71 85 105); }
html.colored-actions.dark .grid-action-info { color: rgb(148 163 184); }
html.colored-actions .grid-action-logs { color: rgb(37 99 235); }
html.colored-actions.dark .grid-action-logs { color: rgb(96 165 250); }
html.colored-actions .grid-action-terminal { color: rgb(22 163 74); }
html.colored-actions.dark .grid-action-terminal { color: rgb(74 222 128); }
html.colored-actions .grid-action-transfer { color: rgb(37 99 235); }
html.colored-actions.dark .grid-action-transfer { color: rgb(96 165 250); }
/* State badge - width scales with grid font size to stay consistent */
.state-badge {
width: calc(70px * var(--grid-font-size-scale, 1));
@@ -1341,13 +1373,19 @@ html {
line-height: 14px;
}
/* Icon animation toggle (#1169): when html.no-icon-animation is set, the
common Tailwind animation utilities collapse to no-op. This keeps the
layout (spinners still occupy space) but removes the motion. */
html.no-icon-animation .animate-spin,
html.no-icon-animation .animate-pulse,
html.no-icon-animation .animate-bounce,
html.no-icon-animation .animate-ping {
/* Icon animation toggle (#1169): when html.no-icon-animation is set, kill
the ambient/decorative spinners but leave active-operation indicators
alone. Lucide icons that ONLY exist as "operation in progress" markers
— Loader2 (renders as lucide-loader-circle), RefreshCw, RefreshCcw,
RotateCw, RotateCcw — are excluded regardless of the toggle. So is
anything explicitly tagged `animate-action`. Permanent ambient
spinners (e.g. the "update available" CircleArrowUp glyph in container
list rows) still stop. */
html.no-icon-animation
.animate-spin:not(.lucide-loader-circle):not(.lucide-refresh-cw):not(.lucide-refresh-ccw):not(.lucide-rotate-cw):not(.lucide-rotate-ccw):not(.animate-action),
html.no-icon-animation .animate-pulse:not(.animate-action),
html.no-icon-animation .animate-bounce:not(.animate-action),
html.no-icon-animation .animate-ping:not(.animate-action) {
animation: none !important;
}
+11 -2
View File
@@ -308,8 +308,9 @@ export const handle: Handle = async ({ event, resolve }) => {
let user = await validateSession(event.cookies);
let authMethod: 'cookie' | 'bearer' | 'none' = user ? 'cookie' : 'none';
// If no session, try Bearer token on API routes
if (!user && event.url.pathname.startsWith('/api/')) {
// If no session, try Bearer token on API routes (and /metrics, which
// Prometheus scrapes with a Bearer token when app auth is enabled).
if (!user && (event.url.pathname.startsWith('/api/') || event.url.pathname === '/metrics')) {
const authHeader = event.request.headers.get('authorization');
if (authHeader && authHeader.startsWith('Bearer dh_') && authHeader.length <= 207) {
const clientIp = getClientIp(event);
@@ -352,6 +353,14 @@ export const handle: Handle = async ({ event, resolve }) => {
return requestContext.run(ctx, async () => compressResponse(event.request, await resolve(event)));
}
// /metrics returns a plain 401 (Prometheus scrape) — never a login redirect.
if (event.url.pathname === '/metrics') {
return new Response('Unauthorized', {
status: 401,
headers: { 'WWW-Authenticate': 'Bearer' }
});
}
// API routes return 401
if (event.url.pathname.startsWith('/api/')) {
return new Response(
@@ -0,0 +1,176 @@
<script lang="ts">
import { onDestroy } from 'svelte';
import { Button } from '$lib/components/ui/button';
import { CircleArrowUp, Check, XCircle } from 'lucide-svelte';
import { toast } from 'svelte-sonner';
import { appendEnvParam } from '$lib/stores/environment';
import { watchJob } from '$lib/utils/sse-fetch';
export interface UpdateCheckResultItem {
containerId: string;
containerName: string;
imageName: string;
}
export interface FailedCheckItem {
containerName: string;
imageName: string;
error: string;
}
interface Props {
envId: number | null;
/** When true and no check is running, the button reflects the "updates found" (green) state — e.g. from persisted pending updates loaded on mount. */
hasPendingUpdates?: boolean;
/** Called when a check finishes with the containers that have updates and any failures. */
onComplete?: (result: { withUpdates: UpdateCheckResultItem[]; failed: FailedCheckItem[] }) => void;
}
let { envId, hasPendingUpdates = false, onComplete }: Props = $props();
type Status = 'idle' | 'checking' | 'found' | 'none' | 'error';
let status = $state<Status>('idle');
let progress = $state({ checked: 0, total: 0 });
let btnEl = $state<HTMLButtonElement | null>(null);
let errorTimeout: ReturnType<typeof setTimeout> | null = null;
function unlockWidth() {
if (btnEl) btnEl.style.minWidth = '';
}
// Reset the button state when the environment changes — a "Latest" result
// from one environment must not linger on another.
let lastEnvId = $state<number | null>(null);
$effect(() => {
if (envId !== lastEnvId) {
lastEnvId = envId;
unlockWidth();
status = 'idle';
}
});
// Exposed so parents can reset after a related action (e.g. batch update).
export function reset() {
unlockWidth();
status = 'idle';
}
onDestroy(() => {
if (errorTimeout) clearTimeout(errorTimeout);
});
// When idle, reflect persisted pending updates as the "found" (green) state.
const displayStatus = $derived(status === 'idle' && hasPendingUpdates ? 'found' : status);
function showFailedChecksToast(failed: FailedCheckItem[], prefix: string) {
const details = failed.map((f) => `• ${f.containerName}: ${f.error}`).join('\n');
toast.warning(`${prefix} (${failed.length} failed to check)`, {
description: details,
descriptionClass: 'whitespace-pre-line',
class: '!w-[28rem] !max-w-[28rem]',
duration: Infinity,
action: { label: 'OK', onClick: () => {} }
});
}
async function checkForUpdates() {
status = 'checking';
progress = { checked: 0, total: 0 };
// Lock button width to prevent layout shift
if (btnEl) btnEl.style.minWidth = `${btnEl.offsetWidth}px`;
const failError = () => {
status = 'error';
if (errorTimeout) clearTimeout(errorTimeout);
errorTimeout = setTimeout(() => { status = 'idle'; }, 3000);
unlockWidth();
};
try {
const response = await fetch(appendEnvParam('/api/containers/check-updates', envId), {
method: 'POST'
});
if (!response.ok) {
failError();
return;
}
const { jobId } = await response.json();
const data: any = await watchJob(jobId, (line) => {
if (line.event === 'progress') {
progress = line.data as { checked: number; total: number };
}
});
// A failed/malformed job resolves without a results array — treat as error
// rather than silently reporting "all up to date".
if (!data || !Array.isArray(data.results)) {
failError();
return;
}
unlockWidth();
const withUpdates: UpdateCheckResultItem[] = data.results
.filter((r: any) => r.hasUpdate && !r.systemContainer && !r.updateDisabled && !r.isLocalImage)
.map((r: any) => ({ containerId: r.containerId, containerName: r.containerName, imageName: r.imageName }));
const failed: FailedCheckItem[] = data.results
.filter((r: any) => r.error && !r.hasUpdate)
.map((r: any) => ({ containerName: r.containerName, imageName: r.imageName, error: r.error }));
if (withUpdates.length === 0) {
// Keep the "Latest" status until re-check / env-switch — don't auto-revert (#1019)
status = 'none';
if (failed.length > 0) {
showFailedChecksToast(failed, 'All containers are up to date');
} else {
toast.success('All containers are up to date');
}
} else {
status = 'found';
if (failed.length > 0) {
showFailedChecksToast(failed, `${withUpdates.length} update(s) available`);
} else {
toast.info(`${withUpdates.length} update(s) available`);
}
}
onComplete?.({ withUpdates, failed });
} catch {
failError();
}
}
</script>
<Button
bind:ref={btnEl}
size="sm"
variant="outline"
onclick={checkForUpdates}
disabled={status === 'checking'}
title="Check for available updates"
class="relative overflow-hidden"
>
{#if displayStatus === 'checking'}
<CircleArrowUp class="w-3.5 h-3.5 animate-spin" />
{#if progress.total > 0}
<span class="tabular-nums">Checking {String(progress.checked).padStart(String(progress.total).length, '')}/{progress.total}</span>
<div
class="absolute bottom-0 left-0 h-px bg-foreground transition-[width] duration-150 ease-out"
style="width: {(progress.checked / progress.total) * 100}%"
></div>
{:else}
Check for updates
{/if}
{:else if displayStatus === 'none' || displayStatus === 'found'}
<Check class="w-3.5 h-3.5 mr-1 text-green-600" />
Check for updates
{:else if displayStatus === 'error'}
<XCircle class="w-3.5 h-3.5 mr-1 text-destructive" />
Check for updates
{:else}
<CircleArrowUp class="w-3.5 h-3.5" />
Check for updates
{/if}
</Button>
@@ -0,0 +1,34 @@
<script lang="ts">
import { Label } from '$lib/components/ui/label';
import { TogglePill } from '$lib/components/ui/toggle-pill';
import { themeStore } from '$lib/stores/theme';
import { authStore } from '$lib/stores/auth';
import { toast } from 'svelte-sonner';
interface Props {
userId?: number; // omit for global default (login page / auth-disabled)
}
let { userId }: Props = $props();
const skipApply = $derived($authStore.loading ? true : ($authStore.authEnabled && !userId));
let checked = $state(false);
$effect(() => {
checked = $themeStore.coloredActionButtons;
});
function onToggle(value: boolean) {
checked = value;
themeStore.setPreference('coloredActionButtons', value, userId, skipApply);
toast.success(value ? 'Action buttons colored' : 'Action buttons reset to default');
}
</script>
<div class="space-y-1">
<div class="flex items-center gap-3">
<Label>Colored grid buttons</Label>
<TogglePill {checked} onchange={onToggle} />
</div>
<p class="text-xs text-muted-foreground">Use semantic colors instead of muted gray</p>
</div>
+9 -7
View File
@@ -5,7 +5,8 @@
import { appSettings } from '$lib/stores/settings';
interface Props {
open: boolean;
/** Optional — the popover self-manages its open state; bind only if the parent needs it. */
open?: boolean;
action: string;
itemName?: string;
itemType: string;
@@ -17,7 +18,8 @@
unstyled?: boolean;
disabled?: boolean;
onConfirm: () => void;
onOpenChange: (open: boolean) => void;
/** Optional — notified when the popover opens/closes. */
onOpenChange?: (open: boolean) => void;
children: Snippet<[{ open: boolean }]>;
extraContent?: Snippet;
}
@@ -56,7 +58,7 @@
if (open && autoHideMs > 0) {
const timeout = setTimeout(() => {
open = false;
onOpenChange(false);
onOpenChange?.(false);
}, autoHideMs);
return () => clearTimeout(timeout);
}
@@ -65,7 +67,7 @@
function handleConfirm() {
onConfirm();
open = false;
onOpenChange(false);
onOpenChange?.(false);
}
function handleTriggerClick(e: MouseEvent) {
@@ -76,17 +78,17 @@
return;
}
open = !open;
onOpenChange(open);
onOpenChange?.(open);
}
function handleOpenChange(newOpen: boolean) {
open = newOpen;
onOpenChange(newOpen);
onOpenChange?.(newOpen);
}
</script>
<Popover.Root bind:open onOpenChange={handleOpenChange}>
<Popover.Trigger asChild>
<Popover.Trigger>
{#snippet child({ props })}
<button
type="button"
+2 -1
View File
@@ -218,6 +218,7 @@
const isProcessing = $derived(pullStatus === 'pulling' || scanStatus === 'scanning' || isDeleting);
const effectiveEnvId = $derived(envId ?? $currentEnvironment?.id ?? null);
const effectiveEnvName = $derived($currentEnvironment?.id === effectiveEnvId ? $currentEnvironment?.name : null);
const title = $derived(envHasScanning ? 'Pull & scan image' : 'Pull image');
</script>
@@ -241,7 +242,7 @@
{:else}
<Download class="w-5 h-5" />
{/if}
{title}
<span>{title}{#if effectiveEnvName}&nbsp;to <span class="text-amber-500">{effectiveEnvName}</span>{/if}</span>
{#if effectiveImageName}
<code class="text-sm font-normal bg-muted px-1.5 py-0.5 rounded ml-1">{effectiveImageName}</code>
{/if}
+6 -4
View File
@@ -59,7 +59,7 @@
</script>
<Select.Root type="multiple" bind:value bind:open>
<Select.Trigger size="sm" class="{width} text-sm">
<Select.Trigger size="sm" class="{width} max-w-full text-sm overflow-hidden">
{#if hasIcons || defaultIcon}
{@const opt = singleOption()}
{@const IconComponent = opt?.icon || defaultIcon}
@@ -67,11 +67,11 @@
<svelte:component this={IconComponent} class="w-3.5 h-3.5 mr-1.5 {opt?.color || 'text-muted-foreground'} shrink-0" />
{/if}
{/if}
<span class="{value.length === 0 ? 'text-muted-foreground' : ''}">
<span class="truncate {value.length === 0 ? 'text-muted-foreground' : ''}" title={value.length === 1 ? displayLabel() : ''}>
{displayLabel()}
</span>
</Select.Trigger>
<Select.Content>
<Select.Content align="start">
{#if value.length > 0}
<button
type="button"
@@ -85,8 +85,10 @@
<Select.Item value={option.value}>
{#if option.icon}
<svelte:component this={option.icon} class="w-4 h-4 mr-2 {option.color || ''}" />
{:else if option.color}
<span class="w-2 h-2 mr-2 rounded-full shrink-0 {option.color.replace('text-', 'bg-')}"></span>
{/if}
{option.label}
<span class={option.color && !option.icon ? option.color : ''}>{option.label}</span>
</Select.Item>
{/each}
</Select.Content>
@@ -0,0 +1,30 @@
<script lang="ts">
import { SUMMARY_SEVERITIES, severityLabel, getSeverityColor } from '$lib/utils/vulnerability';
interface Props {
/** Counts keyed by severity (critical/high/medium/low). */
counts: { critical: number; high: number; medium: number; low: number };
/** Hide pills whose count is 0 (default: always show all four). */
hideZero?: boolean;
/** Pill size preset. */
size?: 'sm' | 'xs';
class?: string;
}
let { counts, hideZero = false, size = 'sm', class: className = '' }: Props = $props();
const pad = $derived(size === 'xs' ? 'px-2 py-0.5 text-2xs' : 'px-2.5 py-0.5 text-xs');
const pills = $derived(
SUMMARY_SEVERITIES.map((sev) => ({ sev, label: severityLabel(sev), count: counts[sev] }))
.filter((p) => !hideZero || p.count > 0)
);
</script>
<div class="flex flex-wrap items-center gap-2 {className}">
{#each pills as p (p.sev)}
<span class="inline-flex items-center gap-1.5 rounded-full border font-medium {pad} {getSeverityColor(p.sev)}">
<span class="tabular-nums font-semibold">{p.count}</span>
<span>{p.label}</span>
</span>
{/each}
</div>
+103
View File
@@ -0,0 +1,103 @@
<script lang="ts">
import { themeStore, type FontSize } from '$lib/stores/theme';
import { sseConnected } from '$lib/stores/events';
import { Badge } from '$lib/components/ui/badge';
import { Wifi } from 'lucide-svelte';
import type { Component } from 'svelte';
export interface HeaderTab {
id: string;
label: string;
icon: Component;
/** Optional count badge shown next to the label. */
count?: number | string;
/** Optional secondary total, rendered as "count of total" (active tab only). */
total?: number;
/** Show the live-connection (wifi) indicator inside this tab's segment. */
showConnection?: boolean;
}
interface Props {
tabs: HeaderTab[];
activeTab: string;
onTabChange: (id: string) => void;
class?: string;
}
let {
tabs,
activeTab,
onTabChange,
class: className = ''
}: Props = $props();
// Font size scaling (mirrors PageHeader so the active tab matches page titles).
// $themeStore auto-subscribes/cleans up; no manual subscription to leak.
const fontSize = $derived<FontSize>($themeStore.fontSize);
const headerTextClass = $derived.by(() => {
switch (fontSize) {
case 'small': return 'text-lg';
case 'normal': return 'text-xl';
case 'medium': return 'text-2xl';
case 'large': return 'text-2xl';
case 'xlarge': return 'text-3xl';
default: return 'text-xl';
}
});
const headerIconClass = $derived.by(() => {
switch (fontSize) {
case 'small': return 'w-4 h-4';
case 'normal': return 'w-5 h-5';
case 'medium': return 'w-6 h-6';
case 'large': return 'w-6 h-6';
case 'xlarge': return 'w-7 h-7';
default: return 'w-5 h-5';
}
});
function countDisplay(tab: HeaderTab): string | null {
if (tab.count === undefined) return null;
const countStr = typeof tab.count === 'number' ? tab.count.toLocaleString() : tab.count;
if (tab.total !== undefined) {
return `${countStr} of ${tab.total.toLocaleString()}`;
}
return countStr;
}
let active = $derived(tabs.find(t => t.id === activeTab) ?? tabs[0]);
</script>
<!-- A header that IS the switcher: all tabs live in one left-aligned segmented
control, the active segment filled and scaled to page-title size. Replaces
the plain PageHeader on pages that have sub-views. -->
<div class="flex items-center gap-3 {className}">
<div class="inline-flex items-center gap-1 rounded-lg bg-muted/60 p-1">
{#each tabs as tab (tab.id)}
{@const TabIcon = tab.icon}
{@const isActive = tab.id === active?.id}
<button
type="button"
aria-pressed={isActive}
onclick={() => onTabChange(tab.id)}
class="flex items-center gap-2 rounded-md font-bold leading-none transition-colors {headerTextClass} px-3 py-1.5 {isActive
? 'bg-background text-foreground shadow-sm'
: 'text-muted-foreground hover:text-foreground hover:bg-background/40'}"
>
<TabIcon class={headerIconClass} />
<span>{tab.label}</span>
{#if countDisplay(tab)}
<Badge variant="secondary" class="text-xs tabular-nums min-w-8 justify-center">
{countDisplay(tab)}
</Badge>
{/if}
{#if tab.showConnection}
<span title={$sseConnected ? 'Live updates active - grid will auto-refresh' : 'Connecting to live updates...'}>
<Wifi class="w-3.5 h-3.5 {$sseConnected ? 'text-emerald-500' : 'text-muted-foreground'}" />
</span>
{/if}
</button>
{/each}
</div>
</div>
+43 -2
View File
@@ -1,10 +1,10 @@
<script lang="ts">
import { onMount } from 'svelte';
import { Sun, Moon, Type, AArrowUp, Table, Terminal, CodeXml } from 'lucide-svelte';
import { Sun, Moon, Type, AArrowUp, Table, Terminal, CodeXml, MousePointerClick } from 'lucide-svelte';
import * as Select from '$lib/components/ui/select';
import { Label } from '$lib/components/ui/label';
import { lightThemes, darkThemes, fonts, monospaceFonts } from '$lib/themes';
import { themeStore, applyTheme, type FontSize } from '$lib/stores/theme';
import { themeStore, applyTheme, type FontSize, type ActionIconSize } from '$lib/stores/theme';
import { authStore } from '$lib/stores/auth';
// Preload all monospace Google Fonts so dropdown previews render correctly
@@ -20,6 +20,13 @@
{ id: 'xlarge', name: 'Extra Large' }
];
const actionIconSizes: { id: ActionIconSize; name: string }[] = [
{ id: 'small', name: 'Small' },
{ id: 'normal', name: 'Normal' },
{ id: 'large', name: 'Large' },
{ id: 'xlarge', name: 'Extra Large' }
];
interface Props {
userId?: number; // Pass userId for per-user settings, undefined for global
}
@@ -39,6 +46,7 @@
let selectedFont = $state('system');
let selectedFontSize = $state<FontSize>('normal');
let selectedGridFontSize = $state<FontSize>('normal');
let selectedActionIconSize = $state<ActionIconSize>('normal');
let selectedTerminalFont = $state('system-mono');
let selectedEditorFont = $state('system-mono');
@@ -66,6 +74,7 @@
selectedFont = $themeStore.font;
selectedFontSize = $themeStore.fontSize;
selectedGridFontSize = $themeStore.gridFontSize;
selectedActionIconSize = $themeStore.actionIconSize;
selectedTerminalFont = $themeStore.terminalFont;
selectedEditorFont = $themeStore.editorFont;
} else {
@@ -79,6 +88,7 @@
selectedFont = data.font || 'system';
selectedFontSize = data.fontSize || 'normal';
selectedGridFontSize = data.gridFontSize || 'normal';
selectedActionIconSize = data.actionIconSize || 'normal';
selectedTerminalFont = data.terminalFont || 'system-mono';
selectedEditorFont = data.editorFont || 'system-mono';
}
@@ -96,6 +106,7 @@
selectedFont = $themeStore.font;
selectedFontSize = $themeStore.fontSize;
selectedGridFontSize = $themeStore.gridFontSize;
selectedActionIconSize = $themeStore.actionIconSize;
selectedTerminalFont = $themeStore.terminalFont;
selectedEditorFont = $themeStore.editorFont;
}
@@ -131,6 +142,12 @@
await themeStore.setPreference('gridFontSize', value as FontSize, userId, skipApply);
}
async function handleActionIconSizeChange(value: string | undefined) {
if (!value) return;
selectedActionIconSize = value as ActionIconSize;
await themeStore.setPreference('actionIconSize', value as ActionIconSize, userId, skipApply);
}
async function handleTerminalFontChange(value: string | undefined) {
if (!value) return;
selectedTerminalFont = value;
@@ -290,6 +307,30 @@
</Select.Root>
</div>
<!-- Grid Buttons Size (#1072) -->
<div class="flex items-center justify-between">
<div class="flex items-center gap-2">
<MousePointerClick class="w-4 h-4 text-muted-foreground" />
<Label>Grid buttons</Label>
</div>
<Select.Root type="single" value={selectedActionIconSize} onValueChange={handleActionIconSizeChange}>
<Select.Trigger class="w-56">
{#each actionIconSizes as size}
{#if size.id === selectedActionIconSize}
<span>{size.name}</span>
{/if}
{/each}
</Select.Trigger>
<Select.Content>
{#each actionIconSizes as size}
<Select.Item value={size.id}>
<span>{size.name}</span>
</Select.Item>
{/each}
</Select.Content>
</Select.Root>
</div>
<!-- Terminal Font -->
<div class="flex items-center justify-between">
<div class="flex items-center gap-2">
+266 -27
View File
@@ -1,4 +1,5 @@
<script lang="ts">
import { onMount } from 'svelte';
import { page } from '$app/stores';
import { goto } from '$app/navigation';
import * as Sidebar from '$lib/components/ui/sidebar';
@@ -23,16 +24,34 @@
ClipboardList,
Activity,
Timer,
LibraryBig
LibraryBig,
CircleArrowUp,
Pencil,
Check,
GripVertical,
Eye,
EyeOff,
RotateCcw
} from 'lucide-svelte';
import { flip } from 'svelte/animate';
import { licenseStore } from '$lib/stores/license';
import { authStore, hasAnyAccess } from '$lib/stores/auth';
import { selfUpdate } from '$lib/stores/self-update';
import { appSettings } from '$lib/stores/settings';
import { sidebarPreferencesStore, orderItems } from '$lib/stores/sidebar-preferences';
import * as Avatar from '$lib/components/ui/avatar';
import * as Tooltip from '$lib/components/ui/tooltip';
const appVersion = __APP_VERSION__ || 'unknown';
const buildCommit = __BUILD_COMMIT__ ?? null;
onMount(() => {
// One-shot Dockhand update check (#1146). Result is cached for the
// browser session — the Settings → About page reads the same store.
selfUpdate.checkOnce();
sidebarPreferencesStore.init();
});
import type { Permissions } from '$lib/stores/auth';
// TypeScript interface for menu items
@@ -57,6 +76,8 @@
async function handleLogout() {
sidebar.setOpenMobile(false);
// Per-user layout must not leak to the next user on this browser
sidebarPreferencesStore.clearLocal();
await authStore.logout();
goto('/login');
}
@@ -109,8 +130,106 @@
{ href: '/audit', Icon: ClipboardList, label: 'Audit log', permission: 'audit_logs', enterpriseOnly: true },
{ href: '/settings', Icon: Settings, label: 'Settings', permission: 'settings' }
] as const;
// --- Sidebar customization (#1252): reorder + hide/show menu items ---
let editMode = $state(false);
let dragHref = $state<string | null>(null);
// Order buffer while dragging - persisted once on drop, not per dragover
let draftOrder = $state<string[] | null>(null);
// Full menu (incl. permission-hidden items) in the user's saved order,
// so reordering never loses positions of items the user can't see.
const orderedItems = $derived(orderItems(menuItems, draftOrder ?? $sidebarPreferencesStore.order));
const hiddenSet = $derived(new Set($sidebarPreferencesStore.hidden));
// Permission filter first, then user-hidden filter
const editableItems = $derived(orderedItems.filter(canSeeMenuItem));
const visibleItems = $derived(editableItems.filter((item) => !hiddenSet.has(item.href)));
// Edit mode only makes sense expanded - exit when the sidebar collapses
$effect(() => {
if (sidebar.state === 'collapsed' && editMode) {
editMode = false;
}
});
function persist(order: string[]) {
sidebarPreferencesStore.save({ order, hidden: [...hiddenSet] });
}
function toggleHidden(href: string) {
const hidden = new Set(hiddenSet);
if (hidden.has(href)) {
hidden.delete(href);
} else {
hidden.add(href);
}
sidebarPreferencesStore.save({ order: orderedItems.map((i) => i.href), hidden: [...hidden] });
}
/**
* Place fromHref before/after toHref in the full order list.
* No-ops when the result wouldn't change, which keeps dragover
* events from thrashing the list while the pointer hovers a row.
*/
function moveTo(fromHref: string, toHref: string, before: boolean) {
const current = orderedItems.map((i) => i.href);
if (!current.includes(fromHref) || !current.includes(toHref)) return;
const order = current.filter((h) => h !== fromHref);
const insertAt = order.indexOf(toHref) + (before ? 0 : 1);
order.splice(insertAt, 0, fromHref);
if (order.join('\n') !== current.join('\n')) {
draftOrder = order;
}
}
function moveBy(href: string, delta: -1 | 1) {
const visible = editableItems.map((i) => i.href);
const from = visible.indexOf(href);
const to = from + delta;
if (to < 0 || to >= visible.length) return;
moveTo(href, visible[to], delta < 0);
if (draftOrder) {
persist(draftOrder);
draftOrder = null;
}
}
function endDrag() {
if (draftOrder) {
persist(draftOrder);
draftOrder = null;
}
dragHref = null;
}
// Transparent 1x1 drag image: suppresses the browser's ghost snapshot so
// the flip animation of the list itself reads as the drag movement.
let ghostImg: HTMLImageElement | null = null;
function transparentGhost(): HTMLImageElement {
if (!ghostImg) {
ghostImg = new Image(1, 1);
ghostImg.src = 'data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7';
}
return ghostImg;
}
</script>
{#snippet versionTooltip()}
<div class="space-y-0.5 text-left">
<div class="flex items-center gap-1.5"><svg class="w-4 h-4 shrink-0" viewBox="0 0 24 18" fill="currentColor"><path d="M23.76 8.68c-.26-.18-.86-.58-1.53-.58-.24 0-.48.04-.72.12-.12-.84-.68-1.56-1.34-2.14l-.28-.22-.24.26c-.28.34-.48.72-.56 1.14-.1.42-.06.82.1 1.2-.42.22-.88.36-1.32.42-.24.04-.48.06-.72.06H.78a.77.77 0 0 0-.78.78c-.02 1.46.22 2.9.72 4.24.56 1.44 1.4 2.5 2.5 3.16 1.26.74 3.32 1.16 5.64 1.16.98 0 2-.1 2.98-.3a11.5 11.5 0 0 0 3.3-1.3 9.67 9.67 0 0 0 2.54-2.34c1.16-1.42 1.86-3.02 2.34-4.38h.2c1.22 0 1.98-.48 2.4-.9.28-.26.5-.58.64-.94l.08-.24-.28-.2zM2.74 8.84H4.7c.1 0 .18-.08.18-.18V7.02c0-.1-.08-.18-.18-.18H2.74c-.1 0-.18.08-.18.18v1.64c0 .1.08.18.18.18zm2.72 0h1.96c.1 0 .18-.08.18-.18V7.02c0-.1-.08-.18-.18-.18H5.46c-.1 0-.18.08-.18.18v1.64c0 .1.08.18.18.18zm2.76 0h1.96c.1 0 .18-.08.18-.18V7.02c0-.1-.08-.18-.18-.18H8.22c-.1 0-.18.08-.18.18v1.64c0 .1.08.18.18.18zm2.76 0h1.96c.1 0 .18-.08.18-.18V7.02c0-.1-.08-.18-.18-.18h-1.96c-.1 0-.18.08-.18.18v1.64c0 .1.08.18.18.18zM5.46 6.2h1.96c.1 0 .18-.08.18-.18V4.38c0-.1-.08-.18-.18-.18H5.46c-.1 0-.18.08-.18.18v1.64c0 .1.08.18.18.18zm2.76 0h1.96c.1 0 .18-.08.18-.18V4.38c0-.1-.08-.18-.18-.18H8.22c-.1 0-.18.08-.18.18v1.64c0 .1.08.18.18.18zm2.76 0h1.96c.1 0 .18-.08.18-.18V4.38c0-.1-.08-.18-.18-.18h-1.96c-.1 0-.18.08-.18.18v1.64c0 .1.08.18.18.18zm0-2.64h1.96c.1 0 .18-.08.18-.18V1.74c0-.1-.08-.18-.18-.18h-1.96c-.1 0-.18.08-.18.18v1.64c0 .1.08.18.18.18zm2.76 5.28h1.96c.1 0 .18-.08.18-.18V7.02c0-.1-.08-.18-.18-.18h-1.96c-.1 0-.18.08-.18.18v1.64c0 .1.08.18.18.18z"/></svg><span class="font-mono">fnsys/dockhand:{appVersion}</span></div>
{#if buildCommit}
<div>Commit: <span class="font-mono">{buildCommit.slice(0, 7)}</span></div>
{/if}
{#if $selfUpdate.updateAvailable && $selfUpdate.latestVersion}
<div class="flex items-center gap-1.5 pt-1 text-amber-500">
<CircleArrowUp class="w-3.5 h-3.5 shrink-0" />
Update available: <span class="font-mono">v{$selfUpdate.latestVersion}</span>
</div>
{/if}
</div>
{/snippet}
<Sidebar.Root collapsible="icon">
<Sidebar.Header class="overflow-visible flex items-center justify-center p-0">
<!-- Expanded state: logo + collapse button -->
@@ -147,37 +266,157 @@
<Sidebar.Content>
<Sidebar.Group>
<Sidebar.Menu>
{#each menuItems as item}
{#if canSeeMenuItem(item)}
<Sidebar.MenuItem>
<Sidebar.MenuButton href={item.href} isActive={isActive(item.href)} tooltipContent={item.label} onclick={() => sidebar.setOpenMobile(false)}>
<item.Icon aria-hidden="true" />
<span class="group-data-[state=collapsed]:hidden">{item.label}</span>
</Sidebar.MenuButton>
</Sidebar.MenuItem>
{/if}
{#each editMode ? editableItems : visibleItems as item (item.href)}
<li class="group/menu-item relative" animate:flip={{ duration: 200 }}>
{#if editMode}
<div
role="listitem"
class="flex items-center gap-1.5 px-1.5 py-1 rounded-md text-xs select-none transition-opacity
{hiddenSet.has(item.href) ? 'opacity-40' : ''}
{dragHref === item.href ? 'opacity-30 bg-sidebar-accent' : ''}"
draggable="true"
ondragstart={(e) => {
dragHref = item.href;
if (e.dataTransfer) {
e.dataTransfer.effectAllowed = 'move';
// Safari won't start a drag without data
e.dataTransfer.setData('text/plain', item.href);
e.dataTransfer.setDragImage(transparentGhost(), 0, 0);
}
}}
ondragover={(e) => {
e.preventDefault();
if (e.dataTransfer) e.dataTransfer.dropEffect = 'move';
if (!dragHref || dragHref === item.href) return;
// Only reorder when the pointer is past the row midpoint,
// so a hover near the boundary doesn't flip back and forth
const rect = (e.currentTarget as HTMLElement).getBoundingClientRect();
const before = e.clientY < rect.top + rect.height / 2;
moveTo(dragHref, item.href, before);
}}
ondrop={(e) => e.preventDefault()}
ondragend={endDrag}
>
<button
type="button"
class="cursor-grab text-muted-foreground/60 hover:text-foreground focus:outline-none focus-visible:text-foreground"
aria-label="Reorder {item.label}"
title="Drag to reorder (or use arrow keys)"
onkeydown={(e) => {
if (e.key === 'ArrowUp') { e.preventDefault(); moveBy(item.href, -1); }
if (e.key === 'ArrowDown') { e.preventDefault(); moveBy(item.href, 1); }
}}
>
<GripVertical class="w-3 h-3" />
</button>
<item.Icon class="!w-3.5 !h-3.5 shrink-0" aria-hidden="true" />
<span class="flex-1 truncate">{item.label}</span>
<button
type="button"
class="text-muted-foreground/60 hover:text-foreground transition-colors"
title={hiddenSet.has(item.href) ? `Show ${item.label}` : `Hide ${item.label}`}
aria-label={hiddenSet.has(item.href) ? `Show ${item.label}` : `Hide ${item.label}`}
onclick={() => toggleHidden(item.href)}
>
{#if hiddenSet.has(item.href)}
<EyeOff class="w-3 h-3" />
{:else}
<Eye class="w-3 h-3" />
{/if}
</button>
</div>
{:else}
<Sidebar.MenuButton href={item.href} isActive={isActive(item.href)} tooltipContent={item.label} onclick={() => sidebar.setOpenMobile(false)}>
<item.Icon aria-hidden="true" />
<span class="group-data-[state=collapsed]:hidden">{item.label}</span>
</Sidebar.MenuButton>
{/if}
</li>
{/each}
</Sidebar.Menu>
{#if editMode}
<div class="flex items-center justify-between px-2 py-1 mt-1 text-xs leading-none">
<button
type="button"
class="flex items-center gap-1 whitespace-nowrap font-medium text-muted-foreground hover:text-foreground transition-colors"
title="Reset menu to default order and visibility"
onclick={() => {
sidebarPreferencesStore.reset();
editMode = false;
}}
>
<RotateCcw class="w-3 h-3 shrink-0 text-red-400" />
Reset
</button>
<button
type="button"
class="flex items-center gap-1 whitespace-nowrap font-medium text-muted-foreground hover:text-foreground transition-colors"
onclick={() => (editMode = false)}
>
<Check class="w-3 h-3 shrink-0 text-emerald-500" />
Apply
</button>
</div>
{/if}
</Sidebar.Group>
</Sidebar.Content>
<!-- Version (expanded sidebar only) -->
<div class="group-data-[state=collapsed]:hidden px-3 py-2 mt-auto text-center">
<Tooltip.Root>
<Tooltip.Trigger>
<span class="text-[10px] text-muted-foreground/60 hover:text-muted-foreground transition-colors cursor-default">
{appVersion}
</span>
</Tooltip.Trigger>
<Tooltip.Content side="top" align="start" sideOffset={8} class="text-xs">
<div class="space-y-0.5">
<div class="flex items-center gap-1.5"><svg class="w-4 h-4 shrink-0" viewBox="0 0 24 18" fill="currentColor"><path d="M23.76 8.68c-.26-.18-.86-.58-1.53-.58-.24 0-.48.04-.72.12-.12-.84-.68-1.56-1.34-2.14l-.28-.22-.24.26c-.28.34-.48.72-.56 1.14-.1.42-.06.82.1 1.2-.42.22-.88.36-1.32.42-.24.04-.48.06-.72.06H.78a.77.77 0 0 0-.78.78c-.02 1.46.22 2.9.72 4.24.56 1.44 1.4 2.5 2.5 3.16 1.26.74 3.32 1.16 5.64 1.16.98 0 2-.1 2.98-.3a11.5 11.5 0 0 0 3.3-1.3 9.67 9.67 0 0 0 2.54-2.34c1.16-1.42 1.86-3.02 2.34-4.38h.2c1.22 0 1.98-.48 2.4-.9.28-.26.5-.58.64-.94l.08-.24-.28-.2zM2.74 8.84H4.7c.1 0 .18-.08.18-.18V7.02c0-.1-.08-.18-.18-.18H2.74c-.1 0-.18.08-.18.18v1.64c0 .1.08.18.18.18zm2.72 0h1.96c.1 0 .18-.08.18-.18V7.02c0-.1-.08-.18-.18-.18H5.46c-.1 0-.18.08-.18.18v1.64c0 .1.08.18.18.18zm2.76 0h1.96c.1 0 .18-.08.18-.18V7.02c0-.1-.08-.18-.18-.18H8.22c-.1 0-.18.08-.18.18v1.64c0 .1.08.18.18.18zm2.76 0h1.96c.1 0 .18-.08.18-.18V7.02c0-.1-.08-.18-.18-.18h-1.96c-.1 0-.18.08-.18.18v1.64c0 .1.08.18.18.18zM5.46 6.2h1.96c.1 0 .18-.08.18-.18V4.38c0-.1-.08-.18-.18-.18H5.46c-.1 0-.18.08-.18.18v1.64c0 .1.08.18.18.18zm2.76 0h1.96c.1 0 .18-.08.18-.18V4.38c0-.1-.08-.18-.18-.18H8.22c-.1 0-.18.08-.18.18v1.64c0 .1.08.18.18.18zm2.76 0h1.96c.1 0 .18-.08.18-.18V4.38c0-.1-.08-.18-.18-.18h-1.96c-.1 0-.18.08-.18.18v1.64c0 .1.08.18.18.18zm0-2.64h1.96c.1 0 .18-.08.18-.18V1.74c0-.1-.08-.18-.18-.18h-1.96c-.1 0-.18.08-.18.18v1.64c0 .1.08.18.18.18zm2.76 5.28h1.96c.1 0 .18-.08.18-.18V7.02c0-.1-.08-.18-.18-.18h-1.96c-.1 0-.18.08-.18.18v1.64c0 .1.08.18.18.18z"/></svg><span class="font-mono">fnsys/dockhand:{appVersion}</span></div>
{#if buildCommit}
<div>Commit: <span class="font-mono">{buildCommit.slice(0, 7)}</span></div>
{/if}
</div>
</Tooltip.Content>
</Tooltip.Root>
<!-- Collapsed-only update indicator at the bottom. Same CSS-popover trick
as the expanded version row — anchored to the icon's top edge, grows
upward, opens to the right of the narrow sidebar. -->
{#if $selfUpdate.updateAvailable}
<div class="hidden group-data-[state=collapsed]:flex justify-center mt-auto pb-2 relative group/update">
<a
href="/settings?tab=about"
class="inline-flex p-1 rounded-md hover:bg-sidebar-accent transition-colors"
aria-label="Dockhand update available"
>
<CircleArrowUp class="w-4 h-4 text-amber-500 {$appSettings.highlightUpdates ? 'glow-amber' : ''}" />
</a>
<div
role="tooltip"
class="pointer-events-none absolute left-0 bottom-full mb-1 whitespace-nowrap rounded-md border bg-popover text-popover-foreground text-xs px-3 py-1.5 shadow-lg opacity-0 group-hover/update:opacity-100 transition-opacity z-50"
>
{@render versionTooltip()}
</div>
</div>
{/if}
<!-- Version (expanded sidebar only). Pure-CSS popover anchored to the row's
top edge, so it always grows upward into available space. -->
<div class="group-data-[state=collapsed]:hidden px-3 py-1 mt-auto text-center relative group/version">
<span class="inline-flex items-center gap-1.5 text-[10px] text-muted-foreground/60 hover:text-muted-foreground transition-colors cursor-default">
{appVersion}
{#if $selfUpdate.updateAvailable}
<a
href="/settings?tab=about"
class="inline-flex"
aria-label="Dockhand update available"
>
<CircleArrowUp class="w-3 h-3 text-amber-500 {$appSettings.highlightUpdates ? 'glow-amber' : ''}" />
</a>
{/if}
</span>
<!-- Customize-menu toggle (#1252): invisible until this bottom strip is
hovered, so it never distracts users who don't customize. Hidden
while editing - the Apply button under the menu exits edit mode. -->
{#if !editMode}
<button
type="button"
class="absolute right-2 top-1/2 -translate-y-1/2 p-1 rounded-md text-muted-foreground/50 hover:text-foreground hover:bg-sidebar-accent transition-all opacity-0 group-hover/version:opacity-100 focus-visible:opacity-100"
title="Customize menu"
aria-label="Customize menu"
onclick={() => (editMode = true)}
>
<Pencil class="w-3 h-3" />
</button>
{/if}
<div
role="tooltip"
class="pointer-events-none absolute left-0 bottom-full mb-1 whitespace-nowrap rounded-md border bg-popover text-popover-foreground text-xs px-3 py-1.5 shadow-lg opacity-0 group-hover/version:opacity-100 transition-opacity z-50"
>
{@render versionTooltip()}
</div>
</div>
<!-- User info footer (only when auth is enabled) -->
+10 -8
View File
@@ -172,22 +172,24 @@
}
});
// Generate hours array based on time format preference
// Generate hours array based on time format preference. 24h shows just the
// 2-digit hour so the combined picker reads "at 03 :15" rather than
// the confusing "at 03:00 :15" (#1198).
const hours = $derived(
Array.from({ length: 24 }, (_, i) => ({
value: String(i),
label: is12Hour
? i === 0 ? '12 AM' : i < 12 ? `${i} AM` : i === 12 ? '12 PM' : `${i - 12} PM`
: i.toString().padStart(2, '0') + ':00'
: i.toString().padStart(2, '0')
}))
);
const minutes = [
{ value: '0', label: ':00' },
{ value: '15', label: ':15' },
{ value: '30', label: ':30' },
{ value: '45', label: ':45' }
];
// 5-minute granularity — fine enough to stagger notifications without
// drowning the dropdown in 60 entries (#1198).
const minutes = Array.from({ length: 12 }, (_, i) => ({
value: String(i * 5),
label: ':' + (i * 5).toString().padStart(2, '0')
}));
const daysOfWeek = [
{ value: '1', label: 'Monday' },
+180 -155
View File
@@ -37,6 +37,16 @@
onLoadMore?: () => void;
loadMoreThreshold?: number;
// Sliding-window mode (opt-in, fully isolated). When `windowed` is false
// (default) every windowed branch below is skipped and the grid behaves
// byte-for-byte as before. When true, `data` holds only rows from absolute
// index `windowOffset` of `windowTotal` total; unloaded positions render a
// loading row, and `onWindowShift(target)` is called to fetch a new window.
windowed?: boolean;
windowOffset?: number;
windowTotal?: number;
onWindowShift?: (targetStart: number) => void;
// Visible range callback (for virtual scroll)
onVisibleRangeChange?: (start: number, end: number, total: number) => void;
@@ -85,6 +95,10 @@
hasMore = false,
onLoadMore,
loadMoreThreshold = 200,
windowed = false,
windowOffset = 0,
windowTotal,
onWindowShift,
onVisibleRangeChange,
onRowClick,
highlightedKey,
@@ -373,8 +387,12 @@
// Container width for grow column calculation
let scrollContainerWidth = $state(0);
// Scroll-height row count. In non-windowed mode `rowCount === data.length`, so
// every use below is identical to the original `data.length`.
const rowCount = $derived(windowed ? (windowTotal ?? data.length) : data.length);
// Virtual scroll calculations
const totalHeight = $derived(virtualScroll ? data.length * rowHeight : 0);
const totalHeight = $derived(virtualScroll ? rowCount * rowHeight : 0);
// Memoization state for visibleData to prevent creating new arrays on every scroll
let prevStartIndex = -1;
@@ -382,13 +400,16 @@
let prevDataRef: T[] | null = null;
let cachedVisibleData: T[] = [];
// Memoized startIndex/endIndex/visibleData calculation
// Memoized startIndex/endIndex — ABSOLUTE indices. Non-windowed: rowCount is
// data.length, so this equals the original.
const startIndex = $derived(virtualScroll ? Math.max(0, Math.floor(scrollTop / rowHeight) - bufferRows) : 0);
const endIndex = $derived(
virtualScroll ? Math.min(data.length, Math.ceil((scrollTop + containerHeight) / rowHeight) + bufferRows) : data.length
virtualScroll ? Math.min(rowCount, Math.ceil((scrollTop + containerHeight) / rowHeight) + bufferRows) : rowCount
);
// Memoized visibleData - only create new array when bounds or data actually change
// Memoized visibleData (ORIGINAL, unchanged) — used by the non-windowed paths.
// In windowed mode the offsets are absolute, so this slice would be wrong; the
// windowed table uses `windowedVisible` instead (below).
const visibleData = $derived.by(() => {
if (!virtualScroll) return data;
@@ -407,15 +428,68 @@
return cachedVisibleData;
});
// Windowed rows for the absolute range [startIndex, endIndex): each position is
// the loaded row or undefined (→ a loading placeholder row).
const windowedVisible = $derived.by<(T | undefined)[]>(() => {
if (!windowed) return [];
const out: (T | undefined)[] = [];
for (let abs = startIndex; abs < endIndex; abs++) {
const local = abs - windowOffset;
out.push(local >= 0 && local < data.length ? data[local] : undefined);
}
return out;
});
// Count of rendered virtual rows (loaded slice, or the windowed range).
const renderedCount = $derived(windowed ? windowedVisible.length : visibleData.length);
const offsetY = $derived(virtualScroll ? startIndex * rowHeight : 0);
// Windowed only: when the viewport nears/leaves the loaded window, ask the
// parent to shift it. Fire once per viewport position while a fetch is pending;
// re-arm when the window actually moves so a failed/superseded shift (window
// never advanced to cover startIndex) can ask again instead of stranding blank
// rows. Keyed on (startIndex, windowOffset): same request suppressed only until
// either the viewport or the loaded window changes.
//
// Failed-shift recovery: if a fetch settles (loading→false) without moving the
// window to cover us, retry ONCE for this exact (startIndex, windowOffset) so a
// transient error doesn't leave permanent blank rows — but not in a loop, so a
// hard-failing endpoint just shows loading placeholders until the user scrolls.
let lastReqStart = -1;
let lastReqOffset = -1;
let retriedUncovered = false;
let wasLoading = false;
$effect(() => {
if (!virtualScroll || !windowed || !onWindowShift) return;
const loadedEnd = windowOffset + data.length;
const needsBelow = endIndex > loadedEnd && loadedEnd < rowCount;
const needsAbove = startIndex < windowOffset && windowOffset > 0;
const settledEdge = wasLoading && !loading; // a fetch just finished
wasLoading = loading;
if (!needsBelow && !needsAbove) { lastReqStart = -1; lastReqOffset = -1; retriedUncovered = false; return; }
const sameRequest = startIndex === lastReqStart && windowOffset === lastReqOffset;
if (sameRequest) {
// Still uncovered for the same (viewport, window). Only re-request on the
// loading→false edge, and only once, so we recover from a failed fetch
// without spinning against a persistently failing endpoint.
if (!(settledEdge && !retriedUncovered)) return;
retriedUncovered = true;
} else {
retriedUncovered = false;
}
lastReqStart = startIndex;
lastReqOffset = windowOffset;
onWindowShift(startIndex);
});
// Notify parent of visible range changes (throttled via RAF)
$effect(() => {
if (virtualScroll && onVisibleRangeChange && data.length > 0) {
// Capture values for RAF callback
const st = scrollTop;
const ch = containerHeight;
const len = data.length;
const len = rowCount; // === data.length when not windowed
const rh = rowHeight;
const cb = onVisibleRangeChange;
@@ -761,91 +835,100 @@
</thead>
{/snippet}
<!--
One data row + its optional expanded row. Shared by the plain virtual body
(tableBody, iterating visibleData) and the windowed body (iterating
windowedVisible). Keeping the row markup in one place means a change reaches
both render paths — the windowed branch used to be a ~85-line copy of this.
-->
{#snippet dataRow(item: T, rowState: ReturnType<typeof getRowState>)}
<tr
class="group cursor-pointer {rowState.isHighlighted ? 'selected' : ''} {rowState.isSelected ? 'checkbox-selected' : ''} {rowState.isExpanded ? 'row-expanded' : ''} {rowClass?.(item) ?? ''}"
onclick={(e) => onRowClick?.(item, e)}
>
<!-- Fixed start columns (select checkbox, expand chevron) -->
{#each fixedStartCols as colId (colId)}
{@const colConfig = columnConfigMap.get(colId)}
<td class="py-1.5 px-1 {colId === 'select' ? 'select-col' : ''} {colId === 'expand' ? 'expand-col' : ''}" style="width: {getDisplayWidth(colId)}px">
{#if colId === 'select' && selectable}
{#if rowState.isSelectable}
<button
type="button"
onclick={(e) => {
e.stopPropagation();
toggleSelection(item[keyField]);
}}
class="flex items-center justify-center w-full h-full min-h-[24px] transition-colors cursor-pointer {rowState.isSelected ? 'opacity-100' : 'opacity-0 group-hover:opacity-40 hover:!opacity-100'}"
>
{#if rowState.isSelected}
<CheckSquare class="w-3.5 h-3.5 text-muted-foreground" />
{:else}
<SquareIcon class="w-3.5 h-3.5 text-muted-foreground" />
{/if}
</button>
{/if}
{:else if colId === 'expand' && expandable}
<button
type="button"
onclick={(e) => {
e.stopPropagation();
toggleExpand(item[keyField]);
}}
class="flex items-center justify-center transition-colors cursor-pointer opacity-50 hover:opacity-100"
title={rowState.isExpanded ? 'Collapse' : 'Expand'}
>
{#if rowState.isExpanded}
<ChevronDown class="w-4 h-4 text-muted-foreground" />
{:else}
<ChevronRight class="w-4 h-4 text-muted-foreground" />
{/if}
</button>
{:else if cell}
{@render cell(colConfig!, item, rowState)}
{/if}
</td>
{/each}
<!-- Configurable columns -->
{#each orderedColumns as colId (colId)}
{@const colConfig = columnConfigMap.get(colId)}
{#if colConfig}
<td class="py-1.5 px-2 {colConfig.noTruncate ? 'no-truncate' : ''}" style="width: {getDisplayWidth(colId)}px">
{#if cell}
{@render cell(colConfig, item, rowState)}
{:else}
<!-- Default: render as text -->
{String(item[colId as keyof T] ?? '')}
{/if}
</td>
{/if}
{/each}
<!-- Fixed end columns (actions) -->
{#each fixedEndCols as colId (colId)}
{@const colConfig = columnConfigMap.get(colId)}
<td class="py-1.5 px-2 text-right actions-col" style="width: {getDisplayWidth(colId)}px" onclick={(e) => e.stopPropagation()}>
{#if cell}
{@render cell(colConfig!, item, rowState)}
{/if}
</td>
{/each}
</tr>
<!-- Expanded row content -->
{#if rowState.isExpanded && expandedRow}
<tr class="expanded-row">
<td colspan={fixedStartCols.length + orderedColumns.length + fixedEndCols.length}>
{@render expandedRow(item, rowState)}
</td>
</tr>
{/if}
{/snippet}
{#snippet tableBody()}
<tbody>
{#each visibleData as item, index (item[keyField])}
{@const rowState = getRowState(item, index)}
<tr
class="group cursor-pointer {rowState.isHighlighted ? 'selected' : ''} {rowState.isSelected ? 'checkbox-selected' : ''} {rowState.isExpanded ? 'row-expanded' : ''} {rowClass?.(item) ?? ''}"
onclick={(e) => onRowClick?.(item, e)}
>
<!-- Fixed start columns (select checkbox, expand chevron) -->
{#each fixedStartCols as colId (colId)}
{@const colConfig = columnConfigMap.get(colId)}
<td class="py-1.5 px-1 {colId === 'select' ? 'select-col' : ''} {colId === 'expand' ? 'expand-col' : ''}" style="width: {getDisplayWidth(colId)}px">
{#if colId === 'select' && selectable}
{#if rowState.isSelectable}
<button
type="button"
onclick={(e) => {
e.stopPropagation();
toggleSelection(item[keyField]);
}}
class="flex items-center justify-center w-full h-full min-h-[24px] transition-colors cursor-pointer {rowState.isSelected ? 'opacity-100' : 'opacity-0 group-hover:opacity-40 hover:!opacity-100'}"
>
{#if rowState.isSelected}
<CheckSquare class="w-3.5 h-3.5 text-muted-foreground" />
{:else}
<SquareIcon class="w-3.5 h-3.5 text-muted-foreground" />
{/if}
</button>
{/if}
{:else if colId === 'expand' && expandable}
<button
type="button"
onclick={(e) => {
e.stopPropagation();
toggleExpand(item[keyField]);
}}
class="flex items-center justify-center transition-colors cursor-pointer opacity-50 hover:opacity-100"
title={rowState.isExpanded ? 'Collapse' : 'Expand'}
>
{#if rowState.isExpanded}
<ChevronDown class="w-4 h-4 text-muted-foreground" />
{:else}
<ChevronRight class="w-4 h-4 text-muted-foreground" />
{/if}
</button>
{:else if cell}
{@render cell(colConfig!, item, rowState)}
{/if}
</td>
{/each}
<!-- Configurable columns -->
{#each orderedColumns as colId (colId)}
{@const colConfig = columnConfigMap.get(colId)}
{#if colConfig}
<td class="py-1.5 px-2 {colConfig.noTruncate ? 'no-truncate' : ''}" style="width: {getDisplayWidth(colId)}px">
{#if cell}
{@render cell(colConfig, item, rowState)}
{:else}
<!-- Default: render as text -->
{String(item[colId as keyof T] ?? '')}
{/if}
</td>
{/if}
{/each}
<!-- Fixed end columns (actions) -->
{#each fixedEndCols as colId (colId)}
{@const colConfig = columnConfigMap.get(colId)}
<td class="py-1.5 px-2 text-right actions-col" style="width: {getDisplayWidth(colId)}px" onclick={(e) => e.stopPropagation()}>
{#if cell}
{@render cell(colConfig!, item, rowState)}
{/if}
</td>
{/each}
</tr>
<!-- Expanded row content -->
{#if rowState.isExpanded && expandedRow}
<tr class="expanded-row">
<td colspan={fixedStartCols.length + orderedColumns.length + fixedEndCols.length}>
{@render expandedRow(item, rowState)}
</td>
</tr>
{/if}
{@render dataRow(item, getRowState(item, index))}
{/each}
</tbody>
{/snippet}
@@ -876,79 +959,21 @@
<tr><td colspan={fixedStartCols.length + orderedColumns.length + fixedEndCols.length} style="height: {offsetY}px; padding: 0; border: none;"></td></tr>
{/if}
<!-- Visible rows -->
{#each visibleData as item, index (item[keyField])}
{@const rowState = getRowState(item, index)}
<tr
class="group cursor-pointer {rowState.isHighlighted ? 'selected' : ''} {rowState.isSelected ? 'checkbox-selected' : ''} {rowState.isExpanded ? 'row-expanded' : ''} {rowClass?.(item) ?? ''}"
onclick={(e) => onRowClick?.(item, e)}
>
{#each fixedStartCols as colId (colId)}
{@const colConfig = columnConfigMap.get(colId)}
<td class="py-1.5 px-1 {colId === 'select' ? 'select-col' : ''} {colId === 'expand' ? 'expand-col' : ''}" style="width: {getDisplayWidth(colId)}px">
{#if colId === 'select' && selectable}
{#if rowState.isSelectable}
<button
type="button"
onclick={(e) => { e.stopPropagation(); toggleSelection(item[keyField]); }}
class="flex items-center justify-center w-full h-full min-h-[24px] transition-colors cursor-pointer {rowState.isSelected ? 'opacity-100' : 'opacity-0 group-hover:opacity-40 hover:!opacity-100'}"
>
{#if rowState.isSelected}
<CheckSquare class="w-3.5 h-3.5 text-muted-foreground" />
{:else}
<SquareIcon class="w-3.5 h-3.5 text-muted-foreground" />
{/if}
</button>
{/if}
{:else if colId === 'expand' && expandable}
<button
type="button"
onclick={(e) => { e.stopPropagation(); toggleExpand(item[keyField]); }}
class="flex items-center justify-center transition-colors cursor-pointer opacity-50 hover:opacity-100"
title={rowState.isExpanded ? 'Collapse' : 'Expand'}
>
{#if rowState.isExpanded}
<ChevronDown class="w-4 h-4 text-muted-foreground" />
{:else}
<ChevronRight class="w-4 h-4 text-muted-foreground" />
{/if}
</button>
{:else if cell}
{@render cell(colConfig!, item, rowState)}
{/if}
</td>
{/each}
{#each orderedColumns as colId (colId)}
{@const colConfig = columnConfigMap.get(colId)}
{#if colConfig}
<td class="py-1.5 px-2 {colConfig.noTruncate ? 'no-truncate' : ''}" style="width: {getDisplayWidth(colId)}px">
{#if cell}
{@render cell(colConfig, item, rowState)}
{:else}
{String(item[colId as keyof T] ?? '')}
{/if}
</td>
{/if}
{/each}
{#each fixedEndCols as colId (colId)}
{@const colConfig = columnConfigMap.get(colId)}
<td class="py-1.5 px-2 text-right actions-col" style="width: {getDisplayWidth(colId)}px" onclick={(e) => e.stopPropagation()}>
{#if cell}
{@render cell(colConfig!, item, rowState)}
{/if}
</td>
{/each}
</tr>
{#if rowState.isExpanded && expandedRow}
<tr class="expanded-row">
<td colspan={fixedStartCols.length + orderedColumns.length + fixedEndCols.length}>
{@render expandedRow(item, rowState)}
{#each (windowed ? windowedVisible : visibleData) as item, index (windowed ? startIndex + index : item![keyField])}
{#if item === undefined}
<!-- Windowed: this absolute row isn't loaded yet — show a loading row. -->
<tr class="animate-pulse">
<td colspan={fixedStartCols.length + orderedColumns.length + fixedEndCols.length} style="height: {rowHeight}px" class="px-2">
<div class="h-3 bg-muted rounded" style="width: 30%"></div>
</td>
</tr>
{:else}
{@render dataRow(item, getRowState(item, index))}
{/if}
{/each}
<!-- Bottom spacer -->
{#if totalHeight - offsetY - (visibleData.length * rowHeight) > 0}
<tr><td colspan={fixedStartCols.length + orderedColumns.length + fixedEndCols.length} style="height: {totalHeight - offsetY - (visibleData.length * rowHeight)}px; padding: 0; border: none;"></td></tr>
{#if totalHeight - offsetY - (renderedCount * rowHeight) > 0}
<tr><td colspan={fixedStartCols.length + orderedColumns.length + fixedEndCols.length} style="height: {totalHeight - offsetY - (renderedCount * rowHeight)}px; padding: 0; border: none;"></td></tr>
{/if}
<!-- Footer (rendered at the bottom of virtual scroll) -->
{#if footer}
+17 -1
View File
@@ -136,6 +136,21 @@ export const environmentColumns: ColumnConfig[] = [
{ id: 'labels', label: 'Labels', width: 150, minWidth: 80 }
];
// Vulnerabilities dashboard grid columns
export const vulnerabilityColumns: ColumnConfig[] = [
{ id: 'cve', label: 'CVE', sortable: true, sortField: 'cve', width: 160, minWidth: 110 },
{ id: 'package', label: 'Package', sortable: true, sortField: 'package', width: 160, minWidth: 100 },
{ id: 'severity', label: 'Severity', sortable: true, sortField: 'severity', width: 110, minWidth: 90, noTruncate: true },
{ id: 'installed', label: 'Installed version', sortable: true, sortField: 'installed', width: 150, minWidth: 100 },
{ id: 'fixed', label: 'Fixed version', sortable: true, sortField: 'fixed', width: 150, minWidth: 100 },
{ id: 'image', label: 'Image', sortable: true, sortField: 'image', width: 220, minWidth: 120, grow: true },
{ id: 'container', label: 'Container', sortable: true, sortField: 'container', width: 180, minWidth: 120 },
{ id: 'stack', label: 'Stack', sortable: true, sortField: 'stack', width: 150, minWidth: 100 },
{ id: 'scannedAt', label: 'Scanned', sortable: true, sortField: 'scannedAt', width: 170, minWidth: 130, noTruncate: true },
// Empty fixed-end column: hosts the column-settings (customize columns) control in its header.
{ id: 'actions', label: '', fixed: 'end', width: 44, resizable: false }
];
// Map of grid ID to column definitions
export const gridColumnConfigs: Record<GridId, ColumnConfig[]> = {
containers: containerColumns,
@@ -147,7 +162,8 @@ export const gridColumnConfigs: Record<GridId, ColumnConfig[]> = {
activity: activityColumns,
schedules: scheduleColumns,
audit: auditColumns,
environments: environmentColumns
environments: environmentColumns,
vulnerabilities: vulnerabilityColumns
};
// Get configurable columns (not fixed)
+62
View File
@@ -1,4 +1,66 @@
[
{
"version": "1.0.37",
"date": "2026-07-11",
"changes": [
{ "type": "feature", "text": "Prometheus metrics at /metrics for env state and internals, gated by EXPORT_METRICS (#339)" },
{ "type": "feature", "text": "export scan results as SARIF 2.1.0, manual export and API for DefectDojo/Dependency-Track/GitHub (#415)" },
{ "type": "feature", "text": "bump bundled docker-compose to 5.2.0-r0 to clear known CVEs" },
{ "type": "feature", "text": "Vulnerabilities dashboard: aggregated CVE findings (#1038)" },
{ "type": "feature", "text": "update container images directly from the Stack view (#1073)" },
{ "type": "feature", "text": "stacks list shows an update indicator for stacks with image updates (#504)" },
{ "type": "fix", "text": "copying an image to another registry keeps its tag instead of defaulting to latest (#1243)" },
{ "type": "fix", "text": "`more-than-current image` re-scans the current image so a stale cached scan doesn't block (#1022)" },
{ "type": "feature", "text": "application logs now include the log level (INFO/WARN/ERROR) in the prefix (#1166)" },
{ "type": "feature", "text": "option to suppress the \"What's New\" popup (#1235)" },
{ "type": "fix", "text": "ntfy notifications now forward the `email` query parameter so ntfy can send email (#1231)" },
{ "type": "feature", "text": "default scanner images bumped to grype v0.115.0 and trivy 0.71.2 (#1241)" },
{ "type": "fix", "text": "the \"Latest\" update-check result now stays visible and doesn't revert (#1019)" },
{ "type": "feature", "text": "a stack with container label `dockhand.adopt=false` excluded from adoption (#998)" },
{ "type": "fix", "text": "dashboard daemon info shows the real host hostname, not Dockhand's container id (#1265)" },
{ "type": "feature", "text": "file editor prompts to save/discard unsaved changes before closing (#1264)" },
{ "type": "fix", "text": "`GET /api/containers/check-updates` now lists pending updates (POST still triggers a check) (#1266)" },
{ "type": "feature", "text": "sidebar menu customizable (order and visibility of items) (#1252)" },
{ "type": "fix", "text": "activity and audit date filters respect the configured timezone instead of UTC (#1269)" },
{ "type": "feature", "text": "git deploy progress shows the changed files before the deploy starts (#1260)" }
],
"imageTag": "fnsys/dockhand:v1.0.37"
},
{
"version": "1.0.36",
"date": "2026-06-27",
"changes": [
{ "type": "feature", "text": "sidebar shows an amber update indicator when a newer Dockhand image is available (#1146)" },
{ "type": "fix", "text": "Tag image modal too narrow for images identified only by SHA (#1205)" },
{ "type": "fix", "text": "self-hosted ntfy: accept raw `tk_...` access tokens in `?auth=` (#1209)" },
{ "type": "fix", "text": "dashboard: a single failed DB stats query no longer poisons the whole environment tile (#1210)" },
{ "type": "fix", "text": "UI dates and times now honor the configured default timezone instead of the browser's timezone (#1183)" },
{ "type": "fix", "text": "add missing `reset-mfa.sh` emergency script referenced by the manual (#1214)" },
{ "type": "fix", "text": "registry browser: copy between registries no longer duplicates the host (#1220)" },
{ "type": "feature", "text": "published image carries standard OCI annotations (source, url, title, description, vendor, licenses) (#1217)" },
{ "type": "feature", "text": "scanner: configurable network mode and DNS servers for vulnerability scans (#1219)" },
{ "type": "feature", "text": "template tiles now show a `Project` link that opens the upstream project page (#1211)" },
{ "type": "fix", "text": "cron picker: hour shows just `HH` (was `HH:00`), minute granularity bumped from 15 to 5 (#1198)" },
{ "type": "feature", "text": "grid buttons: configurable size and optional semantic colors (#1072)" },
{ "type": "fix", "text": "copy buttons (git deploy logs, API tokens) now work over plain HTTP (#1222)" },
{ "type": "fix", "text": "Podman pod-infra containers no longer trigger update-check warnings (#1221)" },
{ "type": "fix", "text": "terminal exec works even when the browser sends a cookie with stray `%` characters (#1224)" }
],
"imageTag": "fnsys/dockhand:v1.0.36"
},
{
"version": "1.0.35",
"date": "2026-06-19",
"changes": [
{ "type": "feature", "text": "image prune skips Dockhand's scanner images (grype, trivy), configurable (#625)" },
{ "type": "fix", "text": "\"No archive\" download format selection not persisted (#1180)" },
{ "type": "fix", "text": "regression: vulnerability scans on direct-TCP envs (#1195)" },
{ "type": "fix", "text": "Pangolin labels — recognise the real `public-resources` / `private-resources` namespaces (#2)" },
{ "type": "fix", "text": "healthcheck uses HTTPS probe when HTTPS_MODE=on (#1191)" },
{ "type": "fix", "text": "shell detection improved for containers with non-standard PATH or shell locations (#1189)" }
],
"imageTag": "fnsys/dockhand:v1.0.35"
},
{
"version": "1.0.34",
"date": "2026-06-17",
+1 -1
View File
@@ -547,7 +547,7 @@
},
{
"name": "js-yaml",
"version": "4.1.1",
"version": "4.2.0",
"license": "MIT",
"repository": "https://github.com/nodeca/js-yaml"
},
+17 -2
View File
@@ -46,12 +46,11 @@ function cacheKey(rawToken: string): string {
return createHash('sha256').update(rawToken).digest('hex');
}
// Pre-computed dummy hash for timing protection on invalid prefixes
let dummyHash: string | null = null;
async function getDummyHash(): Promise<string> {
if (!dummyHash) {
dummyHash = await hashPassword('dh_dummy_token_for_timing_protection');
dummyHash = await hashPassword('dh_init_seed');
}
return dummyHash;
}
@@ -248,6 +247,22 @@ export async function listUserTokens(userId: number) {
.where(eq(table.userId, userId));
}
/**
* Fleet-wide API token stats for the metrics endpoint (no secrets returned):
* total configured, how many are expired, and how many have never been used.
*/
export async function getApiTokenStats(): Promise<{ total: number; expired: number; neverUsed: number }> {
const table = await getApiTokensTable();
const rows = await db.select({ lastUsed: table.lastUsed, expiresAt: table.expiresAt }).from(table);
const now = Date.now();
let expired = 0, neverUsed = 0;
for (const r of rows) {
if (r.expiresAt && new Date(r.expiresAt).getTime() < now) expired++;
if (!r.lastUsed) neverUsed++;
}
return { total: rows.length, expired, neverUsed };
}
/**
* Revoke (delete) a token. Owner or admin can revoke.
*/
+41
View File
@@ -36,6 +36,7 @@
*/
import type { Cookies } from '@sveltejs/kit';
import { json } from '@sveltejs/kit';
import type { Permissions } from './db';
import { getUserAccessibleEnvironments, userCanAccessEnvironment, userHasAdminRole } from './db';
import { validateSession, isAuthEnabled, checkPermission, type AuthenticatedUser } from './auth';
@@ -105,6 +106,27 @@ export interface AuthorizationContext {
* - (User is admin OR has audit_logs view permission)
*/
canViewAuditLog: () => Promise<boolean>;
/**
* Gate handler: require a permission. Returns a 403 Response on denial,
* null when allowed. Use at the top of a handler with `??`-chaining for
* one-line auth/env checks:
*
* const denied = (await auth.requirePermission('schedules', 'edit'))
* ?? (await auth.requireEnvAccess(envId));
* if (denied) return denied;
*
* No-op when authEnabled is false.
*/
requirePermission: (resource: keyof Permissions, action: string, environmentId?: number) => Promise<Response | null>;
/**
* Gate handler: require access to the given environment. Returns a 403
* Response on denial, null when allowed. Pass null/undefined to no-op
* (e.g. system-scoped operations that don't target an env). No-op when
* authEnabled is false.
*/
requireEnvAccess: (environmentId: number | null | undefined) => Promise<Response | null>;
}
/**
@@ -232,6 +254,25 @@ export async function authorize(cookies: Cookies): Promise<AuthorizationContext>
// Check for audit_logs permission
return checkPermission(user, 'audit_logs' as keyof Permissions, 'view');
},
async requirePermission(
resource: keyof Permissions,
action: string,
environmentId?: number
): Promise<Response | null> {
if (!authEnabled) return null;
return (await ctx.can(resource, action, environmentId))
? null
: json({ error: 'Permission denied' }, { status: 403 });
},
async requireEnvAccess(environmentId: number | null | undefined): Promise<Response | null> {
if (!authEnabled) return null;
if (environmentId === null || environmentId === undefined) return null;
return (await ctx.canAccessEnvironment(environmentId))
? null
: json({ error: 'Access denied to this environment' }, { status: 403 });
}
};
+22
View File
@@ -8,6 +8,7 @@
* - dockhand.url=<url> Custom clickable URL displayed alongside container ports
* - dockhand.port.<hostPort>.url=<url> Override the click URL for a specific published port
* - dockhand.order=<int> Controls display order within a stack (lower = first, default 0)
* - dockhand.adopt=false Prevent this stack from being adopted (any container in the stack)
*
* All label values are case-insensitive and accept: true/yes/1 and false/no/0.
* The opt-out model means labels override DB settings (label wins).
@@ -20,6 +21,7 @@ export const DOCKHAND_LABELS = {
NOTIFY: 'dockhand.notify',
URL: 'dockhand.url',
ORDER: 'dockhand.order',
ADOPT: 'dockhand.adopt',
} as const;
const TRUTHY_VALUES = new Set(['true', 'yes', '1']);
@@ -77,6 +79,26 @@ export function isNotifyDisabledByLabel(labels: Record<string, string> | undefin
return value === false; // explicitly disabled
}
/**
* Check if a single container opts out of stack adoption.
* Returns true if dockhand.adopt is explicitly set to false/no/0.
* Default (no label): adoptable (opt-out model).
*/
export function isAdoptDisabledByLabel(labels: Record<string, string> | undefined | null): boolean {
return parseLabelBool(getLabel(labels, DOCKHAND_LABELS.ADOPT)) === false;
}
/**
* Check if a stack should be excluded from adoption. A stack is unadoptable when
* ANY of its containers carries dockhand.adopt=false. Only enforceable while the
* stack is running (the label lives on containers, not the compose file).
*/
export function isStackUnadoptable(
containerLabels: Array<Record<string, string> | undefined | null>
): boolean {
return containerLabels.some(isAdoptDisabledByLabel);
}
/**
* Get the custom URL from dockhand.url label.
* Returns the URL string if set, or undefined.
+19
View File
@@ -0,0 +1,19 @@
/**
* Build a safe `Content-Disposition: attachment` header value (RFC 6266).
*
* The filename is often derived from a user-influenced path, so it must never
* be interpolated raw a `"` would break out of the quoted string and CR/LF
* could attempt header injection. We emit both:
* - an ASCII-sanitized `filename="..."` fallback (quotes/control chars stripped)
* - a percent-encoded `filename*=UTF-8''...` for the real (possibly non-ASCII) name
*/
export function attachmentContentDisposition(filename: string): string {
const fallback = (filename || 'download')
// drop control chars (incl. CR/LF) and characters that break the quoted form
.replace(/[\x00-\x1f\x7f"\\]/g, '')
.trim() || 'download';
const encoded = encodeURIComponent(filename || 'download');
return `attachment; filename="${fallback}"; filename*=UTF-8''${encoded}`;
}
+25
View File
@@ -0,0 +1,25 @@
/** Shared CSV helpers for export endpoints. */
/**
* Escape a value for a CSV cell:
* - Neutralize spreadsheet formula injection: a cell whose first character is
* `=`, `+`, `-`, `@`, tab or CR is executed as a formula by Excel/Sheets.
* Exported vuln reports carry scanner/image-derived strings (an image tag or
* package name could start with `=`), so we prefix such cells with a single
* quote to force them to be treated as text.
* - Quote per RFC-4180 when the value contains a comma, quote, CR or LF.
*/
export function escapeCSV(value: string | number | null | undefined): string {
if (value === null || value === undefined) return '';
let str = String(value);
if (/^[=+\-@\t\r]/.test(str)) str = `'${str}`;
if (str.includes(',') || str.includes('"') || str.includes('\n') || str.includes('\r')) {
return `"${str.replace(/"/g, '""')}"`;
}
return str;
}
/** Build a CSV document from a header row and pre-escaped/raw cell rows. */
export function rowsToCSV(headers: string[], rows: (string | number | null | undefined)[][]): string {
return [headers.join(','), ...rows.map((row) => row.map(escapeCSV).join(','))].join('\n');
}
+182 -8
View File
@@ -79,6 +79,7 @@ import {
import type { AllGridPreferences, GridId, GridColumnPreferences } from '$lib/types';
import { encrypt, decrypt } from './encryption.js';
import { parseEnvInterpolation } from './env-interpolation';
import { invalidateVulnerabilitiesCache } from './vulnerabilities-cache';
// Re-export for backwards compatibility
export { db, isPostgres, isSqlite };
@@ -389,8 +390,10 @@ export async function getUserThemePreferences(userId: number): Promise<{
terminalFont: string;
editorFont: string;
animateIcons: boolean;
coloredActionButtons: boolean;
actionIconSize: string;
}> {
const [lightTheme, darkTheme, font, fontSize, gridFontSize, terminalFont, editorFont, animateIcons] = await Promise.all([
const [lightTheme, darkTheme, font, fontSize, gridFontSize, terminalFont, editorFont, animateIcons, coloredActionButtons, actionIconSize] = await Promise.all([
getUserSetting(userId, 'light_theme'),
getUserSetting(userId, 'dark_theme'),
getUserSetting(userId, 'font'),
@@ -398,7 +401,9 @@ export async function getUserThemePreferences(userId: number): Promise<{
getUserSetting(userId, 'grid_font_size'),
getUserSetting(userId, 'terminal_font'),
getUserSetting(userId, 'editor_font'),
getUserSetting(userId, 'animate_icons')
getUserSetting(userId, 'animate_icons'),
getUserSetting(userId, 'colored_action_buttons'),
getUserSetting(userId, 'action_icon_size')
]);
return {
lightTheme: lightTheme || 'default',
@@ -409,13 +414,16 @@ export async function getUserThemePreferences(userId: number): Promise<{
terminalFont: terminalFont || 'system-mono',
editorFont: editorFont || 'system-mono',
// Default ON — only false when explicitly stored
animateIcons: animateIcons === 'false' ? false : true
animateIcons: animateIcons === 'false' ? false : true,
// Default OFF — only true when explicitly stored
coloredActionButtons: coloredActionButtons === 'true',
actionIconSize: actionIconSize || 'normal'
};
}
export async function setUserThemePreferences(
userId: number,
prefs: { lightTheme?: string; darkTheme?: string; font?: string; fontSize?: string; gridFontSize?: string; terminalFont?: string; editorFont?: string; animateIcons?: boolean }
prefs: { lightTheme?: string; darkTheme?: string; font?: string; fontSize?: string; gridFontSize?: string; terminalFont?: string; editorFont?: string; animateIcons?: boolean; coloredActionButtons?: boolean; actionIconSize?: string }
): Promise<void> {
const updates: Promise<void>[] = [];
if (prefs.lightTheme !== undefined) {
@@ -442,6 +450,12 @@ export async function setUserThemePreferences(
if (prefs.animateIcons !== undefined) {
updates.push(setUserSetting(userId, 'animate_icons', prefs.animateIcons ? 'true' : 'false'));
}
if (prefs.coloredActionButtons !== undefined) {
updates.push(setUserSetting(userId, 'colored_action_buttons', prefs.coloredActionButtons ? 'true' : 'false'));
}
if (prefs.actionIconSize !== undefined) {
updates.push(setUserSetting(userId, 'action_icon_size', prefs.actionIconSize));
}
await Promise.all(updates);
}
@@ -478,6 +492,31 @@ export async function resetAllGridPreferences(userId?: number): Promise<void> {
await deleteSetting(key);
}
// =============================================================================
// SIDEBAR MENU PREFERENCES
// =============================================================================
export interface SidebarPreferences {
order: string[];
hidden: string[];
}
export async function getSidebarPreferences(userId?: number): Promise<SidebarPreferences> {
const key = userId ? `user:${userId}:sidebar_preferences` : 'sidebar_preferences';
const value = await getSetting(key);
return value || { order: [], hidden: [] };
}
export async function setSidebarPreferences(prefs: SidebarPreferences, userId?: number): Promise<void> {
const key = userId ? `user:${userId}:sidebar_preferences` : 'sidebar_preferences';
await setSetting(key, prefs);
}
export async function deleteSidebarPreferences(userId?: number): Promise<void> {
const key = userId ? `user:${userId}:sidebar_preferences` : 'sidebar_preferences';
await deleteSetting(key);
}
// =============================================================================
// ENVIRONMENT PUBLIC IPS (for port links)
// =============================================================================
@@ -2985,6 +3024,9 @@ export async function saveVulnerabilityScan(data: {
vulnerabilities: JSON.stringify(data.vulnerabilities),
error: data.error ?? null
}).returning();
// A new scan makes the dashboard's cached findings stale — drop them so every
// writer (routes + schedulers) refreshes it, no separate call to remember.
invalidateVulnerabilitiesCache(data.environmentId ?? undefined);
return getVulnerabilityScan(result[0].id) as Promise<VulnerabilityScanData>;
}
@@ -3028,9 +3070,47 @@ export async function getLatestScanForImage(
} as VulnerabilityScanData;
}
export async function getScansForImage(imageId: string, limit = 10): Promise<VulnerabilityScanData[]> {
/**
* Delete all previous scan rows for a specific image + scanner in an environment.
* Used by "scan all" to replace an image's prior scan rather than accumulate rows.
* Returns the number of rows removed.
*/
export async function deleteScansForImageScanner(
imageId: string,
scanner: 'grype' | 'trivy',
environmentId?: number | null
): Promise<number> {
const conditions = [
eq(vulnerabilityScans.imageId, imageId),
eq(vulnerabilityScans.scanner, scanner)
];
if (environmentId === null || environmentId === undefined) {
conditions.push(isNull(vulnerabilityScans.environmentId));
} else {
conditions.push(eq(vulnerabilityScans.environmentId, environmentId));
}
const result = await db.delete(vulnerabilityScans).where(and(...conditions)).returning({ id: vulnerabilityScans.id });
return result.length;
}
export async function getScansForImage(
imageId: string,
environmentId?: number | null,
limit = 10
): Promise<VulnerabilityScanData[]> {
// Scope by environment so a caller can't read another environment's scans for
// the same image SHA (the vulnerability_scans table is per-environment). When
// environmentId is omitted (undefined) all environments are returned — callers
// exposed to untrusted input MUST pass a concrete env.
const conditions = [eq(vulnerabilityScans.imageId, imageId)];
if (environmentId !== undefined) {
conditions.push(environmentId === null
? isNull(vulnerabilityScans.environmentId)
: eq(vulnerabilityScans.environmentId, environmentId));
}
const results = await db.select().from(vulnerabilityScans)
.where(eq(vulnerabilityScans.imageId, imageId))
.where(and(...conditions))
.orderBy(desc(vulnerabilityScans.scannedAt))
.limit(limit);
@@ -3122,11 +3202,56 @@ export async function getAllLatestScans(environmentId?: number | null): Promise<
})) as VulnerabilityScanData[];
}
/**
* Scan freshness for the metrics endpoint: how stale the OLDEST scan is
* (surfaces environments whose scans have gone stale) and the average scan
* duration (surfaces slow scanners). Ages/durations in seconds; nulls when there
* are no scans.
*
* A pure SQL aggregate over only scanned_at / scan_duration it deliberately
* does NOT touch the large `vulnerabilities` JSON blob (unlike getAllLatestScans),
* so it stays cheap when called per-environment on every scrape.
*/
export async function getScanFreshness(environmentId?: number | null): Promise<{
scans: number; oldestAgeSeconds: number | null; avgDurationSeconds: number | null;
}> {
const envCond = environmentId === undefined
? undefined
: environmentId === null
? isNull(vulnerabilityScans.environmentId)
: eq(vulnerabilityScans.environmentId, environmentId);
const rows = await db
.select({
n: sql<number>`count(*)`,
oldest: sql<string | null>`min(${vulnerabilityScans.scannedAt})`,
avgDur: sql<number | null>`avg(${vulnerabilityScans.scanDuration})`
})
.from(vulnerabilityScans)
.where(envCond);
const r = rows[0];
const n = Number(r?.n ?? 0);
if (n === 0) return { scans: 0, oldestAgeSeconds: null, avgDurationSeconds: null };
const oldestT = r?.oldest ? new Date(r.oldest).getTime() : NaN;
const oldestAgeSeconds = Number.isNaN(oldestT) ? null : Math.max(0, Math.round((Date.now() - oldestT) / 1000));
const avgDurationSeconds = r?.avgDur != null ? Math.round(Number(r.avgDur) / 1000) : null;
return { scans: n, oldestAgeSeconds, avgDurationSeconds };
}
export async function deleteOldScans(keepDays = 30): Promise<number> {
const cutoffDate = new Date(Date.now() - keepDays * 24 * 60 * 60 * 1000).toISOString();
await db.delete(vulnerabilityScans)
const countResult = await db.select({ count: sql<number>`count(*)` })
.from(vulnerabilityScans)
.where(sql`scanned_at < ${cutoffDate}`);
return 0;
const count = Number(countResult[0]?.count ?? 0);
if (count > 0) {
await db.delete(vulnerabilityScans)
.where(sql`scanned_at < ${cutoffDate}`);
}
return count;
}
// =============================================================================
@@ -4039,6 +4164,55 @@ export async function getScheduleExecutions(filters: ScheduleExecutionFilters =
};
}
/**
* Scheduled-task health for the metrics endpoint: execution counts by
* (scheduleType, status), and per-type age (seconds) of the last run and last
* SUCCESSFUL run. The last-run age detects a scheduler that stopped firing; the
* last-success age detects one that fires but keeps failing. Best-effort.
*/
export async function getScheduleStats(): Promise<{
byTypeStatus: Array<{ type: string; status: string; count: number }>;
lastRunSecondsByType: Record<string, number>;
lastSuccessSecondsByType: Record<string, number>;
}> {
const counts = await db
.select({
type: scheduleExecutions.scheduleType,
status: scheduleExecutions.status,
count: sql<number>`count(*)`
})
.from(scheduleExecutions)
.groupBy(scheduleExecutions.scheduleType, scheduleExecutions.status);
// Most-recent triggeredAt per type (any status) and per type for successes.
const lastAny = await db
.select({ type: scheduleExecutions.scheduleType, ts: sql<string>`max(${scheduleExecutions.triggeredAt})` })
.from(scheduleExecutions)
.groupBy(scheduleExecutions.scheduleType);
const lastOk = await db
.select({ type: scheduleExecutions.scheduleType, ts: sql<string>`max(${scheduleExecutions.triggeredAt})` })
.from(scheduleExecutions)
.where(eq(scheduleExecutions.status, 'success'))
.groupBy(scheduleExecutions.scheduleType);
const now = Date.now();
const ageMap = (rows: Array<{ type: string; ts: string | null }>): Record<string, number> => {
const out: Record<string, number> = {};
for (const r of rows) {
if (!r.ts) continue;
const t = new Date(r.ts).getTime();
if (!Number.isNaN(t)) out[r.type] = Math.max(0, Math.round((now - t) / 1000));
}
return out;
};
return {
byTypeStatus: counts.map((c) => ({ type: c.type, status: c.status, count: Number(c.count) })),
lastRunSecondsByType: ageMap(lastAny),
lastSuccessSecondsByType: ageMap(lastOk)
};
}
export async function getLastExecutionForSchedule(
scheduleType: ScheduleType,
scheduleId: number
+84
View File
@@ -1041,3 +1041,87 @@ export function getPostgresConnectionInfo(): { host: string; port: string } | nu
return null;
}
}
/** High-churn tables worth exposing a row count for (they grow and affect tuning). */
const METRICS_TABLES = [
'container_events', 'vulnerability_scans', 'audit_logs', 'sessions',
'host_metrics', 'schedule_executions', 'pending_container_updates'
];
export interface DatabaseStats {
type: 'postgres' | 'sqlite';
/** Engine version string, e.g. "3.45.1" (sqlite) or "16.2" (postgres). */
version: string;
/** On-disk size in bytes (sqlite: page_count*page_size; postgres: pg_database_size). 0 if unknown. */
sizeBytes: number;
/** Row counts for the high-churn tables (missing tables omitted). */
rowCounts: Record<string, number>;
/**
* Engine-specific extra numeric gauges, keyed by name:
* - sqlite: wal_bytes (WAL file size), freelist_bytes (reclaimable), cache_size_pages
* - postgres: connections (current backends), max_connections
*/
extra: Record<string, number>;
}
/**
* Cheap DB stats for the metrics endpoint: engine + version, on-disk size, row
* counts of the tables that actually grow, plus engine-specific tuning gauges.
* Never throws returns best-effort data so a scrape can't fail on a DB hiccup.
*/
export async function getDatabaseStats(): Promise<DatabaseStats> {
const type = isPostgres ? 'postgres' : 'sqlite';
let sizeBytes = 0;
let version = 'unknown';
const rowCounts: Record<string, number> = {};
const extra: Record<string, number> = {};
try {
if (isPostgres) {
const size = await rawClient`SELECT pg_database_size(current_database()) AS bytes`;
sizeBytes = Number(size[0]?.bytes ?? 0);
try {
const v = await rawClient`SHOW server_version`;
version = String(v[0]?.server_version ?? 'unknown').split(' ')[0];
} catch { /* ignore */ }
try {
const c = await rawClient`SELECT count(*)::int AS n FROM pg_stat_activity WHERE datname = current_database()`;
extra.connections = Number(c[0]?.n ?? 0);
const mc = await rawClient`SELECT setting::int AS n FROM pg_settings WHERE name = 'max_connections'`;
extra.max_connections = Number(mc[0]?.n ?? 0);
} catch { /* ignore */ }
for (const table of METRICS_TABLES) {
try {
// Identifiers can't be parameterized; table names are a fixed const list.
const r = await rawClient.unsafe(`SELECT count(*)::int AS n FROM ${table}`);
rowCounts[table] = Number(r[0]?.n ?? 0);
} catch { /* table may not exist on older schemas */ }
}
} else {
const pc = rawClient.prepare('PRAGMA page_count').get() as { page_count?: number };
const ps = rawClient.prepare('PRAGMA page_size').get() as { page_size?: number };
const pageSize = ps?.page_size ?? 0;
sizeBytes = (pc?.page_count ?? 0) * pageSize;
try {
version = String((rawClient.prepare('SELECT sqlite_version() AS v').get() as { v?: string })?.v ?? 'unknown');
} catch { /* ignore */ }
try {
// freelist_count is a read-only header read (reclaimable space). NB: do
// NOT read WAL size via `PRAGMA wal_checkpoint` — that pragma actively
// CHECKPOINTS the WAL (a write), which would contend on every scrape.
const fl = rawClient.prepare('PRAGMA freelist_count').get() as { freelist_count?: number };
if (fl?.freelist_count != null) extra.freelist_bytes = fl.freelist_count * pageSize;
} catch { /* ignore */ }
for (const table of METRICS_TABLES) {
try {
const r = rawClient.prepare(`SELECT count(*) AS n FROM ${table}`).get() as { n?: number };
rowCounts[table] = Number(r?.n ?? 0);
} catch { /* table may not exist */ }
}
}
} catch {
/* best-effort */
}
return { type, version, sizeBytes, rowCounts, extra };
}
+97 -15
View File
@@ -6,14 +6,14 @@
*/
import { homedir } from 'node:os';
import { existsSync, mkdirSync, rmSync, readdirSync } from 'node:fs';
import { join, resolve } from 'node:path';
import { existsSync } from 'node:fs';
import { join } from 'node:path';
import * as http from 'node:http';
import * as https from 'node:https';
import * as tls from 'node:tls';
import { createHash } from 'node:crypto';
import type { Environment } from './db';
import { getStackEnvVarsAsRecord } from './db';
import { getSetting } from './db';
import { getAdditionalVolumeBinds } from './mount-dedupe';
import { encodeRegistryAuth } from './registry-auth';
import { isSystemContainer } from './scheduler/tasks/update-utils';
@@ -1035,6 +1035,8 @@ export interface ContainerInfo {
id: string;
name: string;
image: string;
/** sha256 image ID (container.ImageID) — matches vulnerability scan.imageId. */
imageId: string;
state: string;
status: string;
created: number;
@@ -1161,6 +1163,7 @@ export async function listContainers(all = true, envId?: number | null): Promise
id: container.Id,
name: container.Names[0]?.replace(/^\//, '') || 'unnamed',
image: container.Image,
imageId: container.ImageID || '',
state: container.State,
status: container.Status,
created: container.Created,
@@ -2611,8 +2614,8 @@ export async function listImages(envId?: number | null): Promise<ImageInfo[]> {
/**
* Diagnostic log for registry auth headers (#1105).
* Logs lengths and boundary char codes never the plaintext credentials.
* Header prefix is safe to log: it encodes the start of `{"username":"...`.
* Logs only whether credentials were present and the target registry no
* credential-derived material (lengths, byte values, or header bytes).
*/
function logAuthDiagnostics(
tag: string,
@@ -2622,12 +2625,9 @@ function logAuthDiagnostics(
password: string,
authHeader: string
): void {
const userLast = username.length ? username.charCodeAt(username.length - 1).toString(16) : 'na';
const pwLast = password.length ? password.charCodeAt(password.length - 1).toString(16) : 'na';
console.log(
`${tag} auth: registry=${registry} user(len=${username.length},last=0x${userLast}) ` +
`pw(len=${password.length},last=0x${pwLast}) serveraddress=${serveraddress} ` +
`authHeader(len=${authHeader.length},prefix=${authHeader.slice(0, 16)})`
`${tag} auth: registry=${registry} serveraddress=${serveraddress} ` +
`hasUser=${username.length > 0} hasPassword=${password.length > 0} hasAuthHeader=${authHeader.length > 0}`
);
}
@@ -2720,6 +2720,10 @@ export async function pullImage(imageName: string, onProgress?: (data: any) => v
const decoder = new TextDecoder();
let buffer = '';
// Docker streams pull failures as {"error":...} lines inside a 200 response.
// Capture the last one and throw after the stream ends so callers see the
// failure (and onProgress still fires for the error line).
let streamError: string | null = null;
while (true) {
const { done, value } = await reader.read();
@@ -2735,6 +2739,7 @@ export async function pullImage(imageName: string, onProgress?: (data: any) => v
const data = JSON.parse(line);
if (data.error || data.errorDetail) {
console.error(`[Pull] stream error: ${line}`);
streamError = data.errorDetail?.message || data.error || 'Image pull failed';
}
if (onProgress) onProgress(data);
} catch {
@@ -2743,6 +2748,10 @@ export async function pullImage(imageName: string, onProgress?: (data: any) => v
}
}
}
if (streamError) {
throw new Error(`Failed to pull image: ${streamError}`);
}
}
export async function removeImage(id: string, force = false, envId?: number | null) {
@@ -4271,11 +4280,69 @@ export async function pruneContainers(envId?: number | null) {
}
export async function pruneImages(dangling = true, envId?: number | null) {
// dangling=true: only remove untagged images (default Docker behavior)
// dangling=false: remove ALL unused images including tagged ones
// Docker API quirk: to remove all unused, we pass dangling=false filter
const filters = dangling ? '{"dangling":["true"]}' : '{"dangling":["false"]}';
return dockerJsonRequest(`/images/prune?filters=${encodeURIComponent(filters)}`, { method: 'POST' }, envId);
// dangling=true: only remove untagged images. Pass straight through —
// scanner images are always tagged so they can't be dangling anyway.
if (dangling) {
return dockerJsonRequest(
`/images/prune?filters=${encodeURIComponent('{"dangling":["true"]}')}`,
{ method: 'POST' },
envId
);
}
// dangling=false: "prune all unused." When the scanner-protection setting
// is on, shield grype + trivy with stopped holder containers (Docker's
// "in use" check keeps them) then tear them down in a finally (#625).
const protect = (await getSetting('protect_scanner_images')) !== false;
if (!protect) {
return dockerJsonRequest(
`/images/prune?filters=${encodeURIComponent('{"dangling":["false"]}')}`,
{ method: 'POST' },
envId
);
}
const { DEFAULT_GRYPE_IMAGE, DEFAULT_TRIVY_IMAGE } = await import('./scanner');
const grypeImg = (await getSetting('default_grype_image')) ?? DEFAULT_GRYPE_IMAGE;
const trivyImg = (await getSetting('default_trivy_image')) ?? DEFAULT_TRIVY_IMAGE;
const holderIds: string[] = [];
for (const image of [grypeImg, trivyImg]) {
try {
const safeName = `dockhand-prune-keep-${image.replace(/[^a-z0-9]/gi, '-')}-${Date.now()}`;
const created = await dockerJsonRequest<{ Id: string }>(
`/containers/create?name=${encodeURIComponent(safeName)}`,
{
method: 'POST',
body: JSON.stringify({ Image: image, Cmd: ['/bin/true'] })
},
envId
);
holderIds.push(created.Id);
} catch {
// Image not present locally → nothing to protect → no-op
}
}
try {
return await dockerJsonRequest(
`/images/prune?filters=${encodeURIComponent('{"dangling":["false"]}')}`,
{ method: 'POST' },
envId
);
} finally {
for (const id of holderIds) {
try {
await dockerJsonRequest(
`/containers/${id}?force=true`,
{ method: 'DELETE' },
envId
);
} catch {
// Best-effort cleanup; orphan holders are visible but harmless
}
}
}
}
export async function pruneVolumes(envId?: number | null) {
@@ -4443,6 +4510,8 @@ export async function runContainer(options: {
extraHosts?: string[];
name?: string;
envId?: number | null;
networkMode?: string; // Docker network mode (e.g., 'host', 'bridge', custom network name)
dns?: string[]; // Custom DNS servers; undefined = inherit from Docker daemon
}): Promise<{ stdout: string; stderr: string }> {
// Add random suffix to avoid naming conflicts
const baseName = options.name || `dockhand-temp-${Date.now()}`;
@@ -4466,6 +4535,14 @@ export async function runContainer(options: {
containerConfig.HostConfig.ExtraHosts = options.extraHosts;
}
if (options.networkMode) {
containerConfig.HostConfig.NetworkMode = options.networkMode;
}
if (options.dns && options.dns.length > 0) {
containerConfig.HostConfig.Dns = options.dns;
}
const createResult = await dockerJsonRequest<{ Id: string }>(
`/containers/create?name=${encodeURIComponent(containerName)}`,
{
@@ -4533,6 +4610,7 @@ export async function runContainerWithStreaming(options: {
onStderr?: (data: string) => void;
timeout?: number; // Overall timeout in ms (0 or undefined = no timeout)
networkMode?: string; // Docker network mode (e.g., network name for TCP access)
dns?: string[]; // Custom DNS servers; undefined = inherit from Docker daemon
}): Promise<string> {
const baseName = options.name || `dockhand-stream-${Date.now()}`;
const containerName = `${baseName}-${randomSuffix()}`;
@@ -4567,6 +4645,10 @@ export async function runContainerWithStreaming(options: {
containerConfig.HostConfig.NetworkMode = options.networkMode;
}
if (options.dns && options.dns.length > 0) {
containerConfig.HostConfig.Dns = options.dns;
}
const createResult = await dockerJsonRequest<{ Id: string }>(
`/containers/create?name=${encodeURIComponent(containerName)}`,
{ method: 'POST', body: JSON.stringify(containerConfig) },
+66
View File
@@ -0,0 +1,66 @@
/** Shared helpers for the vulnerability export endpoints (env-wide + per-image). */
import { findingsToSarif } from '$lib/utils/sarif';
import type { Finding } from '$lib/utils/vulnerability';
/** JSON error response with the given status. */
export function jsonError(message: string, status: number): Response {
return new Response(JSON.stringify({ error: message }), {
status,
headers: { 'Content-Type': 'application/json' }
});
}
/** Make a value safe for use in a download filename. */
export function slugify(name: string, fallback = 'export'): string {
return name.trim().toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '') || fallback;
}
/**
* Build the export download response for json | csv | sarif. The caller supplies
* the format-specific JSON body and a CSV renderer; sarif is generated from the
* shared findings so both endpoints emit identical SARIF.
*/
export function exportResponse(opts: {
format: string;
filenameBase: string;
findings: Finding[];
jsonBody: unknown;
toCSV: (findings: Finding[]) => string;
}): Response {
const { format, filenameBase, findings, jsonBody, toCSV } = opts;
let content: string;
let contentType: string;
let ext: string;
switch (format) {
case 'csv':
content = toCSV(findings);
contentType = 'text/csv';
ext = 'csv';
break;
case 'sarif':
content = JSON.stringify(findingsToSarif(findings), null, 2);
contentType = 'application/sarif+json';
ext = 'sarif';
break;
case 'json':
default:
content = JSON.stringify(jsonBody, null, 2);
contentType = 'application/json';
ext = 'json';
break;
}
// Defensively strip anything that could break out of the quoted filename or
// inject a header, regardless of what the caller passed (today it's slug+date).
const safeBase = filenameBase.replace(/[^\w.-]+/g, '-').replace(/^-+|-+$/g, '') || 'export';
return new Response(content, {
status: 200,
headers: {
'Content-Type': contentType,
'Content-Disposition': `attachment; filename="${safeBase}.${ext}"`
}
});
}
+75
View File
@@ -0,0 +1,75 @@
/**
* Guard for the filesystem-browser endpoints (`/api/system/files*`).
*
* Dockhand runs in a container, so there is no host filesystem to leak users
* are allowed to browse the container freely (they need this to find/adopt
* external stacks). The only things that must never be readable are Dockhand's
* own secrets:
* - the database directory ($DATA_DIR/db, contains dockhand.db)
* - the encryption key file ($DATA_DIR/.encryption_key)
* - /proc /proc/<pid>/environ exposes process env vars, including
* DATABASE_URL (Postgres credentials) and ENCRYPTION_KEY. The whole tree is
* blocked because /proc/self, /proc/1, etc. all leak the same secrets and
* nothing legitimate is browsed there.
*
* isProtectedPath resolves symlinks (via the nearest existing ancestor) so a
* symlink pointing into a protected location can't be used to bypass the check.
*/
import { realpathSync } from 'node:fs';
import { resolve, dirname, join, basename, sep } from 'node:path';
const KEY_FILE_NAME = '.encryption_key';
function getDataDir(): string {
return process.env.DATA_DIR || './data';
}
/** Absolute paths Dockhand must never expose through the file browser. */
function protectedPaths(): { dbDir: string; keyFile: string } {
const dataDir = resolve(getDataDir());
return {
dbDir: join(dataDir, 'db'),
keyFile: join(dataDir, KEY_FILE_NAME)
};
}
/**
* Resolve `p` to an absolute, symlink-free path. If `p` (or part of it) does not
* exist yet, realpath the deepest existing ancestor and re-append the missing
* tail this still defeats a symlinked ancestor.
*/
function safeResolve(p: string): string {
let current = resolve(p);
const tail: string[] = [];
// Walk up until we hit a path that exists, realpath it, then rejoin the tail.
while (true) {
try {
const real = realpathSync(current);
return tail.length ? join(real, ...tail.reverse()) : real;
} catch {
const parent = dirname(current);
if (parent === current) {
// Reached the root without an existing ancestor; return as-is.
return resolve(p);
}
tail.push(basename(current));
current = parent;
}
}
}
function isInside(child: string, parent: string): boolean {
return child === parent || child.startsWith(parent + sep);
}
/**
* Returns true if `requestedPath` resolves to Dockhand's DB directory, the
* encryption-key file, or anywhere under /proc (process env / cmdline leak
* credentials). Such paths must be hidden from the file browser.
*/
export function isProtectedPath(requestedPath: string): boolean {
const { dbDir, keyFile } = protectedPaths();
const resolved = safeResolve(requestedPath);
return isInside(resolved, dbDir) || resolved === keyFile || isInside(resolved, '/proc');
}
+34 -9
View File
@@ -425,6 +425,9 @@ async function computeSyncDeletionPlan(options: {
/**
* Persist the manifest after a deploy and log the per-file change summary.
* Called only after a successful deploy (locally applied or agent-confirmed).
* Progress popovers show the plan-based change table before the deploy
* instead (#1260); this summary (with real apply results) goes to the
* server log only.
*/
async function finalizeDeletionSync(options: {
stackId: number;
@@ -434,9 +437,8 @@ async function finalizeDeletionSync(options: {
newFiles: Record<string, string>;
plan: DeletionPlan;
applyResult: DeletionApplyResult | undefined;
onLine?: (line: string) => void; // extra sink (job output)
}): Promise<void> {
const { stackId, logPrefix, previousManifest, newCommitFull, newFiles, plan, applyResult, onLine } = options;
const { stackId, logPrefix, previousManifest, newCommitFull, newFiles, plan, applyResult } = options;
// No apply result means deletions were requested but nothing reported back
// (defensive — executors always return one). Logged as skips; skips are final.
@@ -455,10 +457,6 @@ async function finalizeDeletionSync(options: {
for (const line of tableLines.slice(1)) {
console.log(`${logPrefix} ${line}`);
}
if (onLine) {
onLine(`File changes: ${tableLines[0]}`);
for (const line of tableLines.slice(1)) onLine(line);
}
const nextManifest = buildNextManifest(newCommitFull, newFiles);
await updateGitStack(stackId, { syncedFiles: serializeManifest(nextManifest) });
@@ -1458,6 +1456,23 @@ export async function deployGitStackWithProgress(
cleanupSshKey(credential);
// Show the git file changes BEFORE the deploy starts, so the user sees
// what changed while the deploy runs and the deploy start/result lines
// stay together (#1260). Removals reflect the deletion plan here;
// apply-stage divergences (rare) are reported after the deploy.
const changeTable = formatChangeTable(
buildSyncChangeSummary(
deletionData.previousManifest.files,
deletionData.newFiles,
{ deleted: deletionData.plan.toDelete.map((f) => f.path), skipped: [] },
deletionData.plan.skipped
)
);
onProgress({ status: 'deploying', message: `File changes: ${changeTable[0]}`, step: 5, totalSteps });
for (const line of changeTable.slice(1)) {
onProgress({ status: 'deploying', message: line, step: 5, totalSteps });
}
// Step 5: Deploying stack
// Uses `docker compose up -d --remove-orphans` which only recreates changed services
onProgress({ status: 'deploying', message: `Deploying ${gitStack.stackName}...`, step: 5, totalSteps });
@@ -1494,7 +1509,8 @@ export async function deployGitStackWithProgress(
if (result.success) {
// Deletion sync: persist manifest + log per-file change summary.
// onLine feeds the per-file change table into the deploy progress popover.
// The change table was already shown before the deploy (#1260);
// report only apply-stage divergences from the plan here.
await finalizeDeletionSync({
stackId,
logPrefix,
@@ -1502,10 +1518,19 @@ export async function deployGitStackWithProgress(
newCommitFull: newCommit,
newFiles: deletionData.newFiles,
plan: deletionData.plan,
applyResult: result.deletion,
onLine: (line) => onProgress({ status: 'deploying', message: line, step: 5, totalSteps })
applyResult: result.deletion
});
const applySkips = (result.deletion?.skipped ?? []).filter((s) => s.reason !== 'already-absent');
for (const skip of applySkips) {
onProgress({
status: 'deploying',
message: `Kept "${skip.path}" — ${skipReasonMessage(skip.reason)}`,
step: 5,
totalSteps
});
}
// Record the stack source with resolved compose path for consistency
const stackDir = await getStackDir(gitStack.stackName, gitStack.environmentId);
const resolvedComposePath = join(stackDir, progressComposeFileName);
+4
View File
@@ -1183,6 +1183,10 @@ async function handleHawserWsMessage(ws: any, msg: any, connId: string, remoteIp
return;
}
// Auth succeeded — clear any prior failure cooldown for this IP so a
// legitimate agent (e.g. after a token rotation) isn't locked out.
hawserAuthFailCache.delete(rateLimitKey);
// Throttle reconnection storms (successful auth but broken Docker = rapid reconnect loop)
const throttle = recordReconnection(result.environmentId);
if (!throttle.allowed) {
+22
View File
@@ -12,6 +12,8 @@ export interface Job {
result?: unknown;
createdAt: number;
updatedAt: number;
/** Set when a client requests cancellation; long-running jobs should poll this. */
cancelRequested?: boolean;
}
const jobs = new Map<string, Job>();
@@ -32,6 +34,17 @@ export function getJob(id: string): Job | undefined {
return jobs.get(id);
}
/** Live job counts by status — for the metrics endpoint. */
export function getJobStats(): { running: number; done: number; error: number; total: number } {
let running = 0, done = 0, error = 0;
for (const job of jobs.values()) {
if (job.status === 'running') running++;
else if (job.status === 'done') done++;
else if (job.status === 'error') error++;
}
return { running, done, error, total: jobs.size };
}
export function appendLine(job: Job, line: JobLine): void {
job.lines.push(line);
job.updatedAt = Date.now();
@@ -49,6 +62,15 @@ export function failJob(job: Job, error: string): void {
job.updatedAt = Date.now();
}
/** Request cancellation of a running job. The job's operation must poll job.cancelRequested. */
export function cancelJob(id: string): boolean {
const job = jobs.get(id);
if (!job || job.status !== 'running') return false;
job.cancelRequested = true;
job.updatedAt = Date.now();
return true;
}
// Cleanup jobs older than 10 minutes that are no longer running
const CLEANUP_INTERVAL_MS = 60_000;
const JOB_TTL_MS = 10 * 60_000;
+349
View File
@@ -0,0 +1,349 @@
/**
* Prometheus metrics for Dockhand (#339), gated by `EXPORT_METRICS=true`.
*
* Three families of series:
* 1. Per-environment DOCKER STATE containers by state/health, images (count +
* bytes), volumes, networks, stacks, vulnerabilities by severity, and event
* counts. Gathered fresh (per scrape) but behind a short TTL cache so a tight
* scrape interval can't hammer Docker; a dead environment is skipped, not fatal.
* 2. Per-environment RESOURCE usage cpu/memory. NOT re-collected here: these are
* surfaced from the Go collector's samples via metrics-store, so we add no load.
* 3. Dockhand INTERNALS build/uptime, process memory (prom-client defaults),
* hawser edge agents, jobs, scan queue, cache occupancy, scheduler, database.
*
* The registry and all metric objects are created once (module singletons). Each
* scrape refreshes gauge values; prom-client renders the exposition text.
*/
import { Registry, collectDefaultMetrics, Gauge } from 'prom-client';
import {
getEnvironments, getContainerEventStats, getPendingContainerUpdates,
getUsers, getRegistries, getScanFreshness, getScheduleStats,
getGitRepositories, getConfigSets
} from '$lib/server/db';
import { getApiTokenStats } from '$lib/server/api-tokens';
import { listContainers, listImages, listVolumes, listNetworks } from '$lib/server/docker';
import { listComposeStacks } from '$lib/server/stacks';
import { getVulnerabilitiesMeta } from '$lib/server/vulnerabilities';
import { getVulnerabilitiesCacheStats } from '$lib/server/vulnerabilities-cache';
import { getLatestMetric } from '$lib/server/metrics-store';
import { getServerUptime } from '$lib/server/uptime';
import { getJobStats } from '$lib/server/jobs';
import { getScannerStats } from '$lib/server/scanner';
import { getSchedulerStats } from '$lib/server/scheduler';
import { edgeConnections } from '$lib/server/hawser';
import { getDatabaseStats } from '$lib/server/db/drizzle';
export const METRICS_ENABLED = process.env.EXPORT_METRICS === 'true';
/** How long a gathered snapshot is reused before a scrape re-collects (ms). */
const COLLECT_TTL_MS = 15_000;
/** Hard cap on a full gather so a slow/hung environment can never stall a scrape. */
const COLLECT_TIMEOUT_MS = 10_000;
/** Max environments gathered concurrently bounds the Docker/DB call burst so a
* scrape never monopolizes those resources against live user traffic. */
const ENV_CONCURRENCY = 4;
const PREFIX = 'dockhand_';
const appVersion = typeof __APP_VERSION__ !== 'undefined' ? (__APP_VERSION__ ?? 'unknown') : 'unknown';
// ---------------------------------------------------------------------------
// Registry + metric definitions (created once).
// ---------------------------------------------------------------------------
const registry = new Registry();
// Free Node/process metrics: heap, RSS, GC, event-loop lag, open FDs, etc.
collectDefaultMetrics({ register: registry, prefix: PREFIX });
const g = (name: string, help: string, labelNames: string[] = []) =>
new Gauge({ name: PREFIX + name, help, labelNames, registers: [registry] });
// --- Build / process ---
const buildInfo = g('build_info', 'Dockhand build info; value is always 1.', ['version']);
const uptimeSeconds = g('uptime_seconds', 'Seconds since the Dockhand server started.');
// --- Per-environment state ---
const envUp = g('env_up', '1 if the environment responded to the last scrape, else 0.', ['env', 'env_id', 'connection_type']);
const containers = g('containers', 'Containers by lifecycle state.', ['env', 'env_id', 'state']);
const containersHealth = g('containers_health', 'Containers by health status.', ['env', 'env_id', 'health']);
const containersRestarts = g('container_restarts_total', 'Sum of container restart counts.', ['env', 'env_id']);
const containersTotal = g('containers_count', 'Total containers (all states).', ['env', 'env_id']);
const updatesAvailable = g('updates_available', 'Containers with a pending image update.', ['env', 'env_id']);
const imagesTotal = g('images_count', 'Number of images.', ['env', 'env_id']);
const imagesDangling = g('images_dangling', 'Untagged (dangling) images.', ['env', 'env_id']);
const imageBytes = g('image_bytes', 'Total on-disk size of images in bytes.', ['env', 'env_id']);
const volumesTotal = g('volumes_count', 'Number of volumes.', ['env', 'env_id']);
const networksTotal = g('networks_count', 'Number of networks.', ['env', 'env_id']);
const stacksTotal = g('stacks_count', 'Number of compose stacks.', ['env', 'env_id']);
const vulnerabilities = g('vulnerabilities', 'Vulnerability findings by severity.', ['env', 'env_id', 'severity']);
const vulnerabilitiesTotal = g('vulnerabilities_count', 'Total vulnerability findings.', ['env', 'env_id']);
const imagesScanned = g('images_scanned', 'Images with at least one persisted scan.', ['env', 'env_id']);
const eventsTotal = g('container_events_total', 'Recorded container events (all time).', ['env', 'env_id']);
const eventsToday = g('container_events_today', 'Recorded container events since local midnight.', ['env', 'env_id']);
const eventsByAction = g('container_events_by_action', 'Recorded container events by action type.', ['env', 'env_id', 'action']);
const scanOldestAge = g('scan_oldest_age_seconds', 'Age of the oldest current scan (staleness).', ['env', 'env_id']);
const scanAvgDuration = g('scan_avg_duration_seconds', 'Average scan duration.', ['env', 'env_id']);
// --- Per-environment resources (from the Go collector, via metrics-store) ---
const envCpu = g('env_cpu_percent', 'Host CPU usage percent (from the collector).', ['env', 'env_id']);
const envMemUsed = g('env_memory_used_bytes', 'Host memory used in bytes (from the collector).', ['env', 'env_id']);
const envMemTotal = g('env_memory_total_bytes', 'Host memory total in bytes (from the collector).', ['env', 'env_id']);
// --- Internals ---
const environmentsTotal = g('environments', 'Configured environments.');
const usersTotal = g('users', 'Configured users.');
const registriesTotal = g('registries', 'Configured image registries.');
const gitReposTotal = g('git_repositories', 'Configured git repositories.');
const configSetsTotal = g('config_sets', 'Saved config sets.');
const apiTokens = g('api_tokens', 'API tokens by state.', ['state']);
const scheduleExec = g('schedule_executions', 'Scheduled task executions by type and status.', ['type', 'status']);
const scheduleLastRun = g('schedule_last_run_seconds', 'Age of the last execution, by schedule type.', ['type']);
const scheduleLastSuccess = g('schedule_last_success_seconds', 'Age of the last SUCCESSFUL execution, by schedule type.', ['type']);
const hawserAgents = g('hawser_agents_connected', 'Connected hawser edge agents.');
const hawserPending = g('hawser_pending_requests', 'In-flight requests across all edge agents.');
const hawserAgentInfo = g('hawser_agent_info', 'Connected edge agent info; value is connection age in seconds.', ['env_id', 'agent', 'agent_version', 'docker_version', 'hostname']);
const jobs = g('jobs', 'Background SSE jobs by status.', ['status']);
const scanQueue = g('scan_queue', 'Vulnerability scan queue depth.', ['kind']);
const cacheEntries = g('vuln_cache_entries', 'Vulnerability aggregation cache occupancy.', ['kind']);
const schedulerRunning = g('scheduler_running', '1 if the scheduler is running, else 0.');
const schedulerJobs = g('scheduler_active_jobs', 'Active scheduled cron jobs.');
const dbSizeBytes = g('database_size_bytes', 'On-disk database size in bytes.', ['type']);
const dbInfo = g('database_info', 'Database engine info; value is always 1.', ['type', 'version']);
const dbRows = g('database_rows', 'Row counts for high-churn tables.', ['table']);
const dbExtra = g('database_stat', 'Engine-specific DB gauges (postgres connections, sqlite wal/freelist bytes, ...).', ['type', 'stat']);
// ---------------------------------------------------------------------------
// Collection.
// ---------------------------------------------------------------------------
let lastCollectAt = 0;
let inflight: Promise<void> | null = null;
/** Gather one environment's Docker state, tolerating a dead host. */
async function collectEnvironment(env: { id: number; name: string; connectionType?: string | null }): Promise<void> {
const labels = { env: env.name, env_id: String(env.id) };
const upLabels = { ...labels, connection_type: env.connectionType ?? 'socket' };
// Emit env_up=0 up front so the series ALWAYS exists for every configured env,
// even if this gather times out before the calls below settle — otherwise a
// down env would have no series at all and `dockhand_env_up == 0` alerts would
// never fire. Flipped to 1 only once Docker actually responds.
envUp.set(upLabels, 0);
// Resource usage from the collector's last sample — cheap, never touches Docker.
const point = getLatestMetric(env.id);
if (point) {
envCpu.set(labels, point.cpuPercent);
envMemUsed.set(labels, point.memoryUsed);
envMemTotal.set(labels, point.memoryTotal);
}
// One failing call marks the env down but doesn't abort the others.
let up = 1;
const [cs, imgs, vols, nets, stacks, vuln, events, updates, freshness] = await Promise.allSettled([
listContainers(true, env.id),
listImages(env.id),
listVolumes(env.id),
listNetworks(env.id),
listComposeStacks(env.id),
getVulnerabilitiesMeta(env.id),
getContainerEventStats(env.id),
getPendingContainerUpdates(env.id),
getScanFreshness(env.id)
]);
if (cs.status === 'fulfilled') {
const byState: Record<string, number> = {};
const byHealth: Record<string, number> = {};
let restarts = 0;
for (const c of cs.value) {
byState[c.state] = (byState[c.state] ?? 0) + 1;
if (c.health) byHealth[c.health] = (byHealth[c.health] ?? 0) + 1;
restarts += c.restartCount ?? 0;
}
for (const [state, n] of Object.entries(byState)) containers.set({ ...labels, state }, n);
for (const [health, n] of Object.entries(byHealth)) containersHealth.set({ ...labels, health }, n);
containersTotal.set(labels, cs.value.length);
containersRestarts.set(labels, restarts);
} else up = 0; // ANY failure of a core Docker call means the env isn't fully reachable
if (imgs.status === 'fulfilled') {
imagesTotal.set(labels, imgs.value.length);
// Dangling = no usable repo tag (untagged layers left after rebuilds/pulls).
const dangling = imgs.value.filter((i) => {
const tags = i.tags ?? [];
return tags.length === 0 || tags.every((t) => t === '<none>:<none>' || t.startsWith('<none>'));
}).length;
imagesDangling.set(labels, dangling);
imageBytes.set(labels, imgs.value.reduce((sum, i) => sum + (i.size ?? 0), 0));
} else up = 0;
if (vols.status === 'fulfilled') volumesTotal.set(labels, vols.value.length);
else up = 0;
if (nets.status === 'fulfilled') networksTotal.set(labels, nets.value.length);
else up = 0;
if (stacks.status === 'fulfilled') stacksTotal.set(labels, stacks.value.length);
if (updates.status === 'fulfilled') updatesAvailable.set(labels, updates.value.length);
if (vuln.status === 'fulfilled') {
const s = vuln.value.summary;
vulnerabilities.set({ ...labels, severity: 'critical' }, s.critical);
vulnerabilities.set({ ...labels, severity: 'high' }, s.high);
vulnerabilities.set({ ...labels, severity: 'medium' }, s.medium);
vulnerabilities.set({ ...labels, severity: 'low' }, s.low);
vulnerabilitiesTotal.set(labels, s.total);
imagesScanned.set(labels, s.imagesScanned);
}
if (events.status === 'fulfilled') {
eventsTotal.set(labels, events.value.total);
eventsToday.set(labels, events.value.today);
for (const [action, n] of Object.entries(events.value.byAction ?? {})) {
eventsByAction.set({ ...labels, action }, n);
}
}
if (freshness.status === 'fulfilled') {
if (freshness.value.oldestAgeSeconds !== null) scanOldestAge.set(labels, freshness.value.oldestAgeSeconds);
if (freshness.value.avgDurationSeconds !== null) scanAvgDuration.set(labels, freshness.value.avgDurationSeconds);
}
envUp.set(upLabels, up);
}
/** Refresh Dockhand's own internal gauges — all cheap in-memory reads + one DB stat. */
async function collectInternals(envCount: number): Promise<void> {
buildInfo.set({ version: appVersion }, 1);
uptimeSeconds.set(getServerUptime());
environmentsTotal.set(envCount);
try { usersTotal.set((await getUsers()).length); } catch { /* auth may be off */ }
try { registriesTotal.set((await getRegistries()).length); } catch { /* best-effort */ }
try { gitReposTotal.set((await getGitRepositories()).length); } catch { /* best-effort */ }
try { configSetsTotal.set((await getConfigSets()).length); } catch { /* best-effort */ }
try {
const t = await getApiTokenStats();
apiTokens.set({ state: 'total' }, t.total);
apiTokens.set({ state: 'expired' }, t.expired);
apiTokens.set({ state: 'never_used' }, t.neverUsed);
} catch { /* best-effort */ }
try {
scheduleExec.reset(); scheduleLastRun.reset(); scheduleLastSuccess.reset();
const s = await getScheduleStats();
for (const r of s.byTypeStatus) scheduleExec.set({ type: r.type, status: r.status }, r.count);
for (const [type, age] of Object.entries(s.lastRunSecondsByType)) scheduleLastRun.set({ type }, age);
for (const [type, age] of Object.entries(s.lastSuccessSecondsByType)) scheduleLastSuccess.set({ type }, age);
} catch { /* best-effort */ }
hawserAgents.set(edgeConnections.size);
hawserAgentInfo.reset();
let pending = 0;
const nowMs = Date.now();
for (const [envId, conn] of edgeConnections) {
pending += conn.pendingRequests.size;
hawserAgentInfo.set({
env_id: String(envId),
agent: conn.agentName || conn.agentId,
agent_version: conn.agentVersion || 'unknown',
docker_version: conn.dockerVersion || 'unknown',
hostname: conn.hostname || 'unknown'
}, Math.max(0, Math.round((nowMs - conn.connectedAt.getTime()) / 1000)));
}
hawserPending.set(pending);
const j = getJobStats();
jobs.set({ status: 'running' }, j.running);
jobs.set({ status: 'done' }, j.done);
jobs.set({ status: 'error' }, j.error);
const sc = getScannerStats();
scanQueue.set({ kind: 'in_progress' }, sc.inProgress);
scanQueue.set({ kind: 'locked' }, sc.locked);
const cache = getVulnerabilitiesCacheStats();
cacheEntries.set({ kind: 'envs' }, cache.envs);
cacheEntries.set({ kind: 'views' }, cache.views);
cacheEntries.set({ kind: 'inflight' }, cache.inflight);
const sched = getSchedulerStats();
schedulerRunning.set(sched.running ? 1 : 0);
schedulerJobs.set(sched.activeJobs);
try {
const db = await getDatabaseStats();
dbSizeBytes.set({ type: db.type }, db.sizeBytes);
dbInfo.set({ type: db.type, version: db.version }, 1);
for (const [table, n] of Object.entries(db.rowCounts)) dbRows.set({ table }, n);
for (const [stat, n] of Object.entries(db.extra)) dbExtra.set({ type: db.type, stat }, n);
} catch { /* best-effort */ }
}
/**
* Reset per-env gauges before a fresh gather so a removed environment (or a
* container state that disappeared) doesn't leave a stale series lingering.
*/
function resetPerEnvGauges(): void {
for (const m of [
envUp, containers, containersHealth, containersTotal, containersRestarts, updatesAvailable,
imagesTotal, imagesDangling, imageBytes, volumesTotal, networksTotal, stacksTotal,
vulnerabilities, vulnerabilitiesTotal, imagesScanned, eventsTotal, eventsToday, eventsByAction,
scanOldestAge, scanAvgDuration, envCpu, envMemUsed, envMemTotal
]) m.reset();
}
/**
* Gather everything. Resolves `true` if the full gather completed within the
* timeout, `false` if it timed out. Bounding the wait protects the live UI from
* a hung environment; the caller uses the return value so a timed-out (partial)
* gather is NOT cached as if it were fresh the next scrape re-collects.
*/
/** Run `worker` over `items` with at most `limit` in flight at once. */
async function mapLimit<T>(items: T[], limit: number, worker: (item: T) => Promise<void>): Promise<void> {
let i = 0;
const runners = Array.from({ length: Math.min(limit, items.length) }, async () => {
while (i < items.length) {
const item = items[i++];
await worker(item).catch(() => { /* isolated per item */ });
}
});
await Promise.all(runners);
}
async function collectAll(): Promise<boolean> {
const envs = await getEnvironments();
resetPerEnvGauges();
const TIMED_OUT = Symbol('timeout');
const deadline = new Promise<typeof TIMED_OUT>((resolve) => setTimeout(() => resolve(TIMED_OUT), COLLECT_TIMEOUT_MS));
// Gather envs in bounded batches (not all at once) so the Docker/DB call burst
// can't monopolize resources against live traffic; internals run alongside.
const gather = Promise.all([
mapLimit(envs, ENV_CONCURRENCY, collectEnvironment),
collectInternals(envs.length).catch(() => {})
]).then(() => true as const);
const result = await Promise.race([gather, deadline]);
return result !== TIMED_OUT;
}
/**
* Render the exposition text. Collection is refreshed at most once per
* COLLECT_TTL_MS; concurrent scrapes within the window share one gather. A
* gather that times out does NOT advance the cache clock, so the next scrape
* retries rather than serving the partial snapshot for a full TTL.
*/
export async function renderMetrics(): Promise<string> {
const now = Date.now();
if (now - lastCollectAt >= COLLECT_TTL_MS) {
if (!inflight) {
inflight = collectAll()
.then((completed) => { if (completed) lastCollectAt = Date.now(); })
.catch((e) => { console.error('[metrics] collection failed:', e); })
.finally(() => { inflight = null; });
}
await inflight;
}
return registry.metrics();
}
export const metricsContentType = registry.contentType;
+2 -2
View File
@@ -14,7 +14,7 @@
*
* Setup docs: https://github.com/caronc/apprise-api
*/
import { drainResponse, type NotificationPayload, type NotificationResult } from './shared';
import { notificationFetch, drainResponse, type NotificationPayload, type NotificationResult } from './shared';
export async function sendApprise(appriseUrl: string, payload: NotificationPayload): Promise<NotificationResult> {
const isSecure = appriseUrl.startsWith('apprises');
@@ -65,7 +65,7 @@ export async function sendApprise(appriseUrl: string, payload: NotificationPaylo
if (format) body.format = format; // text | markdown | html
try {
const response = await fetch(`${baseUrl}/notify/${encodeURIComponent(key)}`, {
const response = await notificationFetch(`${baseUrl}/notify/${encodeURIComponent(key)}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body)
+2 -1
View File
@@ -12,6 +12,7 @@
* ?group=, ?icon=, ?url=, ?badge=N, ?copy=, ?subtitle=,
* ?volume=, ?ttl=, ?call=1, ?autoCopy=1, ?isArchive=1, ?action=none
*/
import { notificationFetch } from './shared';
import type { NotificationPayload, NotificationResult } from './shared';
export async function sendBark(appriseUrl: string, payload: NotificationPayload): Promise<NotificationResult> {
@@ -93,7 +94,7 @@ export async function sendBark(appriseUrl: string, payload: NotificationPayload)
}
try {
const response = await fetch(`${baseUrl}/push`, {
const response = await notificationFetch(`${baseUrl}/push`, {
method: 'POST',
headers: { 'Content-Type': 'application/json; charset=utf-8' },
body: JSON.stringify(body)
+2 -2
View File
@@ -1,5 +1,5 @@
/** Discord webhook notifications. discord:// or discords://. */
import { drainResponse, type NotificationPayload, type NotificationResult } from './shared';
import { notificationFetch, drainResponse, type NotificationPayload, type NotificationResult } from './shared';
export async function sendDiscord(appriseUrl: string, payload: NotificationPayload): Promise<NotificationResult> {
// discord://webhook_id/webhook_token or discords://...
@@ -7,7 +7,7 @@ export async function sendDiscord(appriseUrl: string, payload: NotificationPaylo
const titleWithEnv = payload.environmentName ? `${payload.title} [${payload.environmentName}]` : payload.title;
try {
const response = await fetch(url, {
const response = await notificationFetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
@@ -1,12 +1,12 @@
/** Generic JSON webhook. json:// or jsons:// (HTTPS). */
import { drainResponse, type NotificationPayload, type NotificationResult } from './shared';
import { notificationFetch, drainResponse, type NotificationPayload, type NotificationResult } from './shared';
export async function sendGenericWebhook(appriseUrl: string, payload: NotificationPayload): Promise<NotificationResult> {
// json://hostname/path or jsons://hostname/path
const url = appriseUrl.replace(/^jsons?:\/\//, appriseUrl.startsWith('jsons') ? 'https://' : 'http://');
try {
const response = await fetch(url, {
const response = await notificationFetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
+2 -2
View File
@@ -1,6 +1,6 @@
/** Gotify. gotify:// or gotifys:// (HTTPS). */
import { buildGotifyUrl } from '$lib/utils/notification-parsers';
import { drainResponse, type NotificationPayload, type NotificationResult } from './shared';
import { notificationFetch, drainResponse, type NotificationPayload, type NotificationResult } from './shared';
export async function sendGotify(appriseUrl: string, payload: NotificationPayload): Promise<NotificationResult> {
const parsed = buildGotifyUrl(appriseUrl);
@@ -12,7 +12,7 @@ export async function sendGotify(appriseUrl: string, payload: NotificationPayloa
const defaultPriority = payload.type === 'error' ? 8 : payload.type === 'warning' ? 5 : 2;
try {
const response = await fetch(parsed.url, {
const response = await notificationFetch(parsed.url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
+2 -2
View File
@@ -1,5 +1,5 @@
/** Mattermost incoming webhook. mmost:// or mmosts:// (HTTPS). */
import { drainResponse, type NotificationPayload, type NotificationResult } from './shared';
import { notificationFetch, drainResponse, type NotificationPayload, type NotificationResult } from './shared';
export async function sendMattermost(appriseUrl: string, payload: NotificationPayload): Promise<NotificationResult> {
// mmost://[botname@]hostname[:port][/path]/token or mmosts://...
@@ -37,7 +37,7 @@ export async function sendMattermost(appriseUrl: string, payload: NotificationPa
}
try {
const response = await fetch(url, {
const response = await notificationFetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body)
+39 -6
View File
@@ -1,5 +1,32 @@
/** ntfy.sh + self-hosted ntfy. ntfy:// or ntfys:// (HTTPS). */
import { drainResponse, type NotificationPayload, type NotificationResult } from './shared';
import { notificationFetch, drainResponse, type NotificationPayload, type NotificationResult } from './shared';
// HTTP header values must be ByteString (every char ≤ 0xFF). Reject anything
// else so we never blow up fetch() with a 65533 (U+FFFD) replacement char.
function isHeaderSafe(value: string): boolean {
for (let i = 0; i < value.length; i++) {
if (value.charCodeAt(i) > 0xff) return false;
}
return true;
}
// ?auth= accepts either a raw ntfy access token (typically `tk_...`) or a
// base64-encoded `Bearer <token>` / `Basic <creds>` string. We try base64 first
// only when the input looks plausibly base64; if decoding produces non-ASCII
// garbage we fall back to treating the value as a raw token.
export function resolveQueryAuth(queryAuth: string): string {
if (queryAuth.startsWith('tk_')) {
return `Bearer ${queryAuth}`;
}
const looksBase64 = /^[A-Za-z0-9+/=_-]+$/.test(queryAuth);
if (looksBase64) {
const decoded = Buffer.from(queryAuth, 'base64').toString();
if (isHeaderSafe(decoded) && (decoded.startsWith('Bearer ') || decoded.startsWith('Basic '))) {
return decoded;
}
}
return `Bearer ${queryAuth}`;
}
export async function sendNtfy(appriseUrl: string, payload: NotificationPayload): Promise<NotificationResult> {
// Supported formats:
@@ -7,8 +34,8 @@ export async function sendNtfy(appriseUrl: string, payload: NotificationPayload)
// ntfy://host/topic (custom server, no auth)
// ntfy://user:pass@host/topic (custom server with basic auth)
// ntfy://token@host/topic (custom server with bearer token)
// ntfy://host/topic?auth=BASE64 (custom server with base64-encoded bearer token)
// Query params: ?tags=ship,whale &title=Custom &priority=5
// ntfy://host/topic?auth=TOKEN (raw token, e.g. tk_..., or base64-encoded "Bearer <token>")
// Query params: ?tags=ship,whale &title=Custom &priority=5 &email=me@example.com
// ntfys:// variants for HTTPS
const isSecure = appriseUrl.startsWith('ntfys');
const path = appriseUrl.replace(/^ntfys?:\/\//, '');
@@ -20,6 +47,7 @@ export async function sendNtfy(appriseUrl: string, payload: NotificationPayload)
let queryTags: string | null = null;
let queryTitle: string | null = null;
let queryPriority: string | null = null;
let queryEmail: string | null = null;
let cleanPath = path;
const qIndex = path.indexOf('?');
if (qIndex !== -1) {
@@ -28,6 +56,7 @@ export async function sendNtfy(appriseUrl: string, payload: NotificationPayload)
queryTags = params.get('tags');
queryTitle = params.get('title');
queryPriority = params.get('priority');
queryEmail = params.get('email');
cleanPath = path.substring(0, qIndex);
}
@@ -53,8 +82,7 @@ export async function sendNtfy(appriseUrl: string, payload: NotificationPayload)
}
if (!authHeader && queryAuth) {
const decoded = Buffer.from(queryAuth, 'base64').toString();
authHeader = decoded.startsWith('Bearer ') ? decoded : `Bearer ${decoded}`;
authHeader = resolveQueryAuth(queryAuth);
}
const titleWithEnv = payload.environmentName ? `${payload.title} [${payload.environmentName}]` : payload.title;
@@ -69,8 +97,13 @@ export async function sendNtfy(appriseUrl: string, payload: NotificationPayload)
headers['Authorization'] = authHeader;
}
// ?email=<address> → ntfy's Email header so ntfy forwards the message as email (#1231)
if (queryEmail && isHeaderSafe(queryEmail)) {
headers['Email'] = queryEmail;
}
try {
const response = await fetch(url, {
const response = await notificationFetch(url, {
method: 'POST',
headers,
body: payload.message
+2 -2
View File
@@ -1,5 +1,5 @@
/** Pushover. pushover://user_key/api_token. */
import { drainResponse, type NotificationPayload, type NotificationResult } from './shared';
import { notificationFetch, drainResponse, type NotificationPayload, type NotificationResult } from './shared';
export async function sendPushover(appriseUrl: string, payload: NotificationPayload): Promise<NotificationResult> {
const match = appriseUrl.match(/^pushover:\/\/([^/]+)\/(.+)/);
@@ -12,7 +12,7 @@ export async function sendPushover(appriseUrl: string, payload: NotificationPayl
const titleWithEnv = payload.environmentName ? `${payload.title} [${payload.environmentName}]` : payload.title;
try {
const response = await fetch(url, {
const response = await notificationFetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
+19
View File
@@ -19,6 +19,25 @@ export interface NotificationResult {
error?: string;
}
/**
* Timeout for an outbound notification request, overridable via
* NOTIFICATION_TIMEOUT_MS. Falls back to 10s if unset or invalid (a bad value
* must not silently disable the timeout).
*/
const NOTIFICATION_TIMEOUT_MS = (() => {
const parsed = Number(process.env.NOTIFICATION_TIMEOUT_MS);
return Number.isFinite(parsed) && parsed > 0 ? parsed : 10_000;
})();
/**
* fetch() with a per-request timeout. Notification destinations are
* user-configured (and a potential SSRF target); without a timeout a hung
* endpoint pins the request indefinitely and blocks the rest of the dispatch.
*/
export function notificationFetch(input: string | URL, init: RequestInit = {}): Promise<Response> {
return fetch(input, { ...init, signal: init.signal ?? AbortSignal.timeout(NOTIFICATION_TIMEOUT_MS) });
}
/** Drain a response body to release the underlying socket/TLS connection. */
export async function drainResponse(response: Response): Promise<void> {
if (!response.bodyUsed) {
+2 -2
View File
@@ -11,7 +11,7 @@
* numbers (numeric, '+' gets added) or group IDs (signal-cli's "group.<base64>"
* form, passed through untouched).
*/
import { drainResponse, type NotificationPayload, type NotificationResult } from './shared';
import { notificationFetch, drainResponse, type NotificationPayload, type NotificationResult } from './shared';
export async function sendSignal(appriseUrl: string, payload: NotificationPayload): Promise<NotificationResult> {
const isSecure = appriseUrl.startsWith('signals');
@@ -49,7 +49,7 @@ export async function sendSignal(appriseUrl: string, payload: NotificationPayloa
const baseUrl = `${isSecure ? 'https' : 'http'}://${hostPort}`;
try {
const response = await fetch(`${baseUrl}/v2/send`, {
const response = await notificationFetch(`${baseUrl}/v2/send`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
+2 -2
View File
@@ -1,5 +1,5 @@
/** Slack incoming webhook. slack:// or slacks:// or a raw hooks.slack.com URL. */
import { drainResponse, type NotificationPayload, type NotificationResult } from './shared';
import { notificationFetch, drainResponse, type NotificationPayload, type NotificationResult } from './shared';
export async function sendSlack(appriseUrl: string, payload: NotificationPayload): Promise<NotificationResult> {
// slack://token_a/token_b/token_c or webhook URL
@@ -14,7 +14,7 @@ export async function sendSlack(appriseUrl: string, payload: NotificationPayload
const envTag = payload.environmentName ? ` \`${payload.environmentName}\`` : '';
try {
const response = await fetch(url, {
const response = await notificationFetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
+2 -2
View File
@@ -1,6 +1,6 @@
/** Telegram bot. tgram://bot_token/chat_id[:topic_id]. */
import { escapeTelegramMarkdown, parseTelegramUrl } from '$lib/utils/notification-parsers';
import { drainResponse, type NotificationPayload, type NotificationResult } from './shared';
import { notificationFetch, drainResponse, type NotificationPayload, type NotificationResult } from './shared';
export async function sendTelegram(appriseUrl: string, payload: NotificationPayload): Promise<NotificationResult> {
const parsed = parseTelegramUrl(appriseUrl);
@@ -16,7 +16,7 @@ export async function sendTelegram(appriseUrl: string, payload: NotificationPayl
const envTag = payload.environmentName ? ` [${escapeTelegramMarkdown(payload.environmentName)}]` : '';
try {
const response = await fetch(url, {
const response = await notificationFetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
+2 -2
View File
@@ -1,6 +1,6 @@
/** Microsoft Power Automate Workflows (e.g. Microsoft Teams). workflows://. */
import { parseWorkflowsUrl, buildWorkflowsHttpUrl } from '$lib/utils/notification-parsers';
import { drainResponse, type NotificationPayload, type NotificationResult } from './shared';
import { notificationFetch, drainResponse, type NotificationPayload, type NotificationResult } from './shared';
export async function sendWorkflows(appriseUrl: string, payload: NotificationPayload): Promise<NotificationResult> {
const parsed = parseWorkflowsUrl(appriseUrl);
@@ -12,7 +12,7 @@ export async function sendWorkflows(appriseUrl: string, payload: NotificationPay
const titleWithEnv = payload.environmentName ? `${payload.title} [${payload.environmentName}]` : payload.title;
try {
const response = await fetch(url, {
const response = await notificationFetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
+88 -33
View File
@@ -28,6 +28,7 @@ import {
} from './host-path';
import { resolve } from 'node:path';
import { mkdir, chown, rm } from 'node:fs/promises';
import { resolveScannerOverrides } from '$lib/utils/scanner-overrides';
export type ScannerType = 'none' | 'grype' | 'trivy' | 'both';
@@ -114,13 +115,19 @@ async function withScannerLock<T>(scannerType: string, fn: () => Promise<T>): Pr
// Key: "{scannerType}:{imageName}", Value: Promise that resolves to the scan result
const inProgressScans = new Map<string, Promise<string>>();
/** Scanner queue depth for the metrics endpoint. `inProgress` = distinct
* image scans running/deduped; `locked` = scanner types holding the serial lock. */
export function getScannerStats(): { inProgress: number; locked: number } {
return { inProgress: inProgressScans.size, locked: scannerLocks.size };
}
// Default CLI arguments for scanners (image name is substituted for {image})
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 const DEFAULT_GRYPE_IMAGE = 'anchore/grype:v0.115.0';
export const DEFAULT_TRIVY_IMAGE = 'aquasec/trivy:0.71.2';
export interface VulnerabilitySeverity {
critical: number;
@@ -153,6 +160,31 @@ export interface ScanResult {
error?: string;
}
/**
* Convert a ScanResult into the row shape expected by saveVulnerabilityScan().
* Note: `vulnerabilities` is passed as the raw array saveVulnerabilityScan()
* does the JSON.stringify. (The previous inline copy of this helper stringified
* here too, which double-encoded the column; readers must tolerate both.)
*/
export function scanResultToDbFormat(result: ScanResult, envId?: number | null) {
return {
environmentId: envId ?? null,
imageId: result.imageId || result.imageName, // Fallback to imageName if imageId is undefined
imageName: result.imageName,
scanner: result.scanner,
scannedAt: result.scannedAt,
scanDuration: result.scanDuration,
criticalCount: result.summary.critical,
highCount: result.summary.high,
mediumCount: result.summary.medium,
lowCount: result.summary.low,
negligibleCount: result.summary.negligible,
unknownCount: result.summary.unknown,
vulnerabilities: result.vulnerabilities,
error: result.error ?? null
};
}
export interface ScanProgress {
stage: 'checking' | 'pulling-scanner' | 'scanning' | 'parsing' | 'complete' | 'error';
message: string;
@@ -164,24 +196,47 @@ export interface ScanProgress {
output?: string; // Line of scanner output
}
function parseDnsSetting(raw: string | null | undefined): string[] {
if (!raw) return [];
const trimmed = raw.trim();
if (!trimmed) return [];
// Tolerate either JSON-array storage (preferred) or comma-separated string
// (defensive — UI submits JSON, but legacy / hand-edited values may exist).
if (trimmed.startsWith('[')) {
try {
const parsed = JSON.parse(trimmed);
return Array.isArray(parsed) ? parsed.filter((v) => typeof v === 'string') : [];
} catch {
return [];
}
}
return trimmed.split(',').map((s) => s.trim()).filter(Boolean);
}
// 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;
networkMode: string;
dns: string[];
}> {
const [grypeArgs, trivyArgs, grypeImage, trivyImage] = await Promise.all([
const [grypeArgs, trivyArgs, grypeImage, trivyImage, networkMode, dns] = await Promise.all([
getSetting('default_grype_args'),
getSetting('default_trivy_args'),
getSetting('default_grype_image'),
getSetting('default_trivy_image')
getSetting('default_trivy_image'),
getSetting('default_scanner_network_mode'),
getSetting('default_scanner_dns')
]);
return {
grypeArgs: grypeArgs ?? DEFAULT_GRYPE_ARGS,
trivyArgs: trivyArgs ?? DEFAULT_TRIVY_ARGS,
grypeImage: grypeImage ?? DEFAULT_GRYPE_IMAGE,
trivyImage: trivyImage ?? DEFAULT_TRIVY_IMAGE
trivyImage: trivyImage ?? DEFAULT_TRIVY_IMAGE,
networkMode: networkMode ?? '',
dns: parseDnsSetting(dns)
};
}
@@ -669,31 +724,9 @@ async function runScannerContainerCore(
// detection failure — no regression for stock Docker hosts.
hostSocketPath = await detectRemoteSocketPath(envId);
console.log(`[Scanner] Remote scan via Hawser (${connectionType}) - detected socket path: ${hostSocketPath}`);
} else if (connectionType === 'direct' && env?.host) {
// Direct TCP to a remote daemon (e.g. Docker over TCP, Podman over TCP).
// The scanner container is created on the REMOTE daemon, not on
// Dockhand's host. Binding "/var/run/docker.sock" from Dockhand's
// host into a remote-daemon container is nonsense — on remote Docker
// it silently creates an empty dir (scans fall back to registry); on
// rootless Podman it errors with "mkdir /var/run/docker.sock:
// permission denied" (#1076, #1011). Instead, tell the scanner to
// talk to the daemon over the same TCP endpoint Dockhand uses.
// `host.containers.internal` resolves to the daemon's host from
// inside the scanner container on both Docker (with `host-gateway`)
// and Podman (built-in).
scannerDockerHost = `tcp://host.containers.internal:${env.port}`;
// Add the host-gateway mapping so Docker honours the hostname.
// Podman recognises host.containers.internal natively; the extra
// mapping is harmless there.
scannerExtraHosts = [
...(scannerExtraHosts ?? []),
'host.containers.internal:host-gateway'
];
console.log(
`[Scanner] Direct TCP env (${env.protocol ?? 'http'}://${env.host}:${env.port}) - DOCKER_HOST=${scannerDockerHost} (#1076, #1011)`
);
} else {
// Local socket — detect host socket path (handles rootless Docker)
// Local socket — handles rootless Docker and direct-TCP envs
// (the latter via the same socket bind; see #1195, #1076).
hostSocketPath = getHostDockerSocket();
console.log(`[Scanner] Local socket scan (${connectionType || 'default'}) - detected host Docker socket: ${hostSocketPath}`);
@@ -769,6 +802,19 @@ async function runScannerContainerCore(
envVars.push(`DOCKER_HOST=${scannerDockerHost}`);
}
// Apply user-configured overrides on top of auto-detection (#1219).
// Empty user settings → resolver returns the auto-detected values unchanged.
const userScannerSettings = await getGlobalScannerDefaults();
const overrides = resolveScannerOverrides(
{ networkMode: scannerNetworkMode, extraHosts: scannerExtraHosts },
{ networkMode: userScannerSettings.networkMode, dns: userScannerSettings.dns }
);
if (userScannerSettings.networkMode || userScannerSettings.dns.length > 0) {
console.log(
`[Scanner] User overrides applied — network=${overrides.networkMode ?? 'default'}, dns=${overrides.dns?.join(',') ?? 'default'}`
);
}
console.log(`[Scanner] Running ${scannerType} with cache mounted at ${basePath}`);
console.log(`[Scanner] Container command: ${cmd.join(' ')}`);
// Run the scanner container with a 10-minute timeout to prevent indefinite hangs
@@ -777,10 +823,11 @@ async function runScannerContainerCore(
cmd,
binds,
env: envVars,
extraHosts: scannerExtraHosts,
extraHosts: overrides.extraHosts,
name: `dockhand-${scannerType}-${Date.now()}`,
envId,
networkMode: scannerNetworkMode,
networkMode: overrides.networkMode,
dns: overrides.dns,
timeout: 600_000, // 10 minutes
onStderr: (data) => {
// Stream stderr lines for real-time progress output
@@ -1098,14 +1145,22 @@ async function getScannerVersion(
);
if (!hasImage) return null;
// Create temporary container to get version
// Create temporary container to get version. Apply user overrides so
// version probe works in environments where default networking fails
// (#1219 — host needs --network host for scanner images to resolve DNS).
const versionCmd = scannerType === 'grype' ? ['version'] : ['--version'];
const overrides = resolveScannerOverrides(
{},
{ networkMode: defaults.networkMode, dns: defaults.dns }
);
console.log(`[Scanner] Getting ${scannerType} version with cmd:`, versionCmd);
const { stdout, stderr } = await runContainer({
image: scannerImage,
cmd: versionCmd,
name: `dockhand-${scannerType}-version-${Date.now()}`,
envId
envId,
networkMode: overrides.networkMode,
dns: overrides.dns
});
console.log(`[Scanner] ${scannerType} version check result: stdout="${stdout.substring(0, 100)}", stderr="${stderr.substring(0, 100)}"`);
+10 -2
View File
@@ -184,6 +184,11 @@ export async function startScheduler(): Promise<void> {
/**
* Stop the scheduler service and cleanup all jobs.
*/
/** Scheduler state — for the metrics endpoint. */
export function getSchedulerStats(): { running: boolean; activeJobs: number } {
return { running: isRunning, activeJobs: activeJobs.size };
}
export function stopScheduler(): void {
if (!isRunning) return;
@@ -368,8 +373,11 @@ export async function registerSchedule(
// Get timezone for this environment
const timezone = environmentId ? await getEnvironmentTimezone(environmentId) : 'UTC';
// Create new Cron instance with timezone
const job = new Cron(cronExpression, { timezone, legacyMode: false }, async () => {
// Create new Cron instance with timezone.
// protect: skip a scheduled tick if the previous run is still in progress
// (prevents a slow update/sync from overlapping itself — duplicate
// container recreation, concurrent git pull on the same stack dir).
const job = new Cron(cronExpression, { timezone, legacyMode: false, protect: true }, async () => {
// Defensive check: verify schedule still exists and is enabled
if (type === 'container_update') {
const setting = await getAutoUpdateSettingById(scheduleId);
@@ -0,0 +1,122 @@
/**
* Vulnerability block decision with a fresh current-image scan.
*
* Kept separate from update-utils.ts so that the pure decision logic
* (shouldBlockUpdate, combineScanSummaries) stays free of DB/scanner runtime
* deps and remains unit-testable. This module is allowed to touch the DB and
* the scanner.
*/
import type { VulnerabilityCriteria } from '../../db';
import { type VulnerabilitySeverity, scanImage } from '../../scanner';
import { getCombinedScanForImage, saveVulnerabilityScan } from '../../db';
import { shouldBlockUpdate, combineScanSummaries } from './update-utils';
/**
* Resolve the block decision for a new image, handling the 'more_than_current'
* criteria robustly.
*
* For 'more_than_current' the new image's vuln count is compared against the
* CURRENT image's count. The current count comes from the scan cache, which can
* be (a) stale vuln DBs grow, so an old cached scan understates the current
* image and falsely makes the new image look worse (#1022), or (b) missing no
* scan exists, so the comparison is skipped and a genuinely more-vulnerable
* update slips through. Both are fixed here: when the cached comparison would
* block (or there is no cache), the current image is RE-SCANNED fresh and the
* comparison is redone against up-to-date numbers.
*
* For all other criteria this is a thin wrapper around shouldBlockUpdate.
*
* @param newSummary combined scan summary of the new image
* @param currentImageId sha256 image id of the currently-running image
* @param envId environment id (for scanner settings + cache scoping)
* @param criteria the configured vulnerability criteria
* @param log log sink for human-readable progress
*/
export async function resolveBlockDecision(
newSummary: VulnerabilitySeverity,
currentImageId: string,
envId: number | null | undefined,
criteria: VulnerabilityCriteria,
log: (msg: string) => void
): Promise<{ blocked: boolean; reason: string }> {
if (criteria !== 'more_than_current') {
return shouldBlockUpdate(criteria, newSummary);
}
const total = (s: VulnerabilitySeverity) => s.critical + s.high + s.medium + s.low;
const newTotal = total(newSummary);
// 1. Start from the cached scan of the current image, if any.
let currentSummary: VulnerabilitySeverity | undefined;
let currentFromCache = false;
try {
const cached = await getCombinedScanForImage(currentImageId, envId ?? null);
if (cached) {
currentSummary = cached;
currentFromCache = true;
log(`more_than_current: current image cached scan = ${total(cached)} vulns (${cached.critical}C/${cached.high}H/${cached.medium}M/${cached.low}L)`);
} else {
log(`more_than_current: no cached scan for current image`);
}
} catch (err: any) {
log(`more_than_current: cache lookup failed (${err.message})`);
}
const wouldBlock = currentSummary !== undefined && newTotal > total(currentSummary);
// 2. Re-scan the current image when the cached comparison would block, or
// when there is no cached scan at all. A fresh scan of the SAME current
// image is the only trustworthy basis for blocking (#1022).
if (!currentFromCache || wouldBlock) {
log(
currentFromCache
? `more_than_current: cached comparison would block (new ${newTotal} > current ${total(currentSummary!)}) — re-scanning current image to confirm`
: `more_than_current: scanning current image (${currentImageId.substring(0, 19)}) for comparison`
);
try {
const results = await scanImage(currentImageId, envId ?? undefined, (p) => {
if (p.message) log(` [${p.scanner || 'scan'}] ${p.message}`);
});
if (results.length > 0) {
const fresh = combineScanSummaries(results.map((r) => ({ summary: r.summary })));
log(`more_than_current: current image fresh scan = ${total(fresh)} vulns (${fresh.critical}C/${fresh.high}H/${fresh.medium}M/${fresh.low}L)`);
currentSummary = fresh;
// Persist so the next cycle starts from a current value.
for (const r of results) {
try {
await saveVulnerabilityScan({
environmentId: envId ?? null,
imageId: currentImageId,
imageName: r.imageName,
scanner: r.scanner,
scannedAt: r.scannedAt,
scanDuration: r.scanDuration,
criticalCount: r.summary.critical,
highCount: r.summary.high,
mediumCount: r.summary.medium,
lowCount: r.summary.low,
negligibleCount: r.summary.negligible,
unknownCount: r.summary.unknown,
vulnerabilities: r.vulnerabilities,
error: r.error ?? null
});
} catch { /* ignore save errors */ }
}
} else {
log(`more_than_current: current image scan returned no results`);
}
} catch (err: any) {
log(`more_than_current: current image scan failed (${err.message})`);
}
}
const decision = shouldBlockUpdate('more_than_current', newSummary, currentSummary);
const curTotal = currentSummary ? total(currentSummary) : 'unknown';
log(
decision.blocked
? `more_than_current: BLOCKED — new ${newTotal} > current ${curTotal}`
: `more_than_current: allowed — new ${newTotal} <= current ${curTotal}${currentSummary === undefined ? ' (current count unavailable; not blocking)' : ''}`
);
return decision;
}
@@ -15,8 +15,7 @@ import {
createScheduleExecution,
updateScheduleExecution,
appendScheduleExecutionLog,
saveVulnerabilityScan,
getCombinedScanForImage
saveVulnerabilityScan
} from '../../db';
import {
pullImage,
@@ -37,7 +36,8 @@ import {
} from '../../docker';
import { getScannerSettings, scanImage, type ScanResult, type VulnerabilitySeverity } from '../../scanner';
import { sendEventNotification } from '../../notifications';
import { parseImageNameAndTag, shouldBlockUpdate, combineScanSummaries, isSystemContainer } from './update-utils';
import { parseImageNameAndTag, combineScanSummaries, isSystemContainer, isPodmanInfraContainer } from './update-utils';
import { resolveBlockDecision } from './block-decision';
import { isUpdateDisabledByLabel, isHiddenByLabel } from '../../container-labels';
// =============================================================================
@@ -150,54 +150,16 @@ async function scanAndCheckBlock(ctx: ScanContext): Promise<ScanOutcome> {
}
}
// Handle 'more_than_current' criteria - need to get/scan current image
let currentScanSummary: VulnerabilitySeverity | undefined;
if (vulnerabilityCriteria === 'more_than_current') {
log(`Looking up cached scan for current image...`);
try {
const cachedScan = await getCombinedScanForImage(currentImageId, envId ?? null);
if (cachedScan) {
currentScanSummary = cachedScan;
log(`Cached scan: ${currentScanSummary.critical} critical, ${currentScanSummary.high} high`);
} else {
log(`No cached scan found, scanning current image...`);
const currentScanResults = await scanImage(currentImageId, envId, (progress) => {
const tag = progress.scanner ? `[${progress.scanner}]` : '[scan]';
if (progress.message) log(`${tag} ${progress.message}`);
});
if (currentScanResults.length > 0) {
currentScanSummary = combineScanSummaries(currentScanResults);
log(`Current image: ${currentScanSummary.critical} critical, ${currentScanSummary.high} high`);
// Save for future use
for (const result of currentScanResults) {
try {
await saveVulnerabilityScan({
environmentId: envId ?? null,
imageId: currentImageId,
imageName: result.imageName,
scanner: result.scanner,
scannedAt: result.scannedAt,
scanDuration: result.scanDuration,
criticalCount: result.summary.critical,
highCount: result.summary.high,
mediumCount: result.summary.medium,
lowCount: result.summary.low,
negligibleCount: result.summary.negligible,
unknownCount: result.summary.unknown,
vulnerabilities: result.vulnerabilities,
error: result.error ?? null
});
} catch { /* ignore */ }
}
}
}
} catch (cacheError: any) {
log(`Warning: Could not get current scan: ${cacheError.message}`);
}
}
// Check if update should be blocked
const { blocked, reason } = shouldBlockUpdate(vulnerabilityCriteria, scanSummary, currentScanSummary);
// Decide whether to block. For 'more_than_current' this re-scans the current
// image so the comparison uses up-to-date numbers (#1022) and works on a
// cold cache.
const { blocked, reason } = await resolveBlockDecision(
scanSummary,
currentImageId,
envId,
vulnerabilityCriteria,
log
);
if (blocked) {
log(`UPDATE BLOCKED: ${reason}`);
@@ -339,6 +301,19 @@ export async function runContainerUpdate(
return;
}
// Podman pod-infra containers (#1221): silently skip before any inspect /
// image-resolution that would fail with "Could not determine image name".
if (isPodmanInfraContainer(containerName)) {
log(`Skipping Podman pod-infra container`);
await updateScheduleExecution(execution.id, {
status: 'skipped',
completedAt: new Date().toISOString(),
duration: Date.now() - startTime,
details: { reason: 'Podman pod-infra container' }
});
return;
}
// Get the full container config to extract the image name (tag)
const inspectData = await inspectContainer(container.id, envId) as any;
const imageNameFromConfig = inspectData.Config?.Image;
@@ -30,7 +30,8 @@ import {
} from '../../docker';
import { sendEventNotification } from '../../notifications';
import { getScannerSettings, scanImage, type VulnerabilitySeverity } from '../../scanner';
import { parseImageNameAndTag, shouldBlockUpdate, combineScanSummaries, isSystemContainer } from './update-utils';
import { parseImageNameAndTag, combineScanSummaries, isSystemContainer, isPodmanInfraContainer } from './update-utils';
import { resolveBlockDecision } from './block-decision';
import { isUpdateDisabledByLabel, isHiddenByLabel } from '../../container-labels';
import { recreateContainer } from './container-update';
@@ -108,9 +109,12 @@ export async function runEnvUpdateCheckJob(
// Get all containers in this environment, excluding ones hidden via
// dockhand.hidden=true (consistent with manual check-updates, #1083).
const allContainers = await listContainers(true, environmentId);
const containers = allContainers.filter(c => !isHiddenByLabel(c.labels));
// Skip hidden + Podman pod-infra (#1083, #1221)
const containers = allContainers.filter(
(c) => !isHiddenByLabel(c.labels) && !isPodmanInfraContainer(c.name)
);
const hiddenCount = allContainers.length - containers.length;
await log(`Found ${containers.length} containers${hiddenCount ? ` (${hiddenCount} hidden by label)` : ''}`);
await log(`Found ${containers.length} containers${hiddenCount ? ` (${hiddenCount} hidden/infra)` : ''}`);
const updatesAvailable: UpdateInfo[] = [];
let checkedCount = 0;
@@ -321,8 +325,16 @@ export async function runEnvUpdateCheckJob(
} catch { /* ignore save errors */ }
}
// Check if blocked
const { blocked, reason } = shouldBlockUpdate(vulnerabilityCriteria, scanSummary, undefined);
// Decide whether to block. For 'more_than_current' this
// re-scans the current image so the comparison uses
// up-to-date numbers (#1022) and works on a cold cache.
const { blocked, reason } = await resolveBlockDecision(
scanSummary,
update.currentImageId,
environmentId,
vulnerabilityCriteria,
(m) => { void log(` ${m}`); }
);
if (blocked) {
scanBlocked = true;
blockReason = reason;
@@ -127,6 +127,18 @@ export function isSystemContainer(imageName: string): SystemContainerType | null
return null;
}
/**
* Podman creates a hidden "pod-infra" container per pod, always named
* `<pod-id-or-name>-infra` (#1221). The infra image is locally generated
* and can be overridden via --infra-image, so neither image nor label is
* universal the name suffix is the only reliable signal.
*
* Anchored to the end so user containers like "my-infrastructure" don't match.
*/
export function isPodmanInfraContainer(containerName: string | undefined): boolean {
return !!containerName && /[-_]infra$/i.test(containerName);
}
/**
* Combine multiple scan summaries by taking the maximum of each severity level.
*/
+3 -3
View File
@@ -18,7 +18,7 @@ export { prefersJSON, sseToJSON } from '$lib/server/sse-parser';
* can reconstruct the same event stream semantics used by the old SSE flow.
*/
export function createJobResponse(
operation: (send: (event: string, data: unknown) => void) => Promise<void>,
operation: (send: (event: string, data: unknown) => void, isCancelled: () => boolean) => Promise<void>,
request?: Request
): Response {
// Backward compat: synchronous JSON path for explicit application/json callers
@@ -31,7 +31,7 @@ export function createJobResponse(
resultData = data;
};
try {
await operation(send);
await operation(send, () => false);
} catch (error) {
resultData = { success: false, error: String(error) };
}
@@ -51,7 +51,7 @@ export function createJobResponse(
appendLine(job, { event, data });
};
operation(send)
operation(send, () => job.cancelRequested === true)
.then(() => {
const resultLine = job.lines.findLast((l) => l.event === 'result');
completeJob(job, resultLine?.data ?? { success: true });
+22
View File
@@ -0,0 +1,22 @@
/**
* Filename gate for stack writes (#1196).
*
* Lives in its own file so unit tests can import the helper without pulling
* in stacks.ts (which transitively imports the DB layer and won't load
* under bun:test).
*
* v1.0.34 locked compose/env paths to a hardcoded set (docker-compose.yml,
* compose.yml, .env) which broke users who name their compose files after
* the service (e.g. `headscale.yml`). v1.0.35 relaxes to any .yml / .yaml
* file plus the `.env` family. The load-bearing security control is the
* containment + symlink-realpath + traversal checks in validateStackPath()
* this gate is belt-and-suspenders to keep the write path producing
* stack-shaped files. Still blocks authorized_keys, evil.sh, /etc/cron.d/x.
*/
// Matches: foo.yml, foo.yaml, .env, foo.env, .env.local, prod.env.staging
export const STACK_FILENAME_RE = /\.ya?ml$|(^|\.)env(\.|$)/;
export function isAllowedStackFilename(filename: string): boolean {
return STACK_FILENAME_RE.test(filename);
}
+23 -1
View File
@@ -34,6 +34,7 @@ export interface DiscoveredStack {
sourceDir: string;
serviceCount?: number; // Number of services defined in compose file
runningOn?: RunningStackInfo[];
unadoptable?: boolean; // a running container carries dockhand.adopt=false (#998)
}
export interface ScanResult {
@@ -201,6 +202,20 @@ export async function adoptStack(
stack: DiscoveredStack,
environmentId: number
): Promise<{ success: boolean; adoptedName?: string; error?: string }> {
// Defense in depth: re-check the live running stack for dockhand.adopt=false so a
// forged/stale request can't bypass the UI filter (#998). Only enforceable while
// the stack is running, since the label lives on containers.
try {
const { listComposeStacks } = await import('./stacks.js');
const { isStackUnadoptable } = await import('./container-labels.js');
const running = (await listComposeStacks(environmentId)).find((s) => s.name === stack.name);
if (running && isStackUnadoptable(running.containerDetails?.map((c) => c.labels) ?? [])) {
return { success: false, error: 'Stack is marked not adoptable (dockhand.adopt=false)' };
}
} catch {
// Environment offline / unreachable — fall through; nothing to enforce against.
}
// Get all existing stack sources to check for duplicates
const existingSources = await getStackSources();
@@ -466,6 +481,7 @@ export async function detectRunningStacks(
// Dynamic imports to avoid circular dependencies
const { listComposeStacks } = await import('./stacks.js');
const { getEnvironments } = await import('./db.js');
const { isStackUnadoptable } = await import('./container-labels.js');
// Get all environments
const environments = await getEnvironments();
@@ -476,6 +492,8 @@ export async function detectRunningStacks(
// Build map of stack name -> running info across all environments
const runningStacksMap = new Map<string, RunningStackInfo[]>();
// Stack names that opt out of adoption via dockhand.adopt=false on any container (#998)
const unadoptableStacks = new Set<string>();
// Query each environment in parallel for running stacks
await Promise.all(
@@ -490,6 +508,9 @@ export async function detectRunningStacks(
containerCount: stack.containers?.length || 0
});
runningStacksMap.set(stack.name, existing);
if (isStackUnadoptable(stack.containerDetails?.map((c) => c.labels) ?? [])) {
unadoptableStacks.add(stack.name);
}
}
} catch (error) {
if (error instanceof DockerConnectionError) {
@@ -504,6 +525,7 @@ export async function detectRunningStacks(
// Attach running info to discovered stacks by matching name
return discovered.map((stack) => ({
...stack,
runningOn: runningStacksMap.get(stack.name)
runningOn: runningStacksMap.get(stack.name),
unadoptable: unadoptableStacks.has(stack.name)
}));
}
+35 -96
View File
@@ -18,6 +18,7 @@ import {
type DeletionApplyResult,
type DeletionSkipReason
} from './git-deletions';
import { isAllowedStackFilename } from './stack-filename';
import {
getEnvironment,
getSecretEnvVarsAsRecord,
@@ -32,11 +33,10 @@ import {
getStackSources,
deleteStackEnvVars,
removePendingContainerUpdate,
getPendingContainerUpdates,
deleteAutoUpdateSchedule,
getAutoUpdateSetting,
getStackSourceByComposePath,
getExternalStackPaths,
addExternalStackPath
getStackSourceByComposePath
} from './db';
import { unregisterSchedule } from './scheduler';
import { deleteGitStackFiles, parseEnvFileContent } from './git';
@@ -350,19 +350,6 @@ export async function getStackDir(stackName: string, envId?: number | null): Pro
return join(stacksDir, stackName);
}
/**
* Filenames a stack is allowed to write. Compose files in the conventional
* names + .env. Anything else is rejected even when the directory is in
* the allowlist, so this code path can only ever produce stack-shaped files.
*/
const ALLOWED_STACK_FILENAMES = new Set([
'docker-compose.yml',
'docker-compose.yaml',
'compose.yml',
'compose.yaml',
'.env'
]);
/**
* Resolve a path against the parent's realpath when the parent exists, so
* symlinks resolve to their canonical location. We can't realpath the leaf
@@ -381,13 +368,6 @@ function resolveStackPath(input: string): string {
return abs;
}
function isInside(child: string, parent: string): boolean {
const c = pathNormalize(child);
const p = pathNormalize(parent);
if (c === p) return true;
return c.startsWith(p.endsWith(pathSep) ? p : p + pathSep);
}
export interface StackPathValidation {
ok: boolean;
error?: string;
@@ -396,15 +376,10 @@ export interface StackPathValidation {
/**
* Validate that a custom compose or env file path is writable by this code
* path. A path is accepted when it lives inside getStacksDir() or any
* directory in the external_stack_paths allowlist (admin-controlled, set
* in Settings General), its filename is one of the conventional
* compose/env names, and it does not contain .. segments. The parent is
* resolved via realpath so a symlinked component inside an allowlisted dir
* cannot point elsewhere.
*
* Callers that need to grandfather an existing admin-configured path should
* call validateStackPathWithGrandfather() instead.
* path. A path is accepted when:
* - filename matches the stack-filename gate (.yml/.yaml/.env family)
* - normalized form contains no .. segments (parent directory resolved
* via realpath so a symlinked component can't smuggle traversal in)
*/
export async function validateStackPath(input: string): Promise<StackPathValidation> {
if (!input || typeof input !== 'string') {
@@ -420,53 +395,14 @@ export async function validateStackPath(input: string): Promise<StackPathValidat
}
const filename = basename(resolvedPath);
if (!ALLOWED_STACK_FILENAMES.has(filename)) {
if (!isAllowedStackFilename(filename)) {
return {
ok: false,
error: `File "${filename}" is not an allowed stack filename (expected one of: ${[...ALLOWED_STACK_FILENAMES].join(', ')})`
error: `File "${filename}" is not an allowed stack filename (must end in .yml, .yaml, or .env)`
};
}
const stacksDir = getStacksDir();
if (isInside(resolvedPath, stacksDir)) {
return { ok: true, resolved: resolvedPath };
}
const allowlist = await getExternalStackPaths();
for (const dir of allowlist) {
if (!dir) continue;
const dirResolved = resolve(dir);
if (isInside(resolvedPath, dirResolved)) {
return { ok: true, resolved: resolvedPath };
}
}
return {
ok: false,
error: `Path "${resolvedPath}" is not inside an allowed stack directory. Add its parent directory in Settings → General → External stack paths.`
};
}
/**
* Same as validateStackPath, but when the path comes from an existing
* stack source row (a stored custom path the admin set previously) and
* fails the allowlist check, add its parent dir to external_stack_paths
* and re-validate. Lets old custom-path configurations keep working
* without operator intervention.
*/
export async function validateStackPathWithGrandfather(
input: string,
isPreExisting: boolean
): Promise<StackPathValidation> {
const first = await validateStackPath(input);
if (first.ok || !isPreExisting) return first;
const parent = dirname(resolveStackPath(input));
const added = await addExternalStackPath(parent);
if (added) {
console.log(`[Stack] Grandfathered pre-existing custom path: ${parent}`);
}
return validateStackPath(input);
return { ok: true, resolved: resolvedPath };
}
/**
@@ -675,28 +611,16 @@ export async function saveStackComposeFile(
const source = await getStackSource(name, envId);
const composePath = options?.composePath || source?.composePath;
// Path-allowlist validation. Pre-existing stored paths grandfather into
// the allowlist; new caller-supplied paths must already be inside it.
// Validate every caller-supplied or stored path before any disk write.
// See validateStackPath() docs.
if (composePath) {
const fromDb = !options?.composePath && !!source?.composePath;
const v = await validateStackPathWithGrandfather(composePath, fromDb);
if (!v.ok) return { success: false, error: v.error };
}
if (options?.envPath) {
const v = await validateStackPath(options.envPath);
if (!v.ok) return { success: false, error: v.error };
} else if (source?.envPath && !options?.envPath) {
// Grandfather a DB-stored env path that may have been configured before validation.
const v = await validateStackPathWithGrandfather(source.envPath, true);
if (!v.ok) return { success: false, error: v.error };
}
if (options?.oldComposePath) {
const v = await validateStackPathWithGrandfather(options.oldComposePath, true);
if (!v.ok) return { success: false, error: v.error };
}
if (options?.oldEnvPath) {
const v = await validateStackPathWithGrandfather(options.oldEnvPath, true);
const pathsToCheck = [
composePath,
options?.envPath ?? source?.envPath,
options?.oldComposePath,
options?.oldEnvPath
].filter((p): p is string => !!p);
for (const path of pathsToCheck) {
const v = await validateStackPath(path);
if (!v.ok) return { success: false, error: v.error };
}
@@ -1817,6 +1741,18 @@ export async function listComposeStacks(envId?: number | null): Promise<ComposeS
const containers = await listContainers(true, envId);
const stacks = new Map<string, Set<string>>();
// Container IDs with pending image updates (populated by manual/scheduled update checks).
// Used to flag stacks that contain at least one outdated container.
const pendingUpdateIds = new Set<string>();
if (typeof envId === 'number') {
try {
const pending = await getPendingContainerUpdates(envId);
pending.forEach((p) => pendingUpdateIds.add(p.containerId));
} catch {
// Non-fatal: stacks just won't show update markers
}
}
containers.forEach((container) => {
const projectLabel = container.labels['com.docker.compose.project'];
if (projectLabel) {
@@ -1873,7 +1809,8 @@ export async function listComposeStacks(envId?: number | null): Promise<ComposeS
restartCount: c.restartCount || 0,
exitCode: c.exitCode,
created: c.created,
labels: c.labels || {}
labels: c.labels || {},
updateAvailable: pendingUpdateIds.has(c.id)
};
})
.sort((a, b) => {
@@ -1887,6 +1824,8 @@ export async function listComposeStacks(envId?: number | null): Promise<ComposeS
name,
containers: Array.from(containerIds),
containerDetails,
updatesAvailable: stackContainers.some((c) => pendingUpdateIds.has(c.id)),
updateCount: stackContainers.filter((c) => pendingUpdateIds.has(c.id)).length,
status:
activeTotal === 0
? 'stopped'
+43
View File
@@ -0,0 +1,43 @@
/**
* Shared access check for the vulnerability endpoints (grid / count / export /
* per-image export). Parses `env`, then enforces the same two guards every
* endpoint needs: images:view RBAC, and (enterprise) environment scoping.
*
* Returns the parsed envIdNum plus a `denied` reason when access is refused
* the caller renders it with whatever error helper it already uses (json vs
* jsonError), so response shape stays each endpoint's choice.
*/
import { authorize } from '$lib/server/authorize';
import type { Cookies } from '@sveltejs/kit';
export interface VulnAccess {
envIdNum: number | undefined;
/** Whether app auth is enabled (a tenant boundary exists to enforce). */
authEnabled: boolean;
/** Present when access is refused; { message, status } for the caller to render. */
denied?: { message: string; status: number };
}
export async function authorizeVulnAccess(cookies: Cookies, url: URL): Promise<VulnAccess> {
const auth = await authorize(cookies);
// Parse env strictly: only a non-negative integer counts as a concrete env.
// A present-but-garbage value is a client error, not "all environments".
const envParam = url.searchParams.get('env');
let envIdNum: number | undefined;
if (envParam !== null) {
const n = Number(envParam);
if (!Number.isInteger(n) || n < 0) {
return { envIdNum: undefined, authEnabled: auth.authEnabled, denied: { message: 'Invalid env', status: 400 } };
}
envIdNum = n;
}
if (auth.authEnabled && !(await auth.can('images', 'view', envIdNum))) {
return { envIdNum, authEnabled: auth.authEnabled, denied: { message: 'Permission denied', status: 403 } };
}
if (envIdNum !== undefined && auth.isEnterprise && !(await auth.canAccessEnvironment(envIdNum))) {
return { envIdNum, authEnabled: auth.authEnabled, denied: { message: 'Access denied to this environment', status: 403 } };
}
return { envIdNum, authEnabled: auth.authEnabled };
}
+75
View File
@@ -0,0 +1,75 @@
/**
* Short-TTL per-environment cache for the aggregated vulnerability findings.
*
* Lives in its own module (importing only client-safe types) so that db.ts can
* invalidate it from inside saveVulnerabilityScan WITHOUT a circular import
* (vulnerabilities.ts imports db.ts). That way every writer of a scan row
* automatically refreshes the dashboard no caller can forget.
*/
import { EMPTY_SUMMARY } from '../utils/vulnerability';
import type { Finding, VulnerabilitySummary } from '$lib/utils/vulnerability';
export const CACHE_TTL_MS = 30_000;
/** Per-env cap on memoized filter/sort views (bounds server memory). */
export const MAX_VIEWS = 8;
/** Cap on cached environments bounds total memory when many envs are viewed.
* Least-recently-inserted env entry is evicted past this. */
export const MAX_ENVS = 16;
export interface AggregatedVulnerabilities {
findings: Finding[];
summary: VulnerabilitySummary;
}
export interface VulnerabilitiesMeta {
total: number;
summary: VulnerabilitySummary;
options: { images: string[]; containers: string[]; stacks: string[] };
}
/** Empty meta for the no-environment case — shared so the shape isn't re-typed. */
export const EMPTY_META: VulnerabilitiesMeta = {
total: 0,
summary: EMPTY_SUMMARY,
options: { images: [], containers: [], stacks: [] }
};
export interface CacheEntry {
at: number;
data: AggregatedVulnerabilities;
/** Memoized filtered+sorted arrays within this cache window, keyed by query. */
views: Map<string, Finding[]>;
/** Memoized meta (distinct filter options) — computed once per window. */
meta?: VulnerabilitiesMeta;
}
// Keyed by envId. The null-environment ("local"/default) is keyed as 0 since the
// dashboard aggregation only ever runs for a concrete numeric env id.
export const aggregateCache = new Map<number, CacheEntry>();
/** Collapse concurrent cold-start requests into one aggregation. */
export const inflight = new Map<number, Promise<AggregatedVulnerabilities>>();
/**
* Drop cached findings. Pass an env id to clear just that environment; pass
* nothing to clear all (e.g. a broad change where the affected env is unknown).
*/
/** Cache occupancy for the metrics endpoint. `envs` = cached environments,
* `views` = total memoized filter/sort views, `inflight` = cold aggregations running. */
export function getVulnerabilitiesCacheStats(): { envs: number; views: number; inflight: number } {
let views = 0;
for (const entry of aggregateCache.values()) views += entry.views.size;
return { envs: aggregateCache.size, views, inflight: inflight.size };
}
export function invalidateVulnerabilitiesCache(envIdNum?: number | null): void {
if (envIdNum === undefined || envIdNum === null) {
aggregateCache.clear();
// Also drop in-flight aggregations: one racing a scan-save would otherwise
// resolve to pre-scan data and get installed with a fresh TTL, masking the
// new scan for a full window. Dropping it forces the next reader to re-run.
inflight.clear();
} else {
aggregateCache.delete(envIdNum);
inflight.delete(envIdNum);
}
}
+290
View File
@@ -0,0 +1,290 @@
/**
* Shared aggregation for the Vulnerabilities dashboard: flattens the latest
* persisted scans into per-CVE findings, filters out images no longer present
* on the host, and enriches each finding with the containers/stacks using it.
*
* Used by both GET /api/vulnerabilities (grid) and GET /api/vulnerabilities/export.
*/
import { getAllLatestScans } from '$lib/server/db';
import { listImages, listContainers, DockerConnectionError, EnvironmentNotFoundError } from '$lib/server/docker';
import { flattenScansToFindings, filterFindings, sortFindings } from '$lib/utils/vulnerability';
import type { Finding, FindingContainer, VulnerabilitySummary, ScanRow, FindingFilter, SortField } from '$lib/utils/vulnerability';
import {
aggregateCache, inflight, CACHE_TTL_MS, MAX_VIEWS, MAX_ENVS, invalidateVulnerabilitiesCache,
type CacheEntry, type AggregatedVulnerabilities, type VulnerabilitiesMeta
} from '$lib/server/vulnerabilities-cache';
// Re-export the shared client/server helpers so existing importers keep working.
export type { Finding, FindingContainer, VulnerabilitySummary, ScanRow };
export type { AggregatedVulnerabilities, VulnerabilitiesMeta };
export { flattenScansToFindings, invalidateVulnerabilitiesCache };
export async function aggregateVulnerabilities(envIdNum: number): Promise<AggregatedVulnerabilities> {
const scans = await getAllLatestScans(envIdNum);
// Best-effort live Docker state — total image count (for "Images scanned: N/M")
// and running containers used to enrich each finding. Never throw on Docker down.
let existingImageIds: Set<string> | null = null;
let totalImages = 0;
const containersByImage = new Map<string, FindingContainer[]>();
const stacksByImage = new Map<string, Set<string>>();
try {
const images = await listImages(envIdNum);
totalImages = images.length;
existingImageIds = new Set(images.map((img) => img.id));
} catch (error) {
if (!(error instanceof DockerConnectionError) && !(error instanceof EnvironmentNotFoundError)) {
console.error('Error listing images for vulnerability summary:', error);
}
}
try {
const containers = await listContainers(true, envIdNum);
for (const c of containers) {
if (!c.imageId) continue;
const list = containersByImage.get(c.imageId) ?? [];
list.push({ id: c.id, name: c.name });
containersByImage.set(c.imageId, list);
const stack = c.labels?.['com.docker.compose.project'];
if (stack) {
const set = stacksByImage.get(c.imageId) ?? new Set<string>();
set.add(stack);
stacksByImage.set(c.imageId, set);
}
}
} catch (error) {
if (!(error instanceof DockerConnectionError) && !(error instanceof EnvironmentNotFoundError)) {
console.error('Error listing containers for vulnerability enrichment:', error);
}
}
const scannedImageIds = new Set<string>();
let critical = 0, high = 0, medium = 0, low = 0;
// Keep only scans for images still present on the host (scanned <= total).
const liveScans = existingImageIds
? scans.filter((s) => existingImageIds!.has(s.imageId))
: scans;
for (const s of liveScans) scannedImageIds.add(s.imageId);
const findings = flattenScansToFindings(liveScans, { containersByImage, stacksByImage });
for (const f of findings) {
switch ((f.severity || '').toLowerCase()) {
case 'critical': critical++; break;
case 'high': high++; break;
case 'medium': medium++; break;
case 'low': low++; break;
}
}
// If Docker was unreachable we couldn't filter stale scans; fall back to the raw
// distinct scanned count and use it as the total so the ratio stays sane.
if (!existingImageIds) {
totalImages = scannedImageIds.size;
}
return {
findings,
summary: {
total: findings.length,
critical,
high,
medium,
low,
imagesScanned: scannedImageIds.size,
totalImages
}
};
}
// ---------------------------------------------------------------------------
// Paged access with a short-TTL cache of the flattened+enriched result.
//
// Findings are exploded from per-scan JSON blobs, so we can't page at the SQL
// layer — every request must flatten the full set. To keep paging/scrolling
// cheap, the full AggregatedVulnerabilities is cached per environment for a
// few seconds and reused across page requests. This bounds *browser* memory
// (only a page is sent to the client) without re-flattening on every scroll.
// ---------------------------------------------------------------------------
async function getCacheEntry(envIdNum: number): Promise<CacheEntry> {
const cached = aggregateCache.get(envIdNum);
if (cached && Date.now() - cached.at < CACHE_TTL_MS) return cached;
if (cached) aggregateCache.delete(envIdNum); // release the expired findings array
let promise = inflight.get(envIdNum);
if (!promise) {
promise = aggregateVulnerabilities(envIdNum).finally(() => inflight.delete(envIdNum));
inflight.set(envIdNum, promise);
}
const data = await promise;
// A concurrent cold caller may have already installed an entry for this same
// aggregation — reuse it so both share one views/meta cache.
const existing = aggregateCache.get(envIdNum);
if (existing && existing.data === data) return existing;
const entry: CacheEntry = { at: Date.now(), data, views: new Map() };
aggregateCache.set(envIdNum, entry);
// Bound *cache* memory across environments. This is a performance cache only —
// evicting an env never hides its vulns: the next request for it just
// re-aggregates from the DB. Past the cap, drop an expired entry if there is
// one (free win), else the oldest, never the entry we just created.
if (aggregateCache.size > MAX_ENVS) {
const now = Date.now();
let victim: number | undefined;
for (const [id, e] of aggregateCache) {
if (id === envIdNum) continue;
if (now - e.at >= CACHE_TTL_MS) { victim = id; break; } // prefer an expired entry
if (victim === undefined) victim = id; // fall back to the oldest (insertion order)
}
if (victim !== undefined) aggregateCache.delete(victim);
}
return entry;
}
async function getAggregatedCached(envIdNum: number): Promise<AggregatedVulnerabilities> {
return (await getCacheEntry(envIdNum)).data;
}
export interface VulnerabilitiesQuery extends FindingFilter {
sort?: SortField;
dir?: 'asc' | 'desc';
limit?: number;
offset?: number;
}
export interface PagedVulnerabilities {
findings: Finding[];
total: number;
/** Severity counts + total for the FILTERED set (so the header pills track the
* active filters). imagesScanned/totalImages stay env-wide (not per-filter). */
summary: VulnerabilitySummary;
}
/** Split a comma-separated multi-value filter param into a trimmed, non-empty list. */
function parseList(url: URL, key: string): string[] {
const raw = url.searchParams.get(key);
return raw ? raw.split(',').map((s) => s.trim()).filter(Boolean) : [];
}
/**
* Parse the shared filter/sort/paging query params used by the grid and export
* endpoints, so the parsing lives in one place (a new filter can't be added to
* one endpoint and forgotten in another).
*/
export function parseVulnerabilitiesQuery(url: URL): VulnerabilitiesQuery {
const limit = url.searchParams.get('limit');
const offset = url.searchParams.get('offset');
const sort = url.searchParams.get('sort');
return {
q: url.searchParams.get('q') ?? '',
severities: parseList(url, 'severity'),
images: parseList(url, 'image'),
containers: parseList(url, 'container'),
stacks: parseList(url, 'stack'),
sort: (sort as SortField) || undefined,
dir: url.searchParams.get('dir') === 'desc' ? 'desc' : 'asc',
limit: limit ? parseInt(limit) : undefined,
offset: offset ? parseInt(offset) : undefined
};
}
function viewKey(q: VulnerabilitiesQuery): string {
return [
q.sort ?? '', q.dir ?? '',
(q.q ?? '').toLowerCase().trim(),
(q.severities ?? []).join('|'),
(q.images ?? []).join('|'),
(q.containers ?? []).join('|'),
(q.stacks ?? []).join('|')
].join('¬');
}
/**
* The full filtered+sorted finding set for a query, memoized within the cache
* window so repeated page/scroll requests reuse the filter+sort work. Shared by
* the paged grid endpoint and the export endpoint so their ordering can't drift.
*/
export async function getFilteredSortedFindings(envIdNum: number, query: VulnerabilitiesQuery): Promise<Finding[]> {
const entry = await getCacheEntry(envIdNum);
const key = viewKey(query);
const cachedView = entry.views.get(key);
if (cachedView) {
// LRU touch: move to newest so it survives eviction.
entry.views.delete(key);
entry.views.set(key, cachedView);
return cachedView;
}
const filtered = filterFindings(entry.data.findings, query);
// filterFindings returns the input BY REFERENCE when no filter is active, and an
// unsorted view keeps that reference — storing the master findings array as a
// memoized view would make any accidental in-place mutation of a view corrupt
// the shared cache. Copy when the view would alias the master. (sortFindings
// already returns a fresh array.)
const view = query.sort
? sortFindings(filtered, query.sort, query.dir ?? 'asc')
: (filtered === entry.data.findings ? filtered.slice() : filtered);
// Bound the memo (rapid filter/sort changes would otherwise accumulate a full
// array per distinct query for the cache window). Evict the oldest.
if (entry.views.size >= MAX_VIEWS) {
const oldest = entry.views.keys().next().value;
if (oldest !== undefined) entry.views.delete(oldest);
}
entry.views.set(key, view);
return view;
}
/** Filter + sort the full set, then return one page. `total` is the filtered count. */
export async function getVulnerabilitiesPage(envIdNum: number, query: VulnerabilitiesQuery): Promise<PagedVulnerabilities> {
const view = await getFilteredSortedFindings(envIdNum, query);
const offset = Math.max(0, query.offset ?? 0);
const limit = query.limit ?? view.length;
// Severity breakdown of the FILTERED view so the header pills reflect the
// active filters (imagesScanned/totalImages are env-wide, taken from meta).
let critical = 0, high = 0, medium = 0, low = 0;
for (const f of view) {
switch ((f.severity || '').toLowerCase()) {
case 'critical': critical++; break;
case 'high': high++; break;
case 'medium': medium++; break;
case 'low': low++; break;
}
}
const meta = await getVulnerabilitiesMeta(envIdNum);
return {
findings: view.slice(offset, offset + limit),
total: view.length,
summary: {
total: view.length,
critical, high, medium, low,
imagesScanned: meta.summary.imagesScanned,
totalImages: meta.summary.totalImages
}
};
}
/** Totals + distinct filter-dropdown values across the full (unfiltered) set.
* Memoized on the cache entry computed once per 30s window, not per request. */
export async function getVulnerabilitiesMeta(envIdNum: number): Promise<VulnerabilitiesMeta> {
const entry = await getCacheEntry(envIdNum);
if (entry.meta) return entry.meta;
const { findings, summary } = entry.data;
const images = new Set<string>();
const containers = new Set<string>();
const stacks = new Set<string>();
for (const f of findings) {
images.add(f.imageName);
for (const c of f.containers ?? []) containers.add(c.name);
for (const s of f.stacks ?? []) stacks.add(s);
}
const sorted = (s: Set<string>) => Array.from(s).sort();
entry.meta = {
total: summary.total,
summary,
options: { images: sorted(images), containers: sorted(containers), stacks: sorted(stacks) }
};
return entry.meta;
}
+1 -14
View File
@@ -2,6 +2,7 @@ import { validateSessionById, isAuthEnabled, SESSION_COOKIE } from './auth';
import { validateApiToken } from './api-tokens';
import { isEnterprise } from './license';
import { userHasAdminRole, userCanAccessEnvironment } from './db';
import { parseCookieHeader } from '$lib/utils/cookie-parse';
export interface WsUpgradeAuth {
userId: number;
@@ -10,20 +11,6 @@ export interface WsUpgradeAuth {
authDisabled: boolean;
}
function parseCookieHeader(header: string | undefined): Record<string, string> {
if (!header) return {};
const out: Record<string, string> = {};
for (const part of header.split(';')) {
const eq = part.indexOf('=');
if (eq < 0) continue;
const k = part.slice(0, eq).trim();
let v = part.slice(eq + 1).trim();
if (v.startsWith('"') && v.endsWith('"')) v = v.slice(1, -1);
if (k) out[k] = decodeURIComponent(v);
}
return out;
}
type LowercasedHeaders = Record<string, string | string[] | undefined>;
function pickHeader(headers: LowercasedHeaders, name: string): string | undefined {
+98
View File
@@ -0,0 +1,98 @@
/**
* Session-scoped Dockhand self-update check.
*
* Fires once per browser session (first sidebar mount). The result is shared
* across every consumer (sidebar indicator, Settings About) so we never
* hit the registry more than once unless the user clicks "Check now."
*
* Endpoint requires admin; non-admin users see {error, updateAvailable: false}
* and the consumers render nothing. No retry, no polling.
*/
import { writable } from 'svelte/store';
import { browser } from '$app/environment';
export interface SelfUpdateState {
checked: boolean;
updateAvailable: boolean;
currentImage?: string;
currentDigest?: string;
registryDigest?: string;
/** Parsed semver like "1.0.36" — present when latestVersion came back from the server. */
latestVersion?: string;
/** Resolved newer image tag (registry/repo:newTag). */
newImage?: string;
}
const initial: SelfUpdateState = {
checked: false,
updateAvailable: false
};
function createStore() {
const { subscribe, set } = writable<SelfUpdateState>(initial);
let inFlight: Promise<void> | null = null;
let didCheck = false;
async function checkOnce(force = false): Promise<void> {
if (!browser) return;
if (inFlight) return inFlight;
if (didCheck && !force) return;
inFlight = (async () => {
try {
const res = await fetch('/api/self-update/check', {
headers: force ? { 'Cache-Control': 'no-cache' } : undefined
});
if (!res.ok) {
set({ ...initial, checked: true });
return;
}
const data = await res.json();
set({
checked: true,
updateAvailable: !!data.updateAvailable,
currentImage: data.currentImage,
currentDigest: data.currentDigest,
registryDigest: data.registryDigest,
latestVersion: data.latestVersion,
newImage: data.newImage
});
} catch {
set({ ...initial, checked: true });
} finally {
didCheck = true;
inFlight = null;
}
})();
return inFlight;
}
return {
subscribe,
checkOnce,
/** Force a fresh check (e.g. user clicked "Check now" in About). */
refresh: () => checkOnce(true),
/** Hand-set the result when a caller already fetched it (avoids
* a duplicate /api/self-update/check on the wire). */
setFromResponse: (data: {
updateAvailable?: boolean;
currentImage?: string;
currentDigest?: string;
registryDigest?: string;
latestVersion?: string;
newImage?: string;
}) =>
set({
checked: true,
updateAvailable: !!data.updateAvailable,
currentImage: data.currentImage,
currentDigest: data.currentDigest,
registryDigest: data.registryDigest,
latestVersion: data.latestVersion,
newImage: data.newImage
})
};
}
export const selfUpdate = createStore();
+75 -35
View File
@@ -1,5 +1,11 @@
import { writable, derived, get } from 'svelte/store';
import { browser } from '$app/environment';
import {
buildFormatters,
formatDatePartWith,
formatTimePartWith,
type DateTimeFormatters
} from '$lib/utils/date-format';
export type TimeFormat = '12h' | '24h';
export type DateFormat = 'MM/DD/YYYY' | 'DD/MM/YYYY' | 'YYYY-MM-DD' | 'DD.MM.YYYY';
@@ -41,6 +47,11 @@ export interface AppSettings {
labelFilterMode: LabelFilterMode;
honorProxyLabels: boolean;
showImageChangelogLinks: boolean;
showWhatsNew: boolean; // show the "What's New" modal after an upgrade (#1235)
protectScannerImages: boolean;
// Scanner Advanced settings (#1219). Empty values = use auto-detection.
defaultScannerNetworkMode: string; // '' | 'host' | 'bridge' | 'none' | <custom-network>
defaultScannerDns: string[]; // ['1.1.1.1', '8.8.8.8']; empty = inherit
}
const DEFAULT_SETTINGS: AppSettings = {
@@ -71,11 +82,15 @@ const DEFAULT_SETTINGS: AppSettings = {
formatLogTimestamps: false,
externalStackPaths: [],
primaryStackLocation: null,
defaultGrypeImage: 'anchore/grype:v0.110.0',
defaultTrivyImage: 'aquasec/trivy:0.69.3',
defaultGrypeImage: 'anchore/grype:v0.115.0',
defaultTrivyImage: 'aquasec/trivy:0.71.2',
labelFilterMode: 'any',
honorProxyLabels: true,
showImageChangelogLinks: true,
showWhatsNew: true,
protectScannerImages: true,
defaultScannerNetworkMode: '',
defaultScannerDns: [],
defaultComposeTemplate: `version: "3.8"
services:
@@ -157,7 +172,11 @@ function createSettingsStore() {
defaultComposeTemplate: settings.defaultComposeTemplate ?? DEFAULT_SETTINGS.defaultComposeTemplate,
labelFilterMode: settings.labelFilterMode ?? DEFAULT_SETTINGS.labelFilterMode,
honorProxyLabels: settings.honorProxyLabels ?? DEFAULT_SETTINGS.honorProxyLabels,
showImageChangelogLinks: settings.showImageChangelogLinks ?? DEFAULT_SETTINGS.showImageChangelogLinks
showImageChangelogLinks: settings.showImageChangelogLinks ?? DEFAULT_SETTINGS.showImageChangelogLinks,
showWhatsNew: settings.showWhatsNew ?? DEFAULT_SETTINGS.showWhatsNew,
protectScannerImages: settings.protectScannerImages ?? DEFAULT_SETTINGS.protectScannerImages,
defaultScannerNetworkMode: settings.defaultScannerNetworkMode ?? DEFAULT_SETTINGS.defaultScannerNetworkMode,
defaultScannerDns: Array.isArray(settings.defaultScannerDns) ? settings.defaultScannerDns : DEFAULT_SETTINGS.defaultScannerDns
});
}
} catch {
@@ -210,7 +229,11 @@ function createSettingsStore() {
defaultComposeTemplate: updatedSettings.defaultComposeTemplate ?? DEFAULT_SETTINGS.defaultComposeTemplate,
labelFilterMode: updatedSettings.labelFilterMode ?? DEFAULT_SETTINGS.labelFilterMode,
honorProxyLabels: updatedSettings.honorProxyLabels ?? DEFAULT_SETTINGS.honorProxyLabels,
showImageChangelogLinks: updatedSettings.showImageChangelogLinks ?? DEFAULT_SETTINGS.showImageChangelogLinks
showImageChangelogLinks: updatedSettings.showImageChangelogLinks ?? DEFAULT_SETTINGS.showImageChangelogLinks,
showWhatsNew: updatedSettings.showWhatsNew ?? DEFAULT_SETTINGS.showWhatsNew,
protectScannerImages: updatedSettings.protectScannerImages ?? DEFAULT_SETTINGS.protectScannerImages,
defaultScannerNetworkMode: updatedSettings.defaultScannerNetworkMode ?? DEFAULT_SETTINGS.defaultScannerNetworkMode,
defaultScannerDns: Array.isArray(updatedSettings.defaultScannerDns) ? updatedSettings.defaultScannerDns : DEFAULT_SETTINGS.defaultScannerDns
});
}
} catch (error) {
@@ -293,6 +316,20 @@ function createSettingsStore() {
return newSettings;
});
},
setDefaultScannerNetworkMode: (value: string) => {
update((current) => {
const newSettings = { ...current, defaultScannerNetworkMode: value };
saveSettings({ defaultScannerNetworkMode: value });
return newSettings;
});
},
setDefaultScannerDns: (value: string[]) => {
update((current) => {
const newSettings = { ...current, defaultScannerDns: value };
saveSettings({ defaultScannerDns: value });
return newSettings;
});
},
setScheduleRetentionDays: (value: number) => {
update((current) => {
const newSettings = { ...current, scheduleRetentionDays: value };
@@ -461,6 +498,13 @@ function createSettingsStore() {
return newSettings;
});
},
setProtectScannerImages: (value: boolean) => {
update((current) => {
const newSettings = { ...current, protectScannerImages: value };
saveSettings({ protectScannerImages: value });
return newSettings;
});
},
setShowImageChangelogLinks: (value: boolean) => {
update((current) => {
const newSettings = { ...current, showImageChangelogLinks: value };
@@ -468,6 +512,13 @@ function createSettingsStore() {
return newSettings;
});
},
setShowWhatsNew: (value: boolean) => {
update((current) => {
const newSettings = { ...current, showWhatsNew: value };
saveSettings({ showWhatsNew: value });
return newSettings;
});
},
// Manual refresh from database
refresh: () => {
initialized = false;
@@ -481,12 +532,30 @@ export const appSettings = createSettingsStore();
// Cache current settings for synchronous access (updated reactively)
let cachedTimeFormat: TimeFormat = DEFAULT_SETTINGS.timeFormat;
let cachedDateFormat: DateFormat = DEFAULT_SETTINGS.dateFormat;
let cachedDefaultTimezone: string = DEFAULT_SETTINGS.defaultTimezone;
// Intl.DateTimeFormat construction is expensive; build once per timezone
// change and reuse for every format call (activity log can render hundreds of
// rows). Built lazily on first format call so the heavy work runs only when
// needed and after the store subscription has populated cachedDefaultTimezone.
let formatters: DateTimeFormatters | null = null;
function getFormatters(): DateTimeFormatters {
if (formatters === null) {
formatters = buildFormatters(cachedDefaultTimezone);
}
return formatters;
}
// Subscribe once to keep cache updated
if (browser) {
appSettings.subscribe((s) => {
cachedTimeFormat = s.timeFormat;
cachedDateFormat = s.dateFormat;
if (s.defaultTimezone !== cachedDefaultTimezone) {
cachedDefaultTimezone = s.defaultTimezone;
formatters = buildFormatters(cachedDefaultTimezone);
}
});
}
@@ -495,21 +564,7 @@ if (browser) {
* This is a low-level helper - prefer formatDateTime for most uses.
*/
function formatDatePart(d: Date): string {
const day = d.getDate().toString().padStart(2, '0');
const month = (d.getMonth() + 1).toString().padStart(2, '0');
const year = d.getFullYear();
switch (cachedDateFormat) {
case 'MM/DD/YYYY':
return `${month}/${day}/${year}`;
case 'DD/MM/YYYY':
return `${day}/${month}/${year}`;
case 'YYYY-MM-DD':
return `${year}-${month}-${day}`;
case 'DD.MM.YYYY':
default:
return `${day}.${month}.${year}`;
}
return formatDatePartWith(d, getFormatters().date, cachedDateFormat);
}
/**
@@ -517,22 +572,7 @@ function formatDatePart(d: Date): string {
* This is a low-level helper - prefer formatDateTime for most uses.
*/
function formatTimePart(d: Date, includeSeconds = false): string {
const hours = d.getHours();
const minutes = d.getMinutes().toString().padStart(2, '0');
const seconds = d.getSeconds().toString().padStart(2, '0');
if (cachedTimeFormat === '12h') {
const hour12 = hours === 0 ? 12 : hours > 12 ? hours - 12 : hours;
const ampm = hours >= 12 ? 'PM' : 'AM';
return includeSeconds
? `${hour12}:${minutes}:${seconds} ${ampm}`
: `${hour12}:${minutes} ${ampm}`;
} else {
const hour24 = hours.toString().padStart(2, '0');
return includeSeconds
? `${hour24}:${minutes}:${seconds}`
: `${hour24}:${minutes}`;
}
return formatTimePartWith(d, getFormatters().time, cachedTimeFormat, includeSeconds);
}
/**
+145
View File
@@ -0,0 +1,145 @@
/**
* Sidebar Menu Preferences Store for Dockhand
*
* Manages sidebar item order and visibility with:
* - localStorage sync for flash-free loading
* - Database persistence via API (app-wide without auth, per-user with auth)
*/
import { writable } from 'svelte/store';
export interface SidebarPreferences {
order: string[];
hidden: string[];
}
const STORAGE_KEY = 'dockhand-sidebar-preferences';
const DEFAULTS: SidebarPreferences = { order: [], hidden: [] };
// Load initial state from localStorage
function loadFromStorage(): SidebarPreferences {
if (typeof window === 'undefined') return DEFAULTS;
try {
const stored = localStorage.getItem(STORAGE_KEY);
if (stored) {
const parsed = JSON.parse(stored);
if (Array.isArray(parsed?.order) && Array.isArray(parsed?.hidden)) {
return parsed;
}
}
} catch {
// Ignore parse errors
}
return DEFAULTS;
}
// Save to localStorage
function saveToStorage(prefs: SidebarPreferences) {
if (typeof window === 'undefined') return;
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(prefs));
} catch {
// Ignore storage errors
}
}
/**
* Apply a saved order to the default menu item list.
* Unknown hrefs in the saved order are dropped; items missing from the
* saved order (e.g. added in a newer release) are inserted right after
* their nearest preceding default sibling, so new items land where the
* default layout puts them.
*/
export function orderItems<T extends { href: string }>(items: readonly T[], order: string[]): T[] {
const byHref = new Map(items.map((item) => [item.href, item]));
const result = order.filter((h) => byHref.has(h)).map((h) => byHref.get(h)!);
items.forEach((item, i) => {
if (result.includes(item)) return;
let at = 0;
for (let j = i - 1; j >= 0; j--) {
const k = result.indexOf(items[j]);
if (k !== -1) {
at = k + 1;
break;
}
}
result.splice(at, 0, item);
});
return result;
}
function createSidebarPreferencesStore() {
const { subscribe, set } = writable<SidebarPreferences>(loadFromStorage());
return {
subscribe,
// Initialize from API (called on mount) - server value wins
async init() {
try {
const res = await fetch('/api/preferences/sidebar');
if (res.ok) {
const data = await res.json();
const prefs = { ...DEFAULTS, ...(data.preferences || {}) };
set(prefs);
saveToStorage(prefs);
}
} catch {
// Use localStorage fallback
}
},
// Optimistic update + async persistence
async save(prefs: SidebarPreferences) {
set(prefs);
saveToStorage(prefs);
try {
await fetch('/api/preferences/sidebar', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(prefs)
});
} catch {
// Silently fail - localStorage has the value
}
},
// Reset to default order/visibility
async reset() {
set(DEFAULTS);
if (typeof window !== 'undefined') {
try {
localStorage.removeItem(STORAGE_KEY);
} catch {
// Ignore storage errors
}
}
try {
await fetch('/api/preferences/sidebar', { method: 'DELETE' });
} catch {
// Silently fail
}
},
// Drop the localStorage copy (on logout, so the next user on this
// browser doesn't briefly see the previous user's layout)
clearLocal() {
set(DEFAULTS);
if (typeof window !== 'undefined') {
try {
localStorage.removeItem(STORAGE_KEY);
} catch {
// Ignore storage errors
}
}
}
};
}
export const sidebarPreferencesStore = createSidebarPreferencesStore();
+31 -2
View File
@@ -11,6 +11,17 @@ import { writable, get } from 'svelte/store';
import { getFont, getMonospaceFont, type FontMeta } from '$lib/themes';
export type FontSize = 'xsmall' | 'small' | 'normal' | 'medium' | 'large' | 'xlarge';
export type ActionIconSize = 'small' | 'normal' | 'large' | 'xlarge';
// Pixel values for each action icon size (#1072).
// Normal = 12px matches the historical Tailwind w-3 default — picking
// Normal is a no-op for existing users.
export const ACTION_ICON_SIZE_PX: Record<ActionIconSize, number> = {
small: 10,
normal: 12,
large: 16,
xlarge: 20
};
export interface ThemePreferences {
lightTheme: string;
@@ -21,6 +32,8 @@ export interface ThemePreferences {
terminalFont: string;
editorFont: string;
animateIcons: boolean;
coloredActionButtons: boolean;
actionIconSize: ActionIconSize;
}
const STORAGE_KEY = 'dockhand-theme';
@@ -33,7 +46,9 @@ const defaultPrefs: ThemePreferences = {
gridFontSize: 'normal',
terminalFont: 'system-mono',
editorFont: 'system-mono',
animateIcons: true
animateIcons: true,
coloredActionButtons: false,
actionIconSize: 'normal'
};
// Font size scale mapping
@@ -107,7 +122,10 @@ function createThemeStore() {
animateIcons:
data.animateIcons === undefined && data.animate_icons === undefined
? true
: !!(data.animateIcons ?? data.animate_icons)
: !!(data.animateIcons ?? data.animate_icons),
// Default OFF (#1072)
coloredActionButtons: !!(data.coloredActionButtons ?? data.colored_action_buttons ?? false),
actionIconSize: (data.actionIconSize || data.action_icon_size || 'normal') as ActionIconSize
};
set(prefs);
saveToStorage(prefs);
@@ -208,6 +226,17 @@ export function applyTheme(prefs: ThemePreferences) {
// Apply icon animation toggle (#1169) — single class on <html> drives a CSS rule in app.css
document.documentElement.classList.toggle('no-icon-animation', !prefs.animateIcons);
// Apply colored grid action buttons + action icon size (#1072)
applyActionButtonStyles(prefs.coloredActionButtons, prefs.actionIconSize);
}
// Apply colored-action class and action icon size CSS var
function applyActionButtonStyles(colored: boolean, size: ActionIconSize) {
if (typeof document === 'undefined') return;
document.documentElement.classList.toggle('colored-actions', colored);
const px = ACTION_ICON_SIZE_PX[size] ?? ACTION_ICON_SIZE_PX.small;
document.documentElement.style.setProperty('--action-icon-size', `${px}px`);
}
// Apply font to document
+4 -1
View File
@@ -134,6 +134,7 @@ export interface StackContainer {
restartCount: number;
created: number;
labels: Record<string, string>;
updateAvailable?: boolean;
}
export interface ComposeStackInfo {
@@ -141,6 +142,8 @@ export interface ComposeStackInfo {
containers: string[];
containerDetails: StackContainer[];
status: string;
updatesAvailable?: boolean;
updateCount?: number;
sourceType?: 'external' | 'internal' | 'git';
repository?: {
id: number;
@@ -170,7 +173,7 @@ export interface GitRepository {
}
// Grid column configuration types
export type GridId = 'containers' | 'images' | 'imageTags' | 'networks' | 'stacks' | 'volumes' | 'activity' | 'schedules' | 'audit' | 'environments';
export type GridId = 'containers' | 'images' | 'imageTags' | 'networks' | 'stacks' | 'volumes' | 'activity' | 'schedules' | 'audit' | 'environments' | 'vulnerabilities';
export interface ColumnConfig {
id: string;
+32
View File
@@ -0,0 +1,32 @@
/**
* Safe cookie header parsing for WebSocket upgrades.
*
* Extracted from ws-auth so it can be unit-tested without pulling in the
* DB/SvelteKit graph (#1224).
*/
export function safeDecode(v: string): string {
// Browsers freely send cookies with stray `%` from third-party trackers and
// legacy code. decodeURIComponent throws URIError on `%` not followed by two
// hex digits, which would crash the entire WS upgrade — including the
// session cookie we actually care about. Fall back to the raw value (#1224).
try {
return decodeURIComponent(v);
} catch {
return v;
}
}
export function parseCookieHeader(header: string | undefined): Record<string, string> {
if (!header) return {};
const out: Record<string, string> = {};
for (const part of header.split(';')) {
const eq = part.indexOf('=');
if (eq < 0) continue;
const k = part.slice(0, eq).trim();
let v = part.slice(eq + 1).trim();
if (v.startsWith('"') && v.endsWith('"')) v = v.slice(1, -1);
if (k) out[k] = safeDecode(v);
}
return out;
}
+156
View File
@@ -0,0 +1,156 @@
/**
* Pure date/time formatting helpers no SvelteKit / store imports.
*
* settings.ts wraps these with cached preferences and a reactive subscription;
* keeping the implementation here lets the formatters be unit-tested without
* dragging in $app/environment or svelte/store.
*/
export type TimeFormat = '12h' | '24h';
export type DateFormat = 'MM/DD/YYYY' | 'DD/MM/YYYY' | 'YYYY-MM-DD' | 'DD.MM.YYYY';
export interface DateTimeFormatters {
date: Intl.DateTimeFormat;
time: Intl.DateTimeFormat;
}
// Intl.DateTimeFormat construction is expensive; the caller caches one pair per
// timezone and rebuilds only when the timezone setting changes.
export function buildFormatters(timeZone: string | undefined): DateTimeFormatters {
const tz = timeZone || undefined;
return {
date: new Intl.DateTimeFormat('en-GB', {
timeZone: tz,
year: 'numeric',
month: '2-digit',
day: '2-digit'
}),
time: new Intl.DateTimeFormat('en-GB', {
timeZone: tz,
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
hour12: false
})
};
}
function pickPart(parts: Intl.DateTimeFormatPart[], type: Intl.DateTimeFormatPartTypes): string {
return parts.find((p) => p.type === type)?.value ?? '';
}
export function formatDatePartWith(d: Date, dateFormatter: Intl.DateTimeFormat, dateFormat: DateFormat): string {
const parts = dateFormatter.formatToParts(d);
const day = pickPart(parts, 'day');
const month = pickPart(parts, 'month');
const year = pickPart(parts, 'year');
switch (dateFormat) {
case 'MM/DD/YYYY':
return `${month}/${day}/${year}`;
case 'DD/MM/YYYY':
return `${day}/${month}/${year}`;
case 'YYYY-MM-DD':
return `${year}-${month}-${day}`;
case 'DD.MM.YYYY':
default:
return `${day}.${month}.${year}`;
}
}
export function formatTimePartWith(
d: Date,
timeFormatter: Intl.DateTimeFormat,
timeFormat: TimeFormat,
includeSeconds = false
): string {
const parts = timeFormatter.formatToParts(d);
const hours = parseInt(pickPart(parts, 'hour'), 10);
const minutes = pickPart(parts, 'minute');
const seconds = pickPart(parts, 'second');
if (timeFormat === '12h') {
const hour12 = hours === 0 ? 12 : hours > 12 ? hours - 12 : hours;
const ampm = hours >= 12 ? 'PM' : 'AM';
return includeSeconds
? `${hour12}:${minutes}:${seconds} ${ampm}`
: `${hour12}:${minutes} ${ampm}`;
} else {
const hour24 = hours.toString().padStart(2, '0');
return includeSeconds
? `${hour24}:${minutes}:${seconds}`
: `${hour24}:${minutes}`;
}
}
/**
* Offset of a timezone from UTC (in ms) at a given instant.
* Positive for zones ahead of UTC (e.g. UTC+2 -> 7_200_000).
*/
function tzOffsetMs(timeZone: string, at: Date): number {
const dtf = new Intl.DateTimeFormat('en-US', {
timeZone,
hourCycle: 'h23',
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
second: '2-digit'
});
const parts = dtf.formatToParts(at);
const get = (type: Intl.DateTimeFormatPartTypes) =>
parseInt(parts.find((p) => p.type === type)?.value ?? '0', 10);
const asUtc = Date.UTC(get('year'), get('month') - 1, get('day'), get('hour'), get('minute'), get('second'));
// Drop sub-second precision of `at` - formatToParts only has seconds
return asUtc - Math.floor(at.getTime() / 1000) * 1000;
}
/**
* Current calendar date in a timezone as 'YYYY-MM-DD'.
* Falsy timeZone means the browser's local timezone.
*/
export function currentDateInTimezone(timeZone?: string): string {
const now = new Date();
if (!timeZone) {
const y = now.getFullYear();
const m = String(now.getMonth() + 1).padStart(2, '0');
const d = String(now.getDate()).padStart(2, '0');
return `${y}-${m}-${d}`;
}
const parts = new Intl.DateTimeFormat('en-CA', {
timeZone,
year: 'numeric',
month: '2-digit',
day: '2-digit'
}).formatToParts(now);
const get = (type: Intl.DateTimeFormatPartTypes) => parts.find((p) => p.type === type)?.value ?? '';
return `${get('year')}-${get('month')}-${get('day')}`;
}
/**
* UTC instant (ISO string) of a day boundary in a timezone: midnight for the
* start of the day, 23:59:59.999 for the end. Used to turn date-only filter
* values ('YYYY-MM-DD', meaning a day in the user's configured timezone) into
* timestamps comparable against UTC-stored event times (#1269).
* Falsy timeZone means the browser's local timezone.
*/
export function dayBoundaryToUtcISO(dateStr: string, timeZone: string | undefined, endOfDay: boolean): string {
const [y, m, d] = dateStr.split('-').map(Number);
const h = endOfDay ? 23 : 0;
const min = endOfDay ? 59 : 0;
const s = endOfDay ? 59 : 0;
const ms = endOfDay ? 999 : 0;
if (!timeZone) {
return new Date(y, m - 1, d, h, min, s, ms).toISOString();
}
const wallClockUtc = Date.UTC(y, m - 1, d, h, min, s, ms);
// Two passes so DST transitions near the boundary converge on the right offset
let ts = wallClockUtc;
for (let i = 0; i < 2; i++) {
ts = wallClockUtc - tzOffsetMs(timeZone, new Date(ts));
}
return new Date(ts).toISOString();
}
+5 -3
View File
@@ -11,12 +11,14 @@
* cause breakage are rejected.
*/
// Allowed characters: ASCII letters/digits + _ . - ( ) space + @
// Allowed characters: ASCII letters/digits + _ . - ( ) space + @ '
// First char: any allowed except space (no leading whitespace).
// Last char: any allowed except space and dot (no trailing whitespace,
// no trailing dot to avoid Windows-style "hidden" trailing-dot issues).
// Length: 1..64.
export const ENV_NAME_RE = /^(?! )[A-Za-z0-9_.\-() +@](?:[A-Za-z0-9_.\-() +@]{0,62}[A-Za-z0-9_\-)+@])?$/;
// Apostrophe (#1228): safe — env.name only lands in path.join() and
// array-form spawn(), never in a shell-interpolated string.
export const ENV_NAME_RE = /^(?! )[A-Za-z0-9_.\-()' +@](?:[A-Za-z0-9_.\-()' +@]{0,62}[A-Za-z0-9_\-)'+@])?$/;
export const ENV_NAME_MAX_LENGTH = 64;
@@ -39,7 +41,7 @@ export function validateEnvName(name: unknown): ValidationResult {
return {
ok: false,
reason:
'Name may contain letters, digits, spaces, and any of - _ . ( ) + @ (no leading/trailing whitespace, no trailing dot, no slashes, no wildcards)'
'Name can\'t contain slashes, wildcards, or other shell-special characters, and can\'t start with whitespace or end with whitespace or a dot'
};
}
return { ok: true };
+44
View File
@@ -0,0 +1,44 @@
/**
* Syntax-highlight a Dockerfile/shell command for display via {@html}.
*
* The input (e.g. an image layer's CreatedBy) is attacker-controlled in a
* crafted image, so it MUST be HTML-escaped before any markup is inserted
* otherwise the {@html} render is an XSS sink.
*/
export function highlightCommand(cmd: string): string {
if (!cmd) return '';
// Dockerfile instructions
const dockerInstructions = ['ADD', 'COPY', 'ENV', 'ARG', 'WORKDIR', 'RUN', 'CMD', 'ENTRYPOINT', 'EXPOSE', 'VOLUME', 'USER', 'LABEL', 'HEALTHCHECK', 'SHELL', 'ONBUILD', 'STOPSIGNAL', 'MAINTAINER', 'FROM'];
const dockerInstructionPattern = new RegExp(`\\b(${dockerInstructions.join('|')})\\b`, 'g');
// Shell keywords
const shellKeywords = ['if', 'then', 'else', 'elif', 'fi', 'for', 'while', 'do', 'done', 'case', 'esac', 'function', 'in', 'select'];
const shellKeywordPattern = new RegExp(`\\b(${shellKeywords.join('|')})\\b`, 'g');
// Common commands
const commands = ['apt-get', 'apk', 'yum', 'pip', 'npm', 'yarn', 'git', 'curl', 'wget', 'mkdir', 'cd', 'cp', 'mv', 'rm', 'chmod', 'chown', 'echo', 'cat', 'grep', 'sed', 'awk', 'tar', 'unzip', 'make', 'gcc', 'python', 'node', 'sh', 'bash'];
const commandPattern = new RegExp(`\\b(${commands.join('|')})\\b`, 'g');
// Escape HTML before inserting any markup. The input is attacker-controlled
// (image build history) and rendered via {@html}, so unescaped < > & would be
// an XSS sink.
const highlighted = cmd
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
// Strings in quotes
.replace(/("(?:[^"\\]|\\.)*"|'(?:[^'\\]|\\.)*')/g, '<span class="text-green-600 dark:text-green-400">$1</span>')
// Comments
.replace(/(#[^\n]*)/g, '<span class="text-gray-500 dark:text-gray-400 italic">$1</span>')
// Dockerfile instructions (must be before shell keywords to take precedence)
.replace(dockerInstructionPattern, '<span class="text-blue-600 dark:text-blue-400 font-semibold">$1</span>')
// Shell keywords
.replace(shellKeywordPattern, '<span class="text-purple-600 dark:text-purple-400">$1</span>')
// Commands
.replace(commandPattern, '<span class="text-cyan-600 dark:text-cyan-400">$1</span>')
// Flags (words starting with - or --)
.replace(/(\s)(--?[a-zA-Z0-9-]+)/g, '$1<span class="text-yellow-600 dark:text-yellow-400">$2</span>');
return highlighted;
}
+29 -16
View File
@@ -2,16 +2,20 @@
* Pangolin label public URL extraction (#2 follow-up).
*
* Pangolin Blueprints (https://docs.pangolin.net/manage/blueprints) annotates
* a container with one or more proxy resources. The relevant labels for URL
* extraction are:
* a container with one or more resources, scoped public OR private. The
* relevant labels for URL extraction are:
*
* pangolin.proxy-resources.<name>.name human-friendly label
* pangolin.proxy-resources.<name>.full-domain public hostname (mandatory)
* pangolin.proxy-resources.<name>.protocol http | https (defaults to https)
* pangolin.public-resources.<name>.name human-friendly label
* pangolin.public-resources.<name>.full-domain public hostname (mandatory)
* pangolin.public-resources.<name>.protocol http | https (defaults to https)
* pangolin.private-resources.<name>.{name,full-domain,protocol} private equivalents
*
* Both scopes resolve to a URL the user can click. The scope (`public`/
* `private`) is preserved on the result so callers can label or filter.
*
* The `targets[N].port` family is intentionally ignored Pangolin terminates
* the public connection at full-domain; the internal target port is not part
* of the URL a user sees.
* the connection at full-domain; the internal target port is not part of the
* URL a user sees.
*
* Returns one URL per resource that declares a full-domain. Multiple
* resources on the same container yield multiple URLs. Identical URLs across
@@ -19,37 +23,45 @@
*
* dockhand.url labels override this Pangolin extraction is a fallback,
* never a winner over an explicit user-provided URL.
*
* Earlier v1.0.34 used `pangolin.proxy-resources.*` that label was never
* recognised by Pangolin itself and is no longer accepted here.
*/
export interface PangolinUrl {
url: string;
/** The Pangolin resource key (the `<name>` in the label key). */
resource: string;
/** Scope from the label namespace — public or private. */
scope: 'public' | 'private';
/** Optional human-friendly name from the `.name` label, if set. */
displayName?: string;
}
const RESOURCE_KEY_RE =
/^pangolin\.proxy-resources\.([^.]+)\.(full-domain|protocol|name)$/;
/^pangolin\.(public|private)-resources\.([^.]+)\.(full-domain|protocol|name)$/;
export function extractPangolinUrls(
labels: Record<string, string> | undefined | null
): PangolinUrl[] {
if (!labels) return [];
// Group label values by resource key.
// Group label values by (scope, resource) key. A single resource name can
// legitimately appear under both scopes (rare but allowed by Pangolin);
// they're treated as independent resources here.
const byResource = new Map<
string,
{ fullDomain?: string; protocol?: string; name?: string }
{ scope: 'public' | 'private'; resource: string; fullDomain?: string; protocol?: string; name?: string }
>();
for (const [key, value] of Object.entries(labels)) {
const m = key.match(RESOURCE_KEY_RE);
if (!m) continue;
const [, resource, field] = m;
let entry = byResource.get(resource);
const [, scope, resource, field] = m as unknown as [string, 'public' | 'private', string, string];
const groupKey = `${scope}:${resource}`;
let entry = byResource.get(groupKey);
if (!entry) {
entry = {};
byResource.set(resource, entry);
entry = { scope, resource };
byResource.set(groupKey, entry);
}
const v = (value ?? '').trim();
if (!v) continue;
@@ -61,7 +73,7 @@ export function extractPangolinUrls(
const out: PangolinUrl[] = [];
const seen = new Set<string>();
for (const [resource, entry] of byResource) {
for (const entry of byResource.values()) {
if (!entry.fullDomain) continue;
const proto =
@@ -75,7 +87,8 @@ export function extractPangolinUrls(
out.push({
url,
resource,
resource: entry.resource,
scope: entry.scope,
displayName: entry.name
});
}
+22
View File
@@ -0,0 +1,22 @@
/**
* Path-relative redirect target validation.
*
* Returns true only for targets that are path-relative URLs ('/path?query#hash').
* Rejects absolute URLs ('https://...', 'http://...'), protocol-relative URLs
* ('//example.com'), backslash-prefixed forms ('\\example.com' some browsers
* normalize these), and non-string inputs.
*/
export function isSafeRedirect(target: unknown): target is string {
if (typeof target !== 'string' || target.length === 0) return false;
if (!target.startsWith('/')) return false;
if (target.startsWith('//') || target.startsWith('/\\')) return false;
return true;
}
/**
* Return the input when it passes isSafeRedirect, otherwise '/'. Use this on
* any post-login / post-callback redirect target derived from user input.
*/
export function safeRedirectOrRoot(target: unknown): string {
return isSafeRedirect(target) ? target : '/';
}
+150
View File
@@ -0,0 +1,150 @@
/**
* Build a schema-valid SARIF 2.1.0 document from Dockhand vulnerability findings,
* for ingestion by DefectDojo / GitHub code scanning / Dependency-Track (#415).
*
* SARIF 2.1.0 is a frozen OASIS standard, so we hand-build the object literal
* against the @types/sarif types rather than take a runtime dependency.
*/
import type { Log, Run, Result, ReportingDescriptor } from 'sarif';
import type { Finding } from '$lib/utils/vulnerability';
const SARIF_SCHEMA = 'https://json.schemastore.org/sarif-2.1.0.json';
/** Map a Dockhand severity to a SARIF result level. */
function sarifLevel(severity: string): Result.level {
switch (severity.toLowerCase()) {
case 'critical':
case 'high':
return 'error';
case 'medium':
return 'warning';
case 'low':
case 'negligible':
return 'note';
default:
return 'none';
}
}
/**
* A repo-relative artifact URI for a package inside an image.
*
* SARIF's artifactLocation.uri is a uri-reference; consumers (GitHub code
* scanning especially) resolve it against a base and drop results whose URI is
* an *absolute* URI. An image ref like `nginx:latest` would make `nginx:` parse
* as a URI scheme, so we prefix a constant `images/` segment (guaranteeing the
* first path segment has no colon) and percent-encode each segment.
*/
function artifactUri(imageName: string, pkg: string): string {
return `images/${encodeURIComponent(imageName)}/${encodeURIComponent(pkg)}`;
}
/** GitHub/DefectDojo read `security-severity` (0-10) from rule properties. */
function securitySeverityScore(severity: string): string {
switch (severity.toLowerCase()) {
case 'critical': return '9.5';
case 'high': return '8.0';
case 'medium': return '5.5';
case 'low': return '3.0';
default: return '0.0';
}
}
export interface SarifOptions {
toolName?: string;
toolVersion?: string;
informationUri?: string;
}
/**
* Convert findings into a SARIF log with a single run. Each unique CVE becomes a
* reporting descriptor (rule); each finding becomes a result located at the
* affected package within its image.
*/
export function findingsToSarif(findings: Finding[], opts: SarifOptions = {}): Log {
const toolName = opts.toolName ?? 'Dockhand';
// __APP_VERSION__ is a Vite define (git tag / VERSION file); guard for non-Vite
// contexts such as `bun test`.
const appVersion = typeof __APP_VERSION__ !== 'undefined' ? __APP_VERSION__ : null;
const toolVersion = opts.toolVersion ?? appVersion ?? 'unknown';
const informationUri = opts.informationUri ?? 'https://github.com/Finsys/dockhand';
// One rule per distinct CVE — dedupe, keeping the most detailed description/link.
const ruleMap = new Map<string, ReportingDescriptor>();
for (const f of findings) {
if (ruleMap.has(f.cve)) continue;
ruleMap.set(f.cve, {
id: f.cve,
name: f.cve,
shortDescription: { text: `${f.cve} in ${f.package}` },
fullDescription: { text: f.description || `${f.cve} affecting ${f.package}` },
helpUri: f.link || `https://nvd.nist.gov/vuln/detail/${f.cve}`,
defaultConfiguration: { level: sarifLevel(f.severity) },
properties: {
// GitHub code scanning / DefectDojo read the hyphenated key off the rule.
'security-severity': securitySeverityScore(f.severity),
tags: ['vulnerability', 'security', f.severity.toLowerCase()]
}
});
}
const results: Result[] = findings.map((f) => {
const fixText = f.fixedVersion ? `Fixed in ${f.fixedVersion}.` : 'No fix available.';
return {
ruleId: f.cve,
level: sarifLevel(f.severity),
message: {
text: `${f.severity.toUpperCase()} ${f.cve} in ${f.package} ${f.installedVersion} (image ${f.imageName}). ${fixText}`
},
locations: [
{
physicalLocation: {
artifactLocation: {
// Repo-relative URI identifying the vulnerable package inside the
// image (see artifactUri: image refs contain colons, which must not
// be read as a URI scheme).
uri: artifactUri(f.imageName, f.package)
}
},
logicalLocations: [
{ name: f.package, kind: 'package', fullyQualifiedName: `${f.imageName}/${f.package}@${f.installedVersion}` }
]
}
],
partialFingerprints: {
// Custom stable fingerprint so re-imports dedupe: image|cve|package|version.
// (Not primaryLocationLineHash — that reserved key has hash semantics.)
dockhandFinding: `${f.imageId}|${f.cve}|${f.package}|${f.installedVersion}`
},
properties: {
imageName: f.imageName,
imageId: f.imageId,
package: f.package,
installedVersion: f.installedVersion,
fixedVersion: f.fixedVersion || null,
severity: f.severity,
scannedAt: f.scannedAt || null,
containers: (f.containers ?? []).map((c) => c.name),
stacks: f.stacks ?? []
}
};
});
const run: Run = {
tool: {
driver: {
name: toolName,
version: toolVersion,
informationUri,
rules: Array.from(ruleMap.values())
}
},
results
};
return {
version: '2.1.0',
$schema: SARIF_SCHEMA,
runs: [run]
};
}
+27
View File
@@ -0,0 +1,27 @@
/**
* Pure scanner override resolver (#1219).
*
* Decides which networkMode + DNS values to pass to the scanner container,
* given (auto-detected, user-settings) inputs. Kept SvelteKit/server-free so
* the unit tests can import it without triggering DB initialisation.
*/
export function resolveScannerOverrides(
autoDetected: { networkMode?: string; extraHosts?: string[] },
userSettings: { networkMode?: string; dns?: string[] }
): { networkMode?: string; dns?: string[]; extraHosts?: string[] } {
const userNet = userSettings.networkMode?.trim();
const networkMode = userNet ? userNet : autoDetected.networkMode || undefined;
const cleanedDns = (userSettings.dns ?? [])
.map((d) => d.trim())
.filter((d) => d.length > 0);
const dns = cleanedDns.length > 0 ? cleanedDns : undefined;
const extraHosts =
autoDetected.extraHosts && autoDetected.extraHosts.length > 0
? autoDetected.extraHosts
: undefined;
return { networkMode, dns, extraHosts };
}
+12 -4
View File
@@ -135,16 +135,24 @@ export async function detectShells(
/**
* Get the best available shell from the detection result
* Returns the user's preferred shell if available, otherwise the default
*
* Preference matching is by shell name (basename), not full path so a
* preference of `/bin/bash` will still match when the server resolved bash
* to a non-standard path like `/usr/bin/bash` (issue #1189).
*/
export function getBestShell(
result: ShellDetectionResult,
preferredShell: string
): string | null {
// If preferred shell is available, use it
if (result.shells.includes(preferredShell)) {
return preferredShell;
const exact = result.shells.find(s => s === preferredShell);
if (exact) return exact;
const preferredName = preferredShell.split('/').pop();
if (preferredName) {
const byName = result.shells.find(s => s.split('/').pop() === preferredName);
if (byName) return byName;
}
// Otherwise use the default shell
return result.defaultShell;
}
+40
View File
@@ -0,0 +1,40 @@
/**
* Render a third-party template description (from a Portainer catalog) for
* display via {@html}.
*
* The input is untrusted, so: HTML tags are stripped, and markdown links are
* rebuilt only for safe http(s)/relative URLs javascript:/data:/etc. are
* dropped (keeping just the link text) to prevent a `[x](javascript:...)` XSS.
*/
function escapeHtml(s: string): string {
return s
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}
// Only http(s) and relative URLs are safe to put in href; reject javascript:,
// data:, etc.
export function isSafeLinkUrl(url: string): boolean {
// Browsers ignore ASCII whitespace/control chars when parsing the scheme, so
// strip them before checking (defeats java\tscript: style bypasses).
const cleaned = url.replace(/[\x00-\x20]/g, '').toLowerCase();
if (cleaned.startsWith('http://') || cleaned.startsWith('https://')) return true;
// No scheme (relative path/anchor) is safe; any other scheme is not.
return !/^[a-z][a-z0-9+.-]*:/.test(cleaned);
}
// Convert markdown links [text](url) to HTML <a> tags, strip other HTML.
export function renderDescription(text: string): string {
return text
.replace(/<a\s+href="([^"]+)"[^>]*>([^<]+)<\/a>/gi, '[$2]($1)') // normalize HTML links to markdown first
.replace(/<[^>]+>/g, '') // strip remaining HTML tags
.replace(/\[([^\]]+)\]\(([^)]+)\)/g, (_m, label: string, url: string) => {
const safeText = escapeHtml(label);
if (!isSafeLinkUrl(url)) return safeText; // drop unsafe scheme, keep the text
return `<a href="${escapeHtml(url)}" target="_blank" rel="noopener" class="text-primary hover:underline">${safeText}</a>`;
})
.trim();
}
+250
View File
@@ -0,0 +1,250 @@
/**
* Shared vulnerability/severity helpers used by the per-image scan view and
* the aggregated Vulnerabilities dashboard.
*/
export interface Vulnerability {
id: string;
severity: string;
package: string;
version: string;
fixedVersion?: string;
description?: string;
link?: string;
}
/** Sort order: lower = more severe. Unknown/unmapped sort last. */
export const SEVERITY_ORDER: Record<string, number> = {
critical: 0,
high: 1,
medium: 2,
low: 3,
negligible: 4,
unknown: 5
};
export function severityRank(severity: string): number {
return SEVERITY_ORDER[severity.toLowerCase()] ?? SEVERITY_ORDER.unknown;
}
/** Tailwind classes for a severity Badge (bg + text + border). */
export function getSeverityColor(severity: string): string {
switch (severity.toLowerCase()) {
case 'critical':
return 'bg-red-500/10 text-red-500 border-red-500/30';
case 'high':
return 'bg-orange-500/10 text-orange-500 border-orange-500/30';
case 'medium':
return 'bg-yellow-500/10 text-yellow-600 border-yellow-500/30';
case 'low':
return 'bg-blue-500/10 text-blue-500 border-blue-500/30';
case 'negligible':
case 'unknown':
default:
return 'bg-gray-500/10 text-gray-500 border-gray-500/30';
}
}
/** The four severities shown as summary pills, most severe first. */
export const SUMMARY_SEVERITIES = ['critical', 'high', 'medium', 'low'] as const;
export type SummarySeverity = (typeof SUMMARY_SEVERITIES)[number];
/** Title-case a severity label (e.g. "critical" -> "Critical"). */
export function severityLabel(severity: string): string {
return severity.charAt(0).toUpperCase() + severity.slice(1);
}
/** A container reference attached to a finding. */
export interface FindingContainer {
id: string;
name: string;
}
/** A single vulnerability finding as returned by /api/vulnerabilities. */
export interface Finding {
key: string;
cve: string;
package: string;
severity: string;
installedVersion: string;
fixedVersion: string;
imageId: string;
imageName: string;
description?: string;
link?: string;
scannedAt?: string;
containers?: FindingContainer[];
stacks?: string[];
}
/** Aggregate counts for the dashboard summary row / header badge. */
export interface VulnerabilitySummary {
total: number;
critical: number;
high: number;
medium: number;
low: number;
imagesScanned: number;
totalImages: number;
}
/** A zeroed summary — shared so the 7-field shape isn't re-typed per endpoint. */
export const EMPTY_SUMMARY: VulnerabilitySummary = {
total: 0, critical: 0, high: 0, medium: 0, low: 0, imagesScanned: 0, totalImages: 0
};
/**
* Minimal finding shape needed for filtering. Both the dashboard grid (client)
* and the export endpoint (server) filter against this, so the two can't drift.
*/
export interface FilterableFinding {
cve: string;
package: string;
severity: string;
imageName: string;
containers?: { name: string }[];
stacks?: string[];
}
export interface FindingFilter {
/** Free-text query matched against cve/package/image/container/stack names. */
q?: string;
/** Lowercase severity values; empty = no severity filter. */
severities?: string[];
images?: string[];
containers?: string[];
stacks?: string[];
}
/** A persisted scan row shape (subset used for flattening into findings). */
export interface ScanRow {
imageId: string;
imageName: string;
scannedAt: string;
vulnerabilities: any;
}
/**
* Flatten persisted scan rows into deduped per-CVE findings. Shared by the
* env-wide aggregation and the per-image export so both produce the same shape.
* `enrich` optionally attaches containers/stacks by imageId.
*/
export function flattenScansToFindings(
scans: ScanRow[],
enrich?: {
containersByImage?: Map<string, FindingContainer[]>;
stacksByImage?: Map<string, Set<string>>;
}
): Finding[] {
const seen = new Set<string>();
const findings: Finding[] = [];
for (const scan of scans) {
const containers = enrich?.containersByImage?.get(scan.imageId);
const stacks = enrich?.stacksByImage?.get(scan.imageId);
// Legacy rows were double-JSON-encoded; parse once more if still a string.
let vulns: any[] = scan.vulnerabilities as any;
if (typeof vulns === 'string') {
try { vulns = JSON.parse(vulns); } catch { vulns = []; }
}
if (!Array.isArray(vulns)) vulns = [];
for (const v of vulns) {
const key = `${scan.imageId}|${v.id}|${v.package}|${v.version}`;
if (seen.has(key)) continue;
seen.add(key);
findings.push({
key,
cve: v.id,
package: v.package,
severity: v.severity,
installedVersion: v.version,
fixedVersion: v.fixedVersion || '',
imageId: scan.imageId,
imageName: scan.imageName,
description: v.description,
link: v.link,
scannedAt: scan.scannedAt,
containers: containers && containers.length ? containers : undefined,
stacks: stacks && stacks.size ? Array.from(stacks) : undefined
});
}
}
return findings;
}
/** Filter findings by the dashboard's search + multi-selects. Pure, shared client/server. */
export function filterFindings<T extends FilterableFinding>(findings: T[], filter: FindingFilter): T[] {
const q = (filter.q ?? '').toLowerCase().trim();
const sevSet = filter.severities?.length ? new Set(filter.severities) : null;
const imgSet = filter.images?.length ? new Set(filter.images) : null;
const containerSet = filter.containers?.length ? new Set(filter.containers) : null;
const stackSet = filter.stacks?.length ? new Set(filter.stacks) : null;
// Fast path: nothing to filter — return the input without copying.
if (!q && !sevSet && !imgSet && !containerSet && !stackSet) return findings;
return findings.filter((f) => {
if (sevSet && !sevSet.has(f.severity.toLowerCase())) return false;
if (imgSet && !imgSet.has(f.imageName)) return false;
if (containerSet && !(f.containers ?? []).some((c) => containerSet.has(c.name))) return false;
if (stackSet && !(f.stacks ?? []).some((s) => stackSet.has(s))) return false;
if (q) {
const containerNames = (f.containers ?? []).map((c) => c.name).join(' ');
const stackNames = (f.stacks ?? []).join(' ');
const hay = `${f.cve} ${f.package} ${f.imageName} ${containerNames} ${stackNames}`.toLowerCase();
if (!hay.includes(q)) return false;
}
return true;
});
}
export type SortField =
| 'cve' | 'package' | 'severity' | 'image'
| 'installed' | 'fixed' | 'container' | 'stack' | 'scannedAt';
/** The min (alphabetically first) name in a list — used to sort by container/stack. */
function minName(names: string[]): string {
let min: string | undefined;
for (const n of names) if (min === undefined || n < min) min = n;
return min ?? '';
}
/**
* Sort findings by a column. Pure, shared client/server. Returns a new array.
*
* Uses a Schwartzian transform: the sort key for each row is computed ONCE up
* front, not inside the comparator. This matters for the container/stack fields,
* whose key requires reducing a list doing that per comparison would allocate
* and re-scan O(N log N) times instead of O(N).
*/
export function sortFindings<T extends Finding>(findings: T[], field: SortField, direction: 'asc' | 'desc'): T[] {
const dir = direction === 'asc' ? 1 : -1;
// Numeric key for severity; string key for everything else.
const numericKey = field === 'severity';
const keyFor = (f: T): number | string => {
switch (field) {
case 'severity': return severityRank(f.severity);
case 'cve': return f.cve;
case 'package': return f.package;
case 'image': return f.imageName;
case 'installed': return f.installedVersion;
case 'fixed': return f.fixedVersion || '';
case 'container': return minName((f.containers ?? []).map((c) => c.name));
case 'stack': return minName(f.stacks ?? []);
case 'scannedAt': return f.scannedAt ?? '';
default: return '';
}
};
// Version-ish fields compare numerically within the string (1.2.10 > 1.2.9).
const numericStr = field === 'installed' || field === 'fixed';
const decorated = findings.map((f) => ({ f, key: keyFor(f) }));
decorated.sort((a, b) => {
const cmp = numericKey
? (a.key as number) - (b.key as number)
: (a.key as string).localeCompare(b.key as string, undefined, numericStr ? { numeric: true } : undefined);
return cmp * dir;
});
return decorated.map((d) => d.f);
}
+183
View File
@@ -0,0 +1,183 @@
/**
* Generic sliding-window list for server-backed virtual grids.
*
* Holds at most `windowSize` items in memory regardless of the total row count,
* so large datasets don't blow up browser memory. Pairs directly with the
* DataGrid virtual-scroll windowing props:
*
* <DataGrid data={list.items} virtualScroll windowed
* dataOffset={list.offset} virtualTotal={list.total}
* onWindowShift={list.shiftTo} loading={list.loading} />
*
* You supply one `fetchPage(offset, limit, signal)` that returns `{ items, total }`.
* The primitive owns the window offset, abort/generation guarding of superseded
* fetches, and centering the window on the requested scroll position.
*
* The fetch orchestration lives in `WindowedListCore` (plain, unit-testable);
* `createWindowedList` is a thin Svelte-runes adapter over it.
*/
export interface WindowPage<T> {
items: T[];
total: number;
}
/** State snapshot pushed to observers whenever the window changes. */
export interface WindowState<T> {
items: T[];
offset: number;
total: number;
loading: boolean;
loaded: boolean;
}
export interface WindowedListOptions<T> {
/** Fetch one contiguous slice. Must honor `signal` for cancellation. */
fetchPage: (offset: number, limit: number, signal: AbortSignal) => Promise<WindowPage<T>>;
/** Rows kept in memory at once (default 400). */
windowSize?: number;
/** Called on fetch error (non-abort). */
onError?: (error: unknown) => void;
}
/**
* The absolute offset at which to start a window so it's centered on `targetStart`,
* clamped to [0, ...). Pure unit-tested independently of the reactive factory.
*/
export function windowOffsetFor(targetStart: number, windowSize: number): number {
return Math.max(0, targetStart - Math.floor(windowSize / 2));
}
/**
* Framework-agnostic core: owns the window offset, abort/generation guarding, and
* centering. Pushes a fresh WindowState to `onState` whenever anything changes.
* No Svelte runes, so it's fully unit-testable.
*/
export class WindowedListCore<T> {
private windowSize: number;
private controller: AbortController | null = null;
private generation = 0;
/** Absolute offset of the currently in-flight fetch, or null when idle. */
private inflightDesired: number | null = null;
items: T[] = [];
offset = 0;
total = 0;
loading = false;
loaded = false;
constructor(
private options: WindowedListOptions<T>,
private onState: (s: WindowState<T>) => void = () => {}
) {
this.windowSize = options.windowSize ?? 400;
}
private emit() {
this.onState({
items: this.items,
offset: this.offset,
total: this.total,
loading: this.loading,
loaded: this.loaded
});
}
/**
* Shift/center the window on an absolute row index.
*
* `force` bypasses the same-offset dedupe: a scroll fling emits shiftTo per
* frame and we skip an identical in-flight offset (fling case), but a
* data-invalidating reset (filter/sort change) maps to the SAME offset while
* the underlying dataset differs, so it must NOT be deduped it aborts the
* stale in-flight fetch and refetches with the new query.
*/
async shiftTo(targetStart: number, force = false): Promise<void> {
const desired = windowOffsetFor(targetStart, this.windowSize);
// A fling emits shiftTo per scroll frame; without this every frame would
// abort the in-flight request and start an identical one. If the same
// offset is already loading, let it finish instead of thrashing the network.
// `force` skips this so a reset refetches even at the same offset.
if (!force && this.loading && desired === this.inflightDesired) return;
const gen = ++this.generation;
this.controller?.abort();
this.controller = new AbortController();
this.loading = true;
this.inflightDesired = desired;
this.emit();
try {
const page = await this.options.fetchPage(desired, this.windowSize, this.controller.signal);
if (gen !== this.generation) return; // superseded by a newer fetch
this.total = page.total;
this.items = page.items;
this.offset = desired;
this.loaded = true;
} catch (error) {
if ((error as Error)?.name !== 'AbortError') this.options.onError?.(error);
} finally {
if (gen === this.generation) {
this.loading = false;
this.inflightDesired = null;
this.emit();
}
}
}
/** Reset to the first window (filter/sort/dataset change). Forces a refetch
* even if an offset-0 load is already in flight for the previous query. */
reset(): Promise<void> {
return this.shiftTo(0, true);
}
/** Clear all state without fetching. */
clear(): void {
this.controller?.abort();
this.generation++;
this.items = [];
this.offset = 0;
this.total = 0;
this.loaded = false;
this.loading = false;
this.inflightDesired = null;
this.emit();
}
}
export interface WindowedList<T> {
readonly items: T[];
readonly offset: number;
readonly total: number;
readonly loading: boolean;
readonly loaded: boolean;
shiftTo: (targetStart: number) => void;
reset: () => void;
clear: () => void;
}
/** Svelte-runes adapter over WindowedListCore — the reactive state a component binds to. */
export function createWindowedList<T>(options: WindowedListOptions<T>): WindowedList<T> {
let items = $state<T[]>([]);
let offset = $state(0);
let total = $state(0);
let loading = $state(false);
let loaded = $state(false);
const core = new WindowedListCore<T>(options, (s) => {
items = s.items;
offset = s.offset;
total = s.total;
loading = s.loading;
loaded = s.loaded;
});
return {
get items() { return items; },
get offset() { return offset; },
get total() { return total; },
get loading() { return loading; },
get loaded() { return loaded; },
shiftTo: (targetStart: number) => { void core.shiftTo(targetStart); },
reset: () => { void core.reset(); },
clear: () => core.clear()
};
}
+10 -3
View File
@@ -9,11 +9,16 @@ const PUBLIC_PATHS = ['/login'];
export const load: LayoutServerLoad = async ({ cookies, url }) => {
const authEnabled = await isAuthEnabled();
// Runtime flag to suppress the "What's New" modal (#1235). Read here (not via
// a build-time define) so it can be toggled by an env var at `docker run`.
const disableWhatsNew = process.env.DISABLE_WHATS_NEW === 'true';
// If auth is disabled, allow everything
if (!authEnabled) {
return {
authEnabled: false,
user: null
user: null,
disableWhatsNew
};
}
@@ -31,7 +36,8 @@ export const load: LayoutServerLoad = async ({ cookies, url }) => {
return {
authEnabled: true,
user: null,
setupMode: true
setupMode: true,
disableWhatsNew
};
}
@@ -50,6 +56,7 @@ export const load: LayoutServerLoad = async ({ cookies, url }) => {
avatar: user.avatar,
isAdmin: user.isAdmin,
provider: user.provider
} : null
} : null,
disableWhatsNew
};
};
+5 -1
View File
@@ -17,13 +17,14 @@
import { authStore } from '$lib/stores/auth';
import { themeStore, applyTheme } from '$lib/stores/theme';
import { gridPreferencesStore } from '$lib/stores/grid-preferences';
import { appSettings } from '$lib/stores/settings';
import { shouldShowWhatsNew } from '$lib/utils/version';
import { AlertTriangle, Search } from 'lucide-svelte';
// Check if current route is login page (no sidebar needed)
const isLoginPage = $derived($page.url.pathname === '/login');
let { children } = $props();
let { children, data } = $props();
let envId = $state<number | null>(null);
let commandPaletteOpen = $state(false);
@@ -88,6 +89,9 @@
});
async function checkWhatsNew() {
// Suppressed via DISABLE_WHATS_NEW env var or the "Show What's New" setting (#1235)
if (data?.disableWhatsNew) return;
if ($appSettings.showWhatsNew === false) return;
if (browser && currentVersion && currentVersion !== 'unknown') {
lastSeenVersion = localStorage.getItem('dockhand-whats-new-version');
if (shouldShowWhatsNew(currentVersion, lastSeenVersion)) {
+28 -22
View File
@@ -41,6 +41,7 @@
import ConfirmPopover from '$lib/components/ConfirmPopover.svelte';
import { toast } from 'svelte-sonner';
import { formatDateTime, appSettings } from '$lib/stores/settings';
import { currentDateInTimezone, dayBoundaryToUtcISO } from '$lib/utils/date-format';
import { NoEnvironment } from '$lib/components/ui/empty-state';
import { DataGrid } from '$lib/components/data-grid';
@@ -175,16 +176,21 @@
{ value: 'lastMonth', label: 'Last month' }
];
// Filter dates are calendar days in the configured display timezone (#1269).
// Presets do their arithmetic on UTC-midnight tokens so the browser's own
// timezone never leaks in; boundaries convert to UTC instants when querying.
const filterTimezone = $derived($appSettings.defaultTimezone || undefined);
function formatDateForInput(date: Date): string {
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, '0');
const day = String(date.getDate()).padStart(2, '0');
const year = date.getUTCFullYear();
const month = String(date.getUTCMonth() + 1).padStart(2, '0');
const day = String(date.getUTCDate()).padStart(2, '0');
return `${year}-${month}-${day}`;
}
function applyDatePreset(preset: string): { from: string; to: string } {
const today = new Date();
today.setHours(0, 0, 0, 0);
const [y, m, d] = currentDateInTimezone(filterTimezone).split('-').map(Number);
const today = new Date(Date.UTC(y, m - 1, d));
let from = '';
let to = '';
@@ -196,34 +202,34 @@
break;
case 'yesterday': {
const yesterday = new Date(today);
yesterday.setDate(yesterday.getDate() - 1);
yesterday.setUTCDate(yesterday.getUTCDate() - 1);
from = formatDateForInput(yesterday);
to = formatDateForInput(yesterday);
break;
}
case 'last7days': {
const weekAgo = new Date(today);
weekAgo.setDate(weekAgo.getDate() - 6);
weekAgo.setUTCDate(weekAgo.getUTCDate() - 6);
from = formatDateForInput(weekAgo);
to = formatDateForInput(today);
break;
}
case 'last30days': {
const monthAgo = new Date(today);
monthAgo.setDate(monthAgo.getDate() - 29);
monthAgo.setUTCDate(monthAgo.getUTCDate() - 29);
from = formatDateForInput(monthAgo);
to = formatDateForInput(today);
break;
}
case 'thisMonth': {
const firstOfMonth = new Date(today.getFullYear(), today.getMonth(), 1);
const firstOfMonth = new Date(Date.UTC(today.getUTCFullYear(), today.getUTCMonth(), 1));
from = formatDateForInput(firstOfMonth);
to = formatDateForInput(today);
break;
}
case 'lastMonth': {
const firstOfLastMonth = new Date(today.getFullYear(), today.getMonth() - 1, 1);
const lastOfLastMonth = new Date(today.getFullYear(), today.getMonth(), 0);
const firstOfLastMonth = new Date(Date.UTC(today.getUTCFullYear(), today.getUTCMonth() - 1, 1));
const lastOfLastMonth = new Date(Date.UTC(today.getUTCFullYear(), today.getUTCMonth(), 0));
from = formatDateForInput(firstOfLastMonth);
to = formatDateForInput(lastOfLastMonth);
break;
@@ -292,8 +298,10 @@
if (filterActions.length > 0) params.set('actions', filterActions.join(','));
if (filterEnvironmentId !== null) params.set('environmentId', String(filterEnvironmentId));
if (filterLabels.length > 0) params.set('labels', filterLabels.join(','));
if (filterFromDate) params.set('fromDate', filterFromDate);
if (filterToDate) params.set('toDate', filterToDate + 'T23:59:59');
// Convert configured-timezone day boundaries to UTC instants so they
// compare correctly against UTC-stored timestamps (#1269)
if (filterFromDate) params.set('fromDate', dayBoundaryToUtcISO(filterFromDate, filterTimezone, false));
if (filterToDate) params.set('toDate', dayBoundaryToUtcISO(filterToDate, filterTimezone, true));
params.set('limit', String(FETCH_BATCH_SIZE));
params.set('offset', String(append ? events.length : 0));
@@ -508,14 +516,12 @@
if (filterActions.length > 0 && !filterActions.includes(newEvent.action)) return;
if (filterEnvironmentId !== null && newEvent.environmentId !== filterEnvironmentId) return;
// Check date filters
if (filterFromDate) {
const eventDate = new Date(newEvent.timestamp).toISOString().split('T')[0];
if (eventDate < filterFromDate) return;
}
if (filterToDate) {
const eventDate = new Date(newEvent.timestamp).toISOString().split('T')[0];
if (eventDate > filterToDate) return;
// Check date filters - boundaries are days in the configured
// timezone, compared as UTC instants (#1269)
if (filterFromDate || filterToDate) {
const eventTime = new Date(newEvent.timestamp).getTime();
if (filterFromDate && eventTime < new Date(dayBoundaryToUtcISO(filterFromDate, filterTimezone, false)).getTime()) return;
if (filterToDate && eventTime > new Date(dayBoundaryToUtcISO(filterToDate, filterTimezone, true)).getTime()) return;
}
// Add to beginning of events (prepend new events) - use Set for fast duplicate check
@@ -852,7 +858,7 @@
{:else if column.id === 'actions'}
<div class="flex items-center justify-end">
<Button variant="ghost" size="icon" class="h-6 w-6" onclick={(e) => { e.stopPropagation(); showDetails(event); }}>
<Eye class="w-3.5 h-3.5" />
<Eye class="grid-action-icon grid-action-info" />
</Button>
</div>
{/if}
+11 -19
View File
@@ -1,16 +1,8 @@
import { authorize, enterpriseRequired } from '$lib/server/authorize';
import { getAuditLogs, type AuditLogFilters, type AuditEntityType, type AuditAction, type AuditLog } from '$lib/server/db';
import { rowsToCSV } from '$lib/server/csv';
import type { RequestHandler } from './$types';
function escapeCSV(value: string | null | undefined): string {
if (value === null || value === undefined) return '';
const str = String(value);
if (str.includes(',') || str.includes('"') || str.includes('\n')) {
return `"${str.replace(/"/g, '""')}"`;
}
return str;
}
function formatToJSON(logs: AuditLog[]): string {
return JSON.stringify(logs, null, 2);
}
@@ -34,19 +26,19 @@ function formatToCSV(logs: AuditLog[]): string {
const rows = logs.map((log) => [
log.id,
log.createdAt,
escapeCSV(log.username),
escapeCSV(log.action),
escapeCSV(log.entityType),
escapeCSV(log.entityId),
escapeCSV(log.entityName),
log.username,
log.action,
log.entityType,
log.entityId,
log.entityName,
log.environmentId ?? '',
escapeCSV(log.description),
escapeCSV(log.ipAddress),
escapeCSV(log.userAgent),
escapeCSV(log.details ? JSON.stringify(log.details) : '')
log.description,
log.ipAddress,
log.userAgent,
log.details ? JSON.stringify(log.details) : ''
]);
return [headers.join(','), ...rows.map((row) => row.join(','))].join('\n');
return rowsToCSV(headers, rows);
}
function formatToMarkdown(logs: AuditLog[]): string {
@@ -2,6 +2,7 @@ import { json, redirect } from '@sveltejs/kit';
import type { RequestHandler } from '@sveltejs/kit';
import { buildOidcAuthorizationUrl, isAuthEnabled } from '$lib/server/auth';
import { getOidcConfig } from '$lib/server/db';
import { safeRedirectOrRoot } from '$lib/utils/safe-redirect';
// GET /api/auth/oidc/[id]/initiate - Start OIDC authentication flow
export const GET: RequestHandler = async ({ params, url }) => {
@@ -15,8 +16,8 @@ export const GET: RequestHandler = async ({ params, url }) => {
return json({ error: 'Invalid configuration ID' }, { status: 400 });
}
// Get redirect URL from query params
const redirectUrl = url.searchParams.get('redirect') || '/';
// Get redirect URL from query params (validated path-relative only)
const redirectUrl = safeRedirectOrRoot(url.searchParams.get('redirect'));
try {
const config = await getOidcConfig(id);
@@ -56,7 +57,7 @@ export const POST: RequestHandler = async ({ params, request }) => {
try {
const body = await request.json().catch(() => ({}));
const redirectUrl = body.redirect || '/';
const redirectUrl = safeRedirectOrRoot(body.redirect);
const config = await getOidcConfig(id);
if (!config || !config.enabled) {
+3 -2
View File
@@ -3,6 +3,7 @@ import type { RequestHandler } from '@sveltejs/kit';
import { handleOidcCallback, createUserSession, isAuthEnabled } from '$lib/server/auth';
import { auditAuth } from '$lib/server/audit';
import { getClientIp } from '$lib/server/client-ip';
import { safeRedirectOrRoot } from '$lib/utils/safe-redirect';
// GET /api/auth/oidc/callback - Handle OIDC callback from IdP
export const GET: RequestHandler = async (event) => {
@@ -54,8 +55,8 @@ export const GET: RequestHandler = async (event) => {
providerName: result.providerName
});
// Redirect to the original destination or home
const redirectUrl = result.redirectUrl || '/';
// Redirect to the original destination or home (validated path-relative)
const redirectUrl = safeRedirectOrRoot(result.redirectUrl);
throw redirect(302, redirectUrl);
} catch (error: any) {
// Re-throw redirect
@@ -9,12 +9,20 @@ import {
import { registerSchedule, unregisterSchedule } from '$lib/server/scheduler';
import { authorize } from '$lib/server/authorize';
export const GET: RequestHandler = async ({ params, url }) => {
export const GET: RequestHandler = async ({ params, url, cookies }) => {
const auth = await authorize(cookies);
const permDenied = await auth.requirePermission('schedules', 'view');
if (permDenied) return permDenied;
try {
const containerName = decodeURIComponent(params.containerName);
const envIdParam = url.searchParams.get('env');
const envId = envIdParam ? parseInt(envIdParam) : undefined;
const envDenied = await auth.requireEnvAccess(envId);
if (envDenied) return envDenied;
const setting = await getAutoUpdateSetting(containerName, envId);
if (!setting) {
@@ -41,15 +49,18 @@ export const GET: RequestHandler = async ({ params, url }) => {
export const POST: RequestHandler = async ({ params, url, request, cookies }) => {
const auth = await authorize(cookies);
if (auth.authEnabled && !await auth.can('schedules', 'edit')) {
return json({ error: 'Permission denied' }, { status: 403 });
}
const permDenied = await auth.requirePermission('schedules', 'edit');
if (permDenied) return permDenied;
try {
const containerName = decodeURIComponent(params.containerName);
const envIdParam = url.searchParams.get('env');
const envId = envIdParam ? parseInt(envIdParam) : undefined;
const envDenied = await auth.requireEnvAccess(envId);
if (envDenied) return envDenied;
const body = await request.json();
// Accept both camelCase and snake_case for backward compatibility
const enabled = body.enabled;
@@ -109,15 +120,18 @@ export const POST: RequestHandler = async ({ params, url, request, cookies }) =>
export const DELETE: RequestHandler = async ({ params, url, cookies }) => {
const auth = await authorize(cookies);
if (auth.authEnabled && !await auth.can('schedules', 'edit')) {
return json({ error: 'Permission denied' }, { status: 403 });
}
const permDenied = await auth.requirePermission('schedules', 'edit');
if (permDenied) return permDenied;
try {
const containerName = decodeURIComponent(params.containerName);
const envIdParam = url.searchParams.get('env');
const envId = envIdParam ? parseInt(envIdParam) : undefined;
const envDenied = await auth.requireEnvAccess(envId);
if (envDenied) return envDenied;
// Get the setting ID before deleting
const setting = await getAutoUpdateSetting(containerName, envId);
const settingId = setting?.id;
@@ -3,6 +3,7 @@ import { getContainerArchive, statContainerPath } from '$lib/server/docker';
import { authorize } from '$lib/server/authorize';
import { validateDockerIdParam } from '$lib/server/docker-validation';
import { extractFirstFileFromTar } from '$lib/server/tar-extract';
import { attachmentContentDisposition } from '$lib/server/content-disposition';
import type { RequestHandler } from './$types';
export const GET: RequestHandler = async ({ params, url, cookies }) => {
@@ -74,7 +75,7 @@ export const GET: RequestHandler = async ({ params, url, cookies }) => {
const headers: Record<string, string> = {
'Content-Type': contentType,
'Content-Disposition': `attachment; filename="${filename}${extension}"`
'Content-Disposition': attachmentContentDisposition(`${filename}${extension}`)
};
// Set content length for compressed data
@@ -33,47 +33,64 @@ export const GET: RequestHandler = async ({ params, url, cookies }) => {
try {
const containerId = params.id;
const availableShells: string[] = [];
const shellNames = SHELLS_TO_CHECK.map(s => s.path.split('/').pop()!);
const namedPaths = new Map<string, string>(); // name → resolved absolute path
// Check each shell by testing if the file exists and is executable
// Use a single command to check all shells at once for efficiency
const checkCommand = SHELLS_TO_CHECK.map(s =>
`test -x ${s.path} && echo "${s.path}"`
).join('; ');
// Resolve each shell through the container's own PATH using `command -v`,
// so that images with shells at non-standard locations (e.g. /usr/bin/sh
// where /bin/sh isn't a symlink, or PATH ordering that shadows the
// shell-builtin `test`) still report correctly. `exit 0` at the end
// keeps the exec exit code clean even when some shells are absent —
// otherwise execInContainer treats non-zero as a thrown error and the
// valid output is discarded (issue #1189).
const probe =
`for s in ${shellNames.join(' ')}; do ` +
`p=$(command -v $s 2>/dev/null) && [ -n "$p" ] && echo "$s:$p"; ` +
`done; exit 0`;
try {
const output = await execInContainer(
containerId,
['sh', '-c', checkCommand],
['sh', '-c', probe],
envIdNum
);
// Parse output - each line is an available shell path
const lines = output.trim().split('\n').filter(Boolean);
availableShells.push(...lines);
for (const line of output.trim().split('\n').filter(Boolean)) {
const colon = line.indexOf(':');
if (colon < 0) continue;
const name = line.slice(0, colon);
const resolved = line.slice(colon + 1);
if (resolved.startsWith('/')) namedPaths.set(name, resolved);
}
} catch {
// If even sh fails, try checking with test commands individually
// This handles edge cases where sh might not be available
// `sh` itself was not invocable. Fall back to probing the canonical
// paths directly with a non-shell-resolved test — exec each one
// against its absolute path and rely on docker exec's own
// "executable not found" failure to indicate absence. This handles
// the rare image where there's no `sh` at all but bash exists.
for (const shell of SHELLS_TO_CHECK) {
try {
await execInContainer(
containerId,
['test', '-x', shell.path],
[shell.path, '-c', 'exit 0'],
envIdNum
);
availableShells.push(shell.path);
const name = shell.path.split('/').pop()!;
namedPaths.set(name, shell.path);
} catch {
// Shell not available, continue to next
// Shell not available at this path; try the next.
}
}
}
const availableShells = Array.from(namedPaths.values());
// Determine default shell - prefer bash, then sh, then first available
let defaultShell: string | null = null;
if (availableShells.includes('/bin/bash')) {
defaultShell = '/bin/bash';
} else if (availableShells.includes('/bin/sh')) {
defaultShell = '/bin/sh';
if (namedPaths.has('bash')) {
defaultShell = namedPaths.get('bash')!;
} else if (namedPaths.has('sh')) {
defaultShell = namedPaths.get('sh')!;
} else if (availableShells.length > 0) {
defaultShell = availableShells[0];
}
@@ -81,11 +98,15 @@ export const GET: RequestHandler = async ({ params, url, cookies }) => {
return json({
shells: availableShells,
defaultShell,
allShells: SHELLS_TO_CHECK.map(s => ({
path: s.path,
label: s.label,
available: availableShells.includes(s.path)
}))
allShells: SHELLS_TO_CHECK.map(s => {
const name = s.path.split('/').pop()!;
const resolved = namedPaths.get(name);
return {
path: resolved ?? s.path,
label: s.label,
available: namedPaths.has(name)
};
})
});
} catch (error) {
console.error('Error detecting shells:', error);
@@ -2,8 +2,8 @@ import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { authorize } from '$lib/server/authorize';
import { listContainers, inspectContainer, checkImageUpdateAvailable } from '$lib/server/docker';
import { clearPendingContainerUpdates, addPendingContainerUpdate } from '$lib/server/db';
import { isSystemContainer } from '$lib/server/scheduler/tasks/update-utils';
import { clearPendingContainerUpdates, addPendingContainerUpdate, getPendingContainerUpdates } from '$lib/server/db';
import { isSystemContainer, isPodmanInfraContainer } from '$lib/server/scheduler/tasks/update-utils';
import { isUpdateDisabledByLabel, isHiddenByLabel } from '$lib/server/container-labels';
import { createJobResponse } from '$lib/server/sse';
@@ -21,7 +21,43 @@ export interface UpdateCheckResult {
}
/**
* Check all containers for available image updates.
* GET = READ the cached result of the last update check (no side effects, does NOT
* trigger a new check). Returns the containers currently flagged as having a pending
* image update. Use POST (below) to actually run a fresh check. (issue #1266)
*/
export const GET: RequestHandler = async ({ url, cookies }) => {
const auth = await authorize(cookies);
const envId = url.searchParams.get('env');
const envIdNum = envId ? parseInt(envId) : undefined;
if (!envIdNum) {
return json({ error: 'Environment ID required' }, { status: 400 });
}
if (auth.authEnabled && !await auth.can('containers', 'view', envIdNum)) {
return json({ error: 'Permission denied' }, { status: 403 });
}
try {
const pendingUpdates = await getPendingContainerUpdates(envIdNum);
return json({
environmentId: envIdNum,
pendingUpdates: pendingUpdates.map(u => ({
containerId: u.containerId,
containerName: u.containerName,
currentImage: u.currentImage,
checkedAt: u.checkedAt
}))
});
} catch (error: any) {
console.error('Error getting pending updates:', error);
return json({ error: 'Failed to get pending updates', details: error.message }, { status: 500 });
}
};
/**
* POST = TRIGGER a fresh update check across all containers (job-based).
* Returns progress events during checking, final result when done.
*/
export const POST: RequestHandler = async ({ url, cookies, request }) => {
@@ -42,9 +78,12 @@ export const POST: RequestHandler = async ({ url, cookies, request }) => {
}
const allContainers = await listContainers(true, envIdNum);
// Containers labeled dockhand.hidden=true are excluded from update checks —
// they're invisible to the user, so we won't alert on updates for them either (#1083).
const containers = allContainers.filter(c => !isHiddenByLabel(c.labels));
// Skip:
// - dockhand.hidden=true (invisible to user, so no update alerts) (#1083)
// - Podman pod-infra containers (named <pod>-infra, never published) (#1221)
const containers = allContainers.filter(
(c) => !isHiddenByLabel(c.labels) && !isPodmanInfraContainer(c.name)
);
send('progress', { checked: 0, total: containers.length });
+6 -4
View File
@@ -258,11 +258,13 @@ export const GET: RequestHandler = async ({ cookies, url }) => {
envStats.stacks.partial = stacks.filter((s: any) => s.status === 'partial').length;
envStats.stacks.stopped = stacks.filter((s: any) => s.status === 'stopped').length;
// Get latest metrics, event stats, and pending updates in parallel
// Get latest metrics, event stats, and pending updates in parallel.
// Each call has its own catch so one failed DB query (e.g. a corrupt
// container_events index, #1210) doesn't poison the whole tile.
const [latestMetrics, eventStats, pendingUpdates] = await Promise.all([
getLatestHostMetrics(env.id),
getContainerEventStats(env.id),
getPendingContainerUpdates(env.id)
getLatestHostMetrics(env.id).catch(() => null),
getContainerEventStats(env.id).catch(() => ({ total: 0, today: 0, byAction: {} })),
getPendingContainerUpdates(env.id).catch(() => [])
]);
if (latestMetrics) {
@@ -195,13 +195,15 @@ async function getEnvironmentStatsProgressive(
// Get all database stats in parallel for better performance
// NOTE: We do NOT block on getDockerInfo() here - slow environments would block all others
// Instead, we determine online status from whether listContainers succeeds
// Instead, we determine online status from whether listContainers succeeds.
// Each call has its own catch so one failed DB query (e.g. a corrupt
// container_events index, #1210) doesn't poison the whole tile.
const [latestMetrics, eventStats, recentEventsResult, metricsHistory, pendingUpdates] = await Promise.all([
getLatestHostMetrics(env.id),
getContainerEventStats(env.id),
getContainerEvents({ environmentId: env.id, limit: 10 }),
getHostMetrics(metricsPointCount, env.id),
getPendingContainerUpdates(env.id)
getLatestHostMetrics(env.id).catch(() => null),
getContainerEventStats(env.id).catch(() => ({ total: 0, today: 0, byAction: {} })),
getContainerEvents({ environmentId: env.id, limit: 10 }).catch(() => ({ events: [], total: 0, limit: 10, offset: 0 })),
getHostMetrics(metricsPointCount, env.id).catch(() => []),
getPendingContainerUpdates(env.id).catch(() => [])
]);
if (latestMetrics) {
+10 -1
View File
@@ -1,11 +1,20 @@
import type { RequestHandler } from './$types';
import { getDockerEvents, EnvironmentNotFoundError } from '$lib/server/docker';
import { getEnvironment } from '$lib/server/db';
import { authorize } from '$lib/server/authorize';
export const GET: RequestHandler = async ({ url, cookies }) => {
const auth = await authorize(cookies);
const permDenied = await auth.requirePermission('activity', 'view');
if (permDenied) return permDenied;
export const GET: RequestHandler = async ({ url }) => {
const envId = url.searchParams.get('env');
const envIdNum = envId ? parseInt(envId) : undefined;
const envDenied = await auth.requireEnvAccess(envIdNum);
if (envDenied) return envDenied;
// Early return if no environment specified
if (!envIdNum) {
return new Response(
+26 -21
View File
@@ -1,38 +1,43 @@
/**
* Database Health Check Endpoint
*
* Returns detailed information about the database schema state,
* including migration status, table existence, and connection info.
* Public endpoint suitable for external monitoring. The public payload reports
* enough detail to detect schema drift and table loss without exposing
* connection details (host, port, db name, user) or the running migration tag.
*
* Authenticated callers with settings:view get the full payload connection
* string (password masked) and schema version included which is useful for
* operators debugging from the admin UI.
*
* GET /api/health/database
*
* Response:
* {
* healthy: boolean,
* database: 'sqlite' | 'postgresql',
* connection: string,
* migrationsTable: boolean,
* appliedMigrations: number,
* pendingMigrations: number,
* schemaVersion: string | null,
* tables: {
* expected: number,
* found: number,
* missing: string[]
* },
* timestamp: string
* }
*/
import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { checkSchemaHealth } from '$lib/server/db/drizzle';
import { authorize } from '$lib/server/authorize';
export const GET: RequestHandler = async () => {
export const GET: RequestHandler = async ({ cookies }) => {
try {
const health = await checkSchemaHealth();
return json(health, {
const auth = await authorize(cookies);
const showFullDetail = !auth.authEnabled
|| (auth.isAuthenticated && await auth.can('settings', 'view'));
const payload = showFullDetail
? health
: {
healthy: health.healthy,
database: health.database,
migrationsTable: health.migrationsTable,
appliedMigrations: health.appliedMigrations,
pendingMigrations: health.pendingMigrations,
tables: health.tables,
timestamp: health.timestamp
};
return json(payload, {
status: health.healthy ? 200 : 503,
headers: {
'Cache-Control': 'no-cache, no-store, must-revalidate'
+14 -3
View File
@@ -125,9 +125,20 @@ export const GET: RequestHandler = async ({ url, cookies }) => {
}
const hostInfo: HostInfo = {
// For local connections, show local system info; for remote, show Docker host info
hostname: isLocalConnection ? os.hostname() : (dockerInfo.Name || env.host || 'unknown'),
ipAddress: isLocalConnection ? getLocalIpAddress() : (env.host || 'unknown'),
// Hostname/IP describe the Docker DAEMON's host, NOT Dockhand's own
// container. `os.hostname()` / getLocalIpAddress() run INSIDE this
// container, so on a local socket they returned the container id and the
// bridge IP instead of the real host (issue #1265). Docker's /info `Name`
// is the daemon host's hostname for every connection type (the entrypoint
// also derives it into DOCKHAND_HOSTNAME).
//
// The host's LAN IP is NOT reliably discoverable from inside a container
// over the socket — Docker's API exposes no host-IP field, and every
// container-visible address (bridge gateway, own interfaces) is the wrong
// 172.x value. So we surface DOCKHAND_HOST_IP if the operator set it, else
// the configured env host, else 'localhost' — never a misleading bridge IP.
hostname: dockerInfo?.Name || process.env.DOCKHAND_HOSTNAME || env.host || 'unknown',
ipAddress: isLocalConnection ? (process.env.DOCKHAND_HOST_IP || env.host || 'localhost') : (env.host || 'unknown'),
platform: isLocalConnection ? os.platform() : (dockerInfo.OperatingSystem || 'unknown'),
arch: isLocalConnection ? os.arch() : (dockerInfo.Architecture || 'unknown'),
cpus: isLocalConnection ? os.cpus().length : (dockerInfo.NCPU || 0),
+1
View File
@@ -113,6 +113,7 @@ export const POST: RequestHandler = async (event) => {
});
}
const totalVulns = results.reduce((sum, r) => sum + r.vulnerabilities.length, 0);
sendData({
status: 'scan-complete',
+1 -21
View File
@@ -1,30 +1,10 @@
import { json, type RequestHandler } from '@sveltejs/kit';
import { scanImage, type ScanProgress, type ScanResult } from '$lib/server/scanner';
import { scanImage, scanResultToDbFormat, type ScanProgress } from '$lib/server/scanner';
import { saveVulnerabilityScan, getLatestScanForImage } from '$lib/server/db';
import { inspectImage } from '$lib/server/docker';
import { authorize } from '$lib/server/authorize';
import { createJobResponse } from '$lib/server/sse';
// Helper to convert ScanResult to database format
function scanResultToDbFormat(result: ScanResult, envId?: number) {
return {
environmentId: envId ?? null,
imageId: result.imageId || result.imageName, // Fallback to imageName if imageId is undefined
imageName: result.imageName,
scanner: result.scanner,
scannedAt: result.scannedAt,
scanDuration: result.scanDuration,
criticalCount: result.summary.critical,
highCount: result.summary.high,
mediumCount: result.summary.medium,
lowCount: result.summary.low,
negligibleCount: result.summary.negligible,
unknownCount: result.summary.unknown,
vulnerabilities: JSON.stringify(result.vulnerabilities),
error: result.error ?? null
};
}
// POST - Start a scan (returns { jobId } for progress polling, or synchronous JSON for Accept: application/json)
export const POST: RequestHandler = async ({ request, url, cookies }) => {
const auth = await authorize(cookies);
@@ -0,0 +1,68 @@
/**
* Per-image vulnerability export (#415): reformats the cached scan for one image
* as json | csv | sarif for CI / DefectDojo / Dependency-Track integration.
* Read-only over persisted scans; no new scanning. Auth via cookie or Bearer
* token (CI), with RBAC + enterprise environment scoping.
*/
import { getScansForImage, getEnvironment } from '$lib/server/db';
import { flattenScansToFindings } from '$lib/server/vulnerabilities';
import { authorizeVulnAccess } from '$lib/server/vuln-access';
import { rowsToCSV } from '$lib/server/csv';
import { exportResponse, jsonError, slugify } from '$lib/server/export-response';
import type { Finding } from '$lib/utils/vulnerability';
import type { RequestHandler } from './$types';
function toCSV(findings: Finding[]): string {
const headers = ['CVE', 'Severity', 'Package', 'Installed Version', 'Fixed Version', 'Image', 'Scanned', 'Description', 'Link'];
const rows = findings.map((f) => [
f.cve, f.severity, f.package, f.installedVersion, f.fixedVersion, f.imageName, f.scannedAt, f.description, f.link
]);
return rowsToCSV(headers, rows);
}
export const GET: RequestHandler = async ({ url, cookies }) => {
const imageId = url.searchParams.get('imageId') || url.searchParams.get('image');
if (!imageId) return jsonError('imageId is required', 400);
// RBAC (images:view) + enterprise environment scoping, shared with the other
// vuln endpoints. Auth via cookie or Bearer token is handled inside authorize().
const { envIdNum, denied, authEnabled } = await authorizeVulnAccess(cookies, url);
if (denied) return jsonError(denied.message, denied.status);
// The same image SHA can be scanned in multiple environments. When auth is on,
// require an explicit env so the tenant-scoping check in authorizeVulnAccess
// actually runs — otherwise a user scoped to env A could read env B's scans by
// omitting env (CWE-639). Free/open mode has no tenant boundary to enforce.
if (authEnabled && envIdNum === undefined) {
return jsonError('env is required', 400);
}
try {
// Always scope the query by env so cross-environment scans are never returned,
// even if an access check were somehow bypassed (defense in depth).
const scans = await getScansForImage(imageId, envIdNum);
const findings = flattenScansToFindings(scans);
const timestamp = new Date().toISOString().split('T')[0];
const imageSlug = findings[0]?.imageName
? slugify(findings[0].imageName, 'image')
: slugify(imageId.replace('sha256:', '').slice(0, 12), 'image');
let envSlug = '';
if (envIdNum) {
const env = await getEnvironment(envIdNum);
if (env?.name) envSlug = `-${slugify(env.name, 'env')}`;
}
return exportResponse({
format: url.searchParams.get('format') || 'json',
filenameBase: `vulnerabilities-${imageSlug}${envSlug}-${timestamp}`,
findings,
jsonBody: { imageId, findings },
toCSV
});
} catch (error) {
console.error('Error exporting image vulnerabilities:', error);
return jsonError('Failed to export image vulnerabilities', 500);
}
};
+11 -1
View File
@@ -1,5 +1,5 @@
import { json } from '@sveltejs/kit';
import { getJob } from '$lib/server/jobs';
import { getJob, cancelJob } from '$lib/server/jobs';
import type { RequestHandler } from './$types';
/**
@@ -21,3 +21,13 @@ export const GET: RequestHandler = async ({ params }) => {
result: job.result ?? null
});
};
/**
* DELETE /api/jobs/[id]
* Request cancellation of a running job. The job's operation polls the flag
* between units of work and stops gracefully.
*/
export const DELETE: RequestHandler = async ({ params }) => {
const cancelled = cancelJob(params.id);
return json({ cancelled });
};
@@ -0,0 +1,66 @@
import { json, type RequestHandler } from '@sveltejs/kit';
import { getSidebarPreferences, setSidebarPreferences, deleteSidebarPreferences } from '$lib/server/db';
import { authorize } from '$lib/server/authorize';
import type { SidebarPreferences } from '$lib/server/db';
// GET - retrieve sidebar menu preferences
export const GET: RequestHandler = async ({ cookies }) => {
const auth = await authorize(cookies);
try {
// userId for per-user storage when auth is enabled
const userId = auth.authEnabled ? auth.user?.id : undefined;
const preferences = await getSidebarPreferences(userId);
return json({ preferences });
} catch (error) {
console.error('Failed to get sidebar preferences:', error);
return json({ error: 'Failed to get sidebar preferences' }, { status: 500 });
}
};
// POST - update sidebar menu preferences
export const POST: RequestHandler = async ({ request, cookies }) => {
const auth = await authorize(cookies);
try {
const body = await request.json();
const { order, hidden } = body;
if (!Array.isArray(order) || order.some((h) => typeof h !== 'string')) {
return json({ error: 'order must be an array of strings' }, { status: 400 });
}
if (!Array.isArray(hidden) || hidden.some((h) => typeof h !== 'string')) {
return json({ error: 'hidden must be an array of strings' }, { status: 400 });
}
const prefs: SidebarPreferences = { order, hidden };
// userId for per-user storage when auth is enabled
const userId = auth.authEnabled ? auth.user?.id : undefined;
await setSidebarPreferences(prefs, userId);
const preferences = await getSidebarPreferences(userId);
return json({ preferences });
} catch (error) {
console.error('Failed to save sidebar preferences:', error);
return json({ error: 'Failed to save sidebar preferences' }, { status: 500 });
}
};
// DELETE - reset sidebar menu preferences to default
export const DELETE: RequestHandler = async ({ cookies }) => {
const auth = await authorize(cookies);
try {
const userId = auth.authEnabled ? auth.user?.id : undefined;
await deleteSidebarPreferences(userId);
const preferences = await getSidebarPreferences(userId);
return json({ preferences });
} catch (error) {
console.error('Failed to reset sidebar preferences:', error);
return json({ error: 'Failed to reset sidebar preferences' }, { status: 500 });
}
};
+17 -1
View File
@@ -45,7 +45,9 @@ export const PUT: RequestHandler = async ({ request, cookies }) => {
const validTerminalFontIds = monospaceFonts.map(f => f.id);
const validFontSizes = ['xsmall', 'small', 'normal', 'medium', 'large', 'xlarge'];
const updates: { lightTheme?: string; darkTheme?: string; font?: string; fontSize?: string; gridFontSize?: string; terminalFont?: string; editorFont?: string; animateIcons?: boolean } = {};
const validActionIconSizes = ['small', 'normal', 'large', 'xlarge'];
const updates: { lightTheme?: string; darkTheme?: string; font?: string; fontSize?: string; gridFontSize?: string; terminalFont?: string; editorFont?: string; animateIcons?: boolean; coloredActionButtons?: boolean; actionIconSize?: string } = {};
if (data.lightTheme !== undefined) {
if (!validLightThemeIds.includes(data.lightTheme)) {
@@ -103,6 +105,20 @@ export const PUT: RequestHandler = async ({ request, cookies }) => {
updates.animateIcons = data.animateIcons;
}
if (data.coloredActionButtons !== undefined) {
if (typeof data.coloredActionButtons !== 'boolean') {
return json({ error: 'Invalid coloredActionButtons' }, { status: 400 });
}
updates.coloredActionButtons = data.coloredActionButtons;
}
if (data.actionIconSize !== undefined) {
if (!validActionIconSizes.includes(data.actionIconSize)) {
return json({ error: 'Invalid actionIconSize' }, { status: 400 });
}
updates.actionIconSize = data.actionIconSize;
}
await setUserThemePreferences(currentUser.id, updates);
// Return updated preferences

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