1.0.37
This commit is contained in:
101 files changed
+5011
-556
No files matched your search
+1
-1
@@ -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" \
|
||||
|
||||
+44
-19
@@ -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
@@ -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",
|
||||
|
||||
@@ -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
@@ -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>
|
||||
@@ -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"
|
||||
|
||||
@@ -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} 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}
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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"
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)" },
|
||||
|
||||
@@ -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.
|
||||
*/
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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}`;
|
||||
}
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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}"`
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -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
@@ -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);
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,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) {
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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)
|
||||
}));
|
||||
}
|
||||
@@ -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'
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
// 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;
|
||||
}
|
||||
@@ -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]
|
||||
};
|
||||
}
|
||||
@@ -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, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"');
|
||||
}
|
||||
|
||||
// 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();
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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()
|
||||
};
|
||||
}
|
||||
@@ -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
|
||||
};
|
||||
};
|
||||
@@ -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)) {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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,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);
|
||||
}
|
||||
};
|
||||
@@ -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 });
|
||||
}
|
||||
};
|
||||
@@ -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)
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
@@ -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()}`;
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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 -->
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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,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)) {
|
||||
|
||||
@@ -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} 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>
|
||||
@@ -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" />
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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
Reference in new issue
Block a user