Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c718ac9a84 | ||
|
|
685132ff89 | ||
|
|
ce12d7ae9f |
+15
-2
@@ -37,7 +37,7 @@ RUN APKO_ARCH=$([ "$TARGETARCH" = "arm64" ] && echo "aarch64" || echo "x86_64")
|
||||
" - busybox" \
|
||||
" - tzdata" \
|
||||
" - docker-cli" \
|
||||
" - docker-compose=5.1.4-r5" \
|
||||
" - docker-compose=5.2.0-r0" \
|
||||
" - docker-cli-buildx" \
|
||||
" - sqlite" \
|
||||
" - postgresql-client" \
|
||||
@@ -163,10 +163,23 @@ RUN chmod +x ./scripts/*.sh ./scripts/**/*.sh 2>/dev/null || true
|
||||
RUN mkdir -p /home/dockhand/.dockhand/stacks /app/data \
|
||||
&& chown dockhand:dockhand /app/data /home/dockhand /home/dockhand/.dockhand /home/dockhand/.dockhand/stacks
|
||||
|
||||
# OCI image annotations (#1217) — lets tooling map this image back to its
|
||||
# source repo (image registry name differs from GitHub repo name).
|
||||
LABEL org.opencontainers.image.source="https://github.com/Finsys/dockhand" \
|
||||
org.opencontainers.image.url="https://dockhand.pro" \
|
||||
org.opencontainers.image.title="Dockhand" \
|
||||
org.opencontainers.image.description="Docker management" \
|
||||
org.opencontainers.image.vendor="Finsys" \
|
||||
org.opencontainers.image.licenses="BUSL-1.1"
|
||||
|
||||
EXPOSE 3000
|
||||
|
||||
HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \
|
||||
CMD curl -f http://localhost:${PORT:-3000}/ || exit 1
|
||||
CMD if [ "$(echo "${HTTPS_MODE:-off}" | tr '[:upper:]' '[:lower:]')" = "on" ]; then \
|
||||
curl -fsk https://localhost:${PORT:-3000}/ || exit 1; \
|
||||
else \
|
||||
curl -fs http://localhost:${PORT:-3000}/ || exit 1; \
|
||||
fi
|
||||
|
||||
ENTRYPOINT ["/sbin/tini", "--", "/usr/local/bin/docker-entrypoint.sh"]
|
||||
CMD []
|
||||
|
||||
+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)})
|
||||
}
|
||||
|
||||
|
||||
+9
-5
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "dockhand",
|
||||
"private": true,
|
||||
"version": "1.0.34",
|
||||
"version": "1.0.37",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "npx vite dev",
|
||||
@@ -80,15 +80,16 @@
|
||||
"devalue": "5.8.1",
|
||||
"drizzle-orm": "0.45.2",
|
||||
"fast-xml-parser": "5.7.3",
|
||||
"js-yaml": "4.1.1",
|
||||
"js-yaml": "4.2.0",
|
||||
"ldapts": "8.1.3",
|
||||
"nodemailer": "8.0.9",
|
||||
"nodemailer": "9.0.1",
|
||||
"otpauth": "9.4.1",
|
||||
"postgres": "3.4.8",
|
||||
"prom-client": "15.1.3",
|
||||
"qrcode": "1.5.4",
|
||||
"rollup": "4.60.0",
|
||||
"svelte-sonner": "1.0.7",
|
||||
"undici": "7.24.5",
|
||||
"undici": "7.28.0",
|
||||
"ws": "8.21.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
@@ -103,12 +104,15 @@
|
||||
"@types/better-sqlite3": "^7.6.12",
|
||||
"@types/js-yaml": "^4.0.9",
|
||||
"@types/node": "^22.10.0",
|
||||
"@types/nodemailer": "7.0.11",
|
||||
"@types/nodemailer": "8.0.1",
|
||||
"@types/qrcode": "^1.5.6",
|
||||
"@types/sarif": "^2.1.7",
|
||||
"@types/ws": "^8.5.13",
|
||||
"@xterm/addon-fit": "^0.11.0",
|
||||
"@xterm/addon-web-links": "^0.12.0",
|
||||
"@xterm/xterm": "^6.0.0",
|
||||
"ajv": "^8.20.0",
|
||||
"ajv-draft-04": "^1.0.0",
|
||||
"autoprefixer": "^10.4.23",
|
||||
"bits-ui": "2.15.4",
|
||||
"clsx": "^2.1.1",
|
||||
|
||||
@@ -17,14 +17,19 @@ import { readFileSync } from 'node:fs';
|
||||
import { WebSocketServer } from 'ws';
|
||||
import { handler } from './build/handler.js';
|
||||
|
||||
// Patch console to prepend ISO timestamps
|
||||
// Patch console to prepend an ISO timestamp and a log level (#1166), e.g.
|
||||
// 2026-06-11T12:34:56.789Z INFO ...
|
||||
// 2026-06-11T12:34:56.789Z WARN ...
|
||||
// 2026-06-11T12:34:56.789Z ERROR ...
|
||||
const _log = console.log;
|
||||
const _error = console.error;
|
||||
const _warn = console.warn;
|
||||
const _info = console.info;
|
||||
const ts = () => new Date().toISOString();
|
||||
console.log = (...args) => _log(ts(), ...args);
|
||||
console.error = (...args) => _error(ts(), ...args);
|
||||
console.warn = (...args) => _warn(ts(), ...args);
|
||||
console.log = (...args) => _log(ts(), 'INFO ', ...args);
|
||||
console.info = (...args) => _info(ts(), 'INFO ', ...args);
|
||||
console.warn = (...args) => _warn(ts(), 'WARN ', ...args);
|
||||
console.error = (...args) => _error(ts(), 'ERROR', ...args);
|
||||
|
||||
const PORT = parseInt(process.env.PORT || '3000', 10);
|
||||
const HOST = process.env.HOST || '0.0.0.0';
|
||||
@@ -408,6 +413,13 @@ async function handleTerminalConnection(ws, url, connId) {
|
||||
ws.on('close', () => {
|
||||
wsConnections.delete(connId);
|
||||
});
|
||||
|
||||
// Without an 'error' listener, an emitted socket error (abrupt disconnect,
|
||||
// ECONNRESET) is re-thrown as an uncaught exception and crashes the process.
|
||||
ws.on('error', (err) => {
|
||||
console.error('[Terminal WS] Connection error:', err.message);
|
||||
wsConnections.delete(connId);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -480,6 +492,13 @@ function handleEdgeExec(ws, connId, containerId, shell, user, environmentId) {
|
||||
wsConnections.delete(connId);
|
||||
});
|
||||
|
||||
// An unhandled 'error' event would crash the process; log and clean up.
|
||||
ws.on('error', (err) => {
|
||||
console.error('[Edge exec WS] Connection error:', err.message);
|
||||
edgeExecSessions.delete(execId);
|
||||
wsConnections.delete(connId);
|
||||
});
|
||||
|
||||
wsConnections.set(connId, { ws });
|
||||
}
|
||||
|
||||
|
||||
+45
-7
@@ -58,6 +58,38 @@ html {
|
||||
font-size: calc(12px * var(--grid-font-size-scale, 1)) !important;
|
||||
}
|
||||
|
||||
/* Grid action icons (#1072) — size is independent of grid font size.
|
||||
* Driven by --action-icon-size set from Settings > General. Default 12px
|
||||
* matches the historical Tailwind w-3 size; users can bump up for high-DPI. */
|
||||
.grid-action-icon {
|
||||
width: var(--action-icon-size, 12px) !important;
|
||||
height: var(--action-icon-size, 12px) !important;
|
||||
}
|
||||
|
||||
/* Colored action icons (#1072) — opt-in via Settings > General.
|
||||
* When html.colored-actions is set, semantic .grid-action-* classes paint
|
||||
* their icons. Default (no class) leaves them muted-foreground gray. */
|
||||
html.colored-actions .grid-action-start { color: rgb(22 163 74); }
|
||||
html.colored-actions.dark .grid-action-start { color: rgb(74 222 128); }
|
||||
html.colored-actions .grid-action-stop { color: rgb(217 119 6); }
|
||||
html.colored-actions.dark .grid-action-stop { color: rgb(251 191 36); }
|
||||
html.colored-actions .grid-action-pause { color: rgb(217 119 6); }
|
||||
html.colored-actions.dark .grid-action-pause { color: rgb(251 191 36); }
|
||||
html.colored-actions .grid-action-restart { color: rgb(37 99 235); }
|
||||
html.colored-actions.dark .grid-action-restart { color: rgb(96 165 250); }
|
||||
html.colored-actions .grid-action-delete { color: rgb(220 38 38); }
|
||||
html.colored-actions.dark .grid-action-delete { color: rgb(248 113 113); }
|
||||
html.colored-actions .grid-action-edit { color: rgb(37 99 235); }
|
||||
html.colored-actions.dark .grid-action-edit { color: rgb(96 165 250); }
|
||||
html.colored-actions .grid-action-info { color: rgb(71 85 105); }
|
||||
html.colored-actions.dark .grid-action-info { color: rgb(148 163 184); }
|
||||
html.colored-actions .grid-action-logs { color: rgb(37 99 235); }
|
||||
html.colored-actions.dark .grid-action-logs { color: rgb(96 165 250); }
|
||||
html.colored-actions .grid-action-terminal { color: rgb(22 163 74); }
|
||||
html.colored-actions.dark .grid-action-terminal { color: rgb(74 222 128); }
|
||||
html.colored-actions .grid-action-transfer { color: rgb(37 99 235); }
|
||||
html.colored-actions.dark .grid-action-transfer { color: rgb(96 165 250); }
|
||||
|
||||
/* State badge - width scales with grid font size to stay consistent */
|
||||
.state-badge {
|
||||
width: calc(70px * var(--grid-font-size-scale, 1));
|
||||
@@ -1341,13 +1373,19 @@ html {
|
||||
line-height: 14px;
|
||||
}
|
||||
|
||||
/* Icon animation toggle (#1169): when html.no-icon-animation is set, the
|
||||
common Tailwind animation utilities collapse to no-op. This keeps the
|
||||
layout (spinners still occupy space) but removes the motion. */
|
||||
html.no-icon-animation .animate-spin,
|
||||
html.no-icon-animation .animate-pulse,
|
||||
html.no-icon-animation .animate-bounce,
|
||||
html.no-icon-animation .animate-ping {
|
||||
/* Icon animation toggle (#1169): when html.no-icon-animation is set, kill
|
||||
the ambient/decorative spinners but leave active-operation indicators
|
||||
alone. Lucide icons that ONLY exist as "operation in progress" markers
|
||||
— Loader2 (renders as lucide-loader-circle), RefreshCw, RefreshCcw,
|
||||
RotateCw, RotateCcw — are excluded regardless of the toggle. So is
|
||||
anything explicitly tagged `animate-action`. Permanent ambient
|
||||
spinners (e.g. the "update available" CircleArrowUp glyph in container
|
||||
list rows) still stop. */
|
||||
html.no-icon-animation
|
||||
.animate-spin:not(.lucide-loader-circle):not(.lucide-refresh-cw):not(.lucide-refresh-ccw):not(.lucide-rotate-cw):not(.lucide-rotate-ccw):not(.animate-action),
|
||||
html.no-icon-animation .animate-pulse:not(.animate-action),
|
||||
html.no-icon-animation .animate-bounce:not(.animate-action),
|
||||
html.no-icon-animation .animate-ping:not(.animate-action) {
|
||||
animation: none !important;
|
||||
}
|
||||
|
||||
|
||||
+11
-2
@@ -308,8 +308,9 @@ export const handle: Handle = async ({ event, resolve }) => {
|
||||
let user = await validateSession(event.cookies);
|
||||
let authMethod: 'cookie' | 'bearer' | 'none' = user ? 'cookie' : 'none';
|
||||
|
||||
// If no session, try Bearer token on API routes
|
||||
if (!user && event.url.pathname.startsWith('/api/')) {
|
||||
// If no session, try Bearer token on API routes (and /metrics, which
|
||||
// Prometheus scrapes with a Bearer token when app auth is enabled).
|
||||
if (!user && (event.url.pathname.startsWith('/api/') || event.url.pathname === '/metrics')) {
|
||||
const authHeader = event.request.headers.get('authorization');
|
||||
if (authHeader && authHeader.startsWith('Bearer dh_') && authHeader.length <= 207) {
|
||||
const clientIp = getClientIp(event);
|
||||
@@ -352,6 +353,14 @@ export const handle: Handle = async ({ event, resolve }) => {
|
||||
return requestContext.run(ctx, async () => compressResponse(event.request, await resolve(event)));
|
||||
}
|
||||
|
||||
// /metrics returns a plain 401 (Prometheus scrape) — never a login redirect.
|
||||
if (event.url.pathname === '/metrics') {
|
||||
return new Response('Unauthorized', {
|
||||
status: 401,
|
||||
headers: { 'WWW-Authenticate': 'Bearer' }
|
||||
});
|
||||
}
|
||||
|
||||
// API routes return 401
|
||||
if (event.url.pathname.startsWith('/api/')) {
|
||||
return new Response(
|
||||
|
||||
@@ -0,0 +1,176 @@
|
||||
<script lang="ts">
|
||||
import { onDestroy } from 'svelte';
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import { CircleArrowUp, Check, XCircle } from 'lucide-svelte';
|
||||
import { toast } from 'svelte-sonner';
|
||||
import { appendEnvParam } from '$lib/stores/environment';
|
||||
import { watchJob } from '$lib/utils/sse-fetch';
|
||||
|
||||
export interface UpdateCheckResultItem {
|
||||
containerId: string;
|
||||
containerName: string;
|
||||
imageName: string;
|
||||
}
|
||||
|
||||
export interface FailedCheckItem {
|
||||
containerName: string;
|
||||
imageName: string;
|
||||
error: string;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
envId: number | null;
|
||||
/** When true and no check is running, the button reflects the "updates found" (green) state — e.g. from persisted pending updates loaded on mount. */
|
||||
hasPendingUpdates?: boolean;
|
||||
/** Called when a check finishes with the containers that have updates and any failures. */
|
||||
onComplete?: (result: { withUpdates: UpdateCheckResultItem[]; failed: FailedCheckItem[] }) => void;
|
||||
}
|
||||
|
||||
let { envId, hasPendingUpdates = false, onComplete }: Props = $props();
|
||||
|
||||
type Status = 'idle' | 'checking' | 'found' | 'none' | 'error';
|
||||
let status = $state<Status>('idle');
|
||||
let progress = $state({ checked: 0, total: 0 });
|
||||
let btnEl = $state<HTMLButtonElement | null>(null);
|
||||
let errorTimeout: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
function unlockWidth() {
|
||||
if (btnEl) btnEl.style.minWidth = '';
|
||||
}
|
||||
|
||||
// Reset the button state when the environment changes — a "Latest" result
|
||||
// from one environment must not linger on another.
|
||||
let lastEnvId = $state<number | null>(null);
|
||||
$effect(() => {
|
||||
if (envId !== lastEnvId) {
|
||||
lastEnvId = envId;
|
||||
unlockWidth();
|
||||
status = 'idle';
|
||||
}
|
||||
});
|
||||
|
||||
// Exposed so parents can reset after a related action (e.g. batch update).
|
||||
export function reset() {
|
||||
unlockWidth();
|
||||
status = 'idle';
|
||||
}
|
||||
|
||||
onDestroy(() => {
|
||||
if (errorTimeout) clearTimeout(errorTimeout);
|
||||
});
|
||||
|
||||
// When idle, reflect persisted pending updates as the "found" (green) state.
|
||||
const displayStatus = $derived(status === 'idle' && hasPendingUpdates ? 'found' : status);
|
||||
|
||||
function showFailedChecksToast(failed: FailedCheckItem[], prefix: string) {
|
||||
const details = failed.map((f) => `• ${f.containerName}: ${f.error}`).join('\n');
|
||||
toast.warning(`${prefix} (${failed.length} failed to check)`, {
|
||||
description: details,
|
||||
descriptionClass: 'whitespace-pre-line',
|
||||
class: '!w-[28rem] !max-w-[28rem]',
|
||||
duration: Infinity,
|
||||
action: { label: 'OK', onClick: () => {} }
|
||||
});
|
||||
}
|
||||
|
||||
async function checkForUpdates() {
|
||||
status = 'checking';
|
||||
progress = { checked: 0, total: 0 };
|
||||
|
||||
// Lock button width to prevent layout shift
|
||||
if (btnEl) btnEl.style.minWidth = `${btnEl.offsetWidth}px`;
|
||||
|
||||
const failError = () => {
|
||||
status = 'error';
|
||||
if (errorTimeout) clearTimeout(errorTimeout);
|
||||
errorTimeout = setTimeout(() => { status = 'idle'; }, 3000);
|
||||
unlockWidth();
|
||||
};
|
||||
|
||||
try {
|
||||
const response = await fetch(appendEnvParam('/api/containers/check-updates', envId), {
|
||||
method: 'POST'
|
||||
});
|
||||
if (!response.ok) {
|
||||
failError();
|
||||
return;
|
||||
}
|
||||
const { jobId } = await response.json();
|
||||
|
||||
const data: any = await watchJob(jobId, (line) => {
|
||||
if (line.event === 'progress') {
|
||||
progress = line.data as { checked: number; total: number };
|
||||
}
|
||||
});
|
||||
|
||||
// A failed/malformed job resolves without a results array — treat as error
|
||||
// rather than silently reporting "all up to date".
|
||||
if (!data || !Array.isArray(data.results)) {
|
||||
failError();
|
||||
return;
|
||||
}
|
||||
|
||||
unlockWidth();
|
||||
|
||||
const withUpdates: UpdateCheckResultItem[] = data.results
|
||||
.filter((r: any) => r.hasUpdate && !r.systemContainer && !r.updateDisabled && !r.isLocalImage)
|
||||
.map((r: any) => ({ containerId: r.containerId, containerName: r.containerName, imageName: r.imageName }));
|
||||
const failed: FailedCheckItem[] = data.results
|
||||
.filter((r: any) => r.error && !r.hasUpdate)
|
||||
.map((r: any) => ({ containerName: r.containerName, imageName: r.imageName, error: r.error }));
|
||||
|
||||
if (withUpdates.length === 0) {
|
||||
// Keep the "Latest" status until re-check / env-switch — don't auto-revert (#1019)
|
||||
status = 'none';
|
||||
if (failed.length > 0) {
|
||||
showFailedChecksToast(failed, 'All containers are up to date');
|
||||
} else {
|
||||
toast.success('All containers are up to date');
|
||||
}
|
||||
} else {
|
||||
status = 'found';
|
||||
if (failed.length > 0) {
|
||||
showFailedChecksToast(failed, `${withUpdates.length} update(s) available`);
|
||||
} else {
|
||||
toast.info(`${withUpdates.length} update(s) available`);
|
||||
}
|
||||
}
|
||||
|
||||
onComplete?.({ withUpdates, failed });
|
||||
} catch {
|
||||
failError();
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<Button
|
||||
bind:ref={btnEl}
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onclick={checkForUpdates}
|
||||
disabled={status === 'checking'}
|
||||
title="Check for available updates"
|
||||
class="relative overflow-hidden"
|
||||
>
|
||||
{#if displayStatus === 'checking'}
|
||||
<CircleArrowUp class="w-3.5 h-3.5 animate-spin" />
|
||||
{#if progress.total > 0}
|
||||
<span class="tabular-nums">Checking {String(progress.checked).padStart(String(progress.total).length, ' ')}/{progress.total}</span>
|
||||
<div
|
||||
class="absolute bottom-0 left-0 h-px bg-foreground transition-[width] duration-150 ease-out"
|
||||
style="width: {(progress.checked / progress.total) * 100}%"
|
||||
></div>
|
||||
{:else}
|
||||
Check for updates
|
||||
{/if}
|
||||
{:else if displayStatus === 'none' || displayStatus === 'found'}
|
||||
<Check class="w-3.5 h-3.5 mr-1 text-green-600" />
|
||||
Check for updates
|
||||
{:else if displayStatus === 'error'}
|
||||
<XCircle class="w-3.5 h-3.5 mr-1 text-destructive" />
|
||||
Check for updates
|
||||
{:else}
|
||||
<CircleArrowUp class="w-3.5 h-3.5" />
|
||||
Check for updates
|
||||
{/if}
|
||||
</Button>
|
||||
@@ -0,0 +1,34 @@
|
||||
<script lang="ts">
|
||||
import { Label } from '$lib/components/ui/label';
|
||||
import { TogglePill } from '$lib/components/ui/toggle-pill';
|
||||
import { themeStore } from '$lib/stores/theme';
|
||||
import { authStore } from '$lib/stores/auth';
|
||||
import { toast } from 'svelte-sonner';
|
||||
|
||||
interface Props {
|
||||
userId?: number; // omit for global default (login page / auth-disabled)
|
||||
}
|
||||
|
||||
let { userId }: Props = $props();
|
||||
|
||||
const skipApply = $derived($authStore.loading ? true : ($authStore.authEnabled && !userId));
|
||||
|
||||
let checked = $state(false);
|
||||
$effect(() => {
|
||||
checked = $themeStore.coloredActionButtons;
|
||||
});
|
||||
|
||||
function onToggle(value: boolean) {
|
||||
checked = value;
|
||||
themeStore.setPreference('coloredActionButtons', value, userId, skipApply);
|
||||
toast.success(value ? 'Action buttons colored' : 'Action buttons reset to default');
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="space-y-1">
|
||||
<div class="flex items-center gap-3">
|
||||
<Label>Colored grid buttons</Label>
|
||||
<TogglePill {checked} onchange={onToggle} />
|
||||
</div>
|
||||
<p class="text-xs text-muted-foreground">Use semantic colors instead of muted gray</p>
|
||||
</div>
|
||||
@@ -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>
|
||||
@@ -1,10 +1,10 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { Sun, Moon, Type, AArrowUp, Table, Terminal, CodeXml } from 'lucide-svelte';
|
||||
import { Sun, Moon, Type, AArrowUp, Table, Terminal, CodeXml, MousePointerClick } from 'lucide-svelte';
|
||||
import * as Select from '$lib/components/ui/select';
|
||||
import { Label } from '$lib/components/ui/label';
|
||||
import { lightThemes, darkThemes, fonts, monospaceFonts } from '$lib/themes';
|
||||
import { themeStore, applyTheme, type FontSize } from '$lib/stores/theme';
|
||||
import { themeStore, applyTheme, type FontSize, type ActionIconSize } from '$lib/stores/theme';
|
||||
import { authStore } from '$lib/stores/auth';
|
||||
|
||||
// Preload all monospace Google Fonts so dropdown previews render correctly
|
||||
@@ -20,6 +20,13 @@
|
||||
{ id: 'xlarge', name: 'Extra Large' }
|
||||
];
|
||||
|
||||
const actionIconSizes: { id: ActionIconSize; name: string }[] = [
|
||||
{ id: 'small', name: 'Small' },
|
||||
{ id: 'normal', name: 'Normal' },
|
||||
{ id: 'large', name: 'Large' },
|
||||
{ id: 'xlarge', name: 'Extra Large' }
|
||||
];
|
||||
|
||||
interface Props {
|
||||
userId?: number; // Pass userId for per-user settings, undefined for global
|
||||
}
|
||||
@@ -39,6 +46,7 @@
|
||||
let selectedFont = $state('system');
|
||||
let selectedFontSize = $state<FontSize>('normal');
|
||||
let selectedGridFontSize = $state<FontSize>('normal');
|
||||
let selectedActionIconSize = $state<ActionIconSize>('normal');
|
||||
let selectedTerminalFont = $state('system-mono');
|
||||
let selectedEditorFont = $state('system-mono');
|
||||
|
||||
@@ -66,6 +74,7 @@
|
||||
selectedFont = $themeStore.font;
|
||||
selectedFontSize = $themeStore.fontSize;
|
||||
selectedGridFontSize = $themeStore.gridFontSize;
|
||||
selectedActionIconSize = $themeStore.actionIconSize;
|
||||
selectedTerminalFont = $themeStore.terminalFont;
|
||||
selectedEditorFont = $themeStore.editorFont;
|
||||
} else {
|
||||
@@ -79,6 +88,7 @@
|
||||
selectedFont = data.font || 'system';
|
||||
selectedFontSize = data.fontSize || 'normal';
|
||||
selectedGridFontSize = data.gridFontSize || 'normal';
|
||||
selectedActionIconSize = data.actionIconSize || 'normal';
|
||||
selectedTerminalFont = data.terminalFont || 'system-mono';
|
||||
selectedEditorFont = data.editorFont || 'system-mono';
|
||||
}
|
||||
@@ -96,6 +106,7 @@
|
||||
selectedFont = $themeStore.font;
|
||||
selectedFontSize = $themeStore.fontSize;
|
||||
selectedGridFontSize = $themeStore.gridFontSize;
|
||||
selectedActionIconSize = $themeStore.actionIconSize;
|
||||
selectedTerminalFont = $themeStore.terminalFont;
|
||||
selectedEditorFont = $themeStore.editorFont;
|
||||
}
|
||||
@@ -131,6 +142,12 @@
|
||||
await themeStore.setPreference('gridFontSize', value as FontSize, userId, skipApply);
|
||||
}
|
||||
|
||||
async function handleActionIconSizeChange(value: string | undefined) {
|
||||
if (!value) return;
|
||||
selectedActionIconSize = value as ActionIconSize;
|
||||
await themeStore.setPreference('actionIconSize', value as ActionIconSize, userId, skipApply);
|
||||
}
|
||||
|
||||
async function handleTerminalFontChange(value: string | undefined) {
|
||||
if (!value) return;
|
||||
selectedTerminalFont = value;
|
||||
@@ -290,6 +307,30 @@
|
||||
</Select.Root>
|
||||
</div>
|
||||
|
||||
<!-- Grid Buttons Size (#1072) -->
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center gap-2">
|
||||
<MousePointerClick class="w-4 h-4 text-muted-foreground" />
|
||||
<Label>Grid buttons</Label>
|
||||
</div>
|
||||
<Select.Root type="single" value={selectedActionIconSize} onValueChange={handleActionIconSizeChange}>
|
||||
<Select.Trigger class="w-56">
|
||||
{#each actionIconSizes as size}
|
||||
{#if size.id === selectedActionIconSize}
|
||||
<span>{size.name}</span>
|
||||
{/if}
|
||||
{/each}
|
||||
</Select.Trigger>
|
||||
<Select.Content>
|
||||
{#each actionIconSizes as size}
|
||||
<Select.Item value={size.id}>
|
||||
<span>{size.name}</span>
|
||||
</Select.Item>
|
||||
{/each}
|
||||
</Select.Content>
|
||||
</Select.Root>
|
||||
</div>
|
||||
|
||||
<!-- Terminal Font -->
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center gap-2">
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { page } from '$app/stores';
|
||||
import { goto } from '$app/navigation';
|
||||
import * as Sidebar from '$lib/components/ui/sidebar';
|
||||
@@ -23,16 +24,34 @@
|
||||
ClipboardList,
|
||||
Activity,
|
||||
Timer,
|
||||
LibraryBig
|
||||
LibraryBig,
|
||||
CircleArrowUp,
|
||||
Pencil,
|
||||
Check,
|
||||
GripVertical,
|
||||
Eye,
|
||||
EyeOff,
|
||||
RotateCcw
|
||||
} from 'lucide-svelte';
|
||||
import { flip } from 'svelte/animate';
|
||||
import { licenseStore } from '$lib/stores/license';
|
||||
import { authStore, hasAnyAccess } from '$lib/stores/auth';
|
||||
import { selfUpdate } from '$lib/stores/self-update';
|
||||
import { appSettings } from '$lib/stores/settings';
|
||||
import { sidebarPreferencesStore, orderItems } from '$lib/stores/sidebar-preferences';
|
||||
import * as Avatar from '$lib/components/ui/avatar';
|
||||
import * as Tooltip from '$lib/components/ui/tooltip';
|
||||
|
||||
const appVersion = __APP_VERSION__ || 'unknown';
|
||||
const buildCommit = __BUILD_COMMIT__ ?? null;
|
||||
|
||||
onMount(() => {
|
||||
// One-shot Dockhand update check (#1146). Result is cached for the
|
||||
// browser session — the Settings → About page reads the same store.
|
||||
selfUpdate.checkOnce();
|
||||
sidebarPreferencesStore.init();
|
||||
});
|
||||
|
||||
import type { Permissions } from '$lib/stores/auth';
|
||||
|
||||
// TypeScript interface for menu items
|
||||
@@ -57,6 +76,8 @@
|
||||
|
||||
async function handleLogout() {
|
||||
sidebar.setOpenMobile(false);
|
||||
// Per-user layout must not leak to the next user on this browser
|
||||
sidebarPreferencesStore.clearLocal();
|
||||
await authStore.logout();
|
||||
goto('/login');
|
||||
}
|
||||
@@ -109,8 +130,106 @@
|
||||
{ href: '/audit', Icon: ClipboardList, label: 'Audit log', permission: 'audit_logs', enterpriseOnly: true },
|
||||
{ href: '/settings', Icon: Settings, label: 'Settings', permission: 'settings' }
|
||||
] as const;
|
||||
|
||||
// --- Sidebar customization (#1252): reorder + hide/show menu items ---
|
||||
|
||||
let editMode = $state(false);
|
||||
let dragHref = $state<string | null>(null);
|
||||
// Order buffer while dragging - persisted once on drop, not per dragover
|
||||
let draftOrder = $state<string[] | null>(null);
|
||||
|
||||
// Full menu (incl. permission-hidden items) in the user's saved order,
|
||||
// so reordering never loses positions of items the user can't see.
|
||||
const orderedItems = $derived(orderItems(menuItems, draftOrder ?? $sidebarPreferencesStore.order));
|
||||
const hiddenSet = $derived(new Set($sidebarPreferencesStore.hidden));
|
||||
// Permission filter first, then user-hidden filter
|
||||
const editableItems = $derived(orderedItems.filter(canSeeMenuItem));
|
||||
const visibleItems = $derived(editableItems.filter((item) => !hiddenSet.has(item.href)));
|
||||
|
||||
// Edit mode only makes sense expanded - exit when the sidebar collapses
|
||||
$effect(() => {
|
||||
if (sidebar.state === 'collapsed' && editMode) {
|
||||
editMode = false;
|
||||
}
|
||||
});
|
||||
|
||||
function persist(order: string[]) {
|
||||
sidebarPreferencesStore.save({ order, hidden: [...hiddenSet] });
|
||||
}
|
||||
|
||||
function toggleHidden(href: string) {
|
||||
const hidden = new Set(hiddenSet);
|
||||
if (hidden.has(href)) {
|
||||
hidden.delete(href);
|
||||
} else {
|
||||
hidden.add(href);
|
||||
}
|
||||
sidebarPreferencesStore.save({ order: orderedItems.map((i) => i.href), hidden: [...hidden] });
|
||||
}
|
||||
|
||||
/**
|
||||
* Place fromHref before/after toHref in the full order list.
|
||||
* No-ops when the result wouldn't change, which keeps dragover
|
||||
* events from thrashing the list while the pointer hovers a row.
|
||||
*/
|
||||
function moveTo(fromHref: string, toHref: string, before: boolean) {
|
||||
const current = orderedItems.map((i) => i.href);
|
||||
if (!current.includes(fromHref) || !current.includes(toHref)) return;
|
||||
const order = current.filter((h) => h !== fromHref);
|
||||
const insertAt = order.indexOf(toHref) + (before ? 0 : 1);
|
||||
order.splice(insertAt, 0, fromHref);
|
||||
if (order.join('\n') !== current.join('\n')) {
|
||||
draftOrder = order;
|
||||
}
|
||||
}
|
||||
|
||||
function moveBy(href: string, delta: -1 | 1) {
|
||||
const visible = editableItems.map((i) => i.href);
|
||||
const from = visible.indexOf(href);
|
||||
const to = from + delta;
|
||||
if (to < 0 || to >= visible.length) return;
|
||||
moveTo(href, visible[to], delta < 0);
|
||||
if (draftOrder) {
|
||||
persist(draftOrder);
|
||||
draftOrder = null;
|
||||
}
|
||||
}
|
||||
|
||||
function endDrag() {
|
||||
if (draftOrder) {
|
||||
persist(draftOrder);
|
||||
draftOrder = null;
|
||||
}
|
||||
dragHref = null;
|
||||
}
|
||||
|
||||
// Transparent 1x1 drag image: suppresses the browser's ghost snapshot so
|
||||
// the flip animation of the list itself reads as the drag movement.
|
||||
let ghostImg: HTMLImageElement | null = null;
|
||||
function transparentGhost(): HTMLImageElement {
|
||||
if (!ghostImg) {
|
||||
ghostImg = new Image(1, 1);
|
||||
ghostImg.src = 'data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7';
|
||||
}
|
||||
return ghostImg;
|
||||
}
|
||||
</script>
|
||||
|
||||
{#snippet versionTooltip()}
|
||||
<div class="space-y-0.5 text-left">
|
||||
<div class="flex items-center gap-1.5"><svg class="w-4 h-4 shrink-0" viewBox="0 0 24 18" fill="currentColor"><path d="M23.76 8.68c-.26-.18-.86-.58-1.53-.58-.24 0-.48.04-.72.12-.12-.84-.68-1.56-1.34-2.14l-.28-.22-.24.26c-.28.34-.48.72-.56 1.14-.1.42-.06.82.1 1.2-.42.22-.88.36-1.32.42-.24.04-.48.06-.72.06H.78a.77.77 0 0 0-.78.78c-.02 1.46.22 2.9.72 4.24.56 1.44 1.4 2.5 2.5 3.16 1.26.74 3.32 1.16 5.64 1.16.98 0 2-.1 2.98-.3a11.5 11.5 0 0 0 3.3-1.3 9.67 9.67 0 0 0 2.54-2.34c1.16-1.42 1.86-3.02 2.34-4.38h.2c1.22 0 1.98-.48 2.4-.9.28-.26.5-.58.64-.94l.08-.24-.28-.2zM2.74 8.84H4.7c.1 0 .18-.08.18-.18V7.02c0-.1-.08-.18-.18-.18H2.74c-.1 0-.18.08-.18.18v1.64c0 .1.08.18.18.18zm2.72 0h1.96c.1 0 .18-.08.18-.18V7.02c0-.1-.08-.18-.18-.18H5.46c-.1 0-.18.08-.18.18v1.64c0 .1.08.18.18.18zm2.76 0h1.96c.1 0 .18-.08.18-.18V7.02c0-.1-.08-.18-.18-.18H8.22c-.1 0-.18.08-.18.18v1.64c0 .1.08.18.18.18zm2.76 0h1.96c.1 0 .18-.08.18-.18V7.02c0-.1-.08-.18-.18-.18h-1.96c-.1 0-.18.08-.18.18v1.64c0 .1.08.18.18.18zM5.46 6.2h1.96c.1 0 .18-.08.18-.18V4.38c0-.1-.08-.18-.18-.18H5.46c-.1 0-.18.08-.18.18v1.64c0 .1.08.18.18.18zm2.76 0h1.96c.1 0 .18-.08.18-.18V4.38c0-.1-.08-.18-.18-.18H8.22c-.1 0-.18.08-.18.18v1.64c0 .1.08.18.18.18zm2.76 0h1.96c.1 0 .18-.08.18-.18V4.38c0-.1-.08-.18-.18-.18h-1.96c-.1 0-.18.08-.18.18v1.64c0 .1.08.18.18.18zm0-2.64h1.96c.1 0 .18-.08.18-.18V1.74c0-.1-.08-.18-.18-.18h-1.96c-.1 0-.18.08-.18.18v1.64c0 .1.08.18.18.18zm2.76 5.28h1.96c.1 0 .18-.08.18-.18V7.02c0-.1-.08-.18-.18-.18h-1.96c-.1 0-.18.08-.18.18v1.64c0 .1.08.18.18.18z"/></svg><span class="font-mono">fnsys/dockhand:{appVersion}</span></div>
|
||||
{#if buildCommit}
|
||||
<div>Commit: <span class="font-mono">{buildCommit.slice(0, 7)}</span></div>
|
||||
{/if}
|
||||
{#if $selfUpdate.updateAvailable && $selfUpdate.latestVersion}
|
||||
<div class="flex items-center gap-1.5 pt-1 text-amber-500">
|
||||
<CircleArrowUp class="w-3.5 h-3.5 shrink-0" />
|
||||
Update available: <span class="font-mono">v{$selfUpdate.latestVersion}</span>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/snippet}
|
||||
|
||||
<Sidebar.Root collapsible="icon">
|
||||
<Sidebar.Header class="overflow-visible flex items-center justify-center p-0">
|
||||
<!-- Expanded state: logo + collapse button -->
|
||||
@@ -147,37 +266,157 @@
|
||||
<Sidebar.Content>
|
||||
<Sidebar.Group>
|
||||
<Sidebar.Menu>
|
||||
{#each menuItems as item}
|
||||
{#if canSeeMenuItem(item)}
|
||||
<Sidebar.MenuItem>
|
||||
<Sidebar.MenuButton href={item.href} isActive={isActive(item.href)} tooltipContent={item.label} onclick={() => sidebar.setOpenMobile(false)}>
|
||||
<item.Icon aria-hidden="true" />
|
||||
<span class="group-data-[state=collapsed]:hidden">{item.label}</span>
|
||||
</Sidebar.MenuButton>
|
||||
</Sidebar.MenuItem>
|
||||
{/if}
|
||||
{#each editMode ? editableItems : visibleItems as item (item.href)}
|
||||
<li class="group/menu-item relative" animate:flip={{ duration: 200 }}>
|
||||
{#if editMode}
|
||||
<div
|
||||
role="listitem"
|
||||
class="flex items-center gap-1.5 px-1.5 py-1 rounded-md text-xs select-none transition-opacity
|
||||
{hiddenSet.has(item.href) ? 'opacity-40' : ''}
|
||||
{dragHref === item.href ? 'opacity-30 bg-sidebar-accent' : ''}"
|
||||
draggable="true"
|
||||
ondragstart={(e) => {
|
||||
dragHref = item.href;
|
||||
if (e.dataTransfer) {
|
||||
e.dataTransfer.effectAllowed = 'move';
|
||||
// Safari won't start a drag without data
|
||||
e.dataTransfer.setData('text/plain', item.href);
|
||||
e.dataTransfer.setDragImage(transparentGhost(), 0, 0);
|
||||
}
|
||||
}}
|
||||
ondragover={(e) => {
|
||||
e.preventDefault();
|
||||
if (e.dataTransfer) e.dataTransfer.dropEffect = 'move';
|
||||
if (!dragHref || dragHref === item.href) return;
|
||||
// Only reorder when the pointer is past the row midpoint,
|
||||
// so a hover near the boundary doesn't flip back and forth
|
||||
const rect = (e.currentTarget as HTMLElement).getBoundingClientRect();
|
||||
const before = e.clientY < rect.top + rect.height / 2;
|
||||
moveTo(dragHref, item.href, before);
|
||||
}}
|
||||
ondrop={(e) => e.preventDefault()}
|
||||
ondragend={endDrag}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class="cursor-grab text-muted-foreground/60 hover:text-foreground focus:outline-none focus-visible:text-foreground"
|
||||
aria-label="Reorder {item.label}"
|
||||
title="Drag to reorder (or use arrow keys)"
|
||||
onkeydown={(e) => {
|
||||
if (e.key === 'ArrowUp') { e.preventDefault(); moveBy(item.href, -1); }
|
||||
if (e.key === 'ArrowDown') { e.preventDefault(); moveBy(item.href, 1); }
|
||||
}}
|
||||
>
|
||||
<GripVertical class="w-3 h-3" />
|
||||
</button>
|
||||
<item.Icon class="!w-3.5 !h-3.5 shrink-0" aria-hidden="true" />
|
||||
<span class="flex-1 truncate">{item.label}</span>
|
||||
<button
|
||||
type="button"
|
||||
class="text-muted-foreground/60 hover:text-foreground transition-colors"
|
||||
title={hiddenSet.has(item.href) ? `Show ${item.label}` : `Hide ${item.label}`}
|
||||
aria-label={hiddenSet.has(item.href) ? `Show ${item.label}` : `Hide ${item.label}`}
|
||||
onclick={() => toggleHidden(item.href)}
|
||||
>
|
||||
{#if hiddenSet.has(item.href)}
|
||||
<EyeOff class="w-3 h-3" />
|
||||
{:else}
|
||||
<Eye class="w-3 h-3" />
|
||||
{/if}
|
||||
</button>
|
||||
</div>
|
||||
{:else}
|
||||
<Sidebar.MenuButton href={item.href} isActive={isActive(item.href)} tooltipContent={item.label} onclick={() => sidebar.setOpenMobile(false)}>
|
||||
<item.Icon aria-hidden="true" />
|
||||
<span class="group-data-[state=collapsed]:hidden">{item.label}</span>
|
||||
</Sidebar.MenuButton>
|
||||
{/if}
|
||||
</li>
|
||||
{/each}
|
||||
</Sidebar.Menu>
|
||||
{#if editMode}
|
||||
<div class="flex items-center justify-between px-2 py-1 mt-1 text-xs leading-none">
|
||||
<button
|
||||
type="button"
|
||||
class="flex items-center gap-1 whitespace-nowrap font-medium text-muted-foreground hover:text-foreground transition-colors"
|
||||
title="Reset menu to default order and visibility"
|
||||
onclick={() => {
|
||||
sidebarPreferencesStore.reset();
|
||||
editMode = false;
|
||||
}}
|
||||
>
|
||||
<RotateCcw class="w-3 h-3 shrink-0 text-red-400" />
|
||||
Reset
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="flex items-center gap-1 whitespace-nowrap font-medium text-muted-foreground hover:text-foreground transition-colors"
|
||||
onclick={() => (editMode = false)}
|
||||
>
|
||||
<Check class="w-3 h-3 shrink-0 text-emerald-500" />
|
||||
Apply
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
</Sidebar.Group>
|
||||
</Sidebar.Content>
|
||||
|
||||
<!-- Version (expanded sidebar only) -->
|
||||
<div class="group-data-[state=collapsed]:hidden px-3 py-2 mt-auto text-center">
|
||||
<Tooltip.Root>
|
||||
<Tooltip.Trigger>
|
||||
<span class="text-[10px] text-muted-foreground/60 hover:text-muted-foreground transition-colors cursor-default">
|
||||
{appVersion}
|
||||
</span>
|
||||
</Tooltip.Trigger>
|
||||
<Tooltip.Content side="top" align="start" sideOffset={8} class="text-xs">
|
||||
<div class="space-y-0.5">
|
||||
<div class="flex items-center gap-1.5"><svg class="w-4 h-4 shrink-0" viewBox="0 0 24 18" fill="currentColor"><path d="M23.76 8.68c-.26-.18-.86-.58-1.53-.58-.24 0-.48.04-.72.12-.12-.84-.68-1.56-1.34-2.14l-.28-.22-.24.26c-.28.34-.48.72-.56 1.14-.1.42-.06.82.1 1.2-.42.22-.88.36-1.32.42-.24.04-.48.06-.72.06H.78a.77.77 0 0 0-.78.78c-.02 1.46.22 2.9.72 4.24.56 1.44 1.4 2.5 2.5 3.16 1.26.74 3.32 1.16 5.64 1.16.98 0 2-.1 2.98-.3a11.5 11.5 0 0 0 3.3-1.3 9.67 9.67 0 0 0 2.54-2.34c1.16-1.42 1.86-3.02 2.34-4.38h.2c1.22 0 1.98-.48 2.4-.9.28-.26.5-.58.64-.94l.08-.24-.28-.2zM2.74 8.84H4.7c.1 0 .18-.08.18-.18V7.02c0-.1-.08-.18-.18-.18H2.74c-.1 0-.18.08-.18.18v1.64c0 .1.08.18.18.18zm2.72 0h1.96c.1 0 .18-.08.18-.18V7.02c0-.1-.08-.18-.18-.18H5.46c-.1 0-.18.08-.18.18v1.64c0 .1.08.18.18.18zm2.76 0h1.96c.1 0 .18-.08.18-.18V7.02c0-.1-.08-.18-.18-.18H8.22c-.1 0-.18.08-.18.18v1.64c0 .1.08.18.18.18zm2.76 0h1.96c.1 0 .18-.08.18-.18V7.02c0-.1-.08-.18-.18-.18h-1.96c-.1 0-.18.08-.18.18v1.64c0 .1.08.18.18.18zM5.46 6.2h1.96c.1 0 .18-.08.18-.18V4.38c0-.1-.08-.18-.18-.18H5.46c-.1 0-.18.08-.18.18v1.64c0 .1.08.18.18.18zm2.76 0h1.96c.1 0 .18-.08.18-.18V4.38c0-.1-.08-.18-.18-.18H8.22c-.1 0-.18.08-.18.18v1.64c0 .1.08.18.18.18zm2.76 0h1.96c.1 0 .18-.08.18-.18V4.38c0-.1-.08-.18-.18-.18h-1.96c-.1 0-.18.08-.18.18v1.64c0 .1.08.18.18.18zm0-2.64h1.96c.1 0 .18-.08.18-.18V1.74c0-.1-.08-.18-.18-.18h-1.96c-.1 0-.18.08-.18.18v1.64c0 .1.08.18.18.18zm2.76 5.28h1.96c.1 0 .18-.08.18-.18V7.02c0-.1-.08-.18-.18-.18h-1.96c-.1 0-.18.08-.18.18v1.64c0 .1.08.18.18.18z"/></svg><span class="font-mono">fnsys/dockhand:{appVersion}</span></div>
|
||||
{#if buildCommit}
|
||||
<div>Commit: <span class="font-mono">{buildCommit.slice(0, 7)}</span></div>
|
||||
{/if}
|
||||
</div>
|
||||
</Tooltip.Content>
|
||||
</Tooltip.Root>
|
||||
<!-- Collapsed-only update indicator at the bottom. Same CSS-popover trick
|
||||
as the expanded version row — anchored to the icon's top edge, grows
|
||||
upward, opens to the right of the narrow sidebar. -->
|
||||
{#if $selfUpdate.updateAvailable}
|
||||
<div class="hidden group-data-[state=collapsed]:flex justify-center mt-auto pb-2 relative group/update">
|
||||
<a
|
||||
href="/settings?tab=about"
|
||||
class="inline-flex p-1 rounded-md hover:bg-sidebar-accent transition-colors"
|
||||
aria-label="Dockhand update available"
|
||||
>
|
||||
<CircleArrowUp class="w-4 h-4 text-amber-500 {$appSettings.highlightUpdates ? 'glow-amber' : ''}" />
|
||||
</a>
|
||||
<div
|
||||
role="tooltip"
|
||||
class="pointer-events-none absolute left-0 bottom-full mb-1 whitespace-nowrap rounded-md border bg-popover text-popover-foreground text-xs px-3 py-1.5 shadow-lg opacity-0 group-hover/update:opacity-100 transition-opacity z-50"
|
||||
>
|
||||
{@render versionTooltip()}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Version (expanded sidebar only). Pure-CSS popover anchored to the row's
|
||||
top edge, so it always grows upward into available space. -->
|
||||
<div class="group-data-[state=collapsed]:hidden px-3 py-1 mt-auto text-center relative group/version">
|
||||
<span class="inline-flex items-center gap-1.5 text-[10px] text-muted-foreground/60 hover:text-muted-foreground transition-colors cursor-default">
|
||||
{appVersion}
|
||||
{#if $selfUpdate.updateAvailable}
|
||||
<a
|
||||
href="/settings?tab=about"
|
||||
class="inline-flex"
|
||||
aria-label="Dockhand update available"
|
||||
>
|
||||
<CircleArrowUp class="w-3 h-3 text-amber-500 {$appSettings.highlightUpdates ? 'glow-amber' : ''}" />
|
||||
</a>
|
||||
{/if}
|
||||
</span>
|
||||
<!-- Customize-menu toggle (#1252): invisible until this bottom strip is
|
||||
hovered, so it never distracts users who don't customize. Hidden
|
||||
while editing - the Apply button under the menu exits edit mode. -->
|
||||
{#if !editMode}
|
||||
<button
|
||||
type="button"
|
||||
class="absolute right-2 top-1/2 -translate-y-1/2 p-1 rounded-md text-muted-foreground/50 hover:text-foreground hover:bg-sidebar-accent transition-all opacity-0 group-hover/version:opacity-100 focus-visible:opacity-100"
|
||||
title="Customize menu"
|
||||
aria-label="Customize menu"
|
||||
onclick={() => (editMode = true)}
|
||||
>
|
||||
<Pencil class="w-3 h-3" />
|
||||
</button>
|
||||
{/if}
|
||||
<div
|
||||
role="tooltip"
|
||||
class="pointer-events-none absolute left-0 bottom-full mb-1 whitespace-nowrap rounded-md border bg-popover text-popover-foreground text-xs px-3 py-1.5 shadow-lg opacity-0 group-hover/version:opacity-100 transition-opacity z-50"
|
||||
>
|
||||
{@render versionTooltip()}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- User info footer (only when auth is enabled) -->
|
||||
|
||||
@@ -172,22 +172,24 @@
|
||||
}
|
||||
});
|
||||
|
||||
// Generate hours array based on time format preference
|
||||
// Generate hours array based on time format preference. 24h shows just the
|
||||
// 2-digit hour so the combined picker reads "at 03 :15" rather than
|
||||
// the confusing "at 03:00 :15" (#1198).
|
||||
const hours = $derived(
|
||||
Array.from({ length: 24 }, (_, i) => ({
|
||||
value: String(i),
|
||||
label: is12Hour
|
||||
? i === 0 ? '12 AM' : i < 12 ? `${i} AM` : i === 12 ? '12 PM' : `${i - 12} PM`
|
||||
: i.toString().padStart(2, '0') + ':00'
|
||||
: i.toString().padStart(2, '0')
|
||||
}))
|
||||
);
|
||||
|
||||
const minutes = [
|
||||
{ value: '0', label: ':00' },
|
||||
{ value: '15', label: ':15' },
|
||||
{ value: '30', label: ':30' },
|
||||
{ value: '45', label: ':45' }
|
||||
];
|
||||
// 5-minute granularity — fine enough to stagger notifications without
|
||||
// drowning the dropdown in 60 entries (#1198).
|
||||
const minutes = Array.from({ length: 12 }, (_, i) => ({
|
||||
value: String(i * 5),
|
||||
label: ':' + (i * 5).toString().padStart(2, '0')
|
||||
}));
|
||||
|
||||
const daysOfWeek = [
|
||||
{ value: '1', label: 'Monday' },
|
||||
|
||||
@@ -37,6 +37,16 @@
|
||||
onLoadMore?: () => void;
|
||||
loadMoreThreshold?: number;
|
||||
|
||||
// Sliding-window mode (opt-in, fully isolated). When `windowed` is false
|
||||
// (default) every windowed branch below is skipped and the grid behaves
|
||||
// byte-for-byte as before. When true, `data` holds only rows from absolute
|
||||
// index `windowOffset` of `windowTotal` total; unloaded positions render a
|
||||
// loading row, and `onWindowShift(target)` is called to fetch a new window.
|
||||
windowed?: boolean;
|
||||
windowOffset?: number;
|
||||
windowTotal?: number;
|
||||
onWindowShift?: (targetStart: number) => void;
|
||||
|
||||
// Visible range callback (for virtual scroll)
|
||||
onVisibleRangeChange?: (start: number, end: number, total: number) => void;
|
||||
|
||||
@@ -85,6 +95,10 @@
|
||||
hasMore = false,
|
||||
onLoadMore,
|
||||
loadMoreThreshold = 200,
|
||||
windowed = false,
|
||||
windowOffset = 0,
|
||||
windowTotal,
|
||||
onWindowShift,
|
||||
onVisibleRangeChange,
|
||||
onRowClick,
|
||||
highlightedKey,
|
||||
@@ -373,8 +387,12 @@
|
||||
// Container width for grow column calculation
|
||||
let scrollContainerWidth = $state(0);
|
||||
|
||||
// Scroll-height row count. In non-windowed mode `rowCount === data.length`, so
|
||||
// every use below is identical to the original `data.length`.
|
||||
const rowCount = $derived(windowed ? (windowTotal ?? data.length) : data.length);
|
||||
|
||||
// Virtual scroll calculations
|
||||
const totalHeight = $derived(virtualScroll ? data.length * rowHeight : 0);
|
||||
const totalHeight = $derived(virtualScroll ? rowCount * rowHeight : 0);
|
||||
|
||||
// Memoization state for visibleData to prevent creating new arrays on every scroll
|
||||
let prevStartIndex = -1;
|
||||
@@ -382,13 +400,16 @@
|
||||
let prevDataRef: T[] | null = null;
|
||||
let cachedVisibleData: T[] = [];
|
||||
|
||||
// Memoized startIndex/endIndex/visibleData calculation
|
||||
// Memoized startIndex/endIndex — ABSOLUTE indices. Non-windowed: rowCount is
|
||||
// data.length, so this equals the original.
|
||||
const startIndex = $derived(virtualScroll ? Math.max(0, Math.floor(scrollTop / rowHeight) - bufferRows) : 0);
|
||||
const endIndex = $derived(
|
||||
virtualScroll ? Math.min(data.length, Math.ceil((scrollTop + containerHeight) / rowHeight) + bufferRows) : data.length
|
||||
virtualScroll ? Math.min(rowCount, Math.ceil((scrollTop + containerHeight) / rowHeight) + bufferRows) : rowCount
|
||||
);
|
||||
|
||||
// Memoized visibleData - only create new array when bounds or data actually change
|
||||
// Memoized visibleData (ORIGINAL, unchanged) — used by the non-windowed paths.
|
||||
// In windowed mode the offsets are absolute, so this slice would be wrong; the
|
||||
// windowed table uses `windowedVisible` instead (below).
|
||||
const visibleData = $derived.by(() => {
|
||||
if (!virtualScroll) return data;
|
||||
|
||||
@@ -407,15 +428,68 @@
|
||||
return cachedVisibleData;
|
||||
});
|
||||
|
||||
// Windowed rows for the absolute range [startIndex, endIndex): each position is
|
||||
// the loaded row or undefined (→ a loading placeholder row).
|
||||
const windowedVisible = $derived.by<(T | undefined)[]>(() => {
|
||||
if (!windowed) return [];
|
||||
const out: (T | undefined)[] = [];
|
||||
for (let abs = startIndex; abs < endIndex; abs++) {
|
||||
const local = abs - windowOffset;
|
||||
out.push(local >= 0 && local < data.length ? data[local] : undefined);
|
||||
}
|
||||
return out;
|
||||
});
|
||||
|
||||
// Count of rendered virtual rows (loaded slice, or the windowed range).
|
||||
const renderedCount = $derived(windowed ? windowedVisible.length : visibleData.length);
|
||||
|
||||
const offsetY = $derived(virtualScroll ? startIndex * rowHeight : 0);
|
||||
|
||||
// Windowed only: when the viewport nears/leaves the loaded window, ask the
|
||||
// parent to shift it. Fire once per viewport position while a fetch is pending;
|
||||
// re-arm when the window actually moves so a failed/superseded shift (window
|
||||
// never advanced to cover startIndex) can ask again instead of stranding blank
|
||||
// rows. Keyed on (startIndex, windowOffset): same request suppressed only until
|
||||
// either the viewport or the loaded window changes.
|
||||
//
|
||||
// Failed-shift recovery: if a fetch settles (loading→false) without moving the
|
||||
// window to cover us, retry ONCE for this exact (startIndex, windowOffset) so a
|
||||
// transient error doesn't leave permanent blank rows — but not in a loop, so a
|
||||
// hard-failing endpoint just shows loading placeholders until the user scrolls.
|
||||
let lastReqStart = -1;
|
||||
let lastReqOffset = -1;
|
||||
let retriedUncovered = false;
|
||||
let wasLoading = false;
|
||||
$effect(() => {
|
||||
if (!virtualScroll || !windowed || !onWindowShift) return;
|
||||
const loadedEnd = windowOffset + data.length;
|
||||
const needsBelow = endIndex > loadedEnd && loadedEnd < rowCount;
|
||||
const needsAbove = startIndex < windowOffset && windowOffset > 0;
|
||||
const settledEdge = wasLoading && !loading; // a fetch just finished
|
||||
wasLoading = loading;
|
||||
if (!needsBelow && !needsAbove) { lastReqStart = -1; lastReqOffset = -1; retriedUncovered = false; return; }
|
||||
const sameRequest = startIndex === lastReqStart && windowOffset === lastReqOffset;
|
||||
if (sameRequest) {
|
||||
// Still uncovered for the same (viewport, window). Only re-request on the
|
||||
// loading→false edge, and only once, so we recover from a failed fetch
|
||||
// without spinning against a persistently failing endpoint.
|
||||
if (!(settledEdge && !retriedUncovered)) return;
|
||||
retriedUncovered = true;
|
||||
} else {
|
||||
retriedUncovered = false;
|
||||
}
|
||||
lastReqStart = startIndex;
|
||||
lastReqOffset = windowOffset;
|
||||
onWindowShift(startIndex);
|
||||
});
|
||||
|
||||
// Notify parent of visible range changes (throttled via RAF)
|
||||
$effect(() => {
|
||||
if (virtualScroll && onVisibleRangeChange && data.length > 0) {
|
||||
// Capture values for RAF callback
|
||||
const st = scrollTop;
|
||||
const ch = containerHeight;
|
||||
const len = data.length;
|
||||
const len = rowCount; // === data.length when not windowed
|
||||
const rh = rowHeight;
|
||||
const cb = onVisibleRangeChange;
|
||||
|
||||
@@ -761,91 +835,100 @@
|
||||
</thead>
|
||||
{/snippet}
|
||||
|
||||
<!--
|
||||
One data row + its optional expanded row. Shared by the plain virtual body
|
||||
(tableBody, iterating visibleData) and the windowed body (iterating
|
||||
windowedVisible). Keeping the row markup in one place means a change reaches
|
||||
both render paths — the windowed branch used to be a ~85-line copy of this.
|
||||
-->
|
||||
{#snippet dataRow(item: T, rowState: ReturnType<typeof getRowState>)}
|
||||
<tr
|
||||
class="group cursor-pointer {rowState.isHighlighted ? 'selected' : ''} {rowState.isSelected ? 'checkbox-selected' : ''} {rowState.isExpanded ? 'row-expanded' : ''} {rowClass?.(item) ?? ''}"
|
||||
onclick={(e) => onRowClick?.(item, e)}
|
||||
>
|
||||
<!-- Fixed start columns (select checkbox, expand chevron) -->
|
||||
{#each fixedStartCols as colId (colId)}
|
||||
{@const colConfig = columnConfigMap.get(colId)}
|
||||
<td class="py-1.5 px-1 {colId === 'select' ? 'select-col' : ''} {colId === 'expand' ? 'expand-col' : ''}" style="width: {getDisplayWidth(colId)}px">
|
||||
{#if colId === 'select' && selectable}
|
||||
{#if rowState.isSelectable}
|
||||
<button
|
||||
type="button"
|
||||
onclick={(e) => {
|
||||
e.stopPropagation();
|
||||
toggleSelection(item[keyField]);
|
||||
}}
|
||||
class="flex items-center justify-center w-full h-full min-h-[24px] transition-colors cursor-pointer {rowState.isSelected ? 'opacity-100' : 'opacity-0 group-hover:opacity-40 hover:!opacity-100'}"
|
||||
>
|
||||
{#if rowState.isSelected}
|
||||
<CheckSquare class="w-3.5 h-3.5 text-muted-foreground" />
|
||||
{:else}
|
||||
<SquareIcon class="w-3.5 h-3.5 text-muted-foreground" />
|
||||
{/if}
|
||||
</button>
|
||||
{/if}
|
||||
{:else if colId === 'expand' && expandable}
|
||||
<button
|
||||
type="button"
|
||||
onclick={(e) => {
|
||||
e.stopPropagation();
|
||||
toggleExpand(item[keyField]);
|
||||
}}
|
||||
class="flex items-center justify-center transition-colors cursor-pointer opacity-50 hover:opacity-100"
|
||||
title={rowState.isExpanded ? 'Collapse' : 'Expand'}
|
||||
>
|
||||
{#if rowState.isExpanded}
|
||||
<ChevronDown class="w-4 h-4 text-muted-foreground" />
|
||||
{:else}
|
||||
<ChevronRight class="w-4 h-4 text-muted-foreground" />
|
||||
{/if}
|
||||
</button>
|
||||
{:else if cell}
|
||||
{@render cell(colConfig!, item, rowState)}
|
||||
{/if}
|
||||
</td>
|
||||
{/each}
|
||||
|
||||
<!-- Configurable columns -->
|
||||
{#each orderedColumns as colId (colId)}
|
||||
{@const colConfig = columnConfigMap.get(colId)}
|
||||
{#if colConfig}
|
||||
<td class="py-1.5 px-2 {colConfig.noTruncate ? 'no-truncate' : ''}" style="width: {getDisplayWidth(colId)}px">
|
||||
{#if cell}
|
||||
{@render cell(colConfig, item, rowState)}
|
||||
{:else}
|
||||
<!-- Default: render as text -->
|
||||
{String(item[colId as keyof T] ?? '')}
|
||||
{/if}
|
||||
</td>
|
||||
{/if}
|
||||
{/each}
|
||||
|
||||
<!-- Fixed end columns (actions) -->
|
||||
{#each fixedEndCols as colId (colId)}
|
||||
{@const colConfig = columnConfigMap.get(colId)}
|
||||
<td class="py-1.5 px-2 text-right actions-col" style="width: {getDisplayWidth(colId)}px" onclick={(e) => e.stopPropagation()}>
|
||||
{#if cell}
|
||||
{@render cell(colConfig!, item, rowState)}
|
||||
{/if}
|
||||
</td>
|
||||
{/each}
|
||||
</tr>
|
||||
|
||||
<!-- Expanded row content -->
|
||||
{#if rowState.isExpanded && expandedRow}
|
||||
<tr class="expanded-row">
|
||||
<td colspan={fixedStartCols.length + orderedColumns.length + fixedEndCols.length}>
|
||||
{@render expandedRow(item, rowState)}
|
||||
</td>
|
||||
</tr>
|
||||
{/if}
|
||||
{/snippet}
|
||||
|
||||
{#snippet tableBody()}
|
||||
<tbody>
|
||||
{#each visibleData as item, index (item[keyField])}
|
||||
{@const rowState = getRowState(item, index)}
|
||||
<tr
|
||||
class="group cursor-pointer {rowState.isHighlighted ? 'selected' : ''} {rowState.isSelected ? 'checkbox-selected' : ''} {rowState.isExpanded ? 'row-expanded' : ''} {rowClass?.(item) ?? ''}"
|
||||
onclick={(e) => onRowClick?.(item, e)}
|
||||
>
|
||||
<!-- Fixed start columns (select checkbox, expand chevron) -->
|
||||
{#each fixedStartCols as colId (colId)}
|
||||
{@const colConfig = columnConfigMap.get(colId)}
|
||||
<td class="py-1.5 px-1 {colId === 'select' ? 'select-col' : ''} {colId === 'expand' ? 'expand-col' : ''}" style="width: {getDisplayWidth(colId)}px">
|
||||
{#if colId === 'select' && selectable}
|
||||
{#if rowState.isSelectable}
|
||||
<button
|
||||
type="button"
|
||||
onclick={(e) => {
|
||||
e.stopPropagation();
|
||||
toggleSelection(item[keyField]);
|
||||
}}
|
||||
class="flex items-center justify-center w-full h-full min-h-[24px] transition-colors cursor-pointer {rowState.isSelected ? 'opacity-100' : 'opacity-0 group-hover:opacity-40 hover:!opacity-100'}"
|
||||
>
|
||||
{#if rowState.isSelected}
|
||||
<CheckSquare class="w-3.5 h-3.5 text-muted-foreground" />
|
||||
{:else}
|
||||
<SquareIcon class="w-3.5 h-3.5 text-muted-foreground" />
|
||||
{/if}
|
||||
</button>
|
||||
{/if}
|
||||
{:else if colId === 'expand' && expandable}
|
||||
<button
|
||||
type="button"
|
||||
onclick={(e) => {
|
||||
e.stopPropagation();
|
||||
toggleExpand(item[keyField]);
|
||||
}}
|
||||
class="flex items-center justify-center transition-colors cursor-pointer opacity-50 hover:opacity-100"
|
||||
title={rowState.isExpanded ? 'Collapse' : 'Expand'}
|
||||
>
|
||||
{#if rowState.isExpanded}
|
||||
<ChevronDown class="w-4 h-4 text-muted-foreground" />
|
||||
{:else}
|
||||
<ChevronRight class="w-4 h-4 text-muted-foreground" />
|
||||
{/if}
|
||||
</button>
|
||||
{:else if cell}
|
||||
{@render cell(colConfig!, item, rowState)}
|
||||
{/if}
|
||||
</td>
|
||||
{/each}
|
||||
|
||||
<!-- Configurable columns -->
|
||||
{#each orderedColumns as colId (colId)}
|
||||
{@const colConfig = columnConfigMap.get(colId)}
|
||||
{#if colConfig}
|
||||
<td class="py-1.5 px-2 {colConfig.noTruncate ? 'no-truncate' : ''}" style="width: {getDisplayWidth(colId)}px">
|
||||
{#if cell}
|
||||
{@render cell(colConfig, item, rowState)}
|
||||
{:else}
|
||||
<!-- Default: render as text -->
|
||||
{String(item[colId as keyof T] ?? '')}
|
||||
{/if}
|
||||
</td>
|
||||
{/if}
|
||||
{/each}
|
||||
|
||||
<!-- Fixed end columns (actions) -->
|
||||
{#each fixedEndCols as colId (colId)}
|
||||
{@const colConfig = columnConfigMap.get(colId)}
|
||||
<td class="py-1.5 px-2 text-right actions-col" style="width: {getDisplayWidth(colId)}px" onclick={(e) => e.stopPropagation()}>
|
||||
{#if cell}
|
||||
{@render cell(colConfig!, item, rowState)}
|
||||
{/if}
|
||||
</td>
|
||||
{/each}
|
||||
</tr>
|
||||
|
||||
<!-- Expanded row content -->
|
||||
{#if rowState.isExpanded && expandedRow}
|
||||
<tr class="expanded-row">
|
||||
<td colspan={fixedStartCols.length + orderedColumns.length + fixedEndCols.length}>
|
||||
{@render expandedRow(item, rowState)}
|
||||
</td>
|
||||
</tr>
|
||||
{/if}
|
||||
{@render dataRow(item, getRowState(item, index))}
|
||||
{/each}
|
||||
</tbody>
|
||||
{/snippet}
|
||||
@@ -876,79 +959,21 @@
|
||||
<tr><td colspan={fixedStartCols.length + orderedColumns.length + fixedEndCols.length} style="height: {offsetY}px; padding: 0; border: none;"></td></tr>
|
||||
{/if}
|
||||
<!-- Visible rows -->
|
||||
{#each visibleData as item, index (item[keyField])}
|
||||
{@const rowState = getRowState(item, index)}
|
||||
<tr
|
||||
class="group cursor-pointer {rowState.isHighlighted ? 'selected' : ''} {rowState.isSelected ? 'checkbox-selected' : ''} {rowState.isExpanded ? 'row-expanded' : ''} {rowClass?.(item) ?? ''}"
|
||||
onclick={(e) => onRowClick?.(item, e)}
|
||||
>
|
||||
{#each fixedStartCols as colId (colId)}
|
||||
{@const colConfig = columnConfigMap.get(colId)}
|
||||
<td class="py-1.5 px-1 {colId === 'select' ? 'select-col' : ''} {colId === 'expand' ? 'expand-col' : ''}" style="width: {getDisplayWidth(colId)}px">
|
||||
{#if colId === 'select' && selectable}
|
||||
{#if rowState.isSelectable}
|
||||
<button
|
||||
type="button"
|
||||
onclick={(e) => { e.stopPropagation(); toggleSelection(item[keyField]); }}
|
||||
class="flex items-center justify-center w-full h-full min-h-[24px] transition-colors cursor-pointer {rowState.isSelected ? 'opacity-100' : 'opacity-0 group-hover:opacity-40 hover:!opacity-100'}"
|
||||
>
|
||||
{#if rowState.isSelected}
|
||||
<CheckSquare class="w-3.5 h-3.5 text-muted-foreground" />
|
||||
{:else}
|
||||
<SquareIcon class="w-3.5 h-3.5 text-muted-foreground" />
|
||||
{/if}
|
||||
</button>
|
||||
{/if}
|
||||
{:else if colId === 'expand' && expandable}
|
||||
<button
|
||||
type="button"
|
||||
onclick={(e) => { e.stopPropagation(); toggleExpand(item[keyField]); }}
|
||||
class="flex items-center justify-center transition-colors cursor-pointer opacity-50 hover:opacity-100"
|
||||
title={rowState.isExpanded ? 'Collapse' : 'Expand'}
|
||||
>
|
||||
{#if rowState.isExpanded}
|
||||
<ChevronDown class="w-4 h-4 text-muted-foreground" />
|
||||
{:else}
|
||||
<ChevronRight class="w-4 h-4 text-muted-foreground" />
|
||||
{/if}
|
||||
</button>
|
||||
{:else if cell}
|
||||
{@render cell(colConfig!, item, rowState)}
|
||||
{/if}
|
||||
</td>
|
||||
{/each}
|
||||
{#each orderedColumns as colId (colId)}
|
||||
{@const colConfig = columnConfigMap.get(colId)}
|
||||
{#if colConfig}
|
||||
<td class="py-1.5 px-2 {colConfig.noTruncate ? 'no-truncate' : ''}" style="width: {getDisplayWidth(colId)}px">
|
||||
{#if cell}
|
||||
{@render cell(colConfig, item, rowState)}
|
||||
{:else}
|
||||
{String(item[colId as keyof T] ?? '')}
|
||||
{/if}
|
||||
</td>
|
||||
{/if}
|
||||
{/each}
|
||||
{#each fixedEndCols as colId (colId)}
|
||||
{@const colConfig = columnConfigMap.get(colId)}
|
||||
<td class="py-1.5 px-2 text-right actions-col" style="width: {getDisplayWidth(colId)}px" onclick={(e) => e.stopPropagation()}>
|
||||
{#if cell}
|
||||
{@render cell(colConfig!, item, rowState)}
|
||||
{/if}
|
||||
</td>
|
||||
{/each}
|
||||
</tr>
|
||||
{#if rowState.isExpanded && expandedRow}
|
||||
<tr class="expanded-row">
|
||||
<td colspan={fixedStartCols.length + orderedColumns.length + fixedEndCols.length}>
|
||||
{@render expandedRow(item, rowState)}
|
||||
{#each (windowed ? windowedVisible : visibleData) as item, index (windowed ? startIndex + index : item![keyField])}
|
||||
{#if item === undefined}
|
||||
<!-- Windowed: this absolute row isn't loaded yet — show a loading row. -->
|
||||
<tr class="animate-pulse">
|
||||
<td colspan={fixedStartCols.length + orderedColumns.length + fixedEndCols.length} style="height: {rowHeight}px" class="px-2">
|
||||
<div class="h-3 bg-muted rounded" style="width: 30%"></div>
|
||||
</td>
|
||||
</tr>
|
||||
{:else}
|
||||
{@render dataRow(item, getRowState(item, index))}
|
||||
{/if}
|
||||
{/each}
|
||||
<!-- Bottom spacer -->
|
||||
{#if totalHeight - offsetY - (visibleData.length * rowHeight) > 0}
|
||||
<tr><td colspan={fixedStartCols.length + orderedColumns.length + fixedEndCols.length} style="height: {totalHeight - offsetY - (visibleData.length * rowHeight)}px; padding: 0; border: none;"></td></tr>
|
||||
{#if totalHeight - offsetY - (renderedCount * rowHeight) > 0}
|
||||
<tr><td colspan={fixedStartCols.length + orderedColumns.length + fixedEndCols.length} style="height: {totalHeight - offsetY - (renderedCount * rowHeight)}px; padding: 0; border: none;"></td></tr>
|
||||
{/if}
|
||||
<!-- Footer (rendered at the bottom of virtual scroll) -->
|
||||
{#if footer}
|
||||
|
||||
@@ -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,66 @@
|
||||
[
|
||||
{
|
||||
"version": "1.0.37",
|
||||
"date": "2026-07-11",
|
||||
"changes": [
|
||||
{ "type": "feature", "text": "Prometheus metrics at /metrics for env state and internals, gated by EXPORT_METRICS (#339)" },
|
||||
{ "type": "feature", "text": "export scan results as SARIF 2.1.0, manual export and API for DefectDojo/Dependency-Track/GitHub (#415)" },
|
||||
{ "type": "feature", "text": "bump bundled docker-compose to 5.2.0-r0 to clear known CVEs" },
|
||||
{ "type": "feature", "text": "Vulnerabilities dashboard: aggregated CVE findings (#1038)" },
|
||||
{ "type": "feature", "text": "update container images directly from the Stack view (#1073)" },
|
||||
{ "type": "feature", "text": "stacks list shows an update indicator for stacks with image updates (#504)" },
|
||||
{ "type": "fix", "text": "copying an image to another registry keeps its tag instead of defaulting to latest (#1243)" },
|
||||
{ "type": "fix", "text": "`more-than-current image` re-scans the current image so a stale cached scan doesn't block (#1022)" },
|
||||
{ "type": "feature", "text": "application logs now include the log level (INFO/WARN/ERROR) in the prefix (#1166)" },
|
||||
{ "type": "feature", "text": "option to suppress the \"What's New\" popup (#1235)" },
|
||||
{ "type": "fix", "text": "ntfy notifications now forward the `email` query parameter so ntfy can send email (#1231)" },
|
||||
{ "type": "feature", "text": "default scanner images bumped to grype v0.115.0 and trivy 0.71.2 (#1241)" },
|
||||
{ "type": "fix", "text": "the \"Latest\" update-check result now stays visible and doesn't revert (#1019)" },
|
||||
{ "type": "feature", "text": "a stack with container label `dockhand.adopt=false` excluded from adoption (#998)" },
|
||||
{ "type": "fix", "text": "dashboard daemon info shows the real host hostname, not Dockhand's container id (#1265)" },
|
||||
{ "type": "feature", "text": "file editor prompts to save/discard unsaved changes before closing (#1264)" },
|
||||
{ "type": "fix", "text": "`GET /api/containers/check-updates` now lists pending updates (POST still triggers a check) (#1266)" },
|
||||
{ "type": "feature", "text": "sidebar menu customizable (order and visibility of items) (#1252)" },
|
||||
{ "type": "fix", "text": "activity and audit date filters respect the configured timezone instead of UTC (#1269)" },
|
||||
{ "type": "feature", "text": "git deploy progress shows the changed files before the deploy starts (#1260)" }
|
||||
],
|
||||
"imageTag": "fnsys/dockhand:v1.0.37"
|
||||
},
|
||||
{
|
||||
"version": "1.0.36",
|
||||
"date": "2026-06-27",
|
||||
"changes": [
|
||||
{ "type": "feature", "text": "sidebar shows an amber update indicator when a newer Dockhand image is available (#1146)" },
|
||||
{ "type": "fix", "text": "Tag image modal too narrow for images identified only by SHA (#1205)" },
|
||||
{ "type": "fix", "text": "self-hosted ntfy: accept raw `tk_...` access tokens in `?auth=` (#1209)" },
|
||||
{ "type": "fix", "text": "dashboard: a single failed DB stats query no longer poisons the whole environment tile (#1210)" },
|
||||
{ "type": "fix", "text": "UI dates and times now honor the configured default timezone instead of the browser's timezone (#1183)" },
|
||||
{ "type": "fix", "text": "add missing `reset-mfa.sh` emergency script referenced by the manual (#1214)" },
|
||||
{ "type": "fix", "text": "registry browser: copy between registries no longer duplicates the host (#1220)" },
|
||||
{ "type": "feature", "text": "published image carries standard OCI annotations (source, url, title, description, vendor, licenses) (#1217)" },
|
||||
{ "type": "feature", "text": "scanner: configurable network mode and DNS servers for vulnerability scans (#1219)" },
|
||||
{ "type": "feature", "text": "template tiles now show a `Project` link that opens the upstream project page (#1211)" },
|
||||
{ "type": "fix", "text": "cron picker: hour shows just `HH` (was `HH:00`), minute granularity bumped from 15 to 5 (#1198)" },
|
||||
{ "type": "feature", "text": "grid buttons: configurable size and optional semantic colors (#1072)" },
|
||||
{ "type": "fix", "text": "copy buttons (git deploy logs, API tokens) now work over plain HTTP (#1222)" },
|
||||
{ "type": "fix", "text": "Podman pod-infra containers no longer trigger update-check warnings (#1221)" },
|
||||
{ "type": "fix", "text": "terminal exec works even when the browser sends a cookie with stray `%` characters (#1224)" }
|
||||
],
|
||||
"imageTag": "fnsys/dockhand:v1.0.36"
|
||||
},
|
||||
{
|
||||
"version": "1.0.35",
|
||||
"date": "2026-06-19",
|
||||
"changes": [
|
||||
{ "type": "feature", "text": "image prune skips Dockhand's scanner images (grype, trivy), configurable (#625)" },
|
||||
{ "type": "fix", "text": "\"No archive\" download format selection not persisted (#1180)" },
|
||||
{ "type": "fix", "text": "regression: vulnerability scans on direct-TCP envs (#1195)" },
|
||||
{ "type": "fix", "text": "Pangolin labels — recognise the real `public-resources` / `private-resources` namespaces (#2)" },
|
||||
{ "type": "fix", "text": "healthcheck uses HTTPS probe when HTTPS_MODE=on (#1191)" },
|
||||
{ "type": "fix", "text": "shell detection improved for containers with non-standard PATH or shell locations (#1189)" }
|
||||
],
|
||||
"imageTag": "fnsys/dockhand:v1.0.35"
|
||||
},
|
||||
{
|
||||
"version": "1.0.34",
|
||||
"date": "2026-06-17",
|
||||
|
||||
@@ -547,7 +547,7 @@
|
||||
},
|
||||
{
|
||||
"name": "js-yaml",
|
||||
"version": "4.1.1",
|
||||
"version": "4.2.0",
|
||||
"license": "MIT",
|
||||
"repository": "https://github.com/nodeca/js-yaml"
|
||||
},
|
||||
|
||||
@@ -46,12 +46,11 @@ function cacheKey(rawToken: string): string {
|
||||
return createHash('sha256').update(rawToken).digest('hex');
|
||||
}
|
||||
|
||||
// Pre-computed dummy hash for timing protection on invalid prefixes
|
||||
let dummyHash: string | null = null;
|
||||
|
||||
async function getDummyHash(): Promise<string> {
|
||||
if (!dummyHash) {
|
||||
dummyHash = await hashPassword('dh_dummy_token_for_timing_protection');
|
||||
dummyHash = await hashPassword('dh_init_seed');
|
||||
}
|
||||
return dummyHash;
|
||||
}
|
||||
@@ -248,6 +247,22 @@ export async function listUserTokens(userId: number) {
|
||||
.where(eq(table.userId, userId));
|
||||
}
|
||||
|
||||
/**
|
||||
* Fleet-wide API token stats for the metrics endpoint (no secrets returned):
|
||||
* total configured, how many are expired, and how many have never been used.
|
||||
*/
|
||||
export async function getApiTokenStats(): Promise<{ total: number; expired: number; neverUsed: number }> {
|
||||
const table = await getApiTokensTable();
|
||||
const rows = await db.select({ lastUsed: table.lastUsed, expiresAt: table.expiresAt }).from(table);
|
||||
const now = Date.now();
|
||||
let expired = 0, neverUsed = 0;
|
||||
for (const r of rows) {
|
||||
if (r.expiresAt && new Date(r.expiresAt).getTime() < now) expired++;
|
||||
if (!r.lastUsed) neverUsed++;
|
||||
}
|
||||
return { total: rows.length, expired, neverUsed };
|
||||
}
|
||||
|
||||
/**
|
||||
* Revoke (delete) a token. Owner or admin can revoke.
|
||||
*/
|
||||
|
||||
@@ -36,6 +36,7 @@
|
||||
*/
|
||||
|
||||
import type { Cookies } from '@sveltejs/kit';
|
||||
import { json } from '@sveltejs/kit';
|
||||
import type { Permissions } from './db';
|
||||
import { getUserAccessibleEnvironments, userCanAccessEnvironment, userHasAdminRole } from './db';
|
||||
import { validateSession, isAuthEnabled, checkPermission, type AuthenticatedUser } from './auth';
|
||||
@@ -105,6 +106,27 @@ export interface AuthorizationContext {
|
||||
* - (User is admin OR has audit_logs view permission)
|
||||
*/
|
||||
canViewAuditLog: () => Promise<boolean>;
|
||||
|
||||
/**
|
||||
* Gate handler: require a permission. Returns a 403 Response on denial,
|
||||
* null when allowed. Use at the top of a handler with `??`-chaining for
|
||||
* one-line auth/env checks:
|
||||
*
|
||||
* const denied = (await auth.requirePermission('schedules', 'edit'))
|
||||
* ?? (await auth.requireEnvAccess(envId));
|
||||
* if (denied) return denied;
|
||||
*
|
||||
* No-op when authEnabled is false.
|
||||
*/
|
||||
requirePermission: (resource: keyof Permissions, action: string, environmentId?: number) => Promise<Response | null>;
|
||||
|
||||
/**
|
||||
* Gate handler: require access to the given environment. Returns a 403
|
||||
* Response on denial, null when allowed. Pass null/undefined to no-op
|
||||
* (e.g. system-scoped operations that don't target an env). No-op when
|
||||
* authEnabled is false.
|
||||
*/
|
||||
requireEnvAccess: (environmentId: number | null | undefined) => Promise<Response | null>;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -232,6 +254,25 @@ export async function authorize(cookies: Cookies): Promise<AuthorizationContext>
|
||||
|
||||
// Check for audit_logs permission
|
||||
return checkPermission(user, 'audit_logs' as keyof Permissions, 'view');
|
||||
},
|
||||
|
||||
async requirePermission(
|
||||
resource: keyof Permissions,
|
||||
action: string,
|
||||
environmentId?: number
|
||||
): Promise<Response | null> {
|
||||
if (!authEnabled) return null;
|
||||
return (await ctx.can(resource, action, environmentId))
|
||||
? null
|
||||
: json({ error: 'Permission denied' }, { status: 403 });
|
||||
},
|
||||
|
||||
async requireEnvAccess(environmentId: number | null | undefined): Promise<Response | null> {
|
||||
if (!authEnabled) return null;
|
||||
if (environmentId === null || environmentId === undefined) return null;
|
||||
return (await ctx.canAccessEnvironment(environmentId))
|
||||
? null
|
||||
: json({ error: 'Access denied to this environment' }, { status: 403 });
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
+182
-8
@@ -79,6 +79,7 @@ import {
|
||||
import type { AllGridPreferences, GridId, GridColumnPreferences } from '$lib/types';
|
||||
import { encrypt, decrypt } from './encryption.js';
|
||||
import { parseEnvInterpolation } from './env-interpolation';
|
||||
import { invalidateVulnerabilitiesCache } from './vulnerabilities-cache';
|
||||
|
||||
// Re-export for backwards compatibility
|
||||
export { db, isPostgres, isSqlite };
|
||||
@@ -389,8 +390,10 @@ export async function getUserThemePreferences(userId: number): Promise<{
|
||||
terminalFont: string;
|
||||
editorFont: string;
|
||||
animateIcons: boolean;
|
||||
coloredActionButtons: boolean;
|
||||
actionIconSize: string;
|
||||
}> {
|
||||
const [lightTheme, darkTheme, font, fontSize, gridFontSize, terminalFont, editorFont, animateIcons] = await Promise.all([
|
||||
const [lightTheme, darkTheme, font, fontSize, gridFontSize, terminalFont, editorFont, animateIcons, coloredActionButtons, actionIconSize] = await Promise.all([
|
||||
getUserSetting(userId, 'light_theme'),
|
||||
getUserSetting(userId, 'dark_theme'),
|
||||
getUserSetting(userId, 'font'),
|
||||
@@ -398,7 +401,9 @@ export async function getUserThemePreferences(userId: number): Promise<{
|
||||
getUserSetting(userId, 'grid_font_size'),
|
||||
getUserSetting(userId, 'terminal_font'),
|
||||
getUserSetting(userId, 'editor_font'),
|
||||
getUserSetting(userId, 'animate_icons')
|
||||
getUserSetting(userId, 'animate_icons'),
|
||||
getUserSetting(userId, 'colored_action_buttons'),
|
||||
getUserSetting(userId, 'action_icon_size')
|
||||
]);
|
||||
return {
|
||||
lightTheme: lightTheme || 'default',
|
||||
@@ -409,13 +414,16 @@ export async function getUserThemePreferences(userId: number): Promise<{
|
||||
terminalFont: terminalFont || 'system-mono',
|
||||
editorFont: editorFont || 'system-mono',
|
||||
// Default ON — only false when explicitly stored
|
||||
animateIcons: animateIcons === 'false' ? false : true
|
||||
animateIcons: animateIcons === 'false' ? false : true,
|
||||
// Default OFF — only true when explicitly stored
|
||||
coloredActionButtons: coloredActionButtons === 'true',
|
||||
actionIconSize: actionIconSize || 'normal'
|
||||
};
|
||||
}
|
||||
|
||||
export async function setUserThemePreferences(
|
||||
userId: number,
|
||||
prefs: { lightTheme?: string; darkTheme?: string; font?: string; fontSize?: string; gridFontSize?: string; terminalFont?: string; editorFont?: string; animateIcons?: boolean }
|
||||
prefs: { lightTheme?: string; darkTheme?: string; font?: string; fontSize?: string; gridFontSize?: string; terminalFont?: string; editorFont?: string; animateIcons?: boolean; coloredActionButtons?: boolean; actionIconSize?: string }
|
||||
): Promise<void> {
|
||||
const updates: Promise<void>[] = [];
|
||||
if (prefs.lightTheme !== undefined) {
|
||||
@@ -442,6 +450,12 @@ export async function setUserThemePreferences(
|
||||
if (prefs.animateIcons !== undefined) {
|
||||
updates.push(setUserSetting(userId, 'animate_icons', prefs.animateIcons ? 'true' : 'false'));
|
||||
}
|
||||
if (prefs.coloredActionButtons !== undefined) {
|
||||
updates.push(setUserSetting(userId, 'colored_action_buttons', prefs.coloredActionButtons ? 'true' : 'false'));
|
||||
}
|
||||
if (prefs.actionIconSize !== undefined) {
|
||||
updates.push(setUserSetting(userId, 'action_icon_size', prefs.actionIconSize));
|
||||
}
|
||||
await Promise.all(updates);
|
||||
}
|
||||
|
||||
@@ -478,6 +492,31 @@ export async function resetAllGridPreferences(userId?: number): Promise<void> {
|
||||
await deleteSetting(key);
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// SIDEBAR MENU PREFERENCES
|
||||
// =============================================================================
|
||||
|
||||
export interface SidebarPreferences {
|
||||
order: string[];
|
||||
hidden: string[];
|
||||
}
|
||||
|
||||
export async function getSidebarPreferences(userId?: number): Promise<SidebarPreferences> {
|
||||
const key = userId ? `user:${userId}:sidebar_preferences` : 'sidebar_preferences';
|
||||
const value = await getSetting(key);
|
||||
return value || { order: [], hidden: [] };
|
||||
}
|
||||
|
||||
export async function setSidebarPreferences(prefs: SidebarPreferences, userId?: number): Promise<void> {
|
||||
const key = userId ? `user:${userId}:sidebar_preferences` : 'sidebar_preferences';
|
||||
await setSetting(key, prefs);
|
||||
}
|
||||
|
||||
export async function deleteSidebarPreferences(userId?: number): Promise<void> {
|
||||
const key = userId ? `user:${userId}:sidebar_preferences` : 'sidebar_preferences';
|
||||
await deleteSetting(key);
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// ENVIRONMENT PUBLIC IPS (for port links)
|
||||
// =============================================================================
|
||||
@@ -2985,6 +3024,9 @@ export async function saveVulnerabilityScan(data: {
|
||||
vulnerabilities: JSON.stringify(data.vulnerabilities),
|
||||
error: data.error ?? null
|
||||
}).returning();
|
||||
// A new scan makes the dashboard's cached findings stale — drop them so every
|
||||
// writer (routes + schedulers) refreshes it, no separate call to remember.
|
||||
invalidateVulnerabilitiesCache(data.environmentId ?? undefined);
|
||||
return getVulnerabilityScan(result[0].id) as Promise<VulnerabilityScanData>;
|
||||
}
|
||||
|
||||
@@ -3028,9 +3070,47 @@ export async function getLatestScanForImage(
|
||||
} as VulnerabilityScanData;
|
||||
}
|
||||
|
||||
export async function getScansForImage(imageId: string, limit = 10): Promise<VulnerabilityScanData[]> {
|
||||
/**
|
||||
* Delete all previous scan rows for a specific image + scanner in an environment.
|
||||
* Used by "scan all" to replace an image's prior scan rather than accumulate rows.
|
||||
* Returns the number of rows removed.
|
||||
*/
|
||||
export async function deleteScansForImageScanner(
|
||||
imageId: string,
|
||||
scanner: 'grype' | 'trivy',
|
||||
environmentId?: number | null
|
||||
): Promise<number> {
|
||||
const conditions = [
|
||||
eq(vulnerabilityScans.imageId, imageId),
|
||||
eq(vulnerabilityScans.scanner, scanner)
|
||||
];
|
||||
if (environmentId === null || environmentId === undefined) {
|
||||
conditions.push(isNull(vulnerabilityScans.environmentId));
|
||||
} else {
|
||||
conditions.push(eq(vulnerabilityScans.environmentId, environmentId));
|
||||
}
|
||||
const result = await db.delete(vulnerabilityScans).where(and(...conditions)).returning({ id: vulnerabilityScans.id });
|
||||
return result.length;
|
||||
}
|
||||
|
||||
export async function getScansForImage(
|
||||
imageId: string,
|
||||
environmentId?: number | null,
|
||||
limit = 10
|
||||
): Promise<VulnerabilityScanData[]> {
|
||||
// Scope by environment so a caller can't read another environment's scans for
|
||||
// the same image SHA (the vulnerability_scans table is per-environment). When
|
||||
// environmentId is omitted (undefined) all environments are returned — callers
|
||||
// exposed to untrusted input MUST pass a concrete env.
|
||||
const conditions = [eq(vulnerabilityScans.imageId, imageId)];
|
||||
if (environmentId !== undefined) {
|
||||
conditions.push(environmentId === null
|
||||
? isNull(vulnerabilityScans.environmentId)
|
||||
: eq(vulnerabilityScans.environmentId, environmentId));
|
||||
}
|
||||
|
||||
const results = await db.select().from(vulnerabilityScans)
|
||||
.where(eq(vulnerabilityScans.imageId, imageId))
|
||||
.where(and(...conditions))
|
||||
.orderBy(desc(vulnerabilityScans.scannedAt))
|
||||
.limit(limit);
|
||||
|
||||
@@ -3122,11 +3202,56 @@ export async function getAllLatestScans(environmentId?: number | null): Promise<
|
||||
})) as VulnerabilityScanData[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Scan freshness for the metrics endpoint: how stale the OLDEST scan is
|
||||
* (surfaces environments whose scans have gone stale) and the average scan
|
||||
* duration (surfaces slow scanners). Ages/durations in seconds; nulls when there
|
||||
* are no scans.
|
||||
*
|
||||
* A pure SQL aggregate over only scanned_at / scan_duration — it deliberately
|
||||
* does NOT touch the large `vulnerabilities` JSON blob (unlike getAllLatestScans),
|
||||
* so it stays cheap when called per-environment on every scrape.
|
||||
*/
|
||||
export async function getScanFreshness(environmentId?: number | null): Promise<{
|
||||
scans: number; oldestAgeSeconds: number | null; avgDurationSeconds: number | null;
|
||||
}> {
|
||||
const envCond = environmentId === undefined
|
||||
? undefined
|
||||
: environmentId === null
|
||||
? isNull(vulnerabilityScans.environmentId)
|
||||
: eq(vulnerabilityScans.environmentId, environmentId);
|
||||
|
||||
const rows = await db
|
||||
.select({
|
||||
n: sql<number>`count(*)`,
|
||||
oldest: sql<string | null>`min(${vulnerabilityScans.scannedAt})`,
|
||||
avgDur: sql<number | null>`avg(${vulnerabilityScans.scanDuration})`
|
||||
})
|
||||
.from(vulnerabilityScans)
|
||||
.where(envCond);
|
||||
|
||||
const r = rows[0];
|
||||
const n = Number(r?.n ?? 0);
|
||||
if (n === 0) return { scans: 0, oldestAgeSeconds: null, avgDurationSeconds: null };
|
||||
|
||||
const oldestT = r?.oldest ? new Date(r.oldest).getTime() : NaN;
|
||||
const oldestAgeSeconds = Number.isNaN(oldestT) ? null : Math.max(0, Math.round((Date.now() - oldestT) / 1000));
|
||||
const avgDurationSeconds = r?.avgDur != null ? Math.round(Number(r.avgDur) / 1000) : null;
|
||||
|
||||
return { scans: n, oldestAgeSeconds, avgDurationSeconds };
|
||||
}
|
||||
|
||||
export async function deleteOldScans(keepDays = 30): Promise<number> {
|
||||
const cutoffDate = new Date(Date.now() - keepDays * 24 * 60 * 60 * 1000).toISOString();
|
||||
await db.delete(vulnerabilityScans)
|
||||
const countResult = await db.select({ count: sql<number>`count(*)` })
|
||||
.from(vulnerabilityScans)
|
||||
.where(sql`scanned_at < ${cutoffDate}`);
|
||||
return 0;
|
||||
const count = Number(countResult[0]?.count ?? 0);
|
||||
if (count > 0) {
|
||||
await db.delete(vulnerabilityScans)
|
||||
.where(sql`scanned_at < ${cutoffDate}`);
|
||||
}
|
||||
return count;
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
@@ -4039,6 +4164,55 @@ export async function getScheduleExecutions(filters: ScheduleExecutionFilters =
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Scheduled-task health for the metrics endpoint: execution counts by
|
||||
* (scheduleType, status), and per-type age (seconds) of the last run and last
|
||||
* SUCCESSFUL run. The last-run age detects a scheduler that stopped firing; the
|
||||
* last-success age detects one that fires but keeps failing. Best-effort.
|
||||
*/
|
||||
export async function getScheduleStats(): Promise<{
|
||||
byTypeStatus: Array<{ type: string; status: string; count: number }>;
|
||||
lastRunSecondsByType: Record<string, number>;
|
||||
lastSuccessSecondsByType: Record<string, number>;
|
||||
}> {
|
||||
const counts = await db
|
||||
.select({
|
||||
type: scheduleExecutions.scheduleType,
|
||||
status: scheduleExecutions.status,
|
||||
count: sql<number>`count(*)`
|
||||
})
|
||||
.from(scheduleExecutions)
|
||||
.groupBy(scheduleExecutions.scheduleType, scheduleExecutions.status);
|
||||
|
||||
// Most-recent triggeredAt per type (any status) and per type for successes.
|
||||
const lastAny = await db
|
||||
.select({ type: scheduleExecutions.scheduleType, ts: sql<string>`max(${scheduleExecutions.triggeredAt})` })
|
||||
.from(scheduleExecutions)
|
||||
.groupBy(scheduleExecutions.scheduleType);
|
||||
const lastOk = await db
|
||||
.select({ type: scheduleExecutions.scheduleType, ts: sql<string>`max(${scheduleExecutions.triggeredAt})` })
|
||||
.from(scheduleExecutions)
|
||||
.where(eq(scheduleExecutions.status, 'success'))
|
||||
.groupBy(scheduleExecutions.scheduleType);
|
||||
|
||||
const now = Date.now();
|
||||
const ageMap = (rows: Array<{ type: string; ts: string | null }>): Record<string, number> => {
|
||||
const out: Record<string, number> = {};
|
||||
for (const r of rows) {
|
||||
if (!r.ts) continue;
|
||||
const t = new Date(r.ts).getTime();
|
||||
if (!Number.isNaN(t)) out[r.type] = Math.max(0, Math.round((now - t) / 1000));
|
||||
}
|
||||
return out;
|
||||
};
|
||||
|
||||
return {
|
||||
byTypeStatus: counts.map((c) => ({ type: c.type, status: c.status, count: Number(c.count) })),
|
||||
lastRunSecondsByType: ageMap(lastAny),
|
||||
lastSuccessSecondsByType: ageMap(lastOk)
|
||||
};
|
||||
}
|
||||
|
||||
export async function getLastExecutionForSchedule(
|
||||
scheduleType: ScheduleType,
|
||||
scheduleId: number
|
||||
|
||||
@@ -1041,3 +1041,87 @@ export function getPostgresConnectionInfo(): { host: string; port: string } | nu
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** High-churn tables worth exposing a row count for (they grow and affect tuning). */
|
||||
const METRICS_TABLES = [
|
||||
'container_events', 'vulnerability_scans', 'audit_logs', 'sessions',
|
||||
'host_metrics', 'schedule_executions', 'pending_container_updates'
|
||||
];
|
||||
|
||||
export interface DatabaseStats {
|
||||
type: 'postgres' | 'sqlite';
|
||||
/** Engine version string, e.g. "3.45.1" (sqlite) or "16.2" (postgres). */
|
||||
version: string;
|
||||
/** On-disk size in bytes (sqlite: page_count*page_size; postgres: pg_database_size). 0 if unknown. */
|
||||
sizeBytes: number;
|
||||
/** Row counts for the high-churn tables (missing tables omitted). */
|
||||
rowCounts: Record<string, number>;
|
||||
/**
|
||||
* Engine-specific extra numeric gauges, keyed by name:
|
||||
* - sqlite: wal_bytes (WAL file size), freelist_bytes (reclaimable), cache_size_pages
|
||||
* - postgres: connections (current backends), max_connections
|
||||
*/
|
||||
extra: Record<string, number>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Cheap DB stats for the metrics endpoint: engine + version, on-disk size, row
|
||||
* counts of the tables that actually grow, plus engine-specific tuning gauges.
|
||||
* Never throws — returns best-effort data so a scrape can't fail on a DB hiccup.
|
||||
*/
|
||||
export async function getDatabaseStats(): Promise<DatabaseStats> {
|
||||
const type = isPostgres ? 'postgres' : 'sqlite';
|
||||
let sizeBytes = 0;
|
||||
let version = 'unknown';
|
||||
const rowCounts: Record<string, number> = {};
|
||||
const extra: Record<string, number> = {};
|
||||
|
||||
try {
|
||||
if (isPostgres) {
|
||||
const size = await rawClient`SELECT pg_database_size(current_database()) AS bytes`;
|
||||
sizeBytes = Number(size[0]?.bytes ?? 0);
|
||||
try {
|
||||
const v = await rawClient`SHOW server_version`;
|
||||
version = String(v[0]?.server_version ?? 'unknown').split(' ')[0];
|
||||
} catch { /* ignore */ }
|
||||
try {
|
||||
const c = await rawClient`SELECT count(*)::int AS n FROM pg_stat_activity WHERE datname = current_database()`;
|
||||
extra.connections = Number(c[0]?.n ?? 0);
|
||||
const mc = await rawClient`SELECT setting::int AS n FROM pg_settings WHERE name = 'max_connections'`;
|
||||
extra.max_connections = Number(mc[0]?.n ?? 0);
|
||||
} catch { /* ignore */ }
|
||||
for (const table of METRICS_TABLES) {
|
||||
try {
|
||||
// Identifiers can't be parameterized; table names are a fixed const list.
|
||||
const r = await rawClient.unsafe(`SELECT count(*)::int AS n FROM ${table}`);
|
||||
rowCounts[table] = Number(r[0]?.n ?? 0);
|
||||
} catch { /* table may not exist on older schemas */ }
|
||||
}
|
||||
} else {
|
||||
const pc = rawClient.prepare('PRAGMA page_count').get() as { page_count?: number };
|
||||
const ps = rawClient.prepare('PRAGMA page_size').get() as { page_size?: number };
|
||||
const pageSize = ps?.page_size ?? 0;
|
||||
sizeBytes = (pc?.page_count ?? 0) * pageSize;
|
||||
try {
|
||||
version = String((rawClient.prepare('SELECT sqlite_version() AS v').get() as { v?: string })?.v ?? 'unknown');
|
||||
} catch { /* ignore */ }
|
||||
try {
|
||||
// freelist_count is a read-only header read (reclaimable space). NB: do
|
||||
// NOT read WAL size via `PRAGMA wal_checkpoint` — that pragma actively
|
||||
// CHECKPOINTS the WAL (a write), which would contend on every scrape.
|
||||
const fl = rawClient.prepare('PRAGMA freelist_count').get() as { freelist_count?: number };
|
||||
if (fl?.freelist_count != null) extra.freelist_bytes = fl.freelist_count * pageSize;
|
||||
} catch { /* ignore */ }
|
||||
for (const table of METRICS_TABLES) {
|
||||
try {
|
||||
const r = rawClient.prepare(`SELECT count(*) AS n FROM ${table}`).get() as { n?: number };
|
||||
rowCounts[table] = Number(r?.n ?? 0);
|
||||
} catch { /* table may not exist */ }
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
/* best-effort */
|
||||
}
|
||||
|
||||
return { type, version, sizeBytes, rowCounts, extra };
|
||||
}
|
||||
|
||||
+97
-15
@@ -6,14 +6,14 @@
|
||||
*/
|
||||
|
||||
import { homedir } from 'node:os';
|
||||
import { existsSync, mkdirSync, rmSync, readdirSync } from 'node:fs';
|
||||
import { join, resolve } from 'node:path';
|
||||
import { existsSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import * as http from 'node:http';
|
||||
import * as https from 'node:https';
|
||||
import * as tls from 'node:tls';
|
||||
import { createHash } from 'node:crypto';
|
||||
import type { Environment } from './db';
|
||||
import { getStackEnvVarsAsRecord } from './db';
|
||||
import { getSetting } from './db';
|
||||
import { getAdditionalVolumeBinds } from './mount-dedupe';
|
||||
import { encodeRegistryAuth } from './registry-auth';
|
||||
import { isSystemContainer } from './scheduler/tasks/update-utils';
|
||||
@@ -1035,6 +1035,8 @@ export interface ContainerInfo {
|
||||
id: string;
|
||||
name: string;
|
||||
image: string;
|
||||
/** sha256 image ID (container.ImageID) — matches vulnerability scan.imageId. */
|
||||
imageId: string;
|
||||
state: string;
|
||||
status: string;
|
||||
created: number;
|
||||
@@ -1161,6 +1163,7 @@ export async function listContainers(all = true, envId?: number | null): Promise
|
||||
id: container.Id,
|
||||
name: container.Names[0]?.replace(/^\//, '') || 'unnamed',
|
||||
image: container.Image,
|
||||
imageId: container.ImageID || '',
|
||||
state: container.State,
|
||||
status: container.Status,
|
||||
created: container.Created,
|
||||
@@ -2611,8 +2614,8 @@ export async function listImages(envId?: number | null): Promise<ImageInfo[]> {
|
||||
|
||||
/**
|
||||
* Diagnostic log for registry auth headers (#1105).
|
||||
* Logs lengths and boundary char codes — never the plaintext credentials.
|
||||
* Header prefix is safe to log: it encodes the start of `{"username":"...`.
|
||||
* Logs only whether credentials were present and the target registry — no
|
||||
* credential-derived material (lengths, byte values, or header bytes).
|
||||
*/
|
||||
function logAuthDiagnostics(
|
||||
tag: string,
|
||||
@@ -2622,12 +2625,9 @@ function logAuthDiagnostics(
|
||||
password: string,
|
||||
authHeader: string
|
||||
): void {
|
||||
const userLast = username.length ? username.charCodeAt(username.length - 1).toString(16) : 'na';
|
||||
const pwLast = password.length ? password.charCodeAt(password.length - 1).toString(16) : 'na';
|
||||
console.log(
|
||||
`${tag} auth: registry=${registry} user(len=${username.length},last=0x${userLast}) ` +
|
||||
`pw(len=${password.length},last=0x${pwLast}) serveraddress=${serveraddress} ` +
|
||||
`authHeader(len=${authHeader.length},prefix=${authHeader.slice(0, 16)})`
|
||||
`${tag} auth: registry=${registry} serveraddress=${serveraddress} ` +
|
||||
`hasUser=${username.length > 0} hasPassword=${password.length > 0} hasAuthHeader=${authHeader.length > 0}`
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2720,6 +2720,10 @@ export async function pullImage(imageName: string, onProgress?: (data: any) => v
|
||||
|
||||
const decoder = new TextDecoder();
|
||||
let buffer = '';
|
||||
// Docker streams pull failures as {"error":...} lines inside a 200 response.
|
||||
// Capture the last one and throw after the stream ends so callers see the
|
||||
// failure (and onProgress still fires for the error line).
|
||||
let streamError: string | null = null;
|
||||
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
@@ -2735,6 +2739,7 @@ export async function pullImage(imageName: string, onProgress?: (data: any) => v
|
||||
const data = JSON.parse(line);
|
||||
if (data.error || data.errorDetail) {
|
||||
console.error(`[Pull] stream error: ${line}`);
|
||||
streamError = data.errorDetail?.message || data.error || 'Image pull failed';
|
||||
}
|
||||
if (onProgress) onProgress(data);
|
||||
} catch {
|
||||
@@ -2743,6 +2748,10 @@ export async function pullImage(imageName: string, onProgress?: (data: any) => v
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (streamError) {
|
||||
throw new Error(`Failed to pull image: ${streamError}`);
|
||||
}
|
||||
}
|
||||
|
||||
export async function removeImage(id: string, force = false, envId?: number | null) {
|
||||
@@ -4271,11 +4280,69 @@ export async function pruneContainers(envId?: number | null) {
|
||||
}
|
||||
|
||||
export async function pruneImages(dangling = true, envId?: number | null) {
|
||||
// dangling=true: only remove untagged images (default Docker behavior)
|
||||
// dangling=false: remove ALL unused images including tagged ones
|
||||
// Docker API quirk: to remove all unused, we pass dangling=false filter
|
||||
const filters = dangling ? '{"dangling":["true"]}' : '{"dangling":["false"]}';
|
||||
return dockerJsonRequest(`/images/prune?filters=${encodeURIComponent(filters)}`, { method: 'POST' }, envId);
|
||||
// dangling=true: only remove untagged images. Pass straight through —
|
||||
// scanner images are always tagged so they can't be dangling anyway.
|
||||
if (dangling) {
|
||||
return dockerJsonRequest(
|
||||
`/images/prune?filters=${encodeURIComponent('{"dangling":["true"]}')}`,
|
||||
{ method: 'POST' },
|
||||
envId
|
||||
);
|
||||
}
|
||||
|
||||
// dangling=false: "prune all unused." When the scanner-protection setting
|
||||
// is on, shield grype + trivy with stopped holder containers (Docker's
|
||||
// "in use" check keeps them) then tear them down in a finally (#625).
|
||||
const protect = (await getSetting('protect_scanner_images')) !== false;
|
||||
if (!protect) {
|
||||
return dockerJsonRequest(
|
||||
`/images/prune?filters=${encodeURIComponent('{"dangling":["false"]}')}`,
|
||||
{ method: 'POST' },
|
||||
envId
|
||||
);
|
||||
}
|
||||
|
||||
const { DEFAULT_GRYPE_IMAGE, DEFAULT_TRIVY_IMAGE } = await import('./scanner');
|
||||
const grypeImg = (await getSetting('default_grype_image')) ?? DEFAULT_GRYPE_IMAGE;
|
||||
const trivyImg = (await getSetting('default_trivy_image')) ?? DEFAULT_TRIVY_IMAGE;
|
||||
|
||||
const holderIds: string[] = [];
|
||||
for (const image of [grypeImg, trivyImg]) {
|
||||
try {
|
||||
const safeName = `dockhand-prune-keep-${image.replace(/[^a-z0-9]/gi, '-')}-${Date.now()}`;
|
||||
const created = await dockerJsonRequest<{ Id: string }>(
|
||||
`/containers/create?name=${encodeURIComponent(safeName)}`,
|
||||
{
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ Image: image, Cmd: ['/bin/true'] })
|
||||
},
|
||||
envId
|
||||
);
|
||||
holderIds.push(created.Id);
|
||||
} catch {
|
||||
// Image not present locally → nothing to protect → no-op
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
return await dockerJsonRequest(
|
||||
`/images/prune?filters=${encodeURIComponent('{"dangling":["false"]}')}`,
|
||||
{ method: 'POST' },
|
||||
envId
|
||||
);
|
||||
} finally {
|
||||
for (const id of holderIds) {
|
||||
try {
|
||||
await dockerJsonRequest(
|
||||
`/containers/${id}?force=true`,
|
||||
{ method: 'DELETE' },
|
||||
envId
|
||||
);
|
||||
} catch {
|
||||
// Best-effort cleanup; orphan holders are visible but harmless
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function pruneVolumes(envId?: number | null) {
|
||||
@@ -4443,6 +4510,8 @@ export async function runContainer(options: {
|
||||
extraHosts?: string[];
|
||||
name?: string;
|
||||
envId?: number | null;
|
||||
networkMode?: string; // Docker network mode (e.g., 'host', 'bridge', custom network name)
|
||||
dns?: string[]; // Custom DNS servers; undefined = inherit from Docker daemon
|
||||
}): Promise<{ stdout: string; stderr: string }> {
|
||||
// Add random suffix to avoid naming conflicts
|
||||
const baseName = options.name || `dockhand-temp-${Date.now()}`;
|
||||
@@ -4466,6 +4535,14 @@ export async function runContainer(options: {
|
||||
containerConfig.HostConfig.ExtraHosts = options.extraHosts;
|
||||
}
|
||||
|
||||
if (options.networkMode) {
|
||||
containerConfig.HostConfig.NetworkMode = options.networkMode;
|
||||
}
|
||||
|
||||
if (options.dns && options.dns.length > 0) {
|
||||
containerConfig.HostConfig.Dns = options.dns;
|
||||
}
|
||||
|
||||
const createResult = await dockerJsonRequest<{ Id: string }>(
|
||||
`/containers/create?name=${encodeURIComponent(containerName)}`,
|
||||
{
|
||||
@@ -4533,6 +4610,7 @@ export async function runContainerWithStreaming(options: {
|
||||
onStderr?: (data: string) => void;
|
||||
timeout?: number; // Overall timeout in ms (0 or undefined = no timeout)
|
||||
networkMode?: string; // Docker network mode (e.g., network name for TCP access)
|
||||
dns?: string[]; // Custom DNS servers; undefined = inherit from Docker daemon
|
||||
}): Promise<string> {
|
||||
const baseName = options.name || `dockhand-stream-${Date.now()}`;
|
||||
const containerName = `${baseName}-${randomSuffix()}`;
|
||||
@@ -4567,6 +4645,10 @@ export async function runContainerWithStreaming(options: {
|
||||
containerConfig.HostConfig.NetworkMode = options.networkMode;
|
||||
}
|
||||
|
||||
if (options.dns && options.dns.length > 0) {
|
||||
containerConfig.HostConfig.Dns = options.dns;
|
||||
}
|
||||
|
||||
const createResult = await dockerJsonRequest<{ Id: string }>(
|
||||
`/containers/create?name=${encodeURIComponent(containerName)}`,
|
||||
{ method: 'POST', body: JSON.stringify(containerConfig) },
|
||||
|
||||
@@ -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,32 @@
|
||||
/** ntfy.sh + self-hosted ntfy. ntfy:// or ntfys:// (HTTPS). */
|
||||
import { drainResponse, type NotificationPayload, type NotificationResult } from './shared';
|
||||
import { notificationFetch, drainResponse, type NotificationPayload, type NotificationResult } from './shared';
|
||||
|
||||
// HTTP header values must be ByteString (every char ≤ 0xFF). Reject anything
|
||||
// else so we never blow up fetch() with a 65533 (U+FFFD) replacement char.
|
||||
function isHeaderSafe(value: string): boolean {
|
||||
for (let i = 0; i < value.length; i++) {
|
||||
if (value.charCodeAt(i) > 0xff) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
// ?auth= accepts either a raw ntfy access token (typically `tk_...`) or a
|
||||
// base64-encoded `Bearer <token>` / `Basic <creds>` string. We try base64 first
|
||||
// only when the input looks plausibly base64; if decoding produces non-ASCII
|
||||
// garbage we fall back to treating the value as a raw token.
|
||||
export function resolveQueryAuth(queryAuth: string): string {
|
||||
if (queryAuth.startsWith('tk_')) {
|
||||
return `Bearer ${queryAuth}`;
|
||||
}
|
||||
const looksBase64 = /^[A-Za-z0-9+/=_-]+$/.test(queryAuth);
|
||||
if (looksBase64) {
|
||||
const decoded = Buffer.from(queryAuth, 'base64').toString();
|
||||
if (isHeaderSafe(decoded) && (decoded.startsWith('Bearer ') || decoded.startsWith('Basic '))) {
|
||||
return decoded;
|
||||
}
|
||||
}
|
||||
return `Bearer ${queryAuth}`;
|
||||
}
|
||||
|
||||
export async function sendNtfy(appriseUrl: string, payload: NotificationPayload): Promise<NotificationResult> {
|
||||
// Supported formats:
|
||||
@@ -7,8 +34,8 @@ export async function sendNtfy(appriseUrl: string, payload: NotificationPayload)
|
||||
// ntfy://host/topic (custom server, no auth)
|
||||
// ntfy://user:pass@host/topic (custom server with basic auth)
|
||||
// ntfy://token@host/topic (custom server with bearer token)
|
||||
// ntfy://host/topic?auth=BASE64 (custom server with base64-encoded bearer token)
|
||||
// Query params: ?tags=ship,whale &title=Custom &priority=5
|
||||
// ntfy://host/topic?auth=TOKEN (raw token, e.g. tk_..., or base64-encoded "Bearer <token>")
|
||||
// Query params: ?tags=ship,whale &title=Custom &priority=5 &email=me@example.com
|
||||
// ntfys:// variants for HTTPS
|
||||
const isSecure = appriseUrl.startsWith('ntfys');
|
||||
const path = appriseUrl.replace(/^ntfys?:\/\//, '');
|
||||
@@ -20,6 +47,7 @@ export async function sendNtfy(appriseUrl: string, payload: NotificationPayload)
|
||||
let queryTags: string | null = null;
|
||||
let queryTitle: string | null = null;
|
||||
let queryPriority: string | null = null;
|
||||
let queryEmail: string | null = null;
|
||||
let cleanPath = path;
|
||||
const qIndex = path.indexOf('?');
|
||||
if (qIndex !== -1) {
|
||||
@@ -28,6 +56,7 @@ export async function sendNtfy(appriseUrl: string, payload: NotificationPayload)
|
||||
queryTags = params.get('tags');
|
||||
queryTitle = params.get('title');
|
||||
queryPriority = params.get('priority');
|
||||
queryEmail = params.get('email');
|
||||
cleanPath = path.substring(0, qIndex);
|
||||
}
|
||||
|
||||
@@ -53,8 +82,7 @@ export async function sendNtfy(appriseUrl: string, payload: NotificationPayload)
|
||||
}
|
||||
|
||||
if (!authHeader && queryAuth) {
|
||||
const decoded = Buffer.from(queryAuth, 'base64').toString();
|
||||
authHeader = decoded.startsWith('Bearer ') ? decoded : `Bearer ${decoded}`;
|
||||
authHeader = resolveQueryAuth(queryAuth);
|
||||
}
|
||||
|
||||
const titleWithEnv = payload.environmentName ? `${payload.title} [${payload.environmentName}]` : payload.title;
|
||||
@@ -69,8 +97,13 @@ export async function sendNtfy(appriseUrl: string, payload: NotificationPayload)
|
||||
headers['Authorization'] = authHeader;
|
||||
}
|
||||
|
||||
// ?email=<address> → ntfy's Email header so ntfy forwards the message as email (#1231)
|
||||
if (queryEmail && isHeaderSafe(queryEmail)) {
|
||||
headers['Email'] = queryEmail;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
const response = await notificationFetch(url, {
|
||||
method: 'POST',
|
||||
headers,
|
||||
body: payload.message
|
||||
|
||||
@@ -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({
|
||||
|
||||
+88
-33
@@ -28,6 +28,7 @@ import {
|
||||
} from './host-path';
|
||||
import { resolve } from 'node:path';
|
||||
import { mkdir, chown, rm } from 'node:fs/promises';
|
||||
import { resolveScannerOverrides } from '$lib/utils/scanner-overrides';
|
||||
|
||||
export type ScannerType = 'none' | 'grype' | 'trivy' | 'both';
|
||||
|
||||
@@ -114,13 +115,19 @@ async function withScannerLock<T>(scannerType: string, fn: () => Promise<T>): Pr
|
||||
// Key: "{scannerType}:{imageName}", Value: Promise that resolves to the scan result
|
||||
const inProgressScans = new Map<string, Promise<string>>();
|
||||
|
||||
/** Scanner queue depth — for the metrics endpoint. `inProgress` = distinct
|
||||
* image scans running/deduped; `locked` = scanner types holding the serial lock. */
|
||||
export function getScannerStats(): { inProgress: number; locked: number } {
|
||||
return { inProgress: inProgressScans.size, locked: scannerLocks.size };
|
||||
}
|
||||
|
||||
// Default CLI arguments for scanners (image name is substituted for {image})
|
||||
export const DEFAULT_GRYPE_ARGS = '-o json -v {image}';
|
||||
export const DEFAULT_TRIVY_ARGS = 'image --format json {image}';
|
||||
|
||||
// Pinned scanner images — avoid :latest after the March 2026 Trivy supply chain attack
|
||||
export const DEFAULT_GRYPE_IMAGE = 'anchore/grype:v0.110.0';
|
||||
export const DEFAULT_TRIVY_IMAGE = 'aquasec/trivy:0.69.3';
|
||||
export const DEFAULT_GRYPE_IMAGE = 'anchore/grype:v0.115.0';
|
||||
export const DEFAULT_TRIVY_IMAGE = 'aquasec/trivy:0.71.2';
|
||||
|
||||
export interface VulnerabilitySeverity {
|
||||
critical: number;
|
||||
@@ -153,6 +160,31 @@ export interface ScanResult {
|
||||
error?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert a ScanResult into the row shape expected by saveVulnerabilityScan().
|
||||
* Note: `vulnerabilities` is passed as the raw array — saveVulnerabilityScan()
|
||||
* does the JSON.stringify. (The previous inline copy of this helper stringified
|
||||
* here too, which double-encoded the column; readers must tolerate both.)
|
||||
*/
|
||||
export function scanResultToDbFormat(result: ScanResult, envId?: number | null) {
|
||||
return {
|
||||
environmentId: envId ?? null,
|
||||
imageId: result.imageId || result.imageName, // Fallback to imageName if imageId is undefined
|
||||
imageName: result.imageName,
|
||||
scanner: result.scanner,
|
||||
scannedAt: result.scannedAt,
|
||||
scanDuration: result.scanDuration,
|
||||
criticalCount: result.summary.critical,
|
||||
highCount: result.summary.high,
|
||||
mediumCount: result.summary.medium,
|
||||
lowCount: result.summary.low,
|
||||
negligibleCount: result.summary.negligible,
|
||||
unknownCount: result.summary.unknown,
|
||||
vulnerabilities: result.vulnerabilities,
|
||||
error: result.error ?? null
|
||||
};
|
||||
}
|
||||
|
||||
export interface ScanProgress {
|
||||
stage: 'checking' | 'pulling-scanner' | 'scanning' | 'parsing' | 'complete' | 'error';
|
||||
message: string;
|
||||
@@ -164,24 +196,47 @@ export interface ScanProgress {
|
||||
output?: string; // Line of scanner output
|
||||
}
|
||||
|
||||
function parseDnsSetting(raw: string | null | undefined): string[] {
|
||||
if (!raw) return [];
|
||||
const trimmed = raw.trim();
|
||||
if (!trimmed) return [];
|
||||
// Tolerate either JSON-array storage (preferred) or comma-separated string
|
||||
// (defensive — UI submits JSON, but legacy / hand-edited values may exist).
|
||||
if (trimmed.startsWith('[')) {
|
||||
try {
|
||||
const parsed = JSON.parse(trimmed);
|
||||
return Array.isArray(parsed) ? parsed.filter((v) => typeof v === 'string') : [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
return trimmed.split(',').map((s) => s.trim()).filter(Boolean);
|
||||
}
|
||||
|
||||
// Get global default scanner CLI args and images from general settings (or fallback to hardcoded defaults)
|
||||
export async function getGlobalScannerDefaults(): Promise<{
|
||||
grypeArgs: string;
|
||||
trivyArgs: string;
|
||||
grypeImage: string;
|
||||
trivyImage: string;
|
||||
networkMode: string;
|
||||
dns: string[];
|
||||
}> {
|
||||
const [grypeArgs, trivyArgs, grypeImage, trivyImage] = await Promise.all([
|
||||
const [grypeArgs, trivyArgs, grypeImage, trivyImage, networkMode, dns] = await Promise.all([
|
||||
getSetting('default_grype_args'),
|
||||
getSetting('default_trivy_args'),
|
||||
getSetting('default_grype_image'),
|
||||
getSetting('default_trivy_image')
|
||||
getSetting('default_trivy_image'),
|
||||
getSetting('default_scanner_network_mode'),
|
||||
getSetting('default_scanner_dns')
|
||||
]);
|
||||
return {
|
||||
grypeArgs: grypeArgs ?? DEFAULT_GRYPE_ARGS,
|
||||
trivyArgs: trivyArgs ?? DEFAULT_TRIVY_ARGS,
|
||||
grypeImage: grypeImage ?? DEFAULT_GRYPE_IMAGE,
|
||||
trivyImage: trivyImage ?? DEFAULT_TRIVY_IMAGE
|
||||
trivyImage: trivyImage ?? DEFAULT_TRIVY_IMAGE,
|
||||
networkMode: networkMode ?? '',
|
||||
dns: parseDnsSetting(dns)
|
||||
};
|
||||
}
|
||||
|
||||
@@ -669,31 +724,9 @@ async function runScannerContainerCore(
|
||||
// detection failure — no regression for stock Docker hosts.
|
||||
hostSocketPath = await detectRemoteSocketPath(envId);
|
||||
console.log(`[Scanner] Remote scan via Hawser (${connectionType}) - detected socket path: ${hostSocketPath}`);
|
||||
} else if (connectionType === 'direct' && env?.host) {
|
||||
// Direct TCP to a remote daemon (e.g. Docker over TCP, Podman over TCP).
|
||||
// The scanner container is created on the REMOTE daemon, not on
|
||||
// Dockhand's host. Binding "/var/run/docker.sock" from Dockhand's
|
||||
// host into a remote-daemon container is nonsense — on remote Docker
|
||||
// it silently creates an empty dir (scans fall back to registry); on
|
||||
// rootless Podman it errors with "mkdir /var/run/docker.sock:
|
||||
// permission denied" (#1076, #1011). Instead, tell the scanner to
|
||||
// talk to the daemon over the same TCP endpoint Dockhand uses.
|
||||
// `host.containers.internal` resolves to the daemon's host from
|
||||
// inside the scanner container on both Docker (with `host-gateway`)
|
||||
// and Podman (built-in).
|
||||
scannerDockerHost = `tcp://host.containers.internal:${env.port}`;
|
||||
// Add the host-gateway mapping so Docker honours the hostname.
|
||||
// Podman recognises host.containers.internal natively; the extra
|
||||
// mapping is harmless there.
|
||||
scannerExtraHosts = [
|
||||
...(scannerExtraHosts ?? []),
|
||||
'host.containers.internal:host-gateway'
|
||||
];
|
||||
console.log(
|
||||
`[Scanner] Direct TCP env (${env.protocol ?? 'http'}://${env.host}:${env.port}) - DOCKER_HOST=${scannerDockerHost} (#1076, #1011)`
|
||||
);
|
||||
} else {
|
||||
// Local socket — detect host socket path (handles rootless Docker)
|
||||
// Local socket — handles rootless Docker and direct-TCP envs
|
||||
// (the latter via the same socket bind; see #1195, #1076).
|
||||
hostSocketPath = getHostDockerSocket();
|
||||
console.log(`[Scanner] Local socket scan (${connectionType || 'default'}) - detected host Docker socket: ${hostSocketPath}`);
|
||||
|
||||
@@ -769,6 +802,19 @@ async function runScannerContainerCore(
|
||||
envVars.push(`DOCKER_HOST=${scannerDockerHost}`);
|
||||
}
|
||||
|
||||
// Apply user-configured overrides on top of auto-detection (#1219).
|
||||
// Empty user settings → resolver returns the auto-detected values unchanged.
|
||||
const userScannerSettings = await getGlobalScannerDefaults();
|
||||
const overrides = resolveScannerOverrides(
|
||||
{ networkMode: scannerNetworkMode, extraHosts: scannerExtraHosts },
|
||||
{ networkMode: userScannerSettings.networkMode, dns: userScannerSettings.dns }
|
||||
);
|
||||
if (userScannerSettings.networkMode || userScannerSettings.dns.length > 0) {
|
||||
console.log(
|
||||
`[Scanner] User overrides applied — network=${overrides.networkMode ?? 'default'}, dns=${overrides.dns?.join(',') ?? 'default'}`
|
||||
);
|
||||
}
|
||||
|
||||
console.log(`[Scanner] Running ${scannerType} with cache mounted at ${basePath}`);
|
||||
console.log(`[Scanner] Container command: ${cmd.join(' ')}`);
|
||||
// Run the scanner container with a 10-minute timeout to prevent indefinite hangs
|
||||
@@ -777,10 +823,11 @@ async function runScannerContainerCore(
|
||||
cmd,
|
||||
binds,
|
||||
env: envVars,
|
||||
extraHosts: scannerExtraHosts,
|
||||
extraHosts: overrides.extraHosts,
|
||||
name: `dockhand-${scannerType}-${Date.now()}`,
|
||||
envId,
|
||||
networkMode: scannerNetworkMode,
|
||||
networkMode: overrides.networkMode,
|
||||
dns: overrides.dns,
|
||||
timeout: 600_000, // 10 minutes
|
||||
onStderr: (data) => {
|
||||
// Stream stderr lines for real-time progress output
|
||||
@@ -1098,14 +1145,22 @@ async function getScannerVersion(
|
||||
);
|
||||
if (!hasImage) return null;
|
||||
|
||||
// Create temporary container to get version
|
||||
// Create temporary container to get version. Apply user overrides so
|
||||
// version probe works in environments where default networking fails
|
||||
// (#1219 — host needs --network host for scanner images to resolve DNS).
|
||||
const versionCmd = scannerType === 'grype' ? ['version'] : ['--version'];
|
||||
const overrides = resolveScannerOverrides(
|
||||
{},
|
||||
{ networkMode: defaults.networkMode, dns: defaults.dns }
|
||||
);
|
||||
console.log(`[Scanner] Getting ${scannerType} version with cmd:`, versionCmd);
|
||||
const { stdout, stderr } = await runContainer({
|
||||
image: scannerImage,
|
||||
cmd: versionCmd,
|
||||
name: `dockhand-${scannerType}-version-${Date.now()}`,
|
||||
envId
|
||||
envId,
|
||||
networkMode: overrides.networkMode,
|
||||
dns: overrides.dns
|
||||
});
|
||||
|
||||
console.log(`[Scanner] ${scannerType} version check result: stdout="${stdout.substring(0, 100)}", stderr="${stderr.substring(0, 100)}"`);
|
||||
|
||||
@@ -184,6 +184,11 @@ export async function startScheduler(): Promise<void> {
|
||||
/**
|
||||
* Stop the scheduler service and cleanup all jobs.
|
||||
*/
|
||||
/** Scheduler state — for the metrics endpoint. */
|
||||
export function getSchedulerStats(): { running: boolean; activeJobs: number } {
|
||||
return { running: isRunning, activeJobs: activeJobs.size };
|
||||
}
|
||||
|
||||
export function stopScheduler(): void {
|
||||
if (!isRunning) return;
|
||||
|
||||
@@ -368,8 +373,11 @@ export async function registerSchedule(
|
||||
// Get timezone for this environment
|
||||
const timezone = environmentId ? await getEnvironmentTimezone(environmentId) : 'UTC';
|
||||
|
||||
// Create new Cron instance with timezone
|
||||
const job = new Cron(cronExpression, { timezone, legacyMode: false }, async () => {
|
||||
// Create new Cron instance with timezone.
|
||||
// protect: skip a scheduled tick if the previous run is still in progress
|
||||
// (prevents a slow update/sync from overlapping itself — duplicate
|
||||
// container recreation, concurrent git pull on the same stack dir).
|
||||
const job = new Cron(cronExpression, { timezone, legacyMode: false, protect: true }, async () => {
|
||||
// Defensive check: verify schedule still exists and is enabled
|
||||
if (type === 'container_update') {
|
||||
const setting = await getAutoUpdateSettingById(scheduleId);
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
/**
|
||||
* Vulnerability block decision with a fresh current-image scan.
|
||||
*
|
||||
* Kept separate from update-utils.ts so that the pure decision logic
|
||||
* (shouldBlockUpdate, combineScanSummaries) stays free of DB/scanner runtime
|
||||
* deps and remains unit-testable. This module is allowed to touch the DB and
|
||||
* the scanner.
|
||||
*/
|
||||
|
||||
import type { VulnerabilityCriteria } from '../../db';
|
||||
import { type VulnerabilitySeverity, scanImage } from '../../scanner';
|
||||
import { getCombinedScanForImage, saveVulnerabilityScan } from '../../db';
|
||||
import { shouldBlockUpdate, combineScanSummaries } from './update-utils';
|
||||
|
||||
/**
|
||||
* Resolve the block decision for a new image, handling the 'more_than_current'
|
||||
* criteria robustly.
|
||||
*
|
||||
* For 'more_than_current' the new image's vuln count is compared against the
|
||||
* CURRENT image's count. The current count comes from the scan cache, which can
|
||||
* be (a) stale — vuln DBs grow, so an old cached scan understates the current
|
||||
* image and falsely makes the new image look worse (#1022), or (b) missing — no
|
||||
* scan exists, so the comparison is skipped and a genuinely more-vulnerable
|
||||
* update slips through. Both are fixed here: when the cached comparison would
|
||||
* block (or there is no cache), the current image is RE-SCANNED fresh and the
|
||||
* comparison is redone against up-to-date numbers.
|
||||
*
|
||||
* For all other criteria this is a thin wrapper around shouldBlockUpdate.
|
||||
*
|
||||
* @param newSummary combined scan summary of the new image
|
||||
* @param currentImageId sha256 image id of the currently-running image
|
||||
* @param envId environment id (for scanner settings + cache scoping)
|
||||
* @param criteria the configured vulnerability criteria
|
||||
* @param log log sink for human-readable progress
|
||||
*/
|
||||
export async function resolveBlockDecision(
|
||||
newSummary: VulnerabilitySeverity,
|
||||
currentImageId: string,
|
||||
envId: number | null | undefined,
|
||||
criteria: VulnerabilityCriteria,
|
||||
log: (msg: string) => void
|
||||
): Promise<{ blocked: boolean; reason: string }> {
|
||||
if (criteria !== 'more_than_current') {
|
||||
return shouldBlockUpdate(criteria, newSummary);
|
||||
}
|
||||
|
||||
const total = (s: VulnerabilitySeverity) => s.critical + s.high + s.medium + s.low;
|
||||
const newTotal = total(newSummary);
|
||||
|
||||
// 1. Start from the cached scan of the current image, if any.
|
||||
let currentSummary: VulnerabilitySeverity | undefined;
|
||||
let currentFromCache = false;
|
||||
try {
|
||||
const cached = await getCombinedScanForImage(currentImageId, envId ?? null);
|
||||
if (cached) {
|
||||
currentSummary = cached;
|
||||
currentFromCache = true;
|
||||
log(`more_than_current: current image cached scan = ${total(cached)} vulns (${cached.critical}C/${cached.high}H/${cached.medium}M/${cached.low}L)`);
|
||||
} else {
|
||||
log(`more_than_current: no cached scan for current image`);
|
||||
}
|
||||
} catch (err: any) {
|
||||
log(`more_than_current: cache lookup failed (${err.message})`);
|
||||
}
|
||||
|
||||
const wouldBlock = currentSummary !== undefined && newTotal > total(currentSummary);
|
||||
|
||||
// 2. Re-scan the current image when the cached comparison would block, or
|
||||
// when there is no cached scan at all. A fresh scan of the SAME current
|
||||
// image is the only trustworthy basis for blocking (#1022).
|
||||
if (!currentFromCache || wouldBlock) {
|
||||
log(
|
||||
currentFromCache
|
||||
? `more_than_current: cached comparison would block (new ${newTotal} > current ${total(currentSummary!)}) — re-scanning current image to confirm`
|
||||
: `more_than_current: scanning current image (${currentImageId.substring(0, 19)}) for comparison`
|
||||
);
|
||||
try {
|
||||
const results = await scanImage(currentImageId, envId ?? undefined, (p) => {
|
||||
if (p.message) log(` [${p.scanner || 'scan'}] ${p.message}`);
|
||||
});
|
||||
if (results.length > 0) {
|
||||
const fresh = combineScanSummaries(results.map((r) => ({ summary: r.summary })));
|
||||
log(`more_than_current: current image fresh scan = ${total(fresh)} vulns (${fresh.critical}C/${fresh.high}H/${fresh.medium}M/${fresh.low}L)`);
|
||||
currentSummary = fresh;
|
||||
// Persist so the next cycle starts from a current value.
|
||||
for (const r of results) {
|
||||
try {
|
||||
await saveVulnerabilityScan({
|
||||
environmentId: envId ?? null,
|
||||
imageId: currentImageId,
|
||||
imageName: r.imageName,
|
||||
scanner: r.scanner,
|
||||
scannedAt: r.scannedAt,
|
||||
scanDuration: r.scanDuration,
|
||||
criticalCount: r.summary.critical,
|
||||
highCount: r.summary.high,
|
||||
mediumCount: r.summary.medium,
|
||||
lowCount: r.summary.low,
|
||||
negligibleCount: r.summary.negligible,
|
||||
unknownCount: r.summary.unknown,
|
||||
vulnerabilities: r.vulnerabilities,
|
||||
error: r.error ?? null
|
||||
});
|
||||
} catch { /* ignore save errors */ }
|
||||
}
|
||||
} else {
|
||||
log(`more_than_current: current image scan returned no results`);
|
||||
}
|
||||
} catch (err: any) {
|
||||
log(`more_than_current: current image scan failed (${err.message})`);
|
||||
}
|
||||
}
|
||||
|
||||
const decision = shouldBlockUpdate('more_than_current', newSummary, currentSummary);
|
||||
const curTotal = currentSummary ? total(currentSummary) : 'unknown';
|
||||
log(
|
||||
decision.blocked
|
||||
? `more_than_current: BLOCKED — new ${newTotal} > current ${curTotal}`
|
||||
: `more_than_current: allowed — new ${newTotal} <= current ${curTotal}${currentSummary === undefined ? ' (current count unavailable; not blocking)' : ''}`
|
||||
);
|
||||
return decision;
|
||||
}
|
||||
@@ -15,8 +15,7 @@ import {
|
||||
createScheduleExecution,
|
||||
updateScheduleExecution,
|
||||
appendScheduleExecutionLog,
|
||||
saveVulnerabilityScan,
|
||||
getCombinedScanForImage
|
||||
saveVulnerabilityScan
|
||||
} from '../../db';
|
||||
import {
|
||||
pullImage,
|
||||
@@ -37,7 +36,8 @@ import {
|
||||
} from '../../docker';
|
||||
import { getScannerSettings, scanImage, type ScanResult, type VulnerabilitySeverity } from '../../scanner';
|
||||
import { sendEventNotification } from '../../notifications';
|
||||
import { parseImageNameAndTag, shouldBlockUpdate, combineScanSummaries, isSystemContainer } from './update-utils';
|
||||
import { parseImageNameAndTag, combineScanSummaries, isSystemContainer, isPodmanInfraContainer } from './update-utils';
|
||||
import { resolveBlockDecision } from './block-decision';
|
||||
import { isUpdateDisabledByLabel, isHiddenByLabel } from '../../container-labels';
|
||||
|
||||
// =============================================================================
|
||||
@@ -150,54 +150,16 @@ async function scanAndCheckBlock(ctx: ScanContext): Promise<ScanOutcome> {
|
||||
}
|
||||
}
|
||||
|
||||
// Handle 'more_than_current' criteria - need to get/scan current image
|
||||
let currentScanSummary: VulnerabilitySeverity | undefined;
|
||||
if (vulnerabilityCriteria === 'more_than_current') {
|
||||
log(`Looking up cached scan for current image...`);
|
||||
try {
|
||||
const cachedScan = await getCombinedScanForImage(currentImageId, envId ?? null);
|
||||
if (cachedScan) {
|
||||
currentScanSummary = cachedScan;
|
||||
log(`Cached scan: ${currentScanSummary.critical} critical, ${currentScanSummary.high} high`);
|
||||
} else {
|
||||
log(`No cached scan found, scanning current image...`);
|
||||
const currentScanResults = await scanImage(currentImageId, envId, (progress) => {
|
||||
const tag = progress.scanner ? `[${progress.scanner}]` : '[scan]';
|
||||
if (progress.message) log(`${tag} ${progress.message}`);
|
||||
});
|
||||
if (currentScanResults.length > 0) {
|
||||
currentScanSummary = combineScanSummaries(currentScanResults);
|
||||
log(`Current image: ${currentScanSummary.critical} critical, ${currentScanSummary.high} high`);
|
||||
// Save for future use
|
||||
for (const result of currentScanResults) {
|
||||
try {
|
||||
await saveVulnerabilityScan({
|
||||
environmentId: envId ?? null,
|
||||
imageId: currentImageId,
|
||||
imageName: result.imageName,
|
||||
scanner: result.scanner,
|
||||
scannedAt: result.scannedAt,
|
||||
scanDuration: result.scanDuration,
|
||||
criticalCount: result.summary.critical,
|
||||
highCount: result.summary.high,
|
||||
mediumCount: result.summary.medium,
|
||||
lowCount: result.summary.low,
|
||||
negligibleCount: result.summary.negligible,
|
||||
unknownCount: result.summary.unknown,
|
||||
vulnerabilities: result.vulnerabilities,
|
||||
error: result.error ?? null
|
||||
});
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (cacheError: any) {
|
||||
log(`Warning: Could not get current scan: ${cacheError.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Check if update should be blocked
|
||||
const { blocked, reason } = shouldBlockUpdate(vulnerabilityCriteria, scanSummary, currentScanSummary);
|
||||
// Decide whether to block. For 'more_than_current' this re-scans the current
|
||||
// image so the comparison uses up-to-date numbers (#1022) and works on a
|
||||
// cold cache.
|
||||
const { blocked, reason } = await resolveBlockDecision(
|
||||
scanSummary,
|
||||
currentImageId,
|
||||
envId,
|
||||
vulnerabilityCriteria,
|
||||
log
|
||||
);
|
||||
|
||||
if (blocked) {
|
||||
log(`UPDATE BLOCKED: ${reason}`);
|
||||
@@ -339,6 +301,19 @@ export async function runContainerUpdate(
|
||||
return;
|
||||
}
|
||||
|
||||
// Podman pod-infra containers (#1221): silently skip before any inspect /
|
||||
// image-resolution that would fail with "Could not determine image name".
|
||||
if (isPodmanInfraContainer(containerName)) {
|
||||
log(`Skipping Podman pod-infra container`);
|
||||
await updateScheduleExecution(execution.id, {
|
||||
status: 'skipped',
|
||||
completedAt: new Date().toISOString(),
|
||||
duration: Date.now() - startTime,
|
||||
details: { reason: 'Podman pod-infra container' }
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Get the full container config to extract the image name (tag)
|
||||
const inspectData = await inspectContainer(container.id, envId) as any;
|
||||
const imageNameFromConfig = inspectData.Config?.Image;
|
||||
|
||||
@@ -30,7 +30,8 @@ import {
|
||||
} from '../../docker';
|
||||
import { sendEventNotification } from '../../notifications';
|
||||
import { getScannerSettings, scanImage, type VulnerabilitySeverity } from '../../scanner';
|
||||
import { parseImageNameAndTag, shouldBlockUpdate, combineScanSummaries, isSystemContainer } from './update-utils';
|
||||
import { parseImageNameAndTag, combineScanSummaries, isSystemContainer, isPodmanInfraContainer } from './update-utils';
|
||||
import { resolveBlockDecision } from './block-decision';
|
||||
import { isUpdateDisabledByLabel, isHiddenByLabel } from '../../container-labels';
|
||||
import { recreateContainer } from './container-update';
|
||||
|
||||
@@ -108,9 +109,12 @@ export async function runEnvUpdateCheckJob(
|
||||
// Get all containers in this environment, excluding ones hidden via
|
||||
// dockhand.hidden=true (consistent with manual check-updates, #1083).
|
||||
const allContainers = await listContainers(true, environmentId);
|
||||
const containers = allContainers.filter(c => !isHiddenByLabel(c.labels));
|
||||
// Skip hidden + Podman pod-infra (#1083, #1221)
|
||||
const containers = allContainers.filter(
|
||||
(c) => !isHiddenByLabel(c.labels) && !isPodmanInfraContainer(c.name)
|
||||
);
|
||||
const hiddenCount = allContainers.length - containers.length;
|
||||
await log(`Found ${containers.length} containers${hiddenCount ? ` (${hiddenCount} hidden by label)` : ''}`);
|
||||
await log(`Found ${containers.length} containers${hiddenCount ? ` (${hiddenCount} hidden/infra)` : ''}`);
|
||||
|
||||
const updatesAvailable: UpdateInfo[] = [];
|
||||
let checkedCount = 0;
|
||||
@@ -321,8 +325,16 @@ export async function runEnvUpdateCheckJob(
|
||||
} catch { /* ignore save errors */ }
|
||||
}
|
||||
|
||||
// Check if blocked
|
||||
const { blocked, reason } = shouldBlockUpdate(vulnerabilityCriteria, scanSummary, undefined);
|
||||
// Decide whether to block. For 'more_than_current' this
|
||||
// re-scans the current image so the comparison uses
|
||||
// up-to-date numbers (#1022) and works on a cold cache.
|
||||
const { blocked, reason } = await resolveBlockDecision(
|
||||
scanSummary,
|
||||
update.currentImageId,
|
||||
environmentId,
|
||||
vulnerabilityCriteria,
|
||||
(m) => { void log(` ${m}`); }
|
||||
);
|
||||
if (blocked) {
|
||||
scanBlocked = true;
|
||||
blockReason = reason;
|
||||
|
||||
@@ -127,6 +127,18 @@ export function isSystemContainer(imageName: string): SystemContainerType | null
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Podman creates a hidden "pod-infra" container per pod, always named
|
||||
* `<pod-id-or-name>-infra` (#1221). The infra image is locally generated
|
||||
* and can be overridden via --infra-image, so neither image nor label is
|
||||
* universal — the name suffix is the only reliable signal.
|
||||
*
|
||||
* Anchored to the end so user containers like "my-infrastructure" don't match.
|
||||
*/
|
||||
export function isPodmanInfraContainer(containerName: string | undefined): boolean {
|
||||
return !!containerName && /[-_]infra$/i.test(containerName);
|
||||
}
|
||||
|
||||
/**
|
||||
* Combine multiple scan summaries by taking the maximum of each severity level.
|
||||
*/
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
/**
|
||||
* Filename gate for stack writes (#1196).
|
||||
*
|
||||
* Lives in its own file so unit tests can import the helper without pulling
|
||||
* in stacks.ts (which transitively imports the DB layer and won't load
|
||||
* under bun:test).
|
||||
*
|
||||
* v1.0.34 locked compose/env paths to a hardcoded set (docker-compose.yml,
|
||||
* compose.yml, .env) which broke users who name their compose files after
|
||||
* the service (e.g. `headscale.yml`). v1.0.35 relaxes to any .yml / .yaml
|
||||
* file plus the `.env` family. The load-bearing security control is the
|
||||
* containment + symlink-realpath + traversal checks in validateStackPath() —
|
||||
* this gate is belt-and-suspenders to keep the write path producing
|
||||
* stack-shaped files. Still blocks authorized_keys, evil.sh, /etc/cron.d/x.
|
||||
*/
|
||||
|
||||
// Matches: foo.yml, foo.yaml, .env, foo.env, .env.local, prod.env.staging
|
||||
export const STACK_FILENAME_RE = /\.ya?ml$|(^|\.)env(\.|$)/;
|
||||
|
||||
export function isAllowedStackFilename(filename: string): boolean {
|
||||
return STACK_FILENAME_RE.test(filename);
|
||||
}
|
||||
@@ -34,6 +34,7 @@ export interface DiscoveredStack {
|
||||
sourceDir: string;
|
||||
serviceCount?: number; // Number of services defined in compose file
|
||||
runningOn?: RunningStackInfo[];
|
||||
unadoptable?: boolean; // a running container carries dockhand.adopt=false (#998)
|
||||
}
|
||||
|
||||
export interface ScanResult {
|
||||
@@ -201,6 +202,20 @@ export async function adoptStack(
|
||||
stack: DiscoveredStack,
|
||||
environmentId: number
|
||||
): Promise<{ success: boolean; adoptedName?: string; error?: string }> {
|
||||
// Defense in depth: re-check the live running stack for dockhand.adopt=false so a
|
||||
// forged/stale request can't bypass the UI filter (#998). Only enforceable while
|
||||
// the stack is running, since the label lives on containers.
|
||||
try {
|
||||
const { listComposeStacks } = await import('./stacks.js');
|
||||
const { isStackUnadoptable } = await import('./container-labels.js');
|
||||
const running = (await listComposeStacks(environmentId)).find((s) => s.name === stack.name);
|
||||
if (running && isStackUnadoptable(running.containerDetails?.map((c) => c.labels) ?? [])) {
|
||||
return { success: false, error: 'Stack is marked not adoptable (dockhand.adopt=false)' };
|
||||
}
|
||||
} catch {
|
||||
// Environment offline / unreachable — fall through; nothing to enforce against.
|
||||
}
|
||||
|
||||
// Get all existing stack sources to check for duplicates
|
||||
const existingSources = await getStackSources();
|
||||
|
||||
@@ -466,6 +481,7 @@ export async function detectRunningStacks(
|
||||
// Dynamic imports to avoid circular dependencies
|
||||
const { listComposeStacks } = await import('./stacks.js');
|
||||
const { getEnvironments } = await import('./db.js');
|
||||
const { isStackUnadoptable } = await import('./container-labels.js');
|
||||
|
||||
// Get all environments
|
||||
const environments = await getEnvironments();
|
||||
@@ -476,6 +492,8 @@ export async function detectRunningStacks(
|
||||
|
||||
// Build map of stack name -> running info across all environments
|
||||
const runningStacksMap = new Map<string, RunningStackInfo[]>();
|
||||
// Stack names that opt out of adoption via dockhand.adopt=false on any container (#998)
|
||||
const unadoptableStacks = new Set<string>();
|
||||
|
||||
// Query each environment in parallel for running stacks
|
||||
await Promise.all(
|
||||
@@ -490,6 +508,9 @@ export async function detectRunningStacks(
|
||||
containerCount: stack.containers?.length || 0
|
||||
});
|
||||
runningStacksMap.set(stack.name, existing);
|
||||
if (isStackUnadoptable(stack.containerDetails?.map((c) => c.labels) ?? [])) {
|
||||
unadoptableStacks.add(stack.name);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
if (error instanceof DockerConnectionError) {
|
||||
@@ -504,6 +525,7 @@ export async function detectRunningStacks(
|
||||
// Attach running info to discovered stacks by matching name
|
||||
return discovered.map((stack) => ({
|
||||
...stack,
|
||||
runningOn: runningStacksMap.get(stack.name)
|
||||
runningOn: runningStacksMap.get(stack.name),
|
||||
unadoptable: unadoptableStacks.has(stack.name)
|
||||
}));
|
||||
}
|
||||
|
||||
+35
-96
@@ -18,6 +18,7 @@ import {
|
||||
type DeletionApplyResult,
|
||||
type DeletionSkipReason
|
||||
} from './git-deletions';
|
||||
import { isAllowedStackFilename } from './stack-filename';
|
||||
import {
|
||||
getEnvironment,
|
||||
getSecretEnvVarsAsRecord,
|
||||
@@ -32,11 +33,10 @@ import {
|
||||
getStackSources,
|
||||
deleteStackEnvVars,
|
||||
removePendingContainerUpdate,
|
||||
getPendingContainerUpdates,
|
||||
deleteAutoUpdateSchedule,
|
||||
getAutoUpdateSetting,
|
||||
getStackSourceByComposePath,
|
||||
getExternalStackPaths,
|
||||
addExternalStackPath
|
||||
getStackSourceByComposePath
|
||||
} from './db';
|
||||
import { unregisterSchedule } from './scheduler';
|
||||
import { deleteGitStackFiles, parseEnvFileContent } from './git';
|
||||
@@ -350,19 +350,6 @@ export async function getStackDir(stackName: string, envId?: number | null): Pro
|
||||
return join(stacksDir, stackName);
|
||||
}
|
||||
|
||||
/**
|
||||
* Filenames a stack is allowed to write. Compose files in the conventional
|
||||
* names + .env. Anything else is rejected even when the directory is in
|
||||
* the allowlist, so this code path can only ever produce stack-shaped files.
|
||||
*/
|
||||
const ALLOWED_STACK_FILENAMES = new Set([
|
||||
'docker-compose.yml',
|
||||
'docker-compose.yaml',
|
||||
'compose.yml',
|
||||
'compose.yaml',
|
||||
'.env'
|
||||
]);
|
||||
|
||||
/**
|
||||
* Resolve a path against the parent's realpath when the parent exists, so
|
||||
* symlinks resolve to their canonical location. We can't realpath the leaf
|
||||
@@ -381,13 +368,6 @@ function resolveStackPath(input: string): string {
|
||||
return abs;
|
||||
}
|
||||
|
||||
function isInside(child: string, parent: string): boolean {
|
||||
const c = pathNormalize(child);
|
||||
const p = pathNormalize(parent);
|
||||
if (c === p) return true;
|
||||
return c.startsWith(p.endsWith(pathSep) ? p : p + pathSep);
|
||||
}
|
||||
|
||||
export interface StackPathValidation {
|
||||
ok: boolean;
|
||||
error?: string;
|
||||
@@ -396,15 +376,10 @@ export interface StackPathValidation {
|
||||
|
||||
/**
|
||||
* Validate that a custom compose or env file path is writable by this code
|
||||
* path. A path is accepted when it lives inside getStacksDir() or any
|
||||
* directory in the external_stack_paths allowlist (admin-controlled, set
|
||||
* in Settings → General), its filename is one of the conventional
|
||||
* compose/env names, and it does not contain .. segments. The parent is
|
||||
* resolved via realpath so a symlinked component inside an allowlisted dir
|
||||
* cannot point elsewhere.
|
||||
*
|
||||
* Callers that need to grandfather an existing admin-configured path should
|
||||
* call validateStackPathWithGrandfather() instead.
|
||||
* path. A path is accepted when:
|
||||
* - filename matches the stack-filename gate (.yml/.yaml/.env family)
|
||||
* - normalized form contains no .. segments (parent directory resolved
|
||||
* via realpath so a symlinked component can't smuggle traversal in)
|
||||
*/
|
||||
export async function validateStackPath(input: string): Promise<StackPathValidation> {
|
||||
if (!input || typeof input !== 'string') {
|
||||
@@ -420,53 +395,14 @@ export async function validateStackPath(input: string): Promise<StackPathValidat
|
||||
}
|
||||
|
||||
const filename = basename(resolvedPath);
|
||||
if (!ALLOWED_STACK_FILENAMES.has(filename)) {
|
||||
if (!isAllowedStackFilename(filename)) {
|
||||
return {
|
||||
ok: false,
|
||||
error: `File "${filename}" is not an allowed stack filename (expected one of: ${[...ALLOWED_STACK_FILENAMES].join(', ')})`
|
||||
error: `File "${filename}" is not an allowed stack filename (must end in .yml, .yaml, or .env)`
|
||||
};
|
||||
}
|
||||
|
||||
const stacksDir = getStacksDir();
|
||||
if (isInside(resolvedPath, stacksDir)) {
|
||||
return { ok: true, resolved: resolvedPath };
|
||||
}
|
||||
|
||||
const allowlist = await getExternalStackPaths();
|
||||
for (const dir of allowlist) {
|
||||
if (!dir) continue;
|
||||
const dirResolved = resolve(dir);
|
||||
if (isInside(resolvedPath, dirResolved)) {
|
||||
return { ok: true, resolved: resolvedPath };
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
ok: false,
|
||||
error: `Path "${resolvedPath}" is not inside an allowed stack directory. Add its parent directory in Settings → General → External stack paths.`
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Same as validateStackPath, but when the path comes from an existing
|
||||
* stack source row (a stored custom path the admin set previously) and
|
||||
* fails the allowlist check, add its parent dir to external_stack_paths
|
||||
* and re-validate. Lets old custom-path configurations keep working
|
||||
* without operator intervention.
|
||||
*/
|
||||
export async function validateStackPathWithGrandfather(
|
||||
input: string,
|
||||
isPreExisting: boolean
|
||||
): Promise<StackPathValidation> {
|
||||
const first = await validateStackPath(input);
|
||||
if (first.ok || !isPreExisting) return first;
|
||||
|
||||
const parent = dirname(resolveStackPath(input));
|
||||
const added = await addExternalStackPath(parent);
|
||||
if (added) {
|
||||
console.log(`[Stack] Grandfathered pre-existing custom path: ${parent}`);
|
||||
}
|
||||
return validateStackPath(input);
|
||||
return { ok: true, resolved: resolvedPath };
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -675,28 +611,16 @@ export async function saveStackComposeFile(
|
||||
const source = await getStackSource(name, envId);
|
||||
const composePath = options?.composePath || source?.composePath;
|
||||
|
||||
// Path-allowlist validation. Pre-existing stored paths grandfather into
|
||||
// the allowlist; new caller-supplied paths must already be inside it.
|
||||
// Validate every caller-supplied or stored path before any disk write.
|
||||
// See validateStackPath() docs.
|
||||
if (composePath) {
|
||||
const fromDb = !options?.composePath && !!source?.composePath;
|
||||
const v = await validateStackPathWithGrandfather(composePath, fromDb);
|
||||
if (!v.ok) return { success: false, error: v.error };
|
||||
}
|
||||
if (options?.envPath) {
|
||||
const v = await validateStackPath(options.envPath);
|
||||
if (!v.ok) return { success: false, error: v.error };
|
||||
} else if (source?.envPath && !options?.envPath) {
|
||||
// Grandfather a DB-stored env path that may have been configured before validation.
|
||||
const v = await validateStackPathWithGrandfather(source.envPath, true);
|
||||
if (!v.ok) return { success: false, error: v.error };
|
||||
}
|
||||
if (options?.oldComposePath) {
|
||||
const v = await validateStackPathWithGrandfather(options.oldComposePath, true);
|
||||
if (!v.ok) return { success: false, error: v.error };
|
||||
}
|
||||
if (options?.oldEnvPath) {
|
||||
const v = await validateStackPathWithGrandfather(options.oldEnvPath, true);
|
||||
const pathsToCheck = [
|
||||
composePath,
|
||||
options?.envPath ?? source?.envPath,
|
||||
options?.oldComposePath,
|
||||
options?.oldEnvPath
|
||||
].filter((p): p is string => !!p);
|
||||
for (const path of pathsToCheck) {
|
||||
const v = await validateStackPath(path);
|
||||
if (!v.ok) return { success: false, error: v.error };
|
||||
}
|
||||
|
||||
@@ -1817,6 +1741,18 @@ export async function listComposeStacks(envId?: number | null): Promise<ComposeS
|
||||
const containers = await listContainers(true, envId);
|
||||
const stacks = new Map<string, Set<string>>();
|
||||
|
||||
// Container IDs with pending image updates (populated by manual/scheduled update checks).
|
||||
// Used to flag stacks that contain at least one outdated container.
|
||||
const pendingUpdateIds = new Set<string>();
|
||||
if (typeof envId === 'number') {
|
||||
try {
|
||||
const pending = await getPendingContainerUpdates(envId);
|
||||
pending.forEach((p) => pendingUpdateIds.add(p.containerId));
|
||||
} catch {
|
||||
// Non-fatal: stacks just won't show update markers
|
||||
}
|
||||
}
|
||||
|
||||
containers.forEach((container) => {
|
||||
const projectLabel = container.labels['com.docker.compose.project'];
|
||||
if (projectLabel) {
|
||||
@@ -1873,7 +1809,8 @@ export async function listComposeStacks(envId?: number | null): Promise<ComposeS
|
||||
restartCount: c.restartCount || 0,
|
||||
exitCode: c.exitCode,
|
||||
created: c.created,
|
||||
labels: c.labels || {}
|
||||
labels: c.labels || {},
|
||||
updateAvailable: pendingUpdateIds.has(c.id)
|
||||
};
|
||||
})
|
||||
.sort((a, b) => {
|
||||
@@ -1887,6 +1824,8 @@ export async function listComposeStacks(envId?: number | null): Promise<ComposeS
|
||||
name,
|
||||
containers: Array.from(containerIds),
|
||||
containerDetails,
|
||||
updatesAvailable: stackContainers.some((c) => pendingUpdateIds.has(c.id)),
|
||||
updateCount: stackContainers.filter((c) => pendingUpdateIds.has(c.id)).length,
|
||||
status:
|
||||
activeTotal === 0
|
||||
? 'stopped'
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import { validateSessionById, isAuthEnabled, SESSION_COOKIE } from './auth';
|
||||
import { validateApiToken } from './api-tokens';
|
||||
import { isEnterprise } from './license';
|
||||
import { userHasAdminRole, userCanAccessEnvironment } from './db';
|
||||
import { parseCookieHeader } from '$lib/utils/cookie-parse';
|
||||
|
||||
export interface WsUpgradeAuth {
|
||||
userId: number;
|
||||
@@ -10,20 +11,6 @@ export interface WsUpgradeAuth {
|
||||
authDisabled: boolean;
|
||||
}
|
||||
|
||||
function parseCookieHeader(header: string | undefined): Record<string, string> {
|
||||
if (!header) return {};
|
||||
const out: Record<string, string> = {};
|
||||
for (const part of header.split(';')) {
|
||||
const eq = part.indexOf('=');
|
||||
if (eq < 0) continue;
|
||||
const k = part.slice(0, eq).trim();
|
||||
let v = part.slice(eq + 1).trim();
|
||||
if (v.startsWith('"') && v.endsWith('"')) v = v.slice(1, -1);
|
||||
if (k) out[k] = decodeURIComponent(v);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
type LowercasedHeaders = Record<string, string | string[] | undefined>;
|
||||
|
||||
function pickHeader(headers: LowercasedHeaders, name: string): string | undefined {
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
/**
|
||||
* Session-scoped Dockhand self-update check.
|
||||
*
|
||||
* Fires once per browser session (first sidebar mount). The result is shared
|
||||
* across every consumer (sidebar indicator, Settings → About) so we never
|
||||
* hit the registry more than once unless the user clicks "Check now."
|
||||
*
|
||||
* Endpoint requires admin; non-admin users see {error, updateAvailable: false}
|
||||
* and the consumers render nothing. No retry, no polling.
|
||||
*/
|
||||
import { writable } from 'svelte/store';
|
||||
import { browser } from '$app/environment';
|
||||
|
||||
export interface SelfUpdateState {
|
||||
checked: boolean;
|
||||
updateAvailable: boolean;
|
||||
currentImage?: string;
|
||||
currentDigest?: string;
|
||||
registryDigest?: string;
|
||||
/** Parsed semver like "1.0.36" — present when latestVersion came back from the server. */
|
||||
latestVersion?: string;
|
||||
/** Resolved newer image tag (registry/repo:newTag). */
|
||||
newImage?: string;
|
||||
}
|
||||
|
||||
const initial: SelfUpdateState = {
|
||||
checked: false,
|
||||
updateAvailable: false
|
||||
};
|
||||
|
||||
function createStore() {
|
||||
const { subscribe, set } = writable<SelfUpdateState>(initial);
|
||||
let inFlight: Promise<void> | null = null;
|
||||
let didCheck = false;
|
||||
|
||||
async function checkOnce(force = false): Promise<void> {
|
||||
if (!browser) return;
|
||||
if (inFlight) return inFlight;
|
||||
if (didCheck && !force) return;
|
||||
|
||||
inFlight = (async () => {
|
||||
try {
|
||||
const res = await fetch('/api/self-update/check', {
|
||||
headers: force ? { 'Cache-Control': 'no-cache' } : undefined
|
||||
});
|
||||
if (!res.ok) {
|
||||
set({ ...initial, checked: true });
|
||||
return;
|
||||
}
|
||||
const data = await res.json();
|
||||
set({
|
||||
checked: true,
|
||||
updateAvailable: !!data.updateAvailable,
|
||||
currentImage: data.currentImage,
|
||||
currentDigest: data.currentDigest,
|
||||
registryDigest: data.registryDigest,
|
||||
latestVersion: data.latestVersion,
|
||||
newImage: data.newImage
|
||||
});
|
||||
} catch {
|
||||
set({ ...initial, checked: true });
|
||||
} finally {
|
||||
didCheck = true;
|
||||
inFlight = null;
|
||||
}
|
||||
})();
|
||||
|
||||
return inFlight;
|
||||
}
|
||||
|
||||
return {
|
||||
subscribe,
|
||||
checkOnce,
|
||||
/** Force a fresh check (e.g. user clicked "Check now" in About). */
|
||||
refresh: () => checkOnce(true),
|
||||
/** Hand-set the result when a caller already fetched it (avoids
|
||||
* a duplicate /api/self-update/check on the wire). */
|
||||
setFromResponse: (data: {
|
||||
updateAvailable?: boolean;
|
||||
currentImage?: string;
|
||||
currentDigest?: string;
|
||||
registryDigest?: string;
|
||||
latestVersion?: string;
|
||||
newImage?: string;
|
||||
}) =>
|
||||
set({
|
||||
checked: true,
|
||||
updateAvailable: !!data.updateAvailable,
|
||||
currentImage: data.currentImage,
|
||||
currentDigest: data.currentDigest,
|
||||
registryDigest: data.registryDigest,
|
||||
latestVersion: data.latestVersion,
|
||||
newImage: data.newImage
|
||||
})
|
||||
};
|
||||
}
|
||||
|
||||
export const selfUpdate = createStore();
|
||||
+75
-35
@@ -1,5 +1,11 @@
|
||||
import { writable, derived, get } from 'svelte/store';
|
||||
import { browser } from '$app/environment';
|
||||
import {
|
||||
buildFormatters,
|
||||
formatDatePartWith,
|
||||
formatTimePartWith,
|
||||
type DateTimeFormatters
|
||||
} from '$lib/utils/date-format';
|
||||
|
||||
export type TimeFormat = '12h' | '24h';
|
||||
export type DateFormat = 'MM/DD/YYYY' | 'DD/MM/YYYY' | 'YYYY-MM-DD' | 'DD.MM.YYYY';
|
||||
@@ -41,6 +47,11 @@ export interface AppSettings {
|
||||
labelFilterMode: LabelFilterMode;
|
||||
honorProxyLabels: boolean;
|
||||
showImageChangelogLinks: boolean;
|
||||
showWhatsNew: boolean; // show the "What's New" modal after an upgrade (#1235)
|
||||
protectScannerImages: boolean;
|
||||
// Scanner Advanced settings (#1219). Empty values = use auto-detection.
|
||||
defaultScannerNetworkMode: string; // '' | 'host' | 'bridge' | 'none' | <custom-network>
|
||||
defaultScannerDns: string[]; // ['1.1.1.1', '8.8.8.8']; empty = inherit
|
||||
}
|
||||
|
||||
const DEFAULT_SETTINGS: AppSettings = {
|
||||
@@ -71,11 +82,15 @@ const DEFAULT_SETTINGS: AppSettings = {
|
||||
formatLogTimestamps: false,
|
||||
externalStackPaths: [],
|
||||
primaryStackLocation: null,
|
||||
defaultGrypeImage: 'anchore/grype:v0.110.0',
|
||||
defaultTrivyImage: 'aquasec/trivy:0.69.3',
|
||||
defaultGrypeImage: 'anchore/grype:v0.115.0',
|
||||
defaultTrivyImage: 'aquasec/trivy:0.71.2',
|
||||
labelFilterMode: 'any',
|
||||
honorProxyLabels: true,
|
||||
showImageChangelogLinks: true,
|
||||
showWhatsNew: true,
|
||||
protectScannerImages: true,
|
||||
defaultScannerNetworkMode: '',
|
||||
defaultScannerDns: [],
|
||||
defaultComposeTemplate: `version: "3.8"
|
||||
|
||||
services:
|
||||
@@ -157,7 +172,11 @@ function createSettingsStore() {
|
||||
defaultComposeTemplate: settings.defaultComposeTemplate ?? DEFAULT_SETTINGS.defaultComposeTemplate,
|
||||
labelFilterMode: settings.labelFilterMode ?? DEFAULT_SETTINGS.labelFilterMode,
|
||||
honorProxyLabels: settings.honorProxyLabels ?? DEFAULT_SETTINGS.honorProxyLabels,
|
||||
showImageChangelogLinks: settings.showImageChangelogLinks ?? DEFAULT_SETTINGS.showImageChangelogLinks
|
||||
showImageChangelogLinks: settings.showImageChangelogLinks ?? DEFAULT_SETTINGS.showImageChangelogLinks,
|
||||
showWhatsNew: settings.showWhatsNew ?? DEFAULT_SETTINGS.showWhatsNew,
|
||||
protectScannerImages: settings.protectScannerImages ?? DEFAULT_SETTINGS.protectScannerImages,
|
||||
defaultScannerNetworkMode: settings.defaultScannerNetworkMode ?? DEFAULT_SETTINGS.defaultScannerNetworkMode,
|
||||
defaultScannerDns: Array.isArray(settings.defaultScannerDns) ? settings.defaultScannerDns : DEFAULT_SETTINGS.defaultScannerDns
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
@@ -210,7 +229,11 @@ function createSettingsStore() {
|
||||
defaultComposeTemplate: updatedSettings.defaultComposeTemplate ?? DEFAULT_SETTINGS.defaultComposeTemplate,
|
||||
labelFilterMode: updatedSettings.labelFilterMode ?? DEFAULT_SETTINGS.labelFilterMode,
|
||||
honorProxyLabels: updatedSettings.honorProxyLabels ?? DEFAULT_SETTINGS.honorProxyLabels,
|
||||
showImageChangelogLinks: updatedSettings.showImageChangelogLinks ?? DEFAULT_SETTINGS.showImageChangelogLinks
|
||||
showImageChangelogLinks: updatedSettings.showImageChangelogLinks ?? DEFAULT_SETTINGS.showImageChangelogLinks,
|
||||
showWhatsNew: updatedSettings.showWhatsNew ?? DEFAULT_SETTINGS.showWhatsNew,
|
||||
protectScannerImages: updatedSettings.protectScannerImages ?? DEFAULT_SETTINGS.protectScannerImages,
|
||||
defaultScannerNetworkMode: updatedSettings.defaultScannerNetworkMode ?? DEFAULT_SETTINGS.defaultScannerNetworkMode,
|
||||
defaultScannerDns: Array.isArray(updatedSettings.defaultScannerDns) ? updatedSettings.defaultScannerDns : DEFAULT_SETTINGS.defaultScannerDns
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
@@ -293,6 +316,20 @@ function createSettingsStore() {
|
||||
return newSettings;
|
||||
});
|
||||
},
|
||||
setDefaultScannerNetworkMode: (value: string) => {
|
||||
update((current) => {
|
||||
const newSettings = { ...current, defaultScannerNetworkMode: value };
|
||||
saveSettings({ defaultScannerNetworkMode: value });
|
||||
return newSettings;
|
||||
});
|
||||
},
|
||||
setDefaultScannerDns: (value: string[]) => {
|
||||
update((current) => {
|
||||
const newSettings = { ...current, defaultScannerDns: value };
|
||||
saveSettings({ defaultScannerDns: value });
|
||||
return newSettings;
|
||||
});
|
||||
},
|
||||
setScheduleRetentionDays: (value: number) => {
|
||||
update((current) => {
|
||||
const newSettings = { ...current, scheduleRetentionDays: value };
|
||||
@@ -461,6 +498,13 @@ function createSettingsStore() {
|
||||
return newSettings;
|
||||
});
|
||||
},
|
||||
setProtectScannerImages: (value: boolean) => {
|
||||
update((current) => {
|
||||
const newSettings = { ...current, protectScannerImages: value };
|
||||
saveSettings({ protectScannerImages: value });
|
||||
return newSettings;
|
||||
});
|
||||
},
|
||||
setShowImageChangelogLinks: (value: boolean) => {
|
||||
update((current) => {
|
||||
const newSettings = { ...current, showImageChangelogLinks: value };
|
||||
@@ -468,6 +512,13 @@ function createSettingsStore() {
|
||||
return newSettings;
|
||||
});
|
||||
},
|
||||
setShowWhatsNew: (value: boolean) => {
|
||||
update((current) => {
|
||||
const newSettings = { ...current, showWhatsNew: value };
|
||||
saveSettings({ showWhatsNew: value });
|
||||
return newSettings;
|
||||
});
|
||||
},
|
||||
// Manual refresh from database
|
||||
refresh: () => {
|
||||
initialized = false;
|
||||
@@ -481,12 +532,30 @@ export const appSettings = createSettingsStore();
|
||||
// Cache current settings for synchronous access (updated reactively)
|
||||
let cachedTimeFormat: TimeFormat = DEFAULT_SETTINGS.timeFormat;
|
||||
let cachedDateFormat: DateFormat = DEFAULT_SETTINGS.dateFormat;
|
||||
let cachedDefaultTimezone: string = DEFAULT_SETTINGS.defaultTimezone;
|
||||
|
||||
// Intl.DateTimeFormat construction is expensive; build once per timezone
|
||||
// change and reuse for every format call (activity log can render hundreds of
|
||||
// rows). Built lazily on first format call so the heavy work runs only when
|
||||
// needed and after the store subscription has populated cachedDefaultTimezone.
|
||||
let formatters: DateTimeFormatters | null = null;
|
||||
|
||||
function getFormatters(): DateTimeFormatters {
|
||||
if (formatters === null) {
|
||||
formatters = buildFormatters(cachedDefaultTimezone);
|
||||
}
|
||||
return formatters;
|
||||
}
|
||||
|
||||
// Subscribe once to keep cache updated
|
||||
if (browser) {
|
||||
appSettings.subscribe((s) => {
|
||||
cachedTimeFormat = s.timeFormat;
|
||||
cachedDateFormat = s.dateFormat;
|
||||
if (s.defaultTimezone !== cachedDefaultTimezone) {
|
||||
cachedDefaultTimezone = s.defaultTimezone;
|
||||
formatters = buildFormatters(cachedDefaultTimezone);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -495,21 +564,7 @@ if (browser) {
|
||||
* This is a low-level helper - prefer formatDateTime for most uses.
|
||||
*/
|
||||
function formatDatePart(d: Date): string {
|
||||
const day = d.getDate().toString().padStart(2, '0');
|
||||
const month = (d.getMonth() + 1).toString().padStart(2, '0');
|
||||
const year = d.getFullYear();
|
||||
|
||||
switch (cachedDateFormat) {
|
||||
case 'MM/DD/YYYY':
|
||||
return `${month}/${day}/${year}`;
|
||||
case 'DD/MM/YYYY':
|
||||
return `${day}/${month}/${year}`;
|
||||
case 'YYYY-MM-DD':
|
||||
return `${year}-${month}-${day}`;
|
||||
case 'DD.MM.YYYY':
|
||||
default:
|
||||
return `${day}.${month}.${year}`;
|
||||
}
|
||||
return formatDatePartWith(d, getFormatters().date, cachedDateFormat);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -517,22 +572,7 @@ function formatDatePart(d: Date): string {
|
||||
* This is a low-level helper - prefer formatDateTime for most uses.
|
||||
*/
|
||||
function formatTimePart(d: Date, includeSeconds = false): string {
|
||||
const hours = d.getHours();
|
||||
const minutes = d.getMinutes().toString().padStart(2, '0');
|
||||
const seconds = d.getSeconds().toString().padStart(2, '0');
|
||||
|
||||
if (cachedTimeFormat === '12h') {
|
||||
const hour12 = hours === 0 ? 12 : hours > 12 ? hours - 12 : hours;
|
||||
const ampm = hours >= 12 ? 'PM' : 'AM';
|
||||
return includeSeconds
|
||||
? `${hour12}:${minutes}:${seconds} ${ampm}`
|
||||
: `${hour12}:${minutes} ${ampm}`;
|
||||
} else {
|
||||
const hour24 = hours.toString().padStart(2, '0');
|
||||
return includeSeconds
|
||||
? `${hour24}:${minutes}:${seconds}`
|
||||
: `${hour24}:${minutes}`;
|
||||
}
|
||||
return formatTimePartWith(d, getFormatters().time, cachedTimeFormat, includeSeconds);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,145 @@
|
||||
/**
|
||||
* Sidebar Menu Preferences Store for Dockhand
|
||||
*
|
||||
* Manages sidebar item order and visibility with:
|
||||
* - localStorage sync for flash-free loading
|
||||
* - Database persistence via API (app-wide without auth, per-user with auth)
|
||||
*/
|
||||
|
||||
import { writable } from 'svelte/store';
|
||||
|
||||
export interface SidebarPreferences {
|
||||
order: string[];
|
||||
hidden: string[];
|
||||
}
|
||||
|
||||
const STORAGE_KEY = 'dockhand-sidebar-preferences';
|
||||
|
||||
const DEFAULTS: SidebarPreferences = { order: [], hidden: [] };
|
||||
|
||||
// Load initial state from localStorage
|
||||
function loadFromStorage(): SidebarPreferences {
|
||||
if (typeof window === 'undefined') return DEFAULTS;
|
||||
|
||||
try {
|
||||
const stored = localStorage.getItem(STORAGE_KEY);
|
||||
if (stored) {
|
||||
const parsed = JSON.parse(stored);
|
||||
if (Array.isArray(parsed?.order) && Array.isArray(parsed?.hidden)) {
|
||||
return parsed;
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Ignore parse errors
|
||||
}
|
||||
return DEFAULTS;
|
||||
}
|
||||
|
||||
// Save to localStorage
|
||||
function saveToStorage(prefs: SidebarPreferences) {
|
||||
if (typeof window === 'undefined') return;
|
||||
try {
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(prefs));
|
||||
} catch {
|
||||
// Ignore storage errors
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply a saved order to the default menu item list.
|
||||
* Unknown hrefs in the saved order are dropped; items missing from the
|
||||
* saved order (e.g. added in a newer release) are inserted right after
|
||||
* their nearest preceding default sibling, so new items land where the
|
||||
* default layout puts them.
|
||||
*/
|
||||
export function orderItems<T extends { href: string }>(items: readonly T[], order: string[]): T[] {
|
||||
const byHref = new Map(items.map((item) => [item.href, item]));
|
||||
const result = order.filter((h) => byHref.has(h)).map((h) => byHref.get(h)!);
|
||||
|
||||
items.forEach((item, i) => {
|
||||
if (result.includes(item)) return;
|
||||
let at = 0;
|
||||
for (let j = i - 1; j >= 0; j--) {
|
||||
const k = result.indexOf(items[j]);
|
||||
if (k !== -1) {
|
||||
at = k + 1;
|
||||
break;
|
||||
}
|
||||
}
|
||||
result.splice(at, 0, item);
|
||||
});
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
function createSidebarPreferencesStore() {
|
||||
const { subscribe, set } = writable<SidebarPreferences>(loadFromStorage());
|
||||
|
||||
return {
|
||||
subscribe,
|
||||
|
||||
// Initialize from API (called on mount) - server value wins
|
||||
async init() {
|
||||
try {
|
||||
const res = await fetch('/api/preferences/sidebar');
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
const prefs = { ...DEFAULTS, ...(data.preferences || {}) };
|
||||
set(prefs);
|
||||
saveToStorage(prefs);
|
||||
}
|
||||
} catch {
|
||||
// Use localStorage fallback
|
||||
}
|
||||
},
|
||||
|
||||
// Optimistic update + async persistence
|
||||
async save(prefs: SidebarPreferences) {
|
||||
set(prefs);
|
||||
saveToStorage(prefs);
|
||||
|
||||
try {
|
||||
await fetch('/api/preferences/sidebar', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(prefs)
|
||||
});
|
||||
} catch {
|
||||
// Silently fail - localStorage has the value
|
||||
}
|
||||
},
|
||||
|
||||
// Reset to default order/visibility
|
||||
async reset() {
|
||||
set(DEFAULTS);
|
||||
if (typeof window !== 'undefined') {
|
||||
try {
|
||||
localStorage.removeItem(STORAGE_KEY);
|
||||
} catch {
|
||||
// Ignore storage errors
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
await fetch('/api/preferences/sidebar', { method: 'DELETE' });
|
||||
} catch {
|
||||
// Silently fail
|
||||
}
|
||||
},
|
||||
|
||||
// Drop the localStorage copy (on logout, so the next user on this
|
||||
// browser doesn't briefly see the previous user's layout)
|
||||
clearLocal() {
|
||||
set(DEFAULTS);
|
||||
if (typeof window !== 'undefined') {
|
||||
try {
|
||||
localStorage.removeItem(STORAGE_KEY);
|
||||
} catch {
|
||||
// Ignore storage errors
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export const sidebarPreferencesStore = createSidebarPreferencesStore();
|
||||
+31
-2
@@ -11,6 +11,17 @@ import { writable, get } from 'svelte/store';
|
||||
import { getFont, getMonospaceFont, type FontMeta } from '$lib/themes';
|
||||
|
||||
export type FontSize = 'xsmall' | 'small' | 'normal' | 'medium' | 'large' | 'xlarge';
|
||||
export type ActionIconSize = 'small' | 'normal' | 'large' | 'xlarge';
|
||||
|
||||
// Pixel values for each action icon size (#1072).
|
||||
// Normal = 12px matches the historical Tailwind w-3 default — picking
|
||||
// Normal is a no-op for existing users.
|
||||
export const ACTION_ICON_SIZE_PX: Record<ActionIconSize, number> = {
|
||||
small: 10,
|
||||
normal: 12,
|
||||
large: 16,
|
||||
xlarge: 20
|
||||
};
|
||||
|
||||
export interface ThemePreferences {
|
||||
lightTheme: string;
|
||||
@@ -21,6 +32,8 @@ export interface ThemePreferences {
|
||||
terminalFont: string;
|
||||
editorFont: string;
|
||||
animateIcons: boolean;
|
||||
coloredActionButtons: boolean;
|
||||
actionIconSize: ActionIconSize;
|
||||
}
|
||||
|
||||
const STORAGE_KEY = 'dockhand-theme';
|
||||
@@ -33,7 +46,9 @@ const defaultPrefs: ThemePreferences = {
|
||||
gridFontSize: 'normal',
|
||||
terminalFont: 'system-mono',
|
||||
editorFont: 'system-mono',
|
||||
animateIcons: true
|
||||
animateIcons: true,
|
||||
coloredActionButtons: false,
|
||||
actionIconSize: 'normal'
|
||||
};
|
||||
|
||||
// Font size scale mapping
|
||||
@@ -107,7 +122,10 @@ function createThemeStore() {
|
||||
animateIcons:
|
||||
data.animateIcons === undefined && data.animate_icons === undefined
|
||||
? true
|
||||
: !!(data.animateIcons ?? data.animate_icons)
|
||||
: !!(data.animateIcons ?? data.animate_icons),
|
||||
// Default OFF (#1072)
|
||||
coloredActionButtons: !!(data.coloredActionButtons ?? data.colored_action_buttons ?? false),
|
||||
actionIconSize: (data.actionIconSize || data.action_icon_size || 'normal') as ActionIconSize
|
||||
};
|
||||
set(prefs);
|
||||
saveToStorage(prefs);
|
||||
@@ -208,6 +226,17 @@ export function applyTheme(prefs: ThemePreferences) {
|
||||
|
||||
// Apply icon animation toggle (#1169) — single class on <html> drives a CSS rule in app.css
|
||||
document.documentElement.classList.toggle('no-icon-animation', !prefs.animateIcons);
|
||||
|
||||
// Apply colored grid action buttons + action icon size (#1072)
|
||||
applyActionButtonStyles(prefs.coloredActionButtons, prefs.actionIconSize);
|
||||
}
|
||||
|
||||
// Apply colored-action class and action icon size CSS var
|
||||
function applyActionButtonStyles(colored: boolean, size: ActionIconSize) {
|
||||
if (typeof document === 'undefined') return;
|
||||
document.documentElement.classList.toggle('colored-actions', colored);
|
||||
const px = ACTION_ICON_SIZE_PX[size] ?? ACTION_ICON_SIZE_PX.small;
|
||||
document.documentElement.style.setProperty('--action-icon-size', `${px}px`);
|
||||
}
|
||||
|
||||
// Apply font to document
|
||||
|
||||
+4
-1
@@ -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;
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
/**
|
||||
* Safe cookie header parsing for WebSocket upgrades.
|
||||
*
|
||||
* Extracted from ws-auth so it can be unit-tested without pulling in the
|
||||
* DB/SvelteKit graph (#1224).
|
||||
*/
|
||||
|
||||
export function safeDecode(v: string): string {
|
||||
// Browsers freely send cookies with stray `%` from third-party trackers and
|
||||
// legacy code. decodeURIComponent throws URIError on `%` not followed by two
|
||||
// hex digits, which would crash the entire WS upgrade — including the
|
||||
// session cookie we actually care about. Fall back to the raw value (#1224).
|
||||
try {
|
||||
return decodeURIComponent(v);
|
||||
} catch {
|
||||
return v;
|
||||
}
|
||||
}
|
||||
|
||||
export function parseCookieHeader(header: string | undefined): Record<string, string> {
|
||||
if (!header) return {};
|
||||
const out: Record<string, string> = {};
|
||||
for (const part of header.split(';')) {
|
||||
const eq = part.indexOf('=');
|
||||
if (eq < 0) continue;
|
||||
const k = part.slice(0, eq).trim();
|
||||
let v = part.slice(eq + 1).trim();
|
||||
if (v.startsWith('"') && v.endsWith('"')) v = v.slice(1, -1);
|
||||
if (k) out[k] = safeDecode(v);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
@@ -0,0 +1,156 @@
|
||||
/**
|
||||
* Pure date/time formatting helpers — no SvelteKit / store imports.
|
||||
*
|
||||
* settings.ts wraps these with cached preferences and a reactive subscription;
|
||||
* keeping the implementation here lets the formatters be unit-tested without
|
||||
* dragging in $app/environment or svelte/store.
|
||||
*/
|
||||
|
||||
export type TimeFormat = '12h' | '24h';
|
||||
export type DateFormat = 'MM/DD/YYYY' | 'DD/MM/YYYY' | 'YYYY-MM-DD' | 'DD.MM.YYYY';
|
||||
|
||||
export interface DateTimeFormatters {
|
||||
date: Intl.DateTimeFormat;
|
||||
time: Intl.DateTimeFormat;
|
||||
}
|
||||
|
||||
// Intl.DateTimeFormat construction is expensive; the caller caches one pair per
|
||||
// timezone and rebuilds only when the timezone setting changes.
|
||||
export function buildFormatters(timeZone: string | undefined): DateTimeFormatters {
|
||||
const tz = timeZone || undefined;
|
||||
return {
|
||||
date: new Intl.DateTimeFormat('en-GB', {
|
||||
timeZone: tz,
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit'
|
||||
}),
|
||||
time: new Intl.DateTimeFormat('en-GB', {
|
||||
timeZone: tz,
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
second: '2-digit',
|
||||
hour12: false
|
||||
})
|
||||
};
|
||||
}
|
||||
|
||||
function pickPart(parts: Intl.DateTimeFormatPart[], type: Intl.DateTimeFormatPartTypes): string {
|
||||
return parts.find((p) => p.type === type)?.value ?? '';
|
||||
}
|
||||
|
||||
export function formatDatePartWith(d: Date, dateFormatter: Intl.DateTimeFormat, dateFormat: DateFormat): string {
|
||||
const parts = dateFormatter.formatToParts(d);
|
||||
const day = pickPart(parts, 'day');
|
||||
const month = pickPart(parts, 'month');
|
||||
const year = pickPart(parts, 'year');
|
||||
|
||||
switch (dateFormat) {
|
||||
case 'MM/DD/YYYY':
|
||||
return `${month}/${day}/${year}`;
|
||||
case 'DD/MM/YYYY':
|
||||
return `${day}/${month}/${year}`;
|
||||
case 'YYYY-MM-DD':
|
||||
return `${year}-${month}-${day}`;
|
||||
case 'DD.MM.YYYY':
|
||||
default:
|
||||
return `${day}.${month}.${year}`;
|
||||
}
|
||||
}
|
||||
|
||||
export function formatTimePartWith(
|
||||
d: Date,
|
||||
timeFormatter: Intl.DateTimeFormat,
|
||||
timeFormat: TimeFormat,
|
||||
includeSeconds = false
|
||||
): string {
|
||||
const parts = timeFormatter.formatToParts(d);
|
||||
const hours = parseInt(pickPart(parts, 'hour'), 10);
|
||||
const minutes = pickPart(parts, 'minute');
|
||||
const seconds = pickPart(parts, 'second');
|
||||
|
||||
if (timeFormat === '12h') {
|
||||
const hour12 = hours === 0 ? 12 : hours > 12 ? hours - 12 : hours;
|
||||
const ampm = hours >= 12 ? 'PM' : 'AM';
|
||||
return includeSeconds
|
||||
? `${hour12}:${minutes}:${seconds} ${ampm}`
|
||||
: `${hour12}:${minutes} ${ampm}`;
|
||||
} else {
|
||||
const hour24 = hours.toString().padStart(2, '0');
|
||||
return includeSeconds
|
||||
? `${hour24}:${minutes}:${seconds}`
|
||||
: `${hour24}:${minutes}`;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Offset of a timezone from UTC (in ms) at a given instant.
|
||||
* Positive for zones ahead of UTC (e.g. UTC+2 -> 7_200_000).
|
||||
*/
|
||||
function tzOffsetMs(timeZone: string, at: Date): number {
|
||||
const dtf = new Intl.DateTimeFormat('en-US', {
|
||||
timeZone,
|
||||
hourCycle: 'h23',
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
second: '2-digit'
|
||||
});
|
||||
const parts = dtf.formatToParts(at);
|
||||
const get = (type: Intl.DateTimeFormatPartTypes) =>
|
||||
parseInt(parts.find((p) => p.type === type)?.value ?? '0', 10);
|
||||
const asUtc = Date.UTC(get('year'), get('month') - 1, get('day'), get('hour'), get('minute'), get('second'));
|
||||
// Drop sub-second precision of `at` - formatToParts only has seconds
|
||||
return asUtc - Math.floor(at.getTime() / 1000) * 1000;
|
||||
}
|
||||
|
||||
/**
|
||||
* Current calendar date in a timezone as 'YYYY-MM-DD'.
|
||||
* Falsy timeZone means the browser's local timezone.
|
||||
*/
|
||||
export function currentDateInTimezone(timeZone?: string): string {
|
||||
const now = new Date();
|
||||
if (!timeZone) {
|
||||
const y = now.getFullYear();
|
||||
const m = String(now.getMonth() + 1).padStart(2, '0');
|
||||
const d = String(now.getDate()).padStart(2, '0');
|
||||
return `${y}-${m}-${d}`;
|
||||
}
|
||||
const parts = new Intl.DateTimeFormat('en-CA', {
|
||||
timeZone,
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit'
|
||||
}).formatToParts(now);
|
||||
const get = (type: Intl.DateTimeFormatPartTypes) => parts.find((p) => p.type === type)?.value ?? '';
|
||||
return `${get('year')}-${get('month')}-${get('day')}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* UTC instant (ISO string) of a day boundary in a timezone: midnight for the
|
||||
* start of the day, 23:59:59.999 for the end. Used to turn date-only filter
|
||||
* values ('YYYY-MM-DD', meaning a day in the user's configured timezone) into
|
||||
* timestamps comparable against UTC-stored event times (#1269).
|
||||
* Falsy timeZone means the browser's local timezone.
|
||||
*/
|
||||
export function dayBoundaryToUtcISO(dateStr: string, timeZone: string | undefined, endOfDay: boolean): string {
|
||||
const [y, m, d] = dateStr.split('-').map(Number);
|
||||
const h = endOfDay ? 23 : 0;
|
||||
const min = endOfDay ? 59 : 0;
|
||||
const s = endOfDay ? 59 : 0;
|
||||
const ms = endOfDay ? 999 : 0;
|
||||
|
||||
if (!timeZone) {
|
||||
return new Date(y, m - 1, d, h, min, s, ms).toISOString();
|
||||
}
|
||||
|
||||
const wallClockUtc = Date.UTC(y, m - 1, d, h, min, s, ms);
|
||||
// Two passes so DST transitions near the boundary converge on the right offset
|
||||
let ts = wallClockUtc;
|
||||
for (let i = 0; i < 2; i++) {
|
||||
ts = wallClockUtc - tzOffsetMs(timeZone, new Date(ts));
|
||||
}
|
||||
return new Date(ts).toISOString();
|
||||
}
|
||||
@@ -11,12 +11,14 @@
|
||||
* cause breakage are rejected.
|
||||
*/
|
||||
|
||||
// Allowed characters: ASCII letters/digits + _ . - ( ) space + @
|
||||
// Allowed characters: ASCII letters/digits + _ . - ( ) space + @ '
|
||||
// First char: any allowed except space (no leading whitespace).
|
||||
// Last char: any allowed except space and dot (no trailing whitespace,
|
||||
// no trailing dot to avoid Windows-style "hidden" trailing-dot issues).
|
||||
// Length: 1..64.
|
||||
export const ENV_NAME_RE = /^(?! )[A-Za-z0-9_.\-() +@](?:[A-Za-z0-9_.\-() +@]{0,62}[A-Za-z0-9_\-)+@])?$/;
|
||||
// Apostrophe (#1228): safe — env.name only lands in path.join() and
|
||||
// array-form spawn(), never in a shell-interpolated string.
|
||||
export const ENV_NAME_RE = /^(?! )[A-Za-z0-9_.\-()' +@](?:[A-Za-z0-9_.\-()' +@]{0,62}[A-Za-z0-9_\-)'+@])?$/;
|
||||
|
||||
export const ENV_NAME_MAX_LENGTH = 64;
|
||||
|
||||
@@ -39,7 +41,7 @@ export function validateEnvName(name: unknown): ValidationResult {
|
||||
return {
|
||||
ok: false,
|
||||
reason:
|
||||
'Name may contain letters, digits, spaces, and any of - _ . ( ) + @ (no leading/trailing whitespace, no trailing dot, no slashes, no wildcards)'
|
||||
'Name can\'t contain slashes, wildcards, or other shell-special characters, and can\'t start with whitespace or end with whitespace or a dot'
|
||||
};
|
||||
}
|
||||
return { ok: true };
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -2,16 +2,20 @@
|
||||
* Pangolin label → public URL extraction (#2 follow-up).
|
||||
*
|
||||
* Pangolin Blueprints (https://docs.pangolin.net/manage/blueprints) annotates
|
||||
* a container with one or more proxy resources. The relevant labels for URL
|
||||
* extraction are:
|
||||
* a container with one or more resources, scoped public OR private. The
|
||||
* relevant labels for URL extraction are:
|
||||
*
|
||||
* pangolin.proxy-resources.<name>.name human-friendly label
|
||||
* pangolin.proxy-resources.<name>.full-domain public hostname (mandatory)
|
||||
* pangolin.proxy-resources.<name>.protocol http | https (defaults to https)
|
||||
* pangolin.public-resources.<name>.name human-friendly label
|
||||
* pangolin.public-resources.<name>.full-domain public hostname (mandatory)
|
||||
* pangolin.public-resources.<name>.protocol http | https (defaults to https)
|
||||
* pangolin.private-resources.<name>.{name,full-domain,protocol} private equivalents
|
||||
*
|
||||
* Both scopes resolve to a URL the user can click. The scope (`public`/
|
||||
* `private`) is preserved on the result so callers can label or filter.
|
||||
*
|
||||
* The `targets[N].port` family is intentionally ignored — Pangolin terminates
|
||||
* the public connection at full-domain; the internal target port is not part
|
||||
* of the URL a user sees.
|
||||
* the connection at full-domain; the internal target port is not part of the
|
||||
* URL a user sees.
|
||||
*
|
||||
* Returns one URL per resource that declares a full-domain. Multiple
|
||||
* resources on the same container yield multiple URLs. Identical URLs across
|
||||
@@ -19,37 +23,45 @@
|
||||
*
|
||||
* dockhand.url labels override this — Pangolin extraction is a fallback,
|
||||
* never a winner over an explicit user-provided URL.
|
||||
*
|
||||
* Earlier v1.0.34 used `pangolin.proxy-resources.*` — that label was never
|
||||
* recognised by Pangolin itself and is no longer accepted here.
|
||||
*/
|
||||
export interface PangolinUrl {
|
||||
url: string;
|
||||
/** The Pangolin resource key (the `<name>` in the label key). */
|
||||
resource: string;
|
||||
/** Scope from the label namespace — public or private. */
|
||||
scope: 'public' | 'private';
|
||||
/** Optional human-friendly name from the `.name` label, if set. */
|
||||
displayName?: string;
|
||||
}
|
||||
|
||||
const RESOURCE_KEY_RE =
|
||||
/^pangolin\.proxy-resources\.([^.]+)\.(full-domain|protocol|name)$/;
|
||||
/^pangolin\.(public|private)-resources\.([^.]+)\.(full-domain|protocol|name)$/;
|
||||
|
||||
export function extractPangolinUrls(
|
||||
labels: Record<string, string> | undefined | null
|
||||
): PangolinUrl[] {
|
||||
if (!labels) return [];
|
||||
|
||||
// Group label values by resource key.
|
||||
// Group label values by (scope, resource) key. A single resource name can
|
||||
// legitimately appear under both scopes (rare but allowed by Pangolin);
|
||||
// they're treated as independent resources here.
|
||||
const byResource = new Map<
|
||||
string,
|
||||
{ fullDomain?: string; protocol?: string; name?: string }
|
||||
{ scope: 'public' | 'private'; resource: string; fullDomain?: string; protocol?: string; name?: string }
|
||||
>();
|
||||
|
||||
for (const [key, value] of Object.entries(labels)) {
|
||||
const m = key.match(RESOURCE_KEY_RE);
|
||||
if (!m) continue;
|
||||
const [, resource, field] = m;
|
||||
let entry = byResource.get(resource);
|
||||
const [, scope, resource, field] = m as unknown as [string, 'public' | 'private', string, string];
|
||||
const groupKey = `${scope}:${resource}`;
|
||||
let entry = byResource.get(groupKey);
|
||||
if (!entry) {
|
||||
entry = {};
|
||||
byResource.set(resource, entry);
|
||||
entry = { scope, resource };
|
||||
byResource.set(groupKey, entry);
|
||||
}
|
||||
const v = (value ?? '').trim();
|
||||
if (!v) continue;
|
||||
@@ -61,7 +73,7 @@ export function extractPangolinUrls(
|
||||
const out: PangolinUrl[] = [];
|
||||
const seen = new Set<string>();
|
||||
|
||||
for (const [resource, entry] of byResource) {
|
||||
for (const entry of byResource.values()) {
|
||||
if (!entry.fullDomain) continue;
|
||||
|
||||
const proto =
|
||||
@@ -75,7 +87,8 @@ export function extractPangolinUrls(
|
||||
|
||||
out.push({
|
||||
url,
|
||||
resource,
|
||||
resource: entry.resource,
|
||||
scope: entry.scope,
|
||||
displayName: entry.name
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
/**
|
||||
* Path-relative redirect target validation.
|
||||
*
|
||||
* Returns true only for targets that are path-relative URLs ('/path?query#hash').
|
||||
* Rejects absolute URLs ('https://...', 'http://...'), protocol-relative URLs
|
||||
* ('//example.com'), backslash-prefixed forms ('\\example.com' — some browsers
|
||||
* normalize these), and non-string inputs.
|
||||
*/
|
||||
export function isSafeRedirect(target: unknown): target is string {
|
||||
if (typeof target !== 'string' || target.length === 0) return false;
|
||||
if (!target.startsWith('/')) return false;
|
||||
if (target.startsWith('//') || target.startsWith('/\\')) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the input when it passes isSafeRedirect, otherwise '/'. Use this on
|
||||
* any post-login / post-callback redirect target derived from user input.
|
||||
*/
|
||||
export function safeRedirectOrRoot(target: unknown): string {
|
||||
return isSafeRedirect(target) ? target : '/';
|
||||
}
|
||||
@@ -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,27 @@
|
||||
/**
|
||||
* Pure scanner override resolver (#1219).
|
||||
*
|
||||
* Decides which networkMode + DNS values to pass to the scanner container,
|
||||
* given (auto-detected, user-settings) inputs. Kept SvelteKit/server-free so
|
||||
* the unit tests can import it without triggering DB initialisation.
|
||||
*/
|
||||
|
||||
export function resolveScannerOverrides(
|
||||
autoDetected: { networkMode?: string; extraHosts?: string[] },
|
||||
userSettings: { networkMode?: string; dns?: string[] }
|
||||
): { networkMode?: string; dns?: string[]; extraHosts?: string[] } {
|
||||
const userNet = userSettings.networkMode?.trim();
|
||||
const networkMode = userNet ? userNet : autoDetected.networkMode || undefined;
|
||||
|
||||
const cleanedDns = (userSettings.dns ?? [])
|
||||
.map((d) => d.trim())
|
||||
.filter((d) => d.length > 0);
|
||||
const dns = cleanedDns.length > 0 ? cleanedDns : undefined;
|
||||
|
||||
const extraHosts =
|
||||
autoDetected.extraHosts && autoDetected.extraHosts.length > 0
|
||||
? autoDetected.extraHosts
|
||||
: undefined;
|
||||
|
||||
return { networkMode, dns, extraHosts };
|
||||
}
|
||||
@@ -135,16 +135,24 @@ export async function detectShells(
|
||||
/**
|
||||
* Get the best available shell from the detection result
|
||||
* Returns the user's preferred shell if available, otherwise the default
|
||||
*
|
||||
* Preference matching is by shell name (basename), not full path — so a
|
||||
* preference of `/bin/bash` will still match when the server resolved bash
|
||||
* to a non-standard path like `/usr/bin/bash` (issue #1189).
|
||||
*/
|
||||
export function getBestShell(
|
||||
result: ShellDetectionResult,
|
||||
preferredShell: string
|
||||
): string | null {
|
||||
// If preferred shell is available, use it
|
||||
if (result.shells.includes(preferredShell)) {
|
||||
return preferredShell;
|
||||
const exact = result.shells.find(s => s === preferredShell);
|
||||
if (exact) return exact;
|
||||
|
||||
const preferredName = preferredShell.split('/').pop();
|
||||
if (preferredName) {
|
||||
const byName = result.shells.find(s => s.split('/').pop() === preferredName);
|
||||
if (byName) return byName;
|
||||
}
|
||||
// Otherwise use the default shell
|
||||
|
||||
return result.defaultShell;
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -852,7 +858,7 @@
|
||||
{:else if column.id === 'actions'}
|
||||
<div class="flex items-center justify-end">
|
||||
<Button variant="ghost" size="icon" class="h-6 w-6" onclick={(e) => { e.stopPropagation(); showDetails(event); }}>
|
||||
<Eye class="w-3.5 h-3.5" />
|
||||
<Eye class="grid-action-icon grid-action-info" />
|
||||
</Button>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
@@ -1,16 +1,8 @@
|
||||
import { authorize, enterpriseRequired } from '$lib/server/authorize';
|
||||
import { getAuditLogs, type AuditLogFilters, type AuditEntityType, type AuditAction, type AuditLog } from '$lib/server/db';
|
||||
import { rowsToCSV } from '$lib/server/csv';
|
||||
import type { RequestHandler } from './$types';
|
||||
|
||||
function escapeCSV(value: string | null | undefined): string {
|
||||
if (value === null || value === undefined) return '';
|
||||
const str = String(value);
|
||||
if (str.includes(',') || str.includes('"') || str.includes('\n')) {
|
||||
return `"${str.replace(/"/g, '""')}"`;
|
||||
}
|
||||
return str;
|
||||
}
|
||||
|
||||
function formatToJSON(logs: AuditLog[]): string {
|
||||
return JSON.stringify(logs, null, 2);
|
||||
}
|
||||
@@ -34,19 +26,19 @@ function formatToCSV(logs: AuditLog[]): string {
|
||||
const rows = logs.map((log) => [
|
||||
log.id,
|
||||
log.createdAt,
|
||||
escapeCSV(log.username),
|
||||
escapeCSV(log.action),
|
||||
escapeCSV(log.entityType),
|
||||
escapeCSV(log.entityId),
|
||||
escapeCSV(log.entityName),
|
||||
log.username,
|
||||
log.action,
|
||||
log.entityType,
|
||||
log.entityId,
|
||||
log.entityName,
|
||||
log.environmentId ?? '',
|
||||
escapeCSV(log.description),
|
||||
escapeCSV(log.ipAddress),
|
||||
escapeCSV(log.userAgent),
|
||||
escapeCSV(log.details ? JSON.stringify(log.details) : '')
|
||||
log.description,
|
||||
log.ipAddress,
|
||||
log.userAgent,
|
||||
log.details ? JSON.stringify(log.details) : ''
|
||||
]);
|
||||
|
||||
return [headers.join(','), ...rows.map((row) => row.join(','))].join('\n');
|
||||
return rowsToCSV(headers, rows);
|
||||
}
|
||||
|
||||
function formatToMarkdown(logs: AuditLog[]): string {
|
||||
|
||||
@@ -2,6 +2,7 @@ import { json, redirect } from '@sveltejs/kit';
|
||||
import type { RequestHandler } from '@sveltejs/kit';
|
||||
import { buildOidcAuthorizationUrl, isAuthEnabled } from '$lib/server/auth';
|
||||
import { getOidcConfig } from '$lib/server/db';
|
||||
import { safeRedirectOrRoot } from '$lib/utils/safe-redirect';
|
||||
|
||||
// GET /api/auth/oidc/[id]/initiate - Start OIDC authentication flow
|
||||
export const GET: RequestHandler = async ({ params, url }) => {
|
||||
@@ -15,8 +16,8 @@ export const GET: RequestHandler = async ({ params, url }) => {
|
||||
return json({ error: 'Invalid configuration ID' }, { status: 400 });
|
||||
}
|
||||
|
||||
// Get redirect URL from query params
|
||||
const redirectUrl = url.searchParams.get('redirect') || '/';
|
||||
// Get redirect URL from query params (validated path-relative only)
|
||||
const redirectUrl = safeRedirectOrRoot(url.searchParams.get('redirect'));
|
||||
|
||||
try {
|
||||
const config = await getOidcConfig(id);
|
||||
@@ -56,7 +57,7 @@ export const POST: RequestHandler = async ({ params, request }) => {
|
||||
|
||||
try {
|
||||
const body = await request.json().catch(() => ({}));
|
||||
const redirectUrl = body.redirect || '/';
|
||||
const redirectUrl = safeRedirectOrRoot(body.redirect);
|
||||
|
||||
const config = await getOidcConfig(id);
|
||||
if (!config || !config.enabled) {
|
||||
|
||||
@@ -3,6 +3,7 @@ import type { RequestHandler } from '@sveltejs/kit';
|
||||
import { handleOidcCallback, createUserSession, isAuthEnabled } from '$lib/server/auth';
|
||||
import { auditAuth } from '$lib/server/audit';
|
||||
import { getClientIp } from '$lib/server/client-ip';
|
||||
import { safeRedirectOrRoot } from '$lib/utils/safe-redirect';
|
||||
|
||||
// GET /api/auth/oidc/callback - Handle OIDC callback from IdP
|
||||
export const GET: RequestHandler = async (event) => {
|
||||
@@ -54,8 +55,8 @@ export const GET: RequestHandler = async (event) => {
|
||||
providerName: result.providerName
|
||||
});
|
||||
|
||||
// Redirect to the original destination or home
|
||||
const redirectUrl = result.redirectUrl || '/';
|
||||
// Redirect to the original destination or home (validated path-relative)
|
||||
const redirectUrl = safeRedirectOrRoot(result.redirectUrl);
|
||||
throw redirect(302, redirectUrl);
|
||||
} catch (error: any) {
|
||||
// Re-throw redirect
|
||||
|
||||
@@ -9,12 +9,20 @@ import {
|
||||
import { registerSchedule, unregisterSchedule } from '$lib/server/scheduler';
|
||||
import { authorize } from '$lib/server/authorize';
|
||||
|
||||
export const GET: RequestHandler = async ({ params, url }) => {
|
||||
export const GET: RequestHandler = async ({ params, url, cookies }) => {
|
||||
const auth = await authorize(cookies);
|
||||
|
||||
const permDenied = await auth.requirePermission('schedules', 'view');
|
||||
if (permDenied) return permDenied;
|
||||
|
||||
try {
|
||||
const containerName = decodeURIComponent(params.containerName);
|
||||
const envIdParam = url.searchParams.get('env');
|
||||
const envId = envIdParam ? parseInt(envIdParam) : undefined;
|
||||
|
||||
const envDenied = await auth.requireEnvAccess(envId);
|
||||
if (envDenied) return envDenied;
|
||||
|
||||
const setting = await getAutoUpdateSetting(containerName, envId);
|
||||
|
||||
if (!setting) {
|
||||
@@ -41,15 +49,18 @@ export const GET: RequestHandler = async ({ params, url }) => {
|
||||
|
||||
export const POST: RequestHandler = async ({ params, url, request, cookies }) => {
|
||||
const auth = await authorize(cookies);
|
||||
if (auth.authEnabled && !await auth.can('schedules', 'edit')) {
|
||||
return json({ error: 'Permission denied' }, { status: 403 });
|
||||
}
|
||||
|
||||
const permDenied = await auth.requirePermission('schedules', 'edit');
|
||||
if (permDenied) return permDenied;
|
||||
|
||||
try {
|
||||
const containerName = decodeURIComponent(params.containerName);
|
||||
const envIdParam = url.searchParams.get('env');
|
||||
const envId = envIdParam ? parseInt(envIdParam) : undefined;
|
||||
|
||||
const envDenied = await auth.requireEnvAccess(envId);
|
||||
if (envDenied) return envDenied;
|
||||
|
||||
const body = await request.json();
|
||||
// Accept both camelCase and snake_case for backward compatibility
|
||||
const enabled = body.enabled;
|
||||
@@ -109,15 +120,18 @@ export const POST: RequestHandler = async ({ params, url, request, cookies }) =>
|
||||
|
||||
export const DELETE: RequestHandler = async ({ params, url, cookies }) => {
|
||||
const auth = await authorize(cookies);
|
||||
if (auth.authEnabled && !await auth.can('schedules', 'edit')) {
|
||||
return json({ error: 'Permission denied' }, { status: 403 });
|
||||
}
|
||||
|
||||
const permDenied = await auth.requirePermission('schedules', 'edit');
|
||||
if (permDenied) return permDenied;
|
||||
|
||||
try {
|
||||
const containerName = decodeURIComponent(params.containerName);
|
||||
const envIdParam = url.searchParams.get('env');
|
||||
const envId = envIdParam ? parseInt(envIdParam) : undefined;
|
||||
|
||||
const envDenied = await auth.requireEnvAccess(envId);
|
||||
if (envDenied) return envDenied;
|
||||
|
||||
// Get the setting ID before deleting
|
||||
const setting = await getAutoUpdateSetting(containerName, envId);
|
||||
const settingId = setting?.id;
|
||||
|
||||
@@ -3,6 +3,7 @@ import { getContainerArchive, statContainerPath } from '$lib/server/docker';
|
||||
import { authorize } from '$lib/server/authorize';
|
||||
import { validateDockerIdParam } from '$lib/server/docker-validation';
|
||||
import { extractFirstFileFromTar } from '$lib/server/tar-extract';
|
||||
import { attachmentContentDisposition } from '$lib/server/content-disposition';
|
||||
import type { RequestHandler } from './$types';
|
||||
|
||||
export const GET: RequestHandler = async ({ params, url, cookies }) => {
|
||||
@@ -74,7 +75,7 @@ export const GET: RequestHandler = async ({ params, url, cookies }) => {
|
||||
|
||||
const headers: Record<string, string> = {
|
||||
'Content-Type': contentType,
|
||||
'Content-Disposition': `attachment; filename="${filename}${extension}"`
|
||||
'Content-Disposition': attachmentContentDisposition(`${filename}${extension}`)
|
||||
};
|
||||
|
||||
// Set content length for compressed data
|
||||
|
||||
@@ -33,47 +33,64 @@ export const GET: RequestHandler = async ({ params, url, cookies }) => {
|
||||
|
||||
try {
|
||||
const containerId = params.id;
|
||||
const availableShells: string[] = [];
|
||||
const shellNames = SHELLS_TO_CHECK.map(s => s.path.split('/').pop()!);
|
||||
const namedPaths = new Map<string, string>(); // name → resolved absolute path
|
||||
|
||||
// Check each shell by testing if the file exists and is executable
|
||||
// Use a single command to check all shells at once for efficiency
|
||||
const checkCommand = SHELLS_TO_CHECK.map(s =>
|
||||
`test -x ${s.path} && echo "${s.path}"`
|
||||
).join('; ');
|
||||
// Resolve each shell through the container's own PATH using `command -v`,
|
||||
// so that images with shells at non-standard locations (e.g. /usr/bin/sh
|
||||
// where /bin/sh isn't a symlink, or PATH ordering that shadows the
|
||||
// shell-builtin `test`) still report correctly. `exit 0` at the end
|
||||
// keeps the exec exit code clean even when some shells are absent —
|
||||
// otherwise execInContainer treats non-zero as a thrown error and the
|
||||
// valid output is discarded (issue #1189).
|
||||
const probe =
|
||||
`for s in ${shellNames.join(' ')}; do ` +
|
||||
`p=$(command -v $s 2>/dev/null) && [ -n "$p" ] && echo "$s:$p"; ` +
|
||||
`done; exit 0`;
|
||||
|
||||
try {
|
||||
const output = await execInContainer(
|
||||
containerId,
|
||||
['sh', '-c', checkCommand],
|
||||
['sh', '-c', probe],
|
||||
envIdNum
|
||||
);
|
||||
|
||||
// Parse output - each line is an available shell path
|
||||
const lines = output.trim().split('\n').filter(Boolean);
|
||||
availableShells.push(...lines);
|
||||
for (const line of output.trim().split('\n').filter(Boolean)) {
|
||||
const colon = line.indexOf(':');
|
||||
if (colon < 0) continue;
|
||||
const name = line.slice(0, colon);
|
||||
const resolved = line.slice(colon + 1);
|
||||
if (resolved.startsWith('/')) namedPaths.set(name, resolved);
|
||||
}
|
||||
} catch {
|
||||
// If even sh fails, try checking with test commands individually
|
||||
// This handles edge cases where sh might not be available
|
||||
// `sh` itself was not invocable. Fall back to probing the canonical
|
||||
// paths directly with a non-shell-resolved test — exec each one
|
||||
// against its absolute path and rely on docker exec's own
|
||||
// "executable not found" failure to indicate absence. This handles
|
||||
// the rare image where there's no `sh` at all but bash exists.
|
||||
for (const shell of SHELLS_TO_CHECK) {
|
||||
try {
|
||||
await execInContainer(
|
||||
containerId,
|
||||
['test', '-x', shell.path],
|
||||
[shell.path, '-c', 'exit 0'],
|
||||
envIdNum
|
||||
);
|
||||
availableShells.push(shell.path);
|
||||
const name = shell.path.split('/').pop()!;
|
||||
namedPaths.set(name, shell.path);
|
||||
} catch {
|
||||
// Shell not available, continue to next
|
||||
// Shell not available at this path; try the next.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const availableShells = Array.from(namedPaths.values());
|
||||
|
||||
// Determine default shell - prefer bash, then sh, then first available
|
||||
let defaultShell: string | null = null;
|
||||
if (availableShells.includes('/bin/bash')) {
|
||||
defaultShell = '/bin/bash';
|
||||
} else if (availableShells.includes('/bin/sh')) {
|
||||
defaultShell = '/bin/sh';
|
||||
if (namedPaths.has('bash')) {
|
||||
defaultShell = namedPaths.get('bash')!;
|
||||
} else if (namedPaths.has('sh')) {
|
||||
defaultShell = namedPaths.get('sh')!;
|
||||
} else if (availableShells.length > 0) {
|
||||
defaultShell = availableShells[0];
|
||||
}
|
||||
@@ -81,11 +98,15 @@ export const GET: RequestHandler = async ({ params, url, cookies }) => {
|
||||
return json({
|
||||
shells: availableShells,
|
||||
defaultShell,
|
||||
allShells: SHELLS_TO_CHECK.map(s => ({
|
||||
path: s.path,
|
||||
label: s.label,
|
||||
available: availableShells.includes(s.path)
|
||||
}))
|
||||
allShells: SHELLS_TO_CHECK.map(s => {
|
||||
const name = s.path.split('/').pop()!;
|
||||
const resolved = namedPaths.get(name);
|
||||
return {
|
||||
path: resolved ?? s.path,
|
||||
label: s.label,
|
||||
available: namedPaths.has(name)
|
||||
};
|
||||
})
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error detecting shells:', error);
|
||||
|
||||
@@ -2,8 +2,8 @@ import { json } from '@sveltejs/kit';
|
||||
import type { RequestHandler } from './$types';
|
||||
import { authorize } from '$lib/server/authorize';
|
||||
import { listContainers, inspectContainer, checkImageUpdateAvailable } from '$lib/server/docker';
|
||||
import { clearPendingContainerUpdates, addPendingContainerUpdate } from '$lib/server/db';
|
||||
import { isSystemContainer } from '$lib/server/scheduler/tasks/update-utils';
|
||||
import { clearPendingContainerUpdates, addPendingContainerUpdate, getPendingContainerUpdates } from '$lib/server/db';
|
||||
import { isSystemContainer, isPodmanInfraContainer } from '$lib/server/scheduler/tasks/update-utils';
|
||||
import { isUpdateDisabledByLabel, isHiddenByLabel } from '$lib/server/container-labels';
|
||||
import { createJobResponse } from '$lib/server/sse';
|
||||
|
||||
@@ -21,7 +21,43 @@ export interface UpdateCheckResult {
|
||||
}
|
||||
|
||||
/**
|
||||
* Check all containers for available image updates.
|
||||
* GET = READ the cached result of the last update check (no side effects, does NOT
|
||||
* trigger a new check). Returns the containers currently flagged as having a pending
|
||||
* image update. Use POST (below) to actually run a fresh check. (issue #1266)
|
||||
*/
|
||||
export const GET: RequestHandler = async ({ url, cookies }) => {
|
||||
const auth = await authorize(cookies);
|
||||
|
||||
const envId = url.searchParams.get('env');
|
||||
const envIdNum = envId ? parseInt(envId) : undefined;
|
||||
|
||||
if (!envIdNum) {
|
||||
return json({ error: 'Environment ID required' }, { status: 400 });
|
||||
}
|
||||
|
||||
if (auth.authEnabled && !await auth.can('containers', 'view', envIdNum)) {
|
||||
return json({ error: 'Permission denied' }, { status: 403 });
|
||||
}
|
||||
|
||||
try {
|
||||
const pendingUpdates = await getPendingContainerUpdates(envIdNum);
|
||||
return json({
|
||||
environmentId: envIdNum,
|
||||
pendingUpdates: pendingUpdates.map(u => ({
|
||||
containerId: u.containerId,
|
||||
containerName: u.containerName,
|
||||
currentImage: u.currentImage,
|
||||
checkedAt: u.checkedAt
|
||||
}))
|
||||
});
|
||||
} catch (error: any) {
|
||||
console.error('Error getting pending updates:', error);
|
||||
return json({ error: 'Failed to get pending updates', details: error.message }, { status: 500 });
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* POST = TRIGGER a fresh update check across all containers (job-based).
|
||||
* Returns progress events during checking, final result when done.
|
||||
*/
|
||||
export const POST: RequestHandler = async ({ url, cookies, request }) => {
|
||||
@@ -42,9 +78,12 @@ export const POST: RequestHandler = async ({ url, cookies, request }) => {
|
||||
}
|
||||
|
||||
const allContainers = await listContainers(true, envIdNum);
|
||||
// Containers labeled dockhand.hidden=true are excluded from update checks —
|
||||
// they're invisible to the user, so we won't alert on updates for them either (#1083).
|
||||
const containers = allContainers.filter(c => !isHiddenByLabel(c.labels));
|
||||
// Skip:
|
||||
// - dockhand.hidden=true (invisible to user, so no update alerts) (#1083)
|
||||
// - Podman pod-infra containers (named <pod>-infra, never published) (#1221)
|
||||
const containers = allContainers.filter(
|
||||
(c) => !isHiddenByLabel(c.labels) && !isPodmanInfraContainer(c.name)
|
||||
);
|
||||
|
||||
send('progress', { checked: 0, total: containers.length });
|
||||
|
||||
|
||||
@@ -258,11 +258,13 @@ export const GET: RequestHandler = async ({ cookies, url }) => {
|
||||
envStats.stacks.partial = stacks.filter((s: any) => s.status === 'partial').length;
|
||||
envStats.stacks.stopped = stacks.filter((s: any) => s.status === 'stopped').length;
|
||||
|
||||
// Get latest metrics, event stats, and pending updates in parallel
|
||||
// Get latest metrics, event stats, and pending updates in parallel.
|
||||
// Each call has its own catch so one failed DB query (e.g. a corrupt
|
||||
// container_events index, #1210) doesn't poison the whole tile.
|
||||
const [latestMetrics, eventStats, pendingUpdates] = await Promise.all([
|
||||
getLatestHostMetrics(env.id),
|
||||
getContainerEventStats(env.id),
|
||||
getPendingContainerUpdates(env.id)
|
||||
getLatestHostMetrics(env.id).catch(() => null),
|
||||
getContainerEventStats(env.id).catch(() => ({ total: 0, today: 0, byAction: {} })),
|
||||
getPendingContainerUpdates(env.id).catch(() => [])
|
||||
]);
|
||||
|
||||
if (latestMetrics) {
|
||||
|
||||
@@ -195,13 +195,15 @@ async function getEnvironmentStatsProgressive(
|
||||
|
||||
// Get all database stats in parallel for better performance
|
||||
// NOTE: We do NOT block on getDockerInfo() here - slow environments would block all others
|
||||
// Instead, we determine online status from whether listContainers succeeds
|
||||
// Instead, we determine online status from whether listContainers succeeds.
|
||||
// Each call has its own catch so one failed DB query (e.g. a corrupt
|
||||
// container_events index, #1210) doesn't poison the whole tile.
|
||||
const [latestMetrics, eventStats, recentEventsResult, metricsHistory, pendingUpdates] = await Promise.all([
|
||||
getLatestHostMetrics(env.id),
|
||||
getContainerEventStats(env.id),
|
||||
getContainerEvents({ environmentId: env.id, limit: 10 }),
|
||||
getHostMetrics(metricsPointCount, env.id),
|
||||
getPendingContainerUpdates(env.id)
|
||||
getLatestHostMetrics(env.id).catch(() => null),
|
||||
getContainerEventStats(env.id).catch(() => ({ total: 0, today: 0, byAction: {} })),
|
||||
getContainerEvents({ environmentId: env.id, limit: 10 }).catch(() => ({ events: [], total: 0, limit: 10, offset: 0 })),
|
||||
getHostMetrics(metricsPointCount, env.id).catch(() => []),
|
||||
getPendingContainerUpdates(env.id).catch(() => [])
|
||||
]);
|
||||
|
||||
if (latestMetrics) {
|
||||
|
||||
@@ -1,11 +1,20 @@
|
||||
import type { RequestHandler } from './$types';
|
||||
import { getDockerEvents, EnvironmentNotFoundError } from '$lib/server/docker';
|
||||
import { getEnvironment } from '$lib/server/db';
|
||||
import { authorize } from '$lib/server/authorize';
|
||||
|
||||
export const GET: RequestHandler = async ({ url, cookies }) => {
|
||||
const auth = await authorize(cookies);
|
||||
|
||||
const permDenied = await auth.requirePermission('activity', 'view');
|
||||
if (permDenied) return permDenied;
|
||||
|
||||
export const GET: RequestHandler = async ({ url }) => {
|
||||
const envId = url.searchParams.get('env');
|
||||
const envIdNum = envId ? parseInt(envId) : undefined;
|
||||
|
||||
const envDenied = await auth.requireEnvAccess(envIdNum);
|
||||
if (envDenied) return envDenied;
|
||||
|
||||
// Early return if no environment specified
|
||||
if (!envIdNum) {
|
||||
return new Response(
|
||||
|
||||
@@ -1,38 +1,43 @@
|
||||
/**
|
||||
* Database Health Check Endpoint
|
||||
*
|
||||
* Returns detailed information about the database schema state,
|
||||
* including migration status, table existence, and connection info.
|
||||
* Public endpoint suitable for external monitoring. The public payload reports
|
||||
* enough detail to detect schema drift and table loss without exposing
|
||||
* connection details (host, port, db name, user) or the running migration tag.
|
||||
*
|
||||
* Authenticated callers with settings:view get the full payload — connection
|
||||
* string (password masked) and schema version included — which is useful for
|
||||
* operators debugging from the admin UI.
|
||||
*
|
||||
* GET /api/health/database
|
||||
*
|
||||
* Response:
|
||||
* {
|
||||
* healthy: boolean,
|
||||
* database: 'sqlite' | 'postgresql',
|
||||
* connection: string,
|
||||
* migrationsTable: boolean,
|
||||
* appliedMigrations: number,
|
||||
* pendingMigrations: number,
|
||||
* schemaVersion: string | null,
|
||||
* tables: {
|
||||
* expected: number,
|
||||
* found: number,
|
||||
* missing: string[]
|
||||
* },
|
||||
* timestamp: string
|
||||
* }
|
||||
*/
|
||||
|
||||
import { json } from '@sveltejs/kit';
|
||||
import type { RequestHandler } from './$types';
|
||||
import { checkSchemaHealth } from '$lib/server/db/drizzle';
|
||||
import { authorize } from '$lib/server/authorize';
|
||||
|
||||
export const GET: RequestHandler = async () => {
|
||||
export const GET: RequestHandler = async ({ cookies }) => {
|
||||
try {
|
||||
const health = await checkSchemaHealth();
|
||||
|
||||
return json(health, {
|
||||
const auth = await authorize(cookies);
|
||||
const showFullDetail = !auth.authEnabled
|
||||
|| (auth.isAuthenticated && await auth.can('settings', 'view'));
|
||||
|
||||
const payload = showFullDetail
|
||||
? health
|
||||
: {
|
||||
healthy: health.healthy,
|
||||
database: health.database,
|
||||
migrationsTable: health.migrationsTable,
|
||||
appliedMigrations: health.appliedMigrations,
|
||||
pendingMigrations: health.pendingMigrations,
|
||||
tables: health.tables,
|
||||
timestamp: health.timestamp
|
||||
};
|
||||
|
||||
return json(payload, {
|
||||
status: health.healthy ? 200 : 503,
|
||||
headers: {
|
||||
'Cache-Control': 'no-cache, no-store, must-revalidate'
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
};
|
||||
@@ -45,7 +45,9 @@ export const PUT: RequestHandler = async ({ request, cookies }) => {
|
||||
const validTerminalFontIds = monospaceFonts.map(f => f.id);
|
||||
const validFontSizes = ['xsmall', 'small', 'normal', 'medium', 'large', 'xlarge'];
|
||||
|
||||
const updates: { lightTheme?: string; darkTheme?: string; font?: string; fontSize?: string; gridFontSize?: string; terminalFont?: string; editorFont?: string; animateIcons?: boolean } = {};
|
||||
const validActionIconSizes = ['small', 'normal', 'large', 'xlarge'];
|
||||
|
||||
const updates: { lightTheme?: string; darkTheme?: string; font?: string; fontSize?: string; gridFontSize?: string; terminalFont?: string; editorFont?: string; animateIcons?: boolean; coloredActionButtons?: boolean; actionIconSize?: string } = {};
|
||||
|
||||
if (data.lightTheme !== undefined) {
|
||||
if (!validLightThemeIds.includes(data.lightTheme)) {
|
||||
@@ -103,6 +105,20 @@ export const PUT: RequestHandler = async ({ request, cookies }) => {
|
||||
updates.animateIcons = data.animateIcons;
|
||||
}
|
||||
|
||||
if (data.coloredActionButtons !== undefined) {
|
||||
if (typeof data.coloredActionButtons !== 'boolean') {
|
||||
return json({ error: 'Invalid coloredActionButtons' }, { status: 400 });
|
||||
}
|
||||
updates.coloredActionButtons = data.coloredActionButtons;
|
||||
}
|
||||
|
||||
if (data.actionIconSize !== undefined) {
|
||||
if (!validActionIconSizes.includes(data.actionIconSize)) {
|
||||
return json({ error: 'Invalid actionIconSize' }, { status: 400 });
|
||||
}
|
||||
updates.actionIconSize = data.actionIconSize;
|
||||
}
|
||||
|
||||
await setUserThemePreferences(currentUser.id, updates);
|
||||
|
||||
// Return updated preferences
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user