diff --git a/Dockerfile b/Dockerfile index 42bfa0c..6558613 100644 --- a/Dockerfile +++ b/Dockerfile @@ -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" \ diff --git a/VERSION b/VERSION index 0b8957a..4f312a0 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -v1.0.36 +v1.0.37 diff --git a/collector/main.go b/collector/main.go index cd96736..d00b829 100644 --- a/collector/main.go +++ b/collector/main.go @@ -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)}) } diff --git a/package.json b/package.json index dbb4c49..3600beb 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "dockhand", "private": true, - "version": "1.0.36", + "version": "1.0.37", "type": "module", "scripts": { "dev": "npx vite dev", @@ -82,9 +82,10 @@ "fast-xml-parser": "5.7.3", "js-yaml": "4.2.0", "ldapts": "8.1.3", - "nodemailer": "8.0.9", + "nodemailer": "9.0.1", "otpauth": "9.4.1", "postgres": "3.4.8", + "prom-client": "15.1.3", "qrcode": "1.5.4", "rollup": "4.60.0", "svelte-sonner": "1.0.7", @@ -103,12 +104,15 @@ "@types/better-sqlite3": "^7.6.12", "@types/js-yaml": "^4.0.9", "@types/node": "^22.10.0", - "@types/nodemailer": "7.0.11", + "@types/nodemailer": "8.0.1", "@types/qrcode": "^1.5.6", + "@types/sarif": "^2.1.7", "@types/ws": "^8.5.13", "@xterm/addon-fit": "^0.11.0", "@xterm/addon-web-links": "^0.12.0", "@xterm/xterm": "^6.0.0", + "ajv": "^8.20.0", + "ajv-draft-04": "^1.0.0", "autoprefixer": "^10.4.23", "bits-ui": "2.15.4", "clsx": "^2.1.1", diff --git a/server.js b/server.js index 5f446c2..a763ea8 100644 --- a/server.js +++ b/server.js @@ -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 }); } diff --git a/src/hooks.server.ts b/src/hooks.server.ts index 7a5c4f2..b1cc976 100644 --- a/src/hooks.server.ts +++ b/src/hooks.server.ts @@ -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( diff --git a/src/lib/components/CheckUpdatesButton.svelte b/src/lib/components/CheckUpdatesButton.svelte new file mode 100644 index 0000000..d734fc6 --- /dev/null +++ b/src/lib/components/CheckUpdatesButton.svelte @@ -0,0 +1,176 @@ + + + diff --git a/src/lib/components/ConfirmPopover.svelte b/src/lib/components/ConfirmPopover.svelte index 9f81377..d1807bb 100644 --- a/src/lib/components/ConfirmPopover.svelte +++ b/src/lib/components/ConfirmPopover.svelte @@ -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); } - + {#snippet child({ props })} + {/each} + + diff --git a/src/lib/components/app-sidebar.svelte b/src/lib/components/app-sidebar.svelte index e2c8a88..bb0e804 100644 --- a/src/lib/components/app-sidebar.svelte +++ b/src/lib/components/app-sidebar.svelte @@ -25,12 +25,20 @@ Activity, Timer, LibraryBig, - CircleArrowUp + CircleArrowUp, + Pencil, + Check, + GripVertical, + Eye, + EyeOff, + RotateCcw } from 'lucide-svelte'; + import { flip } from 'svelte/animate'; import { licenseStore } from '$lib/stores/license'; import { authStore, hasAnyAccess } from '$lib/stores/auth'; import { selfUpdate } from '$lib/stores/self-update'; import { appSettings } from '$lib/stores/settings'; + import { sidebarPreferencesStore, orderItems } from '$lib/stores/sidebar-preferences'; import * as Avatar from '$lib/components/ui/avatar'; import * as Tooltip from '$lib/components/ui/tooltip'; @@ -41,6 +49,7 @@ // One-shot Dockhand update check (#1146). Result is cached for the // browser session — the Settings → About page reads the same store. selfUpdate.checkOnce(); + sidebarPreferencesStore.init(); }); import type { Permissions } from '$lib/stores/auth'; @@ -67,6 +76,8 @@ async function handleLogout() { sidebar.setOpenMobile(false); + // Per-user layout must not leak to the next user on this browser + sidebarPreferencesStore.clearLocal(); await authStore.logout(); goto('/login'); } @@ -119,6 +130,89 @@ { href: '/audit', Icon: ClipboardList, label: 'Audit log', permission: 'audit_logs', enterpriseOnly: true }, { href: '/settings', Icon: Settings, label: 'Settings', permission: 'settings' } ] as const; + + // --- Sidebar customization (#1252): reorder + hide/show menu items --- + + let editMode = $state(false); + let dragHref = $state(null); + // Order buffer while dragging - persisted once on drop, not per dragover + let draftOrder = $state(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; + } {#snippet versionTooltip()} @@ -172,17 +266,98 @@ - {#each menuItems as item} - {#if canSeeMenuItem(item)} - - sidebar.setOpenMobile(false)}> - - - {/if} + {#each editMode ? editableItems : visibleItems as item (item.href)} +
  • + {#if editMode} +
    { + 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} + > + +
    + {:else} + sidebar.setOpenMobile(false)}> + + {/if} +
  • {/each}
    + {#if editMode} +
    + + +
    + {/if}
    @@ -222,6 +397,20 @@ {/if} + + {#if !editMode} + + {/if} {/if} + + { if (!o) showCloseConfirm = false; }}> + + + Unsaved changes + + {editingFile?.name ?? 'This file'} has unsaved changes. Save them before closing? + + + + +
    + + +
    +
    +
    +
    + {#if viewingFile}
    diff --git a/src/routes/images/+page.svelte b/src/routes/images/+page.svelte index 8d11532..57934d0 100644 --- a/src/routes/images/+page.svelte +++ b/src/routes/images/+page.svelte @@ -4,14 +4,23 @@
    - + + {#if activeTab === 'images'}
    @@ -829,10 +1034,96 @@ {/if}
    + {/if} + + {#if activeTab === 'vulnerabilities'} +
    +
    + + e.key === 'Escape' && (vulnSearch = '')} + class="pl-8 h-8 w-80 text-sm" + /> +
    + + + + +
    + + + {#snippet child({ props })} + + {/snippet} + + + exportVulnerabilities('json')}> + + JSON + + exportVulnerabilities('csv')}> + + CSV + + exportVulnerabilities('sarif')}> + + SARIF + + + + + {#if scannerEnabled} + + {/if} +
    +
    + {/if}
    - -
    + + + {#if activeTab === 'images'} + + + {#if selectedImages.size > 0} +
    {#if selectedImages.size > 0}
    {selectedInFilter.length} selected @@ -857,6 +1148,7 @@
    {/if}
    + {/if} {#if !loading && ($environments.length === 0 || !$currentEnvironment)} @@ -1171,8 +1463,33 @@ {/snippet} {/if} + {/if} +
    + + + {#if activeTab === 'vulnerabilities'} + { vulnSort = field; vulnDir = dir; }} + onRangeChange={handleVulnRange} + /> + {/if} + +
    + + + diff --git a/src/routes/images/ImageLayersView.svelte b/src/routes/images/ImageLayersView.svelte index 4025ada..aca4dc9 100644 --- a/src/routes/images/ImageLayersView.svelte +++ b/src/routes/images/ImageLayersView.svelte @@ -3,6 +3,7 @@ import { Loader2, Layers, ChevronDown, ChevronRight } from 'lucide-svelte'; import { currentEnvironment, appendEnvParam } from '$lib/stores/environment'; import { formatDateTime } from '$lib/stores/settings'; + import { highlightCommand } from '$lib/utils/highlight-command'; interface Props { imageId: string; @@ -125,37 +126,6 @@ return parts[0]; } - function highlightCommand(cmd: string): string { - if (!cmd) return ''; - - // Dockerfile instructions - const dockerInstructions = ['ADD', 'COPY', 'ENV', 'ARG', 'WORKDIR', 'RUN', 'CMD', 'ENTRYPOINT', 'EXPOSE', 'VOLUME', 'USER', 'LABEL', 'HEALTHCHECK', 'SHELL', 'ONBUILD', 'STOPSIGNAL', 'MAINTAINER', 'FROM']; - const dockerInstructionPattern = new RegExp(`\\b(${dockerInstructions.join('|')})\\b`, 'g'); - - // Shell keywords - const shellKeywords = ['if', 'then', 'else', 'elif', 'fi', 'for', 'while', 'do', 'done', 'case', 'esac', 'function', 'in', 'select']; - const shellKeywordPattern = new RegExp(`\\b(${shellKeywords.join('|')})\\b`, 'g'); - - // Common commands - const commands = ['apt-get', 'apk', 'yum', 'pip', 'npm', 'yarn', 'git', 'curl', 'wget', 'mkdir', 'cd', 'cp', 'mv', 'rm', 'chmod', 'chown', 'echo', 'cat', 'grep', 'sed', 'awk', 'tar', 'unzip', 'make', 'gcc', 'python', 'node', 'sh', 'bash']; - const commandPattern = new RegExp(`\\b(${commands.join('|')})\\b`, 'g'); - - let highlighted = cmd - // Strings in quotes - .replace(/("(?:[^"\\]|\\.)*"|'(?:[^'\\]|\\.)*')/g, '$1') - // Comments - .replace(/(#[^\n]*)/g, '$1') - // Dockerfile instructions (must be before shell keywords to take precedence) - .replace(dockerInstructionPattern, '$1') - // Shell keywords - .replace(shellKeywordPattern, '$1') - // Commands - .replace(commandPattern, '$1') - // Flags (words starting with - or --) - .replace(/(\s)(--?[a-zA-Z0-9-]+)/g, '$1$2'); - - return highlighted; - }
    diff --git a/src/routes/images/ImageScanModal.svelte b/src/routes/images/ImageScanModal.svelte index 11a9593..29c3be8 100644 --- a/src/routes/images/ImageScanModal.svelte +++ b/src/routes/images/ImageScanModal.svelte @@ -3,7 +3,9 @@ import { Button } from '$lib/components/ui/button'; import * as DropdownMenu from '$lib/components/ui/dropdown-menu'; import { Checkbox } from '$lib/components/ui/checkbox'; - import { Download, CheckCircle2, XCircle, ShieldCheck, ShieldAlert, ShieldX, FileText, FileSpreadsheet } from 'lucide-svelte'; + import { Download, CheckCircle2, XCircle, ShieldCheck, ShieldAlert, ShieldX, FileText, FileJson, FileSpreadsheet, ShieldPlus } from 'lucide-svelte'; + import { findingsToSarif } from '$lib/utils/sarif'; + import { flattenScansToFindings } from '$lib/utils/vulnerability'; import { currentEnvironment } from '$lib/stores/environment'; import ScanTab from '$lib/components/ScanTab.svelte'; import type { ScanResult } from '$lib/components/ScanTab.svelte'; @@ -224,6 +226,24 @@ downloadFile(jsonContent, filename, 'application/json'); } + function exportToSARIF() { + if (exportResults.length === 0) return; + // Reuse the shared flattener so cross-scanner duplicates (grype + trivy + // reporting the same CVE) are deduped, matching the server exports. + const findings = flattenScansToFindings( + exportResults.map((r) => ({ + imageId: r.imageId ?? imageName, + imageName, + scannedAt: new Date().toISOString(), + vulnerabilities: r.vulnerabilities + })) + ); + const sarif = findingsToSarif(findings, { toolName: 'Dockhand' }); + const scannerSuffix = exportResults.length > 1 ? 'combined' : exportResults[0].scanner; + const filename = `vuln-report-${sanitizeFilename(imageName)}-${scannerSuffix}-${new Date().toISOString().split('T')[0]}.sarif`; + downloadFile(JSON.stringify(sarif, null, 2), filename, 'application/sarif+json'); + } + const totalVulnerabilities = $derived( scanResults.reduce((total, r) => total + r.vulnerabilities.length, 0) ); @@ -312,9 +332,13 @@ CSV spreadsheet (.csv) - + JSON data (.json) + + + SARIF (.sarif) + {/if} diff --git a/src/routes/images/ScanResultsView.svelte b/src/routes/images/ScanResultsView.svelte index c085729..7368b27 100644 --- a/src/routes/images/ScanResultsView.svelte +++ b/src/routes/images/ScanResultsView.svelte @@ -1,6 +1,7 @@ + +
    + +
    + Images scanned: {summary.imagesScanned}/{summary.totalImages} + Total: {summary.total} + +
    + + {#if !scannerEnabled} +
    + +

    No vulnerability scanner is configured for this environment.

    + Configure a scanner in settings +
    + {:else} + {#if !loading && findings.length === 0} +
    + +

    No vulnerabilities found.

    +

    Click "Scan all images" to populate this view.

    +
    + {:else} +
    + + onSortChange?.(state.field as SortField, state.direction)} + onVisibleRangeChange={(start, end) => onRangeChange?.(start, end)} + > + {#snippet headerCell(column, sortState)} + {#if column.sortable} + + {:else} + {column.label} + {/if} + {/snippet} + {#snippet cell(column, finding)} + {#if column.id === 'cve'} + {#snippet cveLink(extra: Record = {})} + + {finding.cve} + + + {/snippet} + {#if finding.description} + + + {#snippet child({ props })} + {@render cveLink(props)} + {/snippet} + + +
    + +

    {finding.description}

    +
    +
    +
    + {:else} + {@render cveLink()} + {/if} + {:else if column.id === 'package'} + {finding.package} + {:else if column.id === 'severity'} + + {finding.severity} + + {:else if column.id === 'installed'} + {finding.installedVersion} + {:else if column.id === 'fixed'} + {#if finding.fixedVersion} + {finding.fixedVersion} + {:else} + No fix + {/if} + {:else if column.id === 'image'} + {finding.imageName} + {:else if column.id === 'container'} + {#if finding.containers && finding.containers.length} +
    + + {#if finding.containers.length > 1} + c.name).join(', ')}> + +{finding.containers.length - 1} + + {/if} +
    + {:else} + — + {/if} + {:else if column.id === 'stack'} + {#if finding.stacks && finding.stacks.length} + + {:else} + — + {/if} + {:else if column.id === 'scannedAt'} + {#if finding.scannedAt} + {formatDateTime(finding.scannedAt)} + {:else} + — + {/if} + {/if} + {/snippet} +
    +
    + {/if} + {/if} +
    + + diff --git a/src/routes/images/VulnerabilityScanModal.svelte b/src/routes/images/VulnerabilityScanModal.svelte new file mode 100644 index 0000000..9bda455 --- /dev/null +++ b/src/routes/images/VulnerabilityScanModal.svelte @@ -0,0 +1,312 @@ + + + + { + // While scanning, ESC cancels the operation instead of closing the modal. + if (phase === 'scanning') { e.preventDefault(); cancel(); } + }} + onInteractOutside={(e) => { + if (phase === 'scanning') { e.preventDefault(); cancel(); } + }} + > + + + + Scanning images{#if envName} on {envName}{/if} + + + {#if phase === 'scanning'} + Running the configured scanner across all images. This can take a while. + {:else if phase === 'done'} + Scan complete. + {:else if phase === 'cancelled'} + Scan cancelled. + {:else if phase === 'error'} + The scan did not finish successfully. + {/if} + + + +
    +
    + {#if phase === 'scanning'} +
    + + {#if currentImage} + Scanning + {currentImage} + ({current}/{total}) + {:else} + Preparing scan… + {/if} +
    + +
    +
    + + {#if totalFindings === 0 && log.length > 0} + No findings yet + {/if} +
    + {percent}% +
    + {:else} + +
    + {#if phase === 'done'} + + {:else if phase === 'cancelled'} + + {:else} + + {/if} + {#if phase === 'error'} + {errorMessage} + {:else} + + Scanned {scanned} image{scanned === 1 ? '' : 's'}{failed > 0 ? `, ${failed} failed` : ''} + · {totalFindings} finding{totalFindings === 1 ? '' : 's'}. + + {/if} +
    + {#if phase !== 'error'} + + {/if} + {/if} + +
    + + + {#if log.length > 0} +
    + {#each log as entry, i (i)} +
    + {#if entry.ok} + + {:else} + + {/if} + {entry.imageName} + {#if entry.ok} + {@const hasFindings = entry.scanners.some((s) => s.critical + s.high + s.medium + s.low > 0)} + {#if hasFindings} +
    + {#each SCANNERS as sc} + {@const counts = scannerCounts(entry, sc.id)} +
    + {sc.label} + {#each SEVERITY_COLS as col} + {@const n = counts ? counts[col.key] : 0} + {n} + {/each} +
    + {/each} +
    + {:else} + clean + {/if} + {:else} + failed + {/if} +
    + {/each} +
    + {:else} + +
    + Waiting for first result… +
    + {/if} +
    + + + {#if phase === 'scanning'} + + {:else} + + {/if} + +
    +
    diff --git a/src/routes/metrics/+server.ts b/src/routes/metrics/+server.ts new file mode 100644 index 0000000..5b1bc81 --- /dev/null +++ b/src/routes/metrics/+server.ts @@ -0,0 +1,42 @@ +/** + * Prometheus metrics endpoint (#339), served at /metrics (Prometheus convention). + * + * Gating: + * - Disabled unless EXPORT_METRICS=true → 404 (looks like the route doesn't exist). + * - When app auth is DISABLED, /metrics is public (mirrors /api/health), so a + * trusted-network scrape works out of the box. + * - When app auth is ENABLED, a valid session OR API bearer token is required + * (authorize() handles both) → 401 otherwise. Prometheus scrapes with a + * `authorization: { credentials: }`. + */ +import { authorize } from '$lib/server/authorize'; +import { METRICS_ENABLED, renderMetrics, metricsContentType } from '$lib/server/metrics'; +import type { RequestHandler } from './$types'; + +export const GET: RequestHandler = async ({ cookies }) => { + if (!METRICS_ENABLED) { + return new Response('Not found', { status: 404 }); + } + + const auth = await authorize(cookies); + if (auth.authEnabled && !auth.isAuthenticated) { + return new Response('Unauthorized', { + status: 401, + headers: { 'WWW-Authenticate': 'Bearer' } + }); + } + + try { + const body = await renderMetrics(); + return new Response(body, { + status: 200, + headers: { 'Content-Type': metricsContentType } + }); + } catch (error) { + console.error('[metrics] render failed:', error); + return new Response('# metrics collection failed\n', { + status: 500, + headers: { 'Content-Type': 'text/plain' } + }); + } +}; diff --git a/src/routes/registry/CopyToRegistryModal.svelte b/src/routes/registry/CopyToRegistryModal.svelte index 997ca61..c59e35b 100644 --- a/src/routes/registry/CopyToRegistryModal.svelte +++ b/src/routes/registry/CopyToRegistryModal.svelte @@ -79,7 +79,12 @@ const fullSourceImageName = $derived(() => { const tagToUse = sourceTag.trim() || 'latest'; - const imageWithTag = imageName.includes(':') ? imageName : `${imageName}:${tagToUse}`; + // "Already tagged" means a colon AFTER the last slash (or a @digest) — NOT the + // colon in a registry host:port like registry.example.com:5000/myimage, which + // would otherwise swallow the real tag and default it to :latest (#1243). + const lastSlash = imageName.lastIndexOf('/'); + const alreadyTagged = imageName.lastIndexOf(':') > lastSlash || imageName.includes('@'); + const imageWithTag = alreadyTagged ? imageName : `${imageName}:${tagToUse}`; if (sourceRegistry && !isDockerHub(sourceRegistry)) { const urlObj = new URL(sourceRegistry.url); // Include both host and path (e.g., registry.example.com/organization) diff --git a/src/routes/settings/general/GeneralTab.svelte b/src/routes/settings/general/GeneralTab.svelte index f509b67..71c3781 100644 --- a/src/routes/settings/general/GeneralTab.svelte +++ b/src/routes/settings/general/GeneralTab.svelte @@ -26,6 +26,7 @@ let showExposedPorts = $derived($appSettings.showExposedPorts); let honorProxyLabels = $derived($appSettings.honorProxyLabels); let showImageChangelogLinks = $derived($appSettings.showImageChangelogLinks); + let showWhatsNew = $derived($appSettings.showWhatsNew); let timeFormat = $derived($appSettings.timeFormat); let dateFormat = $derived($appSettings.dateFormat); let downloadFormat = $derived($appSettings.downloadFormat); @@ -351,6 +352,20 @@ services:

    Show a release-notes icon next to images with updates available

    +
    +
    + + { + appSettings.setShowWhatsNew(checked); + toast.success(checked ? "What's New popup enabled" : "What's New popup disabled"); + }} + disabled={!$canAccess('settings', 'edit')} + /> +
    +

    Show the "What's New" popup after upgrading to a new version

    +
    diff --git a/src/routes/settings/general/ScanResultsModal.svelte b/src/routes/settings/general/ScanResultsModal.svelte index 48194cb..677b54a 100644 --- a/src/routes/settings/general/ScanResultsModal.svelte +++ b/src/routes/settings/general/ScanResultsModal.svelte @@ -5,7 +5,7 @@ import { Badge } from '$lib/components/ui/badge'; import { Checkbox } from '$lib/components/ui/checkbox'; import { Label } from '$lib/components/ui/label'; - import { Search, FolderOpen, CheckCircle2, SkipForward, AlertCircle, FileText, Import, Loader2, Play, HelpCircle } from 'lucide-svelte'; + import { Search, FolderOpen, CheckCircle2, SkipForward, AlertCircle, FileText, Import, Loader2, Play, HelpCircle, Lock } from 'lucide-svelte'; import * as Tooltip from '$lib/components/ui/tooltip'; import { toast } from 'svelte-sonner'; import { onMount } from 'svelte'; @@ -23,6 +23,7 @@ envPath: string | null; sourceDir?: string; runningOn?: RunningStackInfo[]; + unadoptable?: boolean; } interface AdoptedStack { @@ -93,6 +94,7 @@ // Auto-select the environment where stack is already running, otherwise use default const newSelections = new Map(); for (const stack of result.discovered) { + if (stack.unadoptable) continue; // dockhand.adopt=false — not selectable (#998) const runningEnvId = stack.runningOn?.[0]?.envId; newSelections.set(stack.composePath, runningEnvId ?? defaultEnvId); } @@ -108,15 +110,20 @@ const selectedCount = $derived(stackSelections.size); + // Stacks marked dockhand.adopt=false can't be selected (#998), so select-all + // reasons over the adoptable subset only. + const adoptableCount = $derived( + result?.discovered.filter((s) => !s.unadoptable).length ?? 0 + ); + const allSelected = $derived( - result?.discovered.length > 0 && - stackSelections.size === result.discovered.length + adoptableCount > 0 && + stackSelections.size === adoptableCount ); const someSelected = $derived( stackSelections.size > 0 && - result?.discovered.length > 0 && - stackSelections.size < result.discovered.length + stackSelections.size < adoptableCount ); const defaultEnv = $derived(environments.find(e => e.id === defaultEnvId)); @@ -159,6 +166,7 @@ } else { const newSelections = new Map(); for (const stack of result.discovered) { + if (stack.unadoptable) continue; // dockhand.adopt=false (#998) // Preserve existing env selection or use default const existingEnv = stackSelections.get(stack.composePath); newSelections.set(stack.composePath, existingEnv ?? defaultEnvId); @@ -386,24 +394,42 @@ {@const stackEnv = stackEnvId ? environments.find(e => e.id === stackEnvId) : null}
    + s.updatesAvailable)} + onComplete={() => fetchStacks()} + /> {:else} - {stack.name} + {stack.name} {/if} {#if systemType} @@ -1569,7 +1626,7 @@ {#if stackEnvVarCounts[stack.name]} - + {stackEnvVarCounts[stack.name]} @@ -1579,6 +1636,61 @@ {/if} + {#if stack.updatesAvailable && $appSettings.highlightUpdates} + {#if source.sourceType === 'git' && source.gitStack} + + + {#snippet children()} + + {/snippet} + + {:else if source.sourceType !== 'external' && $canAccess('stacks', 'start')} + + redeployStack(stack.name, options)} + > + {#snippet children()} + + {#if (stack.updateCount ?? 0) > 1} + {stack.updateCount} + {/if} + {/snippet} + + {:else} + + + + + {#if (stack.updateCount ?? 0) > 1} + {stack.updateCount} + {/if} + + + External stack - update possible for individual containers only. + + + {/if} + {/if} + {:else if column.id === 'source'} {#if source.sourceType === 'git'}
    - {container.service} + {container.service} + {#if container.updateAvailable && $appSettings.highlightUpdates} + + + {#if $canAccess('containers', 'manage')} + updateSingleContainer(container.id, container.name)} + > + {#snippet children({ open })} + + {/snippet} + + {/if} + {#if $appSettings.showImageChangelogLinks} + {@const changelogUrl = resolveChangelogUrl(container.image, container.labels)} + {#if changelogUrl} + e.stopPropagation()} + title="View changelog" + class="shrink-0 text-amber-500 hover:text-amber-400 transition-colors" + > + + + {/if} + {/if} + + {/if} + {#if container.health} {#if container.health === 'healthy'} @@ -2408,6 +2555,16 @@ onclose={() => showFileBrowserModal = false} /> + { showBatchUpdateModal = false; singleUpdateContainerId = null; singleUpdateContainerName = null; }} + onComplete={handleSingleUpdateComplete} +/> + Promise; children: Snippet; } @@ -18,6 +20,8 @@ stackName, envId, disabled = false, + side = 'top', + align = 'end', onDeploy, children }: Props = $props(); @@ -61,8 +65,8 @@
    diff --git a/src/routes/templates/TemplateCard.svelte b/src/routes/templates/TemplateCard.svelte index 5bdd667..4040a60 100644 --- a/src/routes/templates/TemplateCard.svelte +++ b/src/routes/templates/TemplateCard.svelte @@ -3,6 +3,7 @@ import { Badge } from '$lib/components/ui/badge'; import { Package, Star, Download, Loader2, ExternalLink } from 'lucide-svelte'; import type { TemplateItem } from '../api/templates/+server'; + import { renderDescription } from '$lib/utils/template-description'; interface Props { template: TemplateItem; @@ -24,14 +25,6 @@ return String(pulls); } - // Convert markdown links [text](url) to HTML tags, strip other HTML - function renderDescription(text: string): string { - return text - .replace(/]*>([^<]+)<\/a>/gi, '[$2]($1)') // normalize HTML links to markdown first - .replace(/<[^>]+>/g, '') // strip remaining HTML tags - .replace(/\[([^\]]+)\]\(([^)]+)\)/g, '$1') - .trim(); - }