This commit is contained in:
jarek committed 2026-07-11 15:31:52 +02:00
1 parent 685132ff89
commit c718ac9a84
101 files changed
+5011 -556

No files matched your search

+1 -1
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" \
+1 -1
View File
@@ -1 +1 @@
v1.0.36
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)})
}
+7 -3
View File
@@ -1,7 +1,7 @@
{
"name": "dockhand",
"private": true,
"version": "1.0.36",
"version": "1.0.37",
"type": "module",
"scripts": {
"dev": "npx vite dev",
@@ -82,9 +82,10 @@
"fast-xml-parser": "5.7.3",
"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",
@@ -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 });
}
+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>
+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>
+194 -5
View File
@@ -25,12 +25,20 @@
Activity,
Timer,
LibraryBig,
CircleArrowUp
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';
@@ -41,6 +49,7 @@
// 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';
@@ -67,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');
}
@@ -119,6 +130,89 @@
{ 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()}
@@ -172,17 +266,98 @@
<Sidebar.Content>
<Sidebar.Group>
<Sidebar.Menu>
{#each menuItems as item}
{#if canSeeMenuItem(item)}
<Sidebar.MenuItem>
{#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>
</Sidebar.MenuItem>
{/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>
@@ -222,6 +397,20 @@
</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"
+102 -77
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,10 +835,13 @@
</thead>
{/snippet}
{#snippet tableBody()}
<tbody>
{#each visibleData as item, index (item[keyField])}
{@const rowState = getRowState(item, index)}
<!--
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)}
@@ -846,6 +923,12 @@
</td>
</tr>
{/if}
{/snippet}
{#snippet tableBody()}
<tbody>
{#each visibleData as item, index (item[keyField])}
{@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)
+29 -2
View File
@@ -1,4 +1,31 @@
[
{
"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",
@@ -10,10 +37,10 @@
{ "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": "improvement", "text": "published image carries standard OCI annotations (source, url, title, description, vendor, licenses) (#1217)" },
{ "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": "improvement", "text": "cron picker: hour shows just `HH` (was `HH:00`), minute granularity bumped from 15 to 5 (#1198)" },
{ "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)" },
+16
View File
@@ -247,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.
*/
+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');
}
+164 -3
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 };
@@ -491,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)
// =============================================================================
@@ -2998,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>;
}
@@ -3041,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);
@@ -3135,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();
const countResult = await db.select({ count: sql<number>`count(*)` })
.from(vulnerabilityScans)
.where(sql`scanned_at < ${cutoffDate}`);
const count = Number(countResult[0]?.count ?? 0);
if (count > 0) {
await db.delete(vulnerabilityScans)
.where(sql`scanned_at < ${cutoffDate}`);
return 0;
}
return count;
}
// =============================================================================
@@ -4052,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 };
}
+16 -7
View File
@@ -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) {
+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)
+10 -3
View File
@@ -1,5 +1,5 @@
/** 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.
@@ -35,7 +35,7 @@ export async function sendNtfy(appriseUrl: string, payload: NotificationPayload)
// ntfy://user:pass@host/topic (custom server with basic auth)
// ntfy://token@host/topic (custom server with bearer token)
// ntfy://host/topic?auth=TOKEN (raw token, e.g. tk_..., or base64-encoded "Bearer <token>")
// Query params: ?tags=ship,whale &title=Custom &priority=5
// 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?:\/\//, '');
@@ -47,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) {
@@ -55,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);
}
@@ -95,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({
+33 -2
View File
@@ -115,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;
@@ -154,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;
+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, isPodmanInfraContainer } 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,13 +301,9 @@ export async function runContainerUpdate(
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;
// Podman pod-infra containers may have an empty Config.Image — silently
// skip them before the "Could not determine image name" failure (#1221).
if (isPodmanInfraContainer(imageNameFromConfig, inspectData.Config?.Labels)) {
// 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',
@@ -356,6 +314,10 @@ export async function runContainerUpdate(
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;
if (!imageNameFromConfig) {
log(`Could not determine image name from container config`);
await updateScheduleExecution(execution.id, {
@@ -30,7 +30,8 @@ import {
} from '../../docker';
import { sendEventNotification } from '../../notifications';
import { getScannerSettings, scanImage, type VulnerabilitySeverity } from '../../scanner';
import { parseImageNameAndTag, shouldBlockUpdate, combineScanSummaries, isSystemContainer, isPodmanInfraContainer } 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';
@@ -110,7 +111,7 @@ export async function runEnvUpdateCheckJob(
const allContainers = await listContainers(true, environmentId);
// Skip hidden + Podman pod-infra (#1083, #1221)
const containers = allContainers.filter(
(c) => !isHiddenByLabel(c.labels) && !isPodmanInfraContainer(c.image, c.labels)
(c) => !isHiddenByLabel(c.labels) && !isPodmanInfraContainer(c.name)
);
const hiddenCount = allContainers.length - containers.length;
await log(`Found ${containers.length} containers${hiddenCount ? ` (${hiddenCount} hidden/infra)` : ''}`);
@@ -324,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;
+8 -12
View File
@@ -128,19 +128,15 @@ export function isSystemContainer(imageName: string): SystemContainerType | null
}
/**
* Podman creates a hidden "pod-infra" container for every pod it runs (#1221).
* The image (`localhost/podman-pause`) is generated locally and never published
* to any registry, so update checks always fail noisily. The label is the
* authoritative signal — but Podman may set Config.Image empty for these
* containers, so the name fallback catches that too.
* 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(
imageName: string | undefined,
labels: Record<string, string> | undefined
): boolean {
if (labels?.['io.podman.annotations.infra'] === 'true') return true;
if (imageName && /podman-pause/i.test(imageName)) return true;
return false;
export function isPodmanInfraContainer(containerName: string | undefined): boolean {
return !!containerName && /[-_]infra$/i.test(containerName);
}
/**
+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 });
+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)
}));
}
+17 -1
View File
@@ -33,6 +33,7 @@ import {
getStackSources,
deleteStackEnvVars,
removePendingContainerUpdate,
getPendingContainerUpdates,
deleteAutoUpdateSchedule,
getAutoUpdateSetting,
getStackSourceByComposePath
@@ -1740,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) {
@@ -1796,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) => {
@@ -1810,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;
}
+13 -2
View File
@@ -47,6 +47,7 @@ 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>
@@ -81,11 +82,12 @@ 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: [],
@@ -171,6 +173,7 @@ function createSettingsStore() {
labelFilterMode: settings.labelFilterMode ?? DEFAULT_SETTINGS.labelFilterMode,
honorProxyLabels: settings.honorProxyLabels ?? DEFAULT_SETTINGS.honorProxyLabels,
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
@@ -227,6 +230,7 @@ function createSettingsStore() {
labelFilterMode: updatedSettings.labelFilterMode ?? DEFAULT_SETTINGS.labelFilterMode,
honorProxyLabels: updatedSettings.honorProxyLabels ?? DEFAULT_SETTINGS.honorProxyLabels,
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
@@ -508,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;
+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();
+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;
+72
View File
@@ -82,3 +82,75 @@ export function formatTimePartWith(
: `${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();
}
+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;
}
+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]
};
}
+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)) {
+27 -21
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
+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 {
@@ -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
@@ -2,7 +2,7 @@ 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 { 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 }) => {
@@ -44,9 +80,9 @@ export const POST: RequestHandler = async ({ url, cookies, request }) => {
const allContainers = await listContainers(true, envIdNum);
// Skip:
// - dockhand.hidden=true (invisible to user, so no update alerts) (#1083)
// - Podman pod-infra containers (localhost/podman-pause, never published) (#1221)
// - Podman pod-infra containers (named <pod>-infra, never published) (#1221)
const containers = allContainers.filter(
(c) => !isHiddenByLabel(c.labels) && !isPodmanInfraContainer(c.image, c.labels)
(c) => !isHiddenByLabel(c.labels) && !isPodmanInfraContainer(c.name)
);
send('progress', { checked: 0, total: containers.length });
+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 });
}
};
+13 -1
View File
@@ -103,6 +103,8 @@ export interface GeneralSettings {
// Whether to surface a "view changelog" link next to the update badge.
// Resolved client-side from OCI labels / GHCR image names; no server hit.
showImageChangelogLinks: boolean;
// Show the "What's New" modal after an upgrade (#1235)
showWhatsNew: boolean;
// Whether spinning icons (animate-spin etc.) are animated (#1169)
animateIcons: boolean;
// Skip Dockhand's scanner images (grype, trivy) during 'prune all unused' (#625)
@@ -146,6 +148,7 @@ const DEFAULT_SETTINGS: Omit<GeneralSettings, 'scheduleRetentionDays' | 'eventRe
labelFilterMode: 'any' as const,
honorProxyLabels: true,
showImageChangelogLinks: true,
showWhatsNew: true,
animateIcons: true,
protectScannerImages: true,
defaultScannerNetworkMode: '',
@@ -250,6 +253,7 @@ export const GET: RequestHandler = async ({ cookies }) => {
labelFilterMode,
honorProxyLabels,
showImageChangelogLinks,
showWhatsNew,
animateIcons,
protectScannerImages,
defaultScannerNetworkMode,
@@ -297,6 +301,7 @@ export const GET: RequestHandler = async ({ cookies }) => {
getSetting('label_filter_mode'),
getSetting('honor_proxy_labels'),
getSetting('show_image_changelog_links'),
getSetting('show_whats_new'),
getSetting('animate_icons'),
getSetting('protect_scanner_images'),
getSetting('default_scanner_network_mode'),
@@ -348,6 +353,7 @@ export const GET: RequestHandler = async ({ cookies }) => {
labelFilterMode: labelFilterMode ?? DEFAULT_SETTINGS.labelFilterMode,
honorProxyLabels: honorProxyLabels ?? DEFAULT_SETTINGS.honorProxyLabels,
showImageChangelogLinks: showImageChangelogLinks ?? DEFAULT_SETTINGS.showImageChangelogLinks,
showWhatsNew: showWhatsNew ?? DEFAULT_SETTINGS.showWhatsNew,
animateIcons: animateIcons ?? DEFAULT_SETTINGS.animateIcons,
protectScannerImages: protectScannerImages ?? DEFAULT_SETTINGS.protectScannerImages,
defaultScannerNetworkMode: defaultScannerNetworkMode ?? DEFAULT_SETTINGS.defaultScannerNetworkMode,
@@ -369,7 +375,7 @@ export const POST: RequestHandler = async ({ request, cookies }) => {
try {
const body = await request.json();
const { confirmDestructive, showStoppedContainers, highlightUpdates, coloredActionButtons, actionIconSize, timeFormat, dateFormat, downloadFormat, defaultGrypeArgs, defaultTrivyArgs, scheduleRetentionDays, eventRetentionDays, scheduleCleanupCron, eventCleanupCron, scheduleCleanupEnabled, eventCleanupEnabled, scannerCleanupCron, scannerCleanupEnabled, logBufferSizeKb, logMaxLines, defaultTimezone, eventCollectionMode, eventPollInterval, metricsCollectionInterval, lightTheme, darkTheme, font, fontSize, gridFontSize, terminalFont, editorFont, compactPorts, showExposedPorts, formatLogTimestamps, externalStackPaths, primaryStackLocation, defaultGrypeImage, defaultTrivyImage, defaultComposeTemplate, labelFilterMode, honorProxyLabels, showImageChangelogLinks, animateIcons, protectScannerImages, defaultScannerNetworkMode, defaultScannerDns } = body;
const { confirmDestructive, showStoppedContainers, highlightUpdates, coloredActionButtons, actionIconSize, timeFormat, dateFormat, downloadFormat, defaultGrypeArgs, defaultTrivyArgs, scheduleRetentionDays, eventRetentionDays, scheduleCleanupCron, eventCleanupCron, scheduleCleanupEnabled, eventCleanupEnabled, scannerCleanupCron, scannerCleanupEnabled, logBufferSizeKb, logMaxLines, defaultTimezone, eventCollectionMode, eventPollInterval, metricsCollectionInterval, lightTheme, darkTheme, font, fontSize, gridFontSize, terminalFont, editorFont, compactPorts, showExposedPorts, formatLogTimestamps, externalStackPaths, primaryStackLocation, defaultGrypeImage, defaultTrivyImage, defaultComposeTemplate, labelFilterMode, honorProxyLabels, showImageChangelogLinks, animateIcons, protectScannerImages, showWhatsNew, defaultScannerNetworkMode, defaultScannerDns } = body;
if (confirmDestructive !== undefined) {
await setSetting('confirm_destructive', confirmDestructive);
@@ -521,6 +527,9 @@ export const POST: RequestHandler = async ({ request, cookies }) => {
if (showImageChangelogLinks !== undefined && typeof showImageChangelogLinks === 'boolean') {
await setSetting('show_image_changelog_links', showImageChangelogLinks);
}
if (showWhatsNew !== undefined && typeof showWhatsNew === 'boolean') {
await setSetting('show_whats_new', showWhatsNew);
}
if (animateIcons !== undefined && typeof animateIcons === 'boolean') {
await setSetting('animate_icons', animateIcons);
}
@@ -586,6 +595,7 @@ export const POST: RequestHandler = async ({ request, cookies }) => {
labelFilterModeVal,
honorProxyLabelsVal,
showImageChangelogLinksVal,
showWhatsNewVal,
animateIconsVal,
protectScannerImagesVal,
defaultScannerNetworkModeVal,
@@ -633,6 +643,7 @@ export const POST: RequestHandler = async ({ request, cookies }) => {
getSetting('label_filter_mode'),
getSetting('honor_proxy_labels'),
getSetting('show_image_changelog_links'),
getSetting('show_whats_new'),
getSetting('animate_icons'),
getSetting('protect_scanner_images'),
getSetting('default_scanner_network_mode'),
@@ -685,6 +696,7 @@ export const POST: RequestHandler = async ({ request, cookies }) => {
honorProxyLabels: honorProxyLabelsVal ?? DEFAULT_SETTINGS.honorProxyLabels,
protectScannerImages: protectScannerImagesVal ?? DEFAULT_SETTINGS.protectScannerImages,
showImageChangelogLinks: showImageChangelogLinksVal ?? DEFAULT_SETTINGS.showImageChangelogLinks,
showWhatsNew: showWhatsNewVal ?? DEFAULT_SETTINGS.showWhatsNew,
animateIcons: animateIconsVal ?? DEFAULT_SETTINGS.animateIcons,
defaultScannerNetworkMode: defaultScannerNetworkModeVal ?? DEFAULT_SETTINGS.defaultScannerNetworkMode,
defaultScannerDns: parseScannerDnsStorage(defaultScannerDnsRawVal)
+11
View File
@@ -3,6 +3,7 @@ import type { RequestHandler } from './$types';
import { readdirSync, statSync, existsSync, mkdirSync } from 'node:fs';
import { join, basename, isAbsolute } from 'node:path';
import { authorize } from '$lib/server/authorize';
import { isProtectedPath } from '$lib/server/fs-guard';
export interface FileEntry {
name: string;
@@ -42,6 +43,10 @@ export const POST: RequestHandler = async ({ request, cookies }) => {
return json({ error: 'Path must not contain ..' }, { status: 400 });
}
if (isProtectedPath(path)) {
return json({ error: 'Access denied' }, { status: 403 });
}
if (existsSync(path)) {
return json({ error: 'Path already exists' }, { status: 409 });
}
@@ -72,6 +77,10 @@ export const GET: RequestHandler = async ({ url, cookies }) => {
const path = url.searchParams.get('path') || '/';
if (isProtectedPath(path)) {
return json({ error: 'Access denied' }, { status: 403 });
}
try {
if (!existsSync(path)) {
return json({ error: `Path not found: ${path}` }, { status: 404 });
@@ -88,6 +97,8 @@ export const GET: RequestHandler = async ({ url, cookies }) => {
for (const entry of dirEntries) {
try {
const fullPath = join(path, entry.name);
// Hide Dockhand's secrets (db dir, encryption key) from the listing.
if (isProtectedPath(fullPath)) continue;
const entryStat = statSync(fullPath);
entries.push({
@@ -2,6 +2,7 @@ import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { readFileSync, existsSync, statSync } from 'node:fs';
import { authorize } from '$lib/server/authorize';
import { isProtectedPath } from '$lib/server/fs-guard';
/**
* GET /api/system/files/content
@@ -23,6 +24,10 @@ export const GET: RequestHandler = async ({ url, cookies }) => {
return json({ error: 'Path is required' }, { status: 400 });
}
if (isProtectedPath(path)) {
return json({ error: 'Access denied' }, { status: 403 });
}
try {
if (!existsSync(path)) {
return json({ error: `File not found: ${path}` }, { status: 404 });
+8
View File
@@ -90,6 +90,14 @@ export const PUT: RequestHandler = async (event) => {
// Check if user is currently an admin (via role)
const existingUserIsAdmin = await userHasAdminRole(userId);
// A non-admin may hold `users:edit` (e.g. a help-desk role) but must not be
// able to modify an Admin account — resetting an admin's password here would
// be a privilege escalation to superadmin. Self-edits and real admins are
// unaffected. Mirrors the isAdmin gate on the MFA-disable endpoint.
if (existingUserIsAdmin && auth.user && auth.user.id !== userId && !auth.isAdmin) {
return json({ error: 'Cannot modify an admin account' }, { status: 403 });
}
// Build update object
const updateData: any = {};
@@ -5,6 +5,7 @@ import { getVolumeArchive, releaseVolumeHelperContainer, statVolumePath } from '
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';
export const GET: RequestHandler = async ({ params, url, cookies }) => {
const invalid = validateDockerIdParam(params.name, 'volume');
@@ -89,7 +90,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
+19
View File
@@ -0,0 +1,19 @@
import { json } from '@sveltejs/kit';
import { getVulnerabilitiesPage, parseVulnerabilitiesQuery } from '$lib/server/vulnerabilities';
import { authorizeVulnAccess } from '$lib/server/vuln-access';
import type { RequestHandler } from './$types';
/**
* A page of aggregated vulnerability findings for an environment, filtered and
* sorted server-side. Query: limit, offset, q, severity, image, container,
* stack, sort, dir. Returns { findings, total } where `total` is the filtered
* count (for the "X-Y of N" counter and infinite scroll).
*/
export const GET: RequestHandler = async ({ url, cookies }) => {
const { envIdNum, denied } = await authorizeVulnAccess(cookies, url);
if (denied) return json({ error: denied.message }, { status: denied.status });
if (!envIdNum) return json({ findings: [], total: 0 });
const result = await getVulnerabilitiesPage(envIdNum, parseVulnerabilitiesQuery(url));
return json(result);
};
@@ -0,0 +1,20 @@
/**
* Vulnerability dashboard metadata for an environment: the total finding count,
* the severity summary, and the distinct filter-dropdown values (image /
* container / stack) across the full set. Lets the header badge and the filter
* dropdowns stay complete without the page loading the full findings array.
*/
import { json } from '@sveltejs/kit';
import { getVulnerabilitiesMeta } from '$lib/server/vulnerabilities';
import { EMPTY_META } from '$lib/server/vulnerabilities-cache';
import { authorizeVulnAccess } from '$lib/server/vuln-access';
import type { RequestHandler } from './$types';
export const GET: RequestHandler = async ({ url, cookies }) => {
const { envIdNum, denied } = await authorizeVulnAccess(cookies, url);
if (denied) return json({ error: denied.message }, { status: denied.status });
if (!envIdNum) return json(EMPTY_META);
const meta = await getVulnerabilitiesMeta(envIdNum);
return json(meta);
};
@@ -0,0 +1,65 @@
import { getFilteredSortedFindings, getVulnerabilitiesMeta, parseVulnerabilitiesQuery, type Finding } from '$lib/server/vulnerabilities';
import { authorizeVulnAccess } from '$lib/server/vuln-access';
import { getEnvironment } from '$lib/server/db';
import { EMPTY_SUMMARY } from '$lib/utils/vulnerability';
import { rowsToCSV } from '$lib/server/csv';
import { exportResponse, jsonError, slugify } from '$lib/server/export-response';
import type { RequestHandler } from './$types';
function toCSV(findings: Finding[]): string {
const headers = [
'CVE', 'Severity', 'Package', 'Installed Version', 'Fixed Version',
'Image', 'Containers', 'Stacks', 'Scanned', 'Description', 'Link'
];
const rows = findings.map((f) => [
f.cve,
f.severity,
f.package,
f.installedVersion,
f.fixedVersion,
f.imageName,
(f.containers ?? []).map((c) => c.name).join('; '),
(f.stacks ?? []).join('; '),
f.scannedAt,
f.description,
f.link
]);
return rowsToCSV(headers, rows);
}
/**
* Export the aggregated vulnerability findings as json | csv.
* Server-side rendering so the download matches the grid data exactly.
*/
export const GET: RequestHandler = async ({ url, cookies }) => {
const { envIdNum, denied } = await authorizeVulnAccess(cookies, url);
if (denied) return jsonError(denied.message, denied.status);
try {
// Apply the same filter + sort the grid uses so the export matches the
// on-screen view (and reuse the cached flatten).
const query = parseVulnerabilitiesQuery(url);
const findings = envIdNum ? await getFilteredSortedFindings(envIdNum, query) : [];
const summary = envIdNum ? (await getVulnerabilitiesMeta(envIdNum)).summary : EMPTY_SUMMARY;
const timestamp = new Date().toISOString().split('T')[0];
// Include the environment name in the filename (e.g. vulnerabilities-local-2026-07-01.json).
let envSlug = 'env';
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-${envSlug}-${timestamp}`,
findings,
jsonBody: { summary, findings },
toCSV
});
} catch (error) {
console.error('Error exporting vulnerabilities:', error);
return jsonError('Failed to export vulnerabilities', 500);
}
};
@@ -0,0 +1,83 @@
import { json, type RequestHandler } from '@sveltejs/kit';
import { scanImage, scanResultToDbFormat } from '$lib/server/scanner';
import { saveVulnerabilityScan, deleteScansForImageScanner } from '$lib/server/db';
import { listImages } from '$lib/server/docker';
import { authorize } from '$lib/server/authorize';
import { createJobResponse } from '$lib/server/sse';
/**
* Scan every (tagged) image in the environment for vulnerabilities.
* Reuses the per-image scan flow, sequentially, reporting N/total progress.
* A single image failing does not abort the batch.
*/
export const POST: RequestHandler = async ({ request, url, cookies }) => {
const auth = await authorize(cookies);
const envIdParam = url.searchParams.get('env');
const envId = envIdParam ? parseInt(envIdParam) : undefined;
if (auth.authEnabled && !(await auth.can('images', 'inspect', envId))) {
return json({ error: 'Permission denied' }, { status: 403 });
}
if (envId && auth.isEnterprise && !(await auth.canAccessEnvironment(envId))) {
return json({ error: 'Access denied to this environment' }, { status: 403 });
}
return createJobResponse(async (send, isCancelled) => {
const images = await listImages(envId);
// One scan target per image: use the first usable tag, skip dangling/<none>.
const targets = images
.map((img) => (img.tags || []).find((t) => t && !t.includes('<none>')))
.filter((t): t is string => !!t);
// Dedupe (a repo can appear once per tag; scan each unique tag once)
const uniqueTargets = Array.from(new Set(targets));
const total = uniqueTargets.length;
send('progress', { current: 0, total, imageName: '' });
let scanned = 0;
let failed = 0;
let cancelled = false;
for (let i = 0; i < uniqueTargets.length; i++) {
if (isCancelled()) {
cancelled = true;
break;
}
const imageName = uniqueTargets[i];
// Announce which image is about to be scanned.
send('progress', { current: i, total, imageName });
try {
const results = await scanImage(imageName, envId);
for (const result of results) {
// Replace (not accumulate): drop this image+scanner's prior rows first,
// so the DB holds only the latest scan per image. Nothing is lost on
// cancel/failure since we only delete right before re-inserting.
await deleteScansForImageScanner(result.imageId, result.scanner, envId ?? null);
await saveVulnerabilityScan(scanResultToDbFormat(result, envId));
}
// Per-image findings: one entry per scanner that ran, with severity counts.
const scanners = results.map((r) => ({
scanner: r.scanner,
critical: r.summary.critical,
high: r.summary.high,
medium: r.summary.medium,
low: r.summary.low,
durationMs: r.scanDuration
}));
send('scanned', { current: i + 1, total, imageName, scanners, ok: true });
scanned++;
} catch (error) {
failed++;
const msg = error instanceof Error ? error.message : String(error);
console.error(`[scan-all] Failed to scan ${imageName}: ${msg}`);
send('scanned', { current: i + 1, total, imageName, scanners: [], ok: false, error: msg });
}
}
send('progress', { current: total, total, imageName: '' });
send('result', { scanned, failed, total, cancelled });
}, request);
};
+24 -16
View File
@@ -59,7 +59,8 @@
onAuditEvent,
type AuditLogEntry as SSEAuditLogEntry
} from '$lib/stores/audit-events';
import { formatDateTime } from '$lib/stores/settings';
import { formatDateTime, appSettings } from '$lib/stores/settings';
import { currentDateInTimezone, dayBoundaryToUtcISO } from '$lib/utils/date-format';
import PageHeader from '$lib/components/PageHeader.svelte';
import { DataGrid } from '$lib/components/data-grid';
import DiffViewer from '$lib/components/DiffViewer.svelte';
@@ -215,16 +216,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 = '';
@@ -236,34 +242,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;
@@ -303,8 +309,10 @@
if (filterEntityTypes.length > 0) params.set('entityTypes', filterEntityTypes.join(','));
if (filterActions.length > 0) params.set('actions', filterActions.join(','));
if (filterEnvironmentId !== null) params.set('environmentId', String(filterEnvironmentId));
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 ? logs.length : 0));
@@ -433,8 +441,8 @@
if (filterUsernames.length > 0) params.set('usernames', filterUsernames.join(','));
if (filterEntityTypes.length > 0) params.set('entityTypes', filterEntityTypes.join(','));
if (filterActions.length > 0) params.set('actions', filterActions.join(','));
if (filterFromDate) params.set('fromDate', filterFromDate);
if (filterToDate) params.set('toDate', filterToDate + 'T23:59:59');
if (filterFromDate) params.set('fromDate', dayBoundaryToUtcISO(filterFromDate, filterTimezone, false));
if (filterToDate) params.set('toDate', dayBoundaryToUtcISO(filterToDate, filterTimezone, true));
params.set('format', format);
window.location.href = `/api/audit/export?${params.toString()}`;
+17 -120
View File
@@ -76,6 +76,7 @@
import ContainerInspectModal from './ContainerInspectModal.svelte';
import FileBrowserModal from './FileBrowserModal.svelte';
import BatchUpdateModal from './BatchUpdateModal.svelte';
import CheckUpdatesButton from '$lib/components/CheckUpdatesButton.svelte';
import BatchOperationModal from '$lib/components/BatchOperationModal.svelte';
import type { ContainerInfo } from '$lib/types';
import { EmptyState, NoEnvironment } from '$lib/components/ui/empty-state';
@@ -85,7 +86,6 @@
import { appSettings } from '$lib/stores/settings';
import { canAccess } from '$lib/stores/auth';
import { vulnerabilityCriteriaIcons } from '$lib/utils/update-steps';
import { watchJob } from '$lib/utils/sse-fetch';
import { ipToNumber } from '$lib/utils/ip';
import { formatHostPortUrl } from '$lib/utils/url';
import { parseCustomUrl } from '$lib/utils/custom-url';
@@ -199,8 +199,7 @@
const isEnvSwitch = envId !== null && newEnvId !== envId;
envId = newEnvId;
initialFetchDone = true;
// Clear update state from previous environment
updateCheckStatus = 'idle';
// (CheckUpdatesButton resets its own state when envId changes)
// Clear shell detection cache for new environment
shellDetectionCache = {};
@@ -213,7 +212,6 @@
} else if (!env) {
// No environment - clear data and stop loading
envId = null;
updateCheckStatus = 'idle';
shellDetectionCache = {};
containerStore.clear();
}
@@ -270,10 +268,7 @@
let confirmUpdateSelected = $state(false);
// Update check state
let updateCheckStatus = $state<'idle' | 'checking' | 'found' | 'none' | 'error'>('idle');
let updateCheckProgress = $state({ checked: 0, total: 0 });
let updateCheckBtnEl = $state<HTMLButtonElement | null>(null);
let failedUpdateChecks = $state<Array<{ containerName: string; imageName: string; error: string }>>([]);
let checkUpdatesBtn = $state<{ reset: () => void } | null>(null);
let showBatchUpdateModal = $state(false);
const batchUpdateContainerIds = $derived($containerStore.pendingUpdateIds);
const batchUpdateContainerNames = $derived($containerStore.pendingUpdateNames);
@@ -462,88 +457,18 @@
}, 3000));
}
function showFailedChecksToast(failed: typeof failedUpdateChecks, 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() {
updateCheckStatus = 'checking';
updateCheckProgress = { checked: 0, total: 0 };
failedUpdateChecks = [];
// Lock button width to prevent layout shift
if (updateCheckBtnEl) {
updateCheckBtnEl.style.minWidth = `${updateCheckBtnEl.offsetWidth}px`;
}
try {
const response = await fetch(appendEnvParam('/api/containers/check-updates', envId), {
method: 'POST'
});
if (!response.ok) {
updateCheckStatus = 'error';
pendingTimeouts.push(setTimeout(() => { updateCheckStatus = 'idle'; }, 3000));
if (updateCheckBtnEl) updateCheckBtnEl.style.minWidth = '';
return;
}
const { jobId } = await response.json();
const data: any = await watchJob(jobId, (line) => {
if (line.event === 'progress') {
updateCheckProgress = line.data as { checked: number; total: number };
}
});
// Unlock button width
if (updateCheckBtnEl) updateCheckBtnEl.style.minWidth = '';
const containersWithUpdates = data.results.filter((r: any) => r.hasUpdate && !r.systemContainer && !r.updateDisabled && !r.isLocalImage);
const failed = data.results.filter((r: any) => r.error && !r.hasUpdate);
failedUpdateChecks = failed.map((r: any) => ({
containerName: r.containerName,
imageName: r.imageName,
error: r.error
}));
if (containersWithUpdates.length === 0) {
function handleUpdateCheckComplete(result: {
withUpdates: Array<{ containerId: string; containerName: string }>;
}) {
if (result.withUpdates.length === 0) {
containerStore.setPendingUpdates([], new Map());
if (failed.length > 0) {
updateCheckStatus = 'none';
showFailedChecksToast(failedUpdateChecks, 'All containers are up to date');
pendingTimeouts.push(setTimeout(() => { updateCheckStatus = 'idle'; }, 3000));
} else {
updateCheckStatus = 'none';
toast.success('All containers are up to date');
pendingTimeouts.push(setTimeout(() => { updateCheckStatus = 'idle'; }, 3000));
}
return;
}
// Prepare data for batch update modal (but don't open it yet)
containerStore.setPendingUpdates(
containersWithUpdates.map((r: any) => r.containerId),
new Map(containersWithUpdates.map((r: any) => [r.containerId, r.containerName]))
result.withUpdates.map((r) => r.containerId),
new Map(result.withUpdates.map((r) => [r.containerId, r.containerName]))
);
updateCheckStatus = 'found';
if (failed.length > 0) {
showFailedChecksToast(failedUpdateChecks, `${containersWithUpdates.length} update(s) available`);
} else {
toast.info(`${containersWithUpdates.length} update(s) available`);
}
} catch (error) {
updateCheckStatus = 'error';
pendingTimeouts.push(setTimeout(() => { updateCheckStatus = 'idle'; }, 3000));
if (updateCheckBtnEl) updateCheckBtnEl.style.minWidth = '';
}
}
@@ -551,10 +476,7 @@
async function loadPendingUpdates() {
if (!envId) return;
await containerStore.loadPendingUpdates(envId);
// Update local UI status if there are pending updates
if ($containerStore.pendingUpdateIds.length > 0) {
updateCheckStatus = 'found';
}
// The "found" (green) button state is driven by hasPendingUpdates below.
}
function updateSelectedContainers() {
@@ -616,7 +538,7 @@
showBatchUpdateModal = false;
singleUpdateContainerId = null;
singleUpdateContainerName = null;
updateCheckStatus = 'idle';
checkUpdatesBtn?.reset();
}
function handleBatchUpdateComplete(results: { success: string[]; failed: string[]; blocked: string[] }) {
@@ -1457,37 +1379,12 @@
Create
</Button>
{/if}
<Button
bind:ref={updateCheckBtnEl}
size="sm"
variant="outline"
onclick={checkForUpdates}
disabled={updateCheckStatus === 'checking'}
title="Check for available updates"
class="relative overflow-hidden"
>
{#if updateCheckStatus === 'checking'}
<CircleArrowUp class="w-3.5 h-3.5 animate-spin" />
{#if updateCheckProgress.total > 0}
<span class="tabular-nums">Checking {String(updateCheckProgress.checked).padStart(String(updateCheckProgress.total).length, '\u2007')}/{updateCheckProgress.total}</span>
<div
class="absolute bottom-0 left-0 h-px bg-foreground transition-[width] duration-150 ease-out"
style="width: {(updateCheckProgress.checked / updateCheckProgress.total) * 100}%"
></div>
{:else}
Check for updates
{/if}
{:else if updateCheckStatus === 'none' || updateCheckStatus === 'found'}
<Check class="w-3.5 h-3.5 mr-1 text-green-600" />
Check for updates
{:else if updateCheckStatus === '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>
<CheckUpdatesButton
bind:this={checkUpdatesBtn}
{envId}
hasPendingUpdates={updatableContainersCount > 0}
onComplete={handleUpdateCheckComplete}
/>
{#if updatableContainersCount > 0}
<ConfirmPopover
open={confirmUpdateAll}
+40 -2
View File
@@ -147,6 +147,10 @@
let viewingFile = $state<{ name: string; path: string; content: string } | null>(null);
let editorContent = $state('');
let loadingFile = $state(false);
// True when the editor buffer differs from the loaded file (unsaved changes).
let editorDirty = $derived(!!editingFile && editorContent !== editingFile.content);
// Shown when the user tries to close the editor with unsaved changes (#1264).
let showCloseConfirm = $state(false);
let savingFile = $state(false);
let editorTheme = $state<'light' | 'dark'>('dark');
@@ -234,7 +238,12 @@
// the key. When no file is open, ESC bubbles normally and closes the dialog.
function handleEscape(e: KeyboardEvent) {
if (e.key !== 'Escape') return;
if (editingFile) {
if (showCloseConfirm) {
// Esc on the unsaved-changes prompt = Cancel (keep editing), never discard.
e.stopPropagation();
e.preventDefault();
showCloseConfirm = false;
} else if (editingFile) {
e.stopPropagation();
e.preventDefault();
closeEditor();
@@ -444,7 +453,7 @@
}
toast.success('File saved');
closeEditor();
forceCloseEditor(); // saved → close without re-prompting the dirty guard
} catch (err: any) {
toast.error(err.message || 'Failed to save file');
} finally {
@@ -452,7 +461,17 @@
}
}
// Guarded close: if the buffer has unsaved edits, ask first (#1264).
function closeEditor() {
if (editorDirty) {
showCloseConfirm = true;
return;
}
forceCloseEditor();
}
function forceCloseEditor() {
showCloseConfirm = false;
editingFile = null;
editorContent = '';
}
@@ -1156,6 +1175,25 @@
</div>
{/if}
<!-- Unsaved-changes confirmation on editor close (#1264) -->
<Dialog.Root open={showCloseConfirm} onOpenChange={(o) => { if (!o) showCloseConfirm = false; }}>
<Dialog.Content class="sm:max-w-md">
<Dialog.Header>
<Dialog.Title>Unsaved changes</Dialog.Title>
<Dialog.Description>
{editingFile?.name ?? 'This file'} has unsaved changes. Save them before closing?
</Dialog.Description>
</Dialog.Header>
<Dialog.Footer class="gap-2 sm:justify-between">
<Button variant="outline" onclick={() => showCloseConfirm = false}>Cancel</Button>
<div class="flex gap-2">
<Button variant="destructive" onclick={forceCloseEditor}>Discard</Button>
<Button onclick={saveFile} disabled={savingFile}>Save</Button>
</div>
</Dialog.Footer>
</Dialog.Content>
</Dialog.Root>
<!-- File Viewer Overlay -->
{#if viewingFile}
<div class="absolute inset-0 bg-background flex flex-col z-10">
+327 -9
View File
@@ -4,14 +4,23 @@
<script lang="ts">
import { onMount, onDestroy } from 'svelte';
import { page } from '$app/stores';
import { goto } from '$app/navigation';
import { toast } from 'svelte-sonner';
import * as Tabs from '$lib/components/ui/tabs';
import VulnerabilitiesTab from './VulnerabilitiesTab.svelte';
import VulnerabilityScanModal from './VulnerabilityScanModal.svelte';
import type { Finding as VulnFinding, VulnerabilitySummary as VulnSummary, SortField as VulnSortField } from '$lib/utils/vulnerability';
import { EMPTY_SUMMARY } from '$lib/utils/vulnerability';
import { createWindowedList } from '$lib/utils/windowed-list.svelte';
import { Badge } from '$lib/components/ui/badge';
import { Button } from '$lib/components/ui/button';
import { Input } from '$lib/components/ui/input';
import { Label } from '$lib/components/ui/label';
import * as Dialog from '$lib/components/ui/dialog';
import * as Select from '$lib/components/ui/select';
import { Trash2, Upload, RefreshCw, Play, Search, Layers, Server, ShieldCheck, CheckSquare, Square, Tag, Check, XCircle, Icon, AlertTriangle, X, Images, Copy, Download, ChevronRight, ChevronDown, Loader2, ArrowUp, ArrowDown, ArrowUpDown, CircleDashed, CircleDot, Circle, Filter } from 'lucide-svelte';
import * as DropdownMenu from '$lib/components/ui/dropdown-menu';
import { Trash2, Upload, RefreshCw, Play, Search, Layers, Server, ShieldCheck, ShieldAlert, Shield, ShieldQuestion, CheckSquare, Square, Tag, Check, XCircle, Icon, AlertTriangle, X, Images, Copy, Download, ChevronRight, ChevronDown, Loader2, ArrowUp, ArrowDown, ArrowUpDown, CircleDashed, CircleDot, Circle, Filter, FileJson, FileSpreadsheet, ShieldPlus } from 'lucide-svelte';
import { broom, whale } from '@lucide/lab';
import { formatBytes } from '$lib/utils/format';
import * as Tooltip from '$lib/components/ui/tooltip';
@@ -30,7 +39,8 @@
import { formatDate, appSettings } from '$lib/stores/settings';
import { readJobResponse } from '$lib/utils/sse-fetch';
import { EmptyState, NoEnvironment } from '$lib/components/ui/empty-state';
import PageHeader from '$lib/components/PageHeader.svelte';
import TabbedPageHeader from '$lib/components/TabbedPageHeader.svelte';
import MultiSelectFilter from '$lib/components/MultiSelectFilter.svelte';
import { DataGrid } from '$lib/components/data-grid';
import type { DataGridSortState } from '$lib/components/data-grid/types';
@@ -105,6 +115,152 @@
// Scanner settings (loaded per-environment)
let scannerEnabled = $state(false);
// Vulnerability findings use a server-fetched sliding window (createWindowedList)
// so the browser only ever holds ~windowSize rows regardless of env size.
let vulnSummary = $state<VulnSummary>({ ...EMPTY_SUMMARY });
let vulnFetched = $state(false); // has the tab been opened + first window fetched
let showVulnScanModal = $state(false);
const vulnList = createWindowedList<VulnFinding>({
windowSize: 400,
onError: (e) => console.error('Failed to load vulnerabilities:', e),
fetchPage: async (offset, limit, signal) => {
// Mark the tab as "opened" up front so a failed load doesn't leave the
// lazy-load effect re-firing in a tight retry loop (it gates on !vulnFetched).
vulnFetched = true;
const res = await fetch(`/api/vulnerabilities?${vulnQueryParams(offset, limit)}`, { signal });
if (!res.ok) throw new Error(`Failed to load vulnerabilities (${res.status})`);
const data = await res.json();
// The paged response carries a filtered summary — update the header pills so
// the severity counts + total reflect the active filters, not the env total.
if (data.summary) vulnSummary = data.summary;
return { items: data.findings ?? [], total: data.total ?? 0 };
}
});
// Vulnerabilities toolbar state (owned here so it renders in the header row).
let vulnSearch = $state('');
let vulnSeverityFilter = $state<string[]>([]);
let vulnImageFilter = $state<string[]>([]);
let vulnContainerFilter = $state<string[]>([]);
let vulnStackFilter = $state<string[]>([]);
// Server-driven sort (grid header clicks bubble up and refetch from offset 0).
let vulnSort = $state<VulnSortField>('severity');
let vulnDir = $state<'asc' | 'desc'>('asc');
// Header badge total (from the meta endpoint), shown even from the Images tab.
let vulnBadgeTotal = $state<number | null>(null);
// Filter-dropdown options come from the meta endpoint (distinct across the full set).
let vulnImageOptions = $state<{ value: string; label: string }[]>([]);
let vulnContainerOptions = $state<{ value: string; label: string }[]>([]);
let vulnStackOptions = $state<{ value: string; label: string }[]>([]);
// Header counter: visible row range (from the grid) over the filtered total.
let vulnVisibleStart = $state(1);
let vulnVisibleEnd = $state(0);
function handleVulnRange(start: number, end: number) {
vulnVisibleStart = start;
vulnVisibleEnd = end;
}
const toOpts = (vals: string[]) => vals.map((v) => ({ value: v, label: v }));
// Meta (total + filter options) — fetched on env change without pulling findings,
// so the Images page stays light for users who never open the Vulnerabilities tab.
async function fetchVulnMeta() {
if (envId === null) {
vulnBadgeTotal = null;
return;
}
try {
const res = await fetch(appendEnvParam('/api/vulnerabilities/count', envId));
if (res.ok) {
const data = await res.json();
vulnBadgeTotal = data.total ?? 0;
vulnSummary = data.summary ?? vulnSummary;
vulnImageOptions = toOpts(data.options?.images ?? []);
vulnContainerOptions = toOpts(data.options?.containers ?? []);
vulnStackOptions = toOpts(data.options?.stacks ?? []);
}
} catch (e) {
console.error('Failed to load vulnerability meta:', e);
}
}
// Single source of the env + filter + sort params. Both the paged grid fetch
// and the export URL build on this, so a new filter can't be added to one and
// silently dropped from the other (export would export a different set).
function vulnFilterParams(): URLSearchParams {
const p = new URLSearchParams();
if (envId !== null) p.set('env', String(envId));
p.set('sort', vulnSort);
p.set('dir', vulnDir);
if (vulnSearch.trim()) p.set('q', vulnSearch.trim());
if (vulnSeverityFilter.length) p.set('severity', vulnSeverityFilter.join(','));
if (vulnImageFilter.length) p.set('image', vulnImageFilter.join(','));
if (vulnContainerFilter.length) p.set('container', vulnContainerFilter.join(','));
if (vulnStackFilter.length) p.set('stack', vulnStackFilter.join(','));
return p;
}
function vulnQueryParams(offset: number, limit: number): URLSearchParams {
const p = vulnFilterParams();
p.set('limit', String(limit));
p.set('offset', String(offset));
return p;
}
// Reset to the first window (env/filter/sort change). The windowed list owns
// the window offset, abort/generation guarding, and centering on scroll.
function fetchVulnerabilities() {
// Drop the old visible range so a filter/env change that yields fewer (or
// zero) rows doesn't briefly show a stale "380-400 of 0" until the grid
// re-emits. The grid only re-emits when it has rows, so an empty result
// would otherwise keep the last non-zero range.
vulnVisibleStart = 1;
vulnVisibleEnd = 0;
if (envId === null) { vulnList.clear(); vulnFetched = false; return; }
vulnList.reset();
}
// Refresh meta + findings — used by the Refresh button and after a scan completes.
function refreshVulnerabilities() {
fetchVulnMeta();
fetchVulnerabilities();
}
// Build an export URL that mirrors the active view (same filters/sort as the
// grid), then trigger a download. Shares vulnFilterParams() with the grid fetch.
function exportVulnerabilities(format: 'json' | 'csv' | 'sarif') {
const params = vulnFilterParams();
params.set('format', format);
window.location.href = `/api/vulnerabilities/export?${params.toString()}`;
}
const vulnSeverityOptions = [
{ value: 'critical', label: 'Critical', color: 'text-red-500', icon: ShieldAlert },
{ value: 'high', label: 'High', color: 'text-orange-500', icon: ShieldAlert },
{ value: 'medium', label: 'Medium', color: 'text-yellow-600', icon: Shield },
{ value: 'low', label: 'Low', color: 'text-blue-500', icon: Shield },
{ value: 'negligible', label: 'Negligible', color: 'text-gray-500', icon: ShieldCheck },
{ value: 'unknown', label: 'Unknown', color: 'text-gray-500', icon: ShieldQuestion }
];
// Tab state (persisted in the URL: /images?tab=vulnerabilities)
let activeTab = $derived($page.url.searchParams.get('tab') || 'images');
function handleTabChange(tab: string) {
goto(`/images?tab=${tab}`, { replaceState: true, noScroll: true });
}
// Vulnerabilities header count: on the vuln tab with rows visible → "X-Y of N";
// otherwise the plain badge total (from the cheap count endpoint).
const showVulnRange = $derived(activeTab === 'vulnerabilities' && vulnFetched && vulnVisibleEnd > 0);
const vulnCount = $derived(
showVulnRange ? `${vulnVisibleStart}-${vulnVisibleEnd}` : (vulnBadgeTotal ?? undefined)
);
const vulnCountTotal = $derived(showVulnRange ? vulnList.total : undefined);
// Search and sort state
let searchQuery = $state('');
let sortField = $state<SortField>('created');
@@ -465,6 +621,8 @@
initialFetchDone = true;
fetchImages();
fetchScannerSettings();
fetchVulnMeta(); // badge total + filter options; findings load lazily on tab open
vulnFetched = false; // env changed → the lazy-load effect below re-fetches if the vuln tab is active
} else if (!env) {
// No environment - clear data and stop loading
envId = null;
@@ -473,6 +631,31 @@
}
});
// Lazy-load the first page the first time the Vulnerabilities tab is opened
// (or re-opened after an env change), so the Images page stays light otherwise.
$effect(() => {
if (activeTab === 'vulnerabilities' && envId !== null && !vulnFetched && !vulnList.loading) {
fetchVulnerabilities();
}
});
// When filters/search/sort change (while the vuln tab is active), refetch from
// offset 0 — the server applies them and returns the new first page + total.
// Debounced so typing in the search box fires one request when the user pauses,
// not one per keystroke (reset() forces through the in-flight dedupe, so an
// un-debounced burst would abort+refetch on every character).
let vulnFilterReady = false;
let vulnFilterTimer: ReturnType<typeof setTimeout> | null = null;
$effect(() => {
// Track all filter/sort inputs.
vulnSearch; vulnSeverityFilter; vulnImageFilter; vulnContainerFilter; vulnStackFilter; vulnSort; vulnDir;
if (!vulnFilterReady) { vulnFilterReady = true; return; }
if (activeTab === 'vulnerabilities' && envId !== null && vulnFetched) {
if (vulnFilterTimer) clearTimeout(vulnFilterTimer);
vulnFilterTimer = setTimeout(() => { vulnFilterTimer = null; fetchVulnerabilities(); }, 200);
}
});
function bulkRemove() {
batchOpTitle = `Removing ${selectedInFilter.length} image${selectedInFilter.length !== 1 ? 's' : ''}`;
batchOpOperation = 'remove';
@@ -706,17 +889,39 @@
document.removeEventListener('resume', handleVisibilityChange);
pendingTimeouts.forEach(id => clearTimeout(id));
pendingTimeouts = [];
// Abort any in-flight vuln window fetch and drop its ~400-row buffer, and
// cancel a pending debounced refetch, so nothing lingers after unmount.
if (vulnFilterTimer) { clearTimeout(vulnFilterTimer); vulnFilterTimer = null; }
vulnList.clear();
});
</script>
<div class="flex-1 min-h-0 flex flex-col gap-3 overflow-hidden">
<div class="shrink-0 flex flex-wrap justify-between items-center gap-3 min-h-8">
<PageHeader
icon={Images}
title="Images"
count={sortedGroups.length}
total={(searchQuery || usageFilter !== 'all') && sortedGroups.length !== groupedImages.length ? groupedImages.length : undefined}
<TabbedPageHeader
tabs={[
{
id: 'images',
label: 'Images',
icon: Images,
count: activeTab === 'images' ? sortedGroups.length : groupedImages.length,
total: activeTab === 'images' && (searchQuery || usageFilter !== 'all') && sortedGroups.length !== groupedImages.length ? groupedImages.length : undefined,
showConnection: true
},
{
id: 'vulnerabilities',
label: 'Vulnerabilities',
icon: ShieldCheck,
count: vulnCount,
total: vulnCountTotal
}
]}
{activeTab}
onTabChange={handleTabChange}
/>
{#if activeTab === 'images'}
<div class="flex flex-wrap items-center gap-2">
<div class="relative">
<Search class="absolute left-2 top-1/2 -translate-y-1/2 w-3.5 h-3.5 text-muted-foreground" />
@@ -829,10 +1034,96 @@
{/if}
<Button size="sm" variant="outline" onclick={fetchImages}>Refresh</Button>
</div>
{/if}
{#if activeTab === 'vulnerabilities'}
<div class="flex flex-wrap items-center gap-2 w-full">
<div class="relative">
<Search class="absolute left-2 top-1/2 -translate-y-1/2 w-3.5 h-3.5 text-muted-foreground" />
<Input
type="text"
placeholder="Search CVE, package, image, container, stack..."
bind:value={vulnSearch}
onkeydown={(e) => e.key === 'Escape' && (vulnSearch = '')}
class="pl-8 h-8 w-80 text-sm"
/>
</div>
<MultiSelectFilter
bind:value={vulnSeverityFilter}
options={vulnSeverityOptions}
placeholder="All severities"
pluralLabel="severities"
width="w-40"
defaultIcon={ShieldCheck}
/>
<MultiSelectFilter
bind:value={vulnImageFilter}
options={vulnImageOptions}
placeholder="All images"
pluralLabel="images"
width="w-48"
/>
<MultiSelectFilter
bind:value={vulnContainerFilter}
options={vulnContainerOptions}
placeholder="All containers"
pluralLabel="containers"
width="w-44"
/>
<MultiSelectFilter
bind:value={vulnStackFilter}
options={vulnStackOptions}
placeholder="All stacks"
pluralLabel="stacks"
width="w-40"
/>
<div class="flex items-center gap-2 ml-auto">
<DropdownMenu.Root>
<DropdownMenu.Trigger>
{#snippet child({ props })}
<Button size="sm" variant="outline" title="Export findings" {...props}>
<Download class="w-3.5 h-3.5" />
Export
</Button>
{/snippet}
</DropdownMenu.Trigger>
<DropdownMenu.Content align="start">
<DropdownMenu.Item onclick={() => exportVulnerabilities('json')}>
<FileJson class="w-4 h-4 mr-2 text-amber-500" />
JSON
</DropdownMenu.Item>
<DropdownMenu.Item onclick={() => exportVulnerabilities('csv')}>
<FileSpreadsheet class="w-4 h-4 mr-2 text-green-500" />
CSV
</DropdownMenu.Item>
<DropdownMenu.Item onclick={() => exportVulnerabilities('sarif')}>
<ShieldPlus class="w-4 h-4 mr-2 text-blue-500" />
SARIF
</DropdownMenu.Item>
</DropdownMenu.Content>
</DropdownMenu.Root>
<Button size="sm" variant="outline" onclick={refreshVulnerabilities} disabled={vulnList.loading}>
<RefreshCw class="w-3.5 h-3.5 {vulnList.loading ? 'animate-spin' : ''}" />
Refresh
</Button>
{#if scannerEnabled}
<Button size="sm" variant="secondary" onclick={() => showVulnScanModal = true}>
<ShieldCheck class="w-3.5 h-3.5" />
Scan all images
</Button>
{/if}
</div>
</div>
{/if}
</div>
<!-- Selection bar - always reserve space to prevent layout shift -->
<div class="h-4 shrink-0">
<Tabs.Root value={activeTab} onValueChange={handleTabChange} class="w-full flex-1 min-h-0 flex flex-col gap-2">
<Tabs.Content value="images" class="flex-1 min-h-0 flex flex-col gap-2">
{#if activeTab === 'images'}
<!-- Selection bar - only occupies space when something is selected -->
{#if selectedImages.size > 0}
<div class="shrink-0">
{#if selectedImages.size > 0}
<div class="flex items-center gap-1 text-xs text-muted-foreground h-full">
<span>{selectedInFilter.length} selected</span>
@@ -857,6 +1148,7 @@
</div>
{/if}
</div>
{/if}
{#if !loading && ($environments.length === 0 || !$currentEnvironment)}
<NoEnvironment />
@@ -1171,8 +1463,33 @@
{/snippet}
</DataGrid>
{/if}
{/if}
</Tabs.Content>
<Tabs.Content value="vulnerabilities" class="flex-1 min-h-0 flex flex-col">
{#if activeTab === 'vulnerabilities'}
<VulnerabilitiesTab
{envId}
{scannerEnabled}
findings={vulnList.items}
summary={vulnSummary}
loading={vulnList.loading}
dataOffset={vulnList.offset}
virtualTotal={vulnList.total}
onWindowShift={vulnList.shiftTo}
sortField={vulnSort}
sortDirection={vulnDir}
onSortChange={(field, dir) => { vulnSort = field; vulnDir = dir; }}
onRangeChange={handleVulnRange}
/>
{/if}
</Tabs.Content>
</Tabs.Root>
</div>
<!-- Vulnerability Scan-all Modal -->
<VulnerabilityScanModal bind:open={showVulnScanModal} {envId} envName={$currentEnvironment?.name} onComplete={refreshVulnerabilities} />
<!-- Pull Image Modal -->
<ImagePullModal
bind:open={showPullModal}
@@ -1217,6 +1534,7 @@
imageName={scanImageName}
mode="scan"
{envId}
onComplete={refreshVulnerabilities}
/>
<!-- Batch Operation Modal -->
+1 -31
View File
@@ -3,6 +3,7 @@
import { Loader2, Layers, ChevronDown, ChevronRight } from 'lucide-svelte';
import { currentEnvironment, appendEnvParam } from '$lib/stores/environment';
import { formatDateTime } from '$lib/stores/settings';
import { highlightCommand } from '$lib/utils/highlight-command';
interface Props {
imageId: string;
@@ -125,37 +126,6 @@
return parts[0];
}
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');
let highlighted = cmd
// 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;
}
</script>
<div class="space-y-4">
+26 -2
View File
@@ -3,7 +3,9 @@
import { Button } from '$lib/components/ui/button';
import * as DropdownMenu from '$lib/components/ui/dropdown-menu';
import { Checkbox } from '$lib/components/ui/checkbox';
import { Download, CheckCircle2, XCircle, ShieldCheck, ShieldAlert, ShieldX, FileText, FileSpreadsheet } from 'lucide-svelte';
import { Download, CheckCircle2, XCircle, ShieldCheck, ShieldAlert, ShieldX, FileText, FileJson, FileSpreadsheet, ShieldPlus } from 'lucide-svelte';
import { findingsToSarif } from '$lib/utils/sarif';
import { flattenScansToFindings } from '$lib/utils/vulnerability';
import { currentEnvironment } from '$lib/stores/environment';
import ScanTab from '$lib/components/ScanTab.svelte';
import type { ScanResult } from '$lib/components/ScanTab.svelte';
@@ -224,6 +226,24 @@
downloadFile(jsonContent, filename, 'application/json');
}
function exportToSARIF() {
if (exportResults.length === 0) return;
// Reuse the shared flattener so cross-scanner duplicates (grype + trivy
// reporting the same CVE) are deduped, matching the server exports.
const findings = flattenScansToFindings(
exportResults.map((r) => ({
imageId: r.imageId ?? imageName,
imageName,
scannedAt: new Date().toISOString(),
vulnerabilities: r.vulnerabilities
}))
);
const sarif = findingsToSarif(findings, { toolName: 'Dockhand' });
const scannerSuffix = exportResults.length > 1 ? 'combined' : exportResults[0].scanner;
const filename = `vuln-report-${sanitizeFilename(imageName)}-${scannerSuffix}-${new Date().toISOString().split('T')[0]}.sarif`;
downloadFile(JSON.stringify(sarif, null, 2), filename, 'application/sarif+json');
}
const totalVulnerabilities = $derived(
scanResults.reduce((total, r) => total + r.vulnerabilities.length, 0)
);
@@ -312,9 +332,13 @@
CSV spreadsheet (.csv)
</DropdownMenu.Item>
<DropdownMenu.Item onclick={exportToJSON} disabled={exportResults.length === 0}>
<FileText class="w-4 h-4 mr-2 text-amber-500" />
<FileJson class="w-4 h-4 mr-2 text-amber-500" />
JSON data (.json)
</DropdownMenu.Item>
<DropdownMenu.Item onclick={exportToSARIF} disabled={exportResults.length === 0}>
<ShieldPlus class="w-4 h-4 mr-2 text-blue-500" />
SARIF (.sarif)
</DropdownMenu.Item>
</DropdownMenu.Content>
</DropdownMenu.Root>
{/if}
+1 -20
View File
@@ -1,6 +1,7 @@
<script lang="ts">
import { Badge } from '$lib/components/ui/badge';
import { CheckCircle2, ExternalLink, ArrowUpDown, ArrowUp, ArrowDown } from 'lucide-svelte';
import { getSeverityColor, SEVERITY_ORDER } from '$lib/utils/vulnerability';
interface ScanResult {
scanner: 'grype' | 'trivy';
@@ -40,10 +41,6 @@
let sortBy = $state<'severity' | 'id' | 'package'>('severity');
let sortDir = $state<'asc' | 'desc'>('asc');
const SEVERITY_ORDER: Record<string, number> = {
critical: 0, high: 1, medium: 2, low: 3, negligible: 4, unknown: 5
};
const sortedVulns = $derived.by(() => {
if (!activeResult) return [];
const vulns = [...activeResult.vulnerabilities];
@@ -77,22 +74,6 @@
return `${(ms / 1000).toFixed(1)}s`;
}
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';
}
}
function toggleVulnDetails(id: string) {
if (expandedVulns.has(id)) {
+235
View File
@@ -0,0 +1,235 @@
<script lang="ts">
import { DataGrid } from '$lib/components/data-grid';
import { Badge } from '$lib/components/ui/badge';
import { appendEnvParam } from '$lib/stores/environment';
import { getSeverityColor } from '$lib/utils/vulnerability';
import type { Finding, FindingContainer, VulnerabilitySummary as Summary, SortField } from '$lib/utils/vulnerability';
import { formatDateTime } from '$lib/stores/settings';
import { goto } from '$app/navigation';
import ContainerInspectModal from '../containers/ContainerInspectModal.svelte';
import SeveritySummaryPills from '$lib/components/SeveritySummaryPills.svelte';
import {
ShieldCheck, ExternalLink,
ArrowUp, ArrowDown, ArrowUpDown, ShieldAlert, Box, Layers, NotepadText
} from 'lucide-svelte';
import * as Tooltip from '$lib/components/ui/tooltip';
interface Props {
envId: number | null;
scannerEnabled: boolean;
// Server-windowed data + sort state are owned by the page (toolbar is in the header).
findings: Finding[];
summary: Summary;
loading: boolean;
/** Sliding-window: absolute index of findings[0] and the full row count. */
dataOffset?: number;
virtualTotal?: number;
sortField?: SortField;
sortDirection?: 'asc' | 'desc';
/** The grid scrolled outside the loaded window — shift it to this row. */
onWindowShift?: (targetStart: number) => void;
/** A grid header sort click — the page refetches from the server. */
onSortChange?: (field: SortField, direction: 'asc' | 'desc') => void;
/** Reports the visible row range for the header counter. */
onRangeChange?: (start: number, end: number) => void;
}
let {
envId,
scannerEnabled,
findings,
summary,
loading,
dataOffset = 0,
virtualTotal,
sortField = 'severity',
sortDirection = 'asc',
onWindowShift,
onSortChange,
onRangeChange
}: Props = $props();
// Container inspect modal
let showInspectModal = $state(false);
let inspectContainerId = $state('');
let inspectContainerName = $state('');
function inspectContainer(c: FindingContainer) {
inspectContainerId = c.id;
inspectContainerName = c.name;
showInspectModal = true;
}
function openStack(stack: string) {
goto(appendEnvParam(`/stacks?search=${encodeURIComponent(stack)}`, envId));
}
function toggleSort(field: SortField) {
const dir = field === sortField && sortDirection === 'asc' ? 'desc' : 'asc';
onSortChange?.(field, dir);
}
</script>
<div class="flex-1 min-h-0 flex flex-col gap-2">
<!-- Summary row: scanned count + severity pills -->
<div class="shrink-0 flex flex-wrap items-center gap-3 text-sm text-muted-foreground">
<span>Images scanned: <span class="font-medium text-foreground">{summary.imagesScanned}/{summary.totalImages}</span></span>
<span>Total: <span class="font-medium text-foreground">{summary.total}</span></span>
<SeveritySummaryPills counts={summary} />
</div>
{#if !scannerEnabled}
<div class="flex-1 min-h-0 flex flex-col items-center justify-center text-center gap-2 text-muted-foreground">
<ShieldAlert class="w-10 h-10 opacity-40" />
<p class="text-sm">No vulnerability scanner is configured for this environment.</p>
<a href="/settings?tab=environments" class="text-sm text-primary hover:underline">Configure a scanner in settings</a>
</div>
{:else}
{#if !loading && findings.length === 0}
<div class="flex-1 min-h-0 flex flex-col items-center justify-center text-center gap-2 text-muted-foreground">
<ShieldCheck class="w-10 h-10 opacity-40" />
<p class="text-sm">No vulnerabilities found.</p>
<p class="text-xs">Click "Scan all images" to populate this view.</p>
</div>
{:else}
<div class="flex-1 min-h-0 flex flex-col">
<!-- Findings arrive already filtered + sorted + paged from the server. -->
<DataGrid
data={findings}
keyField="key"
gridId="vulnerabilities"
virtualScroll
windowed
loading={loading}
windowOffset={dataOffset}
windowTotal={virtualTotal}
onWindowShift={onWindowShift}
wrapperClass="border rounded-lg"
sortState={{ field: sortField, direction: sortDirection }}
onSortChange={(state) => onSortChange?.(state.field as SortField, state.direction)}
onVisibleRangeChange={(start, end) => onRangeChange?.(start, end)}
>
{#snippet headerCell(column, sortState)}
{#if column.sortable}
<button
type="button"
onclick={() => toggleSort((column.sortField ?? column.id) as SortField)}
class="flex items-center gap-1 hover:text-foreground transition-colors w-full"
>
{column.label}
{#if sortState?.field === (column.sortField ?? column.id)}
{#if sortState.direction === 'asc'}
<ArrowUp class="w-3 h-3" />
{:else}
<ArrowDown class="w-3 h-3" />
{/if}
{:else}
<ArrowUpDown class="w-3 h-3 opacity-30" />
{/if}
</button>
{:else}
{column.label}
{/if}
{/snippet}
{#snippet cell(column, finding)}
{#if column.id === 'cve'}
{#snippet cveLink(extra: Record<string, unknown> = {})}
<a
href={finding.link || `https://nvd.nist.gov/vuln/detail/${finding.cve}`}
target="_blank"
rel="noopener noreferrer"
class="inline-flex items-center gap-1 font-mono text-xs text-blue-500 hover:underline"
{...extra}
>
{finding.cve}
<ExternalLink class="w-3 h-3 opacity-60" />
</a>
{/snippet}
{#if finding.description}
<Tooltip.Root>
<Tooltip.Trigger>
{#snippet child({ props })}
{@render cveLink(props)}
{/snippet}
</Tooltip.Trigger>
<Tooltip.Content side="right" class="w-[28rem] max-w-[90vw] text-left">
<div class="flex gap-2">
<NotepadText class="w-4 h-4 mt-0.5 shrink-0 opacity-70" />
<p class="text-xs leading-relaxed text-left [text-wrap:normal]">{finding.description}</p>
</div>
</Tooltip.Content>
</Tooltip.Root>
{:else}
{@render cveLink()}
{/if}
{:else if column.id === 'package'}
<span class="font-mono text-xs truncate" title={finding.package}>{finding.package}</span>
{:else if column.id === 'severity'}
<Badge variant="outline" class="{getSeverityColor(finding.severity)} text-xs py-0 px-1.5">
{finding.severity}
</Badge>
{:else if column.id === 'installed'}
<span class="font-mono text-xs truncate" title={finding.installedVersion}>{finding.installedVersion}</span>
{:else if column.id === 'fixed'}
{#if finding.fixedVersion}
<span class="font-mono text-xs truncate" title={finding.fixedVersion}>{finding.fixedVersion}</span>
{:else}
<span class="text-xs text-muted-foreground">No fix</span>
{/if}
{:else if column.id === 'image'}
<span class="font-mono text-xs truncate" title={finding.imageName}>{finding.imageName}</span>
{:else if column.id === 'container'}
{#if finding.containers && finding.containers.length}
<div class="flex items-center gap-1 min-w-0">
<button
type="button"
onclick={() => inspectContainer(finding.containers![0])}
class="inline-flex items-center gap-1 text-xs text-blue-500 hover:underline truncate"
title={`Inspect ${finding.containers[0].name}`}
>
<Box class="w-3 h-3 shrink-0 opacity-70" />
<span class="truncate">{finding.containers[0].name}</span>
</button>
{#if finding.containers.length > 1}
<Badge variant="secondary" class="text-2xs px-1 py-0 h-4 shrink-0" title={finding.containers.map((c) => c.name).join(', ')}>
+{finding.containers.length - 1}
</Badge>
{/if}
</div>
{:else}
<span class="text-xs text-muted-foreground">—</span>
{/if}
{:else if column.id === 'stack'}
{#if finding.stacks && finding.stacks.length}
<button
type="button"
onclick={() => openStack(finding.stacks![0])}
class="inline-flex items-center gap-1 text-xs text-blue-500 hover:underline truncate"
title={`View stack ${finding.stacks[0]}`}
>
<Layers class="w-3 h-3 shrink-0 opacity-70" />
<span class="truncate">{finding.stacks[0]}</span>
</button>
{:else}
<span class="text-xs text-muted-foreground">—</span>
{/if}
{:else if column.id === 'scannedAt'}
{#if finding.scannedAt}
<span class="text-xs text-muted-foreground whitespace-nowrap" title={finding.scannedAt}>{formatDateTime(finding.scannedAt)}</span>
{:else}
<span class="text-xs text-muted-foreground">—</span>
{/if}
{/if}
{/snippet}
</DataGrid>
</div>
{/if}
{/if}
</div>
<ContainerInspectModal
bind:open={showInspectModal}
containerId={inspectContainerId}
containerName={inspectContainerName}
/>
@@ -0,0 +1,312 @@
<script lang="ts">
import * as Dialog from '$lib/components/ui/dialog';
import { Progress } from '$lib/components/ui/progress';
import { Button } from '$lib/components/ui/button';
import SeveritySummaryPills from '$lib/components/SeveritySummaryPills.svelte';
import { appendEnvParam } from '$lib/stores/environment';
import { watchJob } from '$lib/utils/sse-fetch';
import { Loader2, ShieldCheck, CheckCircle2, XCircle, Ban, Box } from 'lucide-svelte';
interface Props {
open: boolean;
envId: number | null;
/** Environment name shown in the title. */
envName?: string | null;
/** Called once a scan finishes (or is cancelled) so the parent can refresh findings. */
onComplete?: () => void;
}
let { open = $bindable(), envId, envName = null, onComplete }: Props = $props();
type Phase = 'idle' | 'scanning' | 'done' | 'error' | 'cancelled';
type ScannerCount = { scanner: 'grype' | 'trivy'; critical: number; high: number; medium: number; low: number; durationMs: number };
type LogEntry = { imageName: string; scanners: ScannerCount[]; ok: boolean; error?: string };
// Fixed severity columns so C/H/M/L align vertically across every log row.
const SEVERITY_COLS = [
{ key: 'critical', on: 'bg-red-500/15 text-red-500 border-red-500/40', title: 'Critical' },
{ key: 'high', on: 'bg-orange-500/15 text-orange-500 border-orange-500/40', title: 'High' },
{ key: 'medium', on: 'bg-yellow-500/15 text-yellow-600 border-yellow-500/40', title: 'Medium' },
{ key: 'low', on: 'bg-blue-500/15 text-blue-500 border-blue-500/40', title: 'Low' }
] as const;
// Which scanners to show as columns, in fixed order.
const SCANNERS = [
{ id: 'grype', label: 'Grype' },
{ id: 'trivy', label: 'Trivy' }
] as const;
function scannerCounts(entry: LogEntry, id: 'grype' | 'trivy'): ScannerCount | undefined {
return entry.scanners.find((s) => s.scanner === id);
}
let phase = $state<Phase>('idle');
let current = $state(0);
let total = $state(0);
let currentImage = $state('');
let scanned = $state(0);
let failed = $state(0);
let errorMessage = $state('');
let jobId = $state<string | null>(null);
let cancelling = $state(false);
// Live scan log (newest at bottom) and a running severity tally across all images.
let log = $state<LogEntry[]>([]);
let tally = $state({ critical: 0, high: 0, medium: 0, low: 0 });
// Auto-scroll: follow the bottom, but pause if the user scrolls up manually.
let logEl = $state<HTMLDivElement | null>(null);
let stickToBottom = $state(true);
function onLogScroll() {
if (!logEl) return;
// Consider "at bottom" within a small threshold to tolerate sub-pixel rounding.
const atBottom = logEl.scrollHeight - logEl.scrollTop - logEl.clientHeight < 24;
stickToBottom = atBottom;
}
// When new log entries arrive and we're sticky, scroll to the bottom.
$effect(() => {
// Depend on log length so this re-runs as entries are appended.
log.length;
if (stickToBottom && logEl) {
logEl.scrollTop = logEl.scrollHeight;
}
});
let percent = $derived(total > 0 ? Math.round((current / total) * 100) : 0);
let totalFindings = $derived(tally.critical + tally.high + tally.medium + tally.low);
// Start a scan whenever the modal is opened fresh.
let started = false;
$effect(() => {
if (open && !started) {
started = true;
void runScan();
} else if (!open) {
started = false;
}
});
function reset() {
phase = 'scanning';
current = 0;
total = 0;
currentImage = '';
scanned = 0;
failed = 0;
errorMessage = '';
jobId = null;
cancelling = false;
log = [];
tally = { critical: 0, high: 0, medium: 0, low: 0 };
}
async function runScan() {
reset();
try {
const res = await fetch(appendEnvParam('/api/vulnerabilities/scan-all', envId), { method: 'POST' });
if (!res.ok) {
const data = await res.json().catch(() => ({}));
throw new Error(data.error || 'Failed to start scan');
}
const startResp = await res.json();
jobId = startResp.jobId;
const result: any = await watchJob(jobId!, (line) => {
if (line.event === 'progress') {
const d = line.data as { current?: number; total?: number; imageName?: string };
current = d.current ?? current;
total = d.total ?? total;
if (d.imageName) currentImage = d.imageName;
} else if (line.event === 'scanned') {
const d = line.data as LogEntry & { current?: number };
// Append to the log (newest at bottom, so auto-scroll follows the latest)
log = [...log, { imageName: d.imageName, scanners: d.scanners ?? [], ok: d.ok, error: d.error }];
// Accumulate the running tally (use the max across scanners per image
// to avoid double-counting grype+trivy overlap — take the larger set).
if (d.scanners && d.scanners.length) {
const worst = d.scanners.reduce((a, b) =>
(b.critical + b.high + b.medium + b.low) > (a.critical + a.high + a.medium + a.low) ? b : a
);
tally = {
critical: tally.critical + worst.critical,
high: tally.high + worst.high,
medium: tally.medium + worst.medium,
low: tally.low + worst.low
};
}
}
});
scanned = result?.scanned ?? scanned;
failed = result?.failed ?? failed;
phase = result?.cancelled ? 'cancelled' : 'done';
onComplete?.();
} catch (e) {
errorMessage = e instanceof Error ? e.message : 'Scan failed';
phase = 'error';
}
}
async function cancel() {
if (!jobId || cancelling) return;
cancelling = true;
try {
await fetch(`/api/jobs/${jobId}`, { method: 'DELETE' });
} catch {
// best-effort; the loop checks the flag between images
}
}
function close() {
open = false;
}
</script>
<Dialog.Root bind:open>
<Dialog.Content
class="sm:max-w-2xl h-[85vh] max-h-[720px] flex flex-col"
onEscapeKeydown={(e) => {
// While scanning, ESC cancels the operation instead of closing the modal.
if (phase === 'scanning') { e.preventDefault(); cancel(); }
}}
onInteractOutside={(e) => {
if (phase === 'scanning') { e.preventDefault(); cancel(); }
}}
>
<Dialog.Header class="shrink-0">
<Dialog.Title class="flex items-center gap-2">
<ShieldCheck class="w-5 h-5" />
<span>Scanning images{#if envName}&nbsp;on <span class="text-amber-500">{envName}</span>{/if}</span>
</Dialog.Title>
<Dialog.Description>
{#if phase === 'scanning'}
Running the configured scanner across all images. This can take a while.
{:else if phase === 'done'}
Scan complete.
{:else if phase === 'cancelled'}
Scan cancelled.
{:else if phase === 'error'}
The scan did not finish successfully.
{/if}
</Dialog.Description>
</Dialog.Header>
<div class="py-2 flex-1 min-h-0 flex flex-col gap-3">
<div class="shrink-0 space-y-3">
{#if phase === 'scanning'}
<div class="flex items-center gap-2 text-sm">
<Loader2 class="w-4 h-4 animate-spin text-muted-foreground shrink-0" />
{#if currentImage}
<span class="text-muted-foreground shrink-0">Scanning</span>
<span class="font-mono text-xs truncate" title={currentImage}>{currentImage}</span>
<span class="text-muted-foreground shrink-0 tabular-nums">({current}/{total})</span>
{:else}
<span class="text-muted-foreground">Preparing scan…</span>
{/if}
</div>
<Progress value={percent} />
<div class="flex items-center justify-between">
<div class="flex flex-wrap items-center gap-1.5">
<SeveritySummaryPills counts={tally} hideZero size="xs" />
{#if totalFindings === 0 && log.length > 0}
<span class="text-2xs text-muted-foreground">No findings yet</span>
{/if}
</div>
<span class="text-xs text-muted-foreground tabular-nums">{percent}%</span>
</div>
{:else}
<!-- Terminal states: summary line -->
<div class="flex items-center gap-2 text-sm">
{#if phase === 'done'}
<CheckCircle2 class="w-5 h-5 text-emerald-500 shrink-0" />
{:else if phase === 'cancelled'}
<Ban class="w-5 h-5 text-amber-500 shrink-0" />
{:else}
<XCircle class="w-5 h-5 text-destructive shrink-0" />
{/if}
{#if phase === 'error'}
<span class="text-destructive">{errorMessage}</span>
{:else}
<span>
Scanned <span class="font-medium">{scanned}</span> image{scanned === 1 ? '' : 's'}{failed > 0 ? `, ${failed} failed` : ''}
· <span class="font-medium">{totalFindings}</span> finding{totalFindings === 1 ? '' : 's'}.
</span>
{/if}
</div>
{#if phase !== 'error'}
<SeveritySummaryPills counts={tally} size="xs" />
{/if}
{/if}
</div>
<!-- Live scan log (fills remaining height, scrolls, auto-follows bottom) -->
{#if log.length > 0}
<div
bind:this={logEl}
onscroll={onLogScroll}
class="flex-1 min-h-0 overflow-auto rounded-md border divide-y divide-border"
>
{#each log as entry, i (i)}
<div class="flex items-center gap-2 px-2.5 py-1.5 text-xs">
{#if entry.ok}
<Box class="w-3.5 h-3.5 text-muted-foreground shrink-0" />
{:else}
<XCircle class="w-3.5 h-3.5 text-destructive shrink-0" />
{/if}
<span class="font-mono truncate flex-1" title={entry.imageName}>{entry.imageName}</span>
{#if entry.ok}
{@const hasFindings = entry.scanners.some((s) => s.critical + s.high + s.medium + s.low > 0)}
{#if hasFindings}
<div class="shrink-0 flex items-center gap-4">
{#each SCANNERS as sc}
{@const counts = scannerCounts(entry, sc.id)}
<div class="flex items-center gap-1.5">
<span class="text-2xs text-muted-foreground w-9 text-right">{sc.label}</span>
{#each SEVERITY_COLS as col}
{@const n = counts ? counts[col.key] : 0}
<span
title={col.title}
class="inline-flex items-center justify-center w-6 h-4 rounded border text-2xs tabular-nums font-medium
{n > 0 ? col.on : 'text-muted-foreground/30 border-transparent'}"
>{n}</span>
{/each}
</div>
{/each}
</div>
{:else}
<span class="text-2xs text-muted-foreground shrink-0">clean</span>
{/if}
{:else}
<span class="text-2xs text-destructive shrink-0 truncate max-w-40" title={entry.error}>failed</span>
{/if}
</div>
{/each}
</div>
{:else}
<!-- Reserve space so the dialog height stays fixed before entries arrive -->
<div class="flex-1 min-h-0 rounded-md border flex items-center justify-center text-xs text-muted-foreground">
Waiting for first result…
</div>
{/if}
</div>
<Dialog.Footer class="shrink-0">
{#if phase === 'scanning'}
<Button variant="destructive" onclick={cancel} disabled={cancelling || !jobId}>
{#if cancelling}
<Loader2 class="w-3.5 h-3.5 animate-spin mr-1.5" />
Cancelling…
{:else}
<Ban class="w-3.5 h-3.5 mr-1.5" />
Cancel
{/if}
</Button>
{:else}
<Button onclick={close}>Close</Button>
{/if}
</Dialog.Footer>
</Dialog.Content>
</Dialog.Root>
+42
View File
@@ -0,0 +1,42 @@
/**
* Prometheus metrics endpoint (#339), served at /metrics (Prometheus convention).
*
* Gating:
* - Disabled unless EXPORT_METRICS=true → 404 (looks like the route doesn't exist).
* - When app auth is DISABLED, /metrics is public (mirrors /api/health), so a
* trusted-network scrape works out of the box.
* - When app auth is ENABLED, a valid session OR API bearer token is required
* (authorize() handles both) → 401 otherwise. Prometheus scrapes with a
* `authorization: { credentials: <token> }`.
*/
import { authorize } from '$lib/server/authorize';
import { METRICS_ENABLED, renderMetrics, metricsContentType } from '$lib/server/metrics';
import type { RequestHandler } from './$types';
export const GET: RequestHandler = async ({ cookies }) => {
if (!METRICS_ENABLED) {
return new Response('Not found', { status: 404 });
}
const auth = await authorize(cookies);
if (auth.authEnabled && !auth.isAuthenticated) {
return new Response('Unauthorized', {
status: 401,
headers: { 'WWW-Authenticate': 'Bearer' }
});
}
try {
const body = await renderMetrics();
return new Response(body, {
status: 200,
headers: { 'Content-Type': metricsContentType }
});
} catch (error) {
console.error('[metrics] render failed:', error);
return new Response('# metrics collection failed\n', {
status: 500,
headers: { 'Content-Type': 'text/plain' }
});
}
};
@@ -79,7 +79,12 @@
const fullSourceImageName = $derived(() => {
const tagToUse = sourceTag.trim() || 'latest';
const imageWithTag = imageName.includes(':') ? imageName : `${imageName}:${tagToUse}`;
// "Already tagged" means a colon AFTER the last slash (or a @digest) — NOT the
// colon in a registry host:port like registry.example.com:5000/myimage, which
// would otherwise swallow the real tag and default it to :latest (#1243).
const lastSlash = imageName.lastIndexOf('/');
const alreadyTagged = imageName.lastIndexOf(':') > lastSlash || imageName.includes('@');
const imageWithTag = alreadyTagged ? imageName : `${imageName}:${tagToUse}`;
if (sourceRegistry && !isDockerHub(sourceRegistry)) {
const urlObj = new URL(sourceRegistry.url);
// Include both host and path (e.g., registry.example.com/organization)
@@ -26,6 +26,7 @@
let showExposedPorts = $derived($appSettings.showExposedPorts);
let honorProxyLabels = $derived($appSettings.honorProxyLabels);
let showImageChangelogLinks = $derived($appSettings.showImageChangelogLinks);
let showWhatsNew = $derived($appSettings.showWhatsNew);
let timeFormat = $derived($appSettings.timeFormat);
let dateFormat = $derived($appSettings.dateFormat);
let downloadFormat = $derived($appSettings.downloadFormat);
@@ -351,6 +352,20 @@ services:
</div>
<p class="text-xs text-muted-foreground">Show a release-notes icon next to images with updates available</p>
</div>
<div class="space-y-1">
<div class="flex items-center gap-3">
<Label>Show "What's New"</Label>
<TogglePill
checked={showWhatsNew}
onchange={(checked) => {
appSettings.setShowWhatsNew(checked);
toast.success(checked ? "What's New popup enabled" : "What's New popup disabled");
}}
disabled={!$canAccess('settings', 'edit')}
/>
</div>
<p class="text-xs text-muted-foreground">Show the "What's New" popup after upgrading to a new version</p>
</div>
<div class="space-y-1">
<div class="flex items-center gap-3">
<Label>Compact port display</Label>
@@ -5,7 +5,7 @@
import { Badge } from '$lib/components/ui/badge';
import { Checkbox } from '$lib/components/ui/checkbox';
import { Label } from '$lib/components/ui/label';
import { Search, FolderOpen, CheckCircle2, SkipForward, AlertCircle, FileText, Import, Loader2, Play, HelpCircle } from 'lucide-svelte';
import { Search, FolderOpen, CheckCircle2, SkipForward, AlertCircle, FileText, Import, Loader2, Play, HelpCircle, Lock } from 'lucide-svelte';
import * as Tooltip from '$lib/components/ui/tooltip';
import { toast } from 'svelte-sonner';
import { onMount } from 'svelte';
@@ -23,6 +23,7 @@
envPath: string | null;
sourceDir?: string;
runningOn?: RunningStackInfo[];
unadoptable?: boolean;
}
interface AdoptedStack {
@@ -93,6 +94,7 @@
// Auto-select the environment where stack is already running, otherwise use default
const newSelections = new Map<string, number>();
for (const stack of result.discovered) {
if (stack.unadoptable) continue; // dockhand.adopt=false — not selectable (#998)
const runningEnvId = stack.runningOn?.[0]?.envId;
newSelections.set(stack.composePath, runningEnvId ?? defaultEnvId);
}
@@ -108,15 +110,20 @@
const selectedCount = $derived(stackSelections.size);
// Stacks marked dockhand.adopt=false can't be selected (#998), so select-all
// reasons over the adoptable subset only.
const adoptableCount = $derived(
result?.discovered.filter((s) => !s.unadoptable).length ?? 0
);
const allSelected = $derived(
result?.discovered.length > 0 &&
stackSelections.size === result.discovered.length
adoptableCount > 0 &&
stackSelections.size === adoptableCount
);
const someSelected = $derived(
stackSelections.size > 0 &&
result?.discovered.length > 0 &&
stackSelections.size < result.discovered.length
stackSelections.size < adoptableCount
);
const defaultEnv = $derived(environments.find(e => e.id === defaultEnvId));
@@ -159,6 +166,7 @@
} else {
const newSelections = new Map<string, number>();
for (const stack of result.discovered) {
if (stack.unadoptable) continue; // dockhand.adopt=false (#998)
// Preserve existing env selection or use default
const existingEnv = stackSelections.get(stack.composePath);
newSelections.set(stack.composePath, existingEnv ?? defaultEnvId);
@@ -386,24 +394,42 @@
{@const stackEnv = stackEnvId ? environments.find(e => e.id === stackEnvId) : null}
<div
class="flex items-start gap-3 p-2 rounded-md border transition-colors
{isSelected(stack.composePath)
{stack.unadoptable
? 'bg-muted/20 border-transparent opacity-60'
: isSelected(stack.composePath)
? 'bg-blue-500/10 border-blue-500/30'
: 'bg-muted/30 border-transparent hover:bg-muted/50'}"
>
<button
type="button"
class="pt-0.5 shrink-0"
disabled={stack.unadoptable}
onclick={() => toggleStack(stack.composePath)}
>
<Checkbox checked={isSelected(stack.composePath)} />
<Checkbox checked={isSelected(stack.composePath)} disabled={stack.unadoptable} />
</button>
<button
type="button"
class="flex-1 min-w-0 text-left"
disabled={stack.unadoptable}
onclick={() => toggleStack(stack.composePath)}
>
<div class="flex items-center gap-2 flex-wrap">
<p class="text-sm font-medium">{stack.name}</p>
{#if stack.unadoptable}
<Badge variant="outline" class="text-xs text-amber-600 dark:text-amber-500 border-amber-300 dark:border-amber-600 gap-1">
<Lock class="w-3 h-3" />
Not adoptable
<Tooltip.Root>
<Tooltip.Trigger>
<HelpCircle class="w-3 h-3 opacity-60" />
</Tooltip.Trigger>
<Tooltip.Content class="max-w-sm">
<p class="text-xs">A container in this stack is labelled <code class="bg-muted px-1 rounded">dockhand.adopt=false</code>, so Dockhand won't adopt it.</p>
</Tooltip.Content>
</Tooltip.Root>
</Badge>
{/if}
{#if stack.runningOn && stack.runningOn.length > 0}
<Badge variant="outline" class="text-xs text-green-600 dark:text-green-500 border-green-300 dark:border-green-600 gap-1">
<Play class="w-3 h-3" />
+162 -5
View File
@@ -15,10 +15,11 @@
import * as Popover from '$lib/components/ui/popover';
import { formatBytes } from '$lib/utils/format';
import MultiSelectFilter from '$lib/components/MultiSelectFilter.svelte';
import { Play, Square, Trash2, Plus, ArrowBigDown, Search, Pencil, ExternalLink, GitBranch, RefreshCw, Loader2, FileCode, FileText, FileOutput, Box, RotateCcw, ScrollText, Terminal, Eye, Network, HardDrive, Heart, HeartPulse, HeartOff, ChevronsUpDown, ChevronsDownUp, Rocket, AlertTriangle, X, Layers, Pause, CircleDashed, Skull, FolderOpen, Variable, Clock, RotateCw, Import, Ship, Cable, LayoutPanelLeft, Rows3, GripVertical, Globe } from 'lucide-svelte';
import { Play, Square, Trash2, Plus, ArrowBigDown, Search, Pencil, ExternalLink, GitBranch, RefreshCw, Loader2, FileCode, FileText, FileOutput, Box, RotateCcw, ScrollText, Terminal, Eye, Network, HardDrive, Heart, HeartPulse, HeartOff, ChevronsUpDown, ChevronsDownUp, Rocket, AlertTriangle, X, Layers, Pause, CircleDashed, Skull, FolderOpen, Variable, Clock, RotateCw, Import, Ship, Cable, LayoutPanelLeft, Rows3, GripVertical, Globe, CircleArrowUp, NotepadText } from 'lucide-svelte';
import { formatPorts } from '$lib/utils/port-format';
import { parseCustomUrl } from '$lib/utils/custom-url';
import { extractTraefikUrls } from '$lib/utils/traefik-urls';
import { resolveChangelogUrl } from '$lib/utils/changelog-url';
import { extractPangolinUrls } from '$lib/utils/pangolin-urls';
import { appSettings } from '$lib/stores/settings';
import ConfirmPopover from '$lib/components/ConfirmPopover.svelte';
@@ -31,6 +32,8 @@
import RedeployPopover from './RedeployPopover.svelte';
import ContainerInspectModal from '../containers/ContainerInspectModal.svelte';
import FileBrowserModal from '../containers/FileBrowserModal.svelte';
import BatchUpdateModal from '../containers/BatchUpdateModal.svelte';
import CheckUpdatesButton from '$lib/components/CheckUpdatesButton.svelte';
import LogsPanel from '../logs/LogsPanel.svelte';
import { currentEnvironment, environments, appendEnvParam, clearStaleEnvironment } from '$lib/stores/environment';
import { onDockerEvent, isContainerListChange } from '$lib/stores/events';
@@ -65,6 +68,53 @@
let editingGitStack = $state<any>(null);
let envId = $state<number | null>(null);
// Single-container update (mirrors the containers page action)
let showBatchUpdateModal = $state(false);
let singleUpdateContainerId = $state<string | null>(null);
let singleUpdateContainerName = $state<string | null>(null);
let envHasScanning = $state(false);
let envVulnerabilityCriteria = $state<'never' | 'any' | 'critical_high' | 'critical' | 'more_than_current'>('never');
async function loadScannerSettings() {
if (!envId) {
envHasScanning = false;
envVulnerabilityCriteria = 'never';
return;
}
try {
const [scannerRes, updateCheckRes] = await Promise.all([
fetch(`/api/settings/scanner?env=${envId}&settingsOnly=true`),
fetch(`/api/environments/${envId}/update-check`)
]);
if (scannerRes.ok) {
const data = await scannerRes.json();
const settings = data.settings || data;
envHasScanning = settings.scanner !== 'none';
}
if (updateCheckRes.ok) {
const data = await updateCheckRes.json();
envVulnerabilityCriteria = data.settings?.vulnerabilityCriteria || 'never';
}
} catch {
// Non-fatal: fall back to no vuln blocking
}
}
function updateSingleContainer(containerId: string, containerName: string) {
singleUpdateContainerId = containerId;
singleUpdateContainerName = containerName;
showBatchUpdateModal = true;
}
function handleSingleUpdateComplete(results: { success: string[]; failed: string[]; blocked: string[] }) {
if (results.success.length > 0) toast.success(`Updated ${results.success.length} container(s)`);
if (results.failed.length > 0) toast.error(`Failed to update ${results.failed.length} container(s)`);
if (results.blocked.length > 0) toast.warning(`${results.blocked.length} update(s) blocked by vulnerability policy`);
singleUpdateContainerId = null;
singleUpdateContainerName = null;
fetchStacks();
}
// Derived: current environment details for reactive port URL generation
const currentEnvDetails = $derived($environments.find(e => e.id === $currentEnvironment?.id) ?? null);
@@ -690,6 +740,7 @@
initialFetchDone = true;
fetchStacks();
fetchStats();
loadScannerSettings();
} else if (!env) {
// No environment - clear data and stop loading
envId = null;
@@ -1351,6 +1402,11 @@
<RefreshCw class="w-3.5 h-3.5" />
Refresh
</Button>
<CheckUpdatesButton
{envId}
hasPendingUpdates={stacks.some((s) => s.updatesAvailable)}
onComplete={() => fetchStacks()}
/>
<Button
size="sm"
variant="outline"
@@ -1536,18 +1592,19 @@
{@const source = getStackSource(stack.name)}
{#if column.id === 'name'}
{@const systemType = getStackSystemType(stack)}
<span class="flex items-center gap-1 min-w-0 w-full">
{#if source.sourceType !== 'git'}
<!-- Internal stacks (including those needing file location) are clickable -->
<button
type="button"
class="font-medium text-xs hover:text-primary hover:underline cursor-pointer text-left"
class="font-medium text-xs hover:text-primary hover:underline cursor-pointer text-left truncate min-w-0"
onclick={(e) => { e.stopPropagation(); editStack(stack.name); }}
>
{stack.name}
</button>
{:else}
<!-- Git stacks open in GitStackModal instead -->
<span class="font-medium text-xs">{stack.name}</span>
<span class="font-medium text-xs truncate min-w-0">{stack.name}</span>
{/if}
{#if systemType}
<Tooltip.Root>
@@ -1569,7 +1626,7 @@
{#if stackEnvVarCounts[stack.name]}
<Tooltip.Root>
<Tooltip.Trigger>
<span class="inline-flex items-center gap-0.5 ml-1.5 text-2xs px-1 py-0.5 rounded bg-cyan-100 text-cyan-700 dark:bg-cyan-900/50 dark:text-cyan-300">
<span class="inline-flex items-center gap-0.5 shrink-0 text-2xs px-1 py-0.5 rounded bg-cyan-100 text-cyan-700 dark:bg-cyan-900/50 dark:text-cyan-300">
<Variable class="w-2.5 h-2.5" />
{stackEnvVarCounts[stack.name]}
</span>
@@ -1579,6 +1636,61 @@
</Tooltip.Content>
</Tooltip.Root>
{/if}
{#if stack.updatesAvailable && $appSettings.highlightUpdates}
{#if source.sourceType === 'git' && source.gitStack}
<!-- Git stack: updates applied by deploying from the repo -->
<GitDeployProgressPopover
stackId={source.gitStack.id}
stackName={stack.name}
onComplete={fetchStacks}
>
{#snippet children()}
<button
type="button"
title="Image updates available — deploy from Git to apply"
onclick={(e) => e.stopPropagation()}
class="shrink-0 rounded hover:bg-muted transition-colors cursor-pointer inline-flex items-center gap-0.5"
>
<CircleArrowUp class="w-3.5 h-3.5 text-amber-500" />
{#if (stack.updateCount ?? 0) > 1}
<span class="text-2xs font-medium text-amber-500 leading-none">{stack.updateCount}</span>
{/if}
</button>
{/snippet}
</GitDeployProgressPopover>
{:else if source.sourceType !== 'external' && $canAccess('stacks', 'start')}
<!-- Internal stack: updates applied by redeploying with pull -->
<RedeployPopover
stackName={stack.name}
{envId}
side="bottom"
align="start"
disabled={stackActionLoading === stack.name}
onDeploy={(options) => redeployStack(stack.name, options)}
>
{#snippet children()}
<CircleArrowUp class="w-3.5 h-3.5 text-amber-500 shrink-0" />
{#if (stack.updateCount ?? 0) > 1}
<span class="ml-0.5 text-2xs font-medium text-amber-500 leading-none">{stack.updateCount}</span>
{/if}
{/snippet}
</RedeployPopover>
{:else}
<!-- External/untracked stack: no stack-level action, passive indicator -->
<Tooltip.Root>
<Tooltip.Trigger class="inline-flex items-center gap-0.5">
<CircleArrowUp class="w-3.5 h-3.5 text-amber-500" />
{#if (stack.updateCount ?? 0) > 1}
<span class="text-2xs font-medium text-amber-500 leading-none">{stack.updateCount}</span>
{/if}
</Tooltip.Trigger>
<Tooltip.Content class="whitespace-nowrap">
External stack - update possible for individual containers only.
</Tooltip.Content>
</Tooltip.Root>
{/if}
{/if}
</span>
{:else if column.id === 'source'}
{#if source.sourceType === 'git'}
<span
@@ -1946,7 +2058,42 @@
<div class="p-3 rounded-lg bg-background border text-xs">
<div class="flex items-center gap-2 mb-2">
<Box class="w-4 h-4 shrink-0 {container.state === 'running' ? 'text-emerald-500' : 'text-muted-foreground'}" />
<span class="font-medium truncate flex-1" title={container.name}>{container.service}</span>
<span class="font-medium truncate" title={container.name}>{container.service}</span>
{#if container.updateAvailable && $appSettings.highlightUpdates}
<!-- Update arrow + changelog link read as one pair — keep them tight. -->
<span class="inline-flex items-center gap-0.5 shrink-0">
{#if $canAccess('containers', 'manage')}
<ConfirmPopover
action="Update"
itemType="container"
itemName={container.name}
position="left"
title="Update available - click to update"
onConfirm={() => updateSingleContainer(container.id, container.name)}
>
{#snippet children({ open })}
<CircleArrowUp class="w-3.5 h-3.5 shrink-0 text-amber-500 cursor-pointer" />
{/snippet}
</ConfirmPopover>
{/if}
{#if $appSettings.showImageChangelogLinks}
{@const changelogUrl = resolveChangelogUrl(container.image, container.labels)}
{#if changelogUrl}
<a
href={changelogUrl}
target="_blank"
rel="noopener noreferrer"
onclick={(e) => e.stopPropagation()}
title="View changelog"
class="shrink-0 text-amber-500 hover:text-amber-400 transition-colors"
>
<NotepadText class="w-3 h-3" />
</a>
{/if}
{/if}
</span>
{/if}
<span class="flex-1"></span>
{#if container.health}
<span title={container.health}>
{#if container.health === 'healthy'}
@@ -2408,6 +2555,16 @@
onclose={() => showFileBrowserModal = false}
/>
<BatchUpdateModal
bind:open={showBatchUpdateModal}
containerIds={singleUpdateContainerId ? [singleUpdateContainerId] : []}
containerNames={singleUpdateContainerId && singleUpdateContainerName ? new Map([[singleUpdateContainerId, singleUpdateContainerName]]) : new Map()}
{envId}
vulnerabilityCriteria={envHasScanning ? envVulnerabilityCriteria : 'never'}
onClose={() => { showBatchUpdateModal = false; singleUpdateContainerId = null; singleUpdateContainerName = null; }}
onComplete={handleSingleUpdateComplete}
/>
<BatchOperationModal
bind:open={showBatchOpModal}
title={batchOpTitle}
+6 -2
View File
@@ -10,6 +10,8 @@
stackName: string;
envId: number | null;
disabled?: boolean;
side?: 'top' | 'bottom';
align?: 'start' | 'center' | 'end';
onDeploy: (options: { pull: boolean; build: boolean; forceRecreate: boolean }) => Promise<void>;
children: Snippet;
}
@@ -18,6 +20,8 @@
stackName,
envId,
disabled = false,
side = 'top',
align = 'end',
onDeploy,
children
}: Props = $props();
@@ -61,8 +65,8 @@
</Popover.Trigger>
<Popover.Content
class="w-56 p-3 z-[200]"
side="top"
align="end"
{side}
{align}
sideOffset={8}
>
<div class="space-y-3">
+1 -8
View File
@@ -3,6 +3,7 @@
import { Badge } from '$lib/components/ui/badge';
import { Package, Star, Download, Loader2, ExternalLink } from 'lucide-svelte';
import type { TemplateItem } from '../api/templates/+server';
import { renderDescription } from '$lib/utils/template-description';
interface Props {
template: TemplateItem;
@@ -24,14 +25,6 @@
return String(pulls);
}
// Convert markdown links [text](url) to HTML <a> tags, strip other HTML
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, '<a href="$2" target="_blank" rel="noopener" class="text-primary hover:underline">$1</a>')
.trim();
}
</script>
<button
Loaded 100 of 101 files, more files were not shown because too many files have changed in this diff. Show more