diff --git a/Dockerfile b/Dockerfile index e98dd97..79d34ec 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.3-r2" \ + " - docker-compose=5.1.3-r3" \ " - docker-cli-buildx" \ " - sqlite" \ " - postgresql-client" \ @@ -93,7 +93,7 @@ RUN cp -r node_modules/better-sqlite3/build /tmp/better-sqlite3-build \ && rm -rf node_modules/@types /tmp/better-sqlite3-build # Build Go collector -FROM --platform=$BUILDPLATFORM golang:1.25.9 AS go-builder +FROM --platform=$BUILDPLATFORM golang:1.25.10 AS go-builder ARG TARGETARCH WORKDIR /app COPY collector/ ./collector/ diff --git a/VERSION b/VERSION index db97376..385844c 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -v1.0.28 +v1.0.29 diff --git a/collector/go.mod b/collector/go.mod index a4a26b2..ffe64b0 100644 --- a/collector/go.mod +++ b/collector/go.mod @@ -1,3 +1,3 @@ module github.com/Finsys/dockhand/collector -go 1.25.9 +go 1.25.10 diff --git a/package.json b/package.json index 6e568c5..acf0e53 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "dockhand", "private": true, - "version": "1.0.28", + "version": "1.0.29", "type": "module", "scripts": { "dev": "npx vite dev", @@ -76,7 +76,7 @@ "better-sqlite3": "11.7.0", "croner": "9.1.0", "cronstrue": "3.9.0", - "devalue": "5.6.4", + "devalue": "5.8.1", "drizzle-orm": "0.45.2", "fast-xml-parser": "5.7.3", "js-yaml": "4.1.1", @@ -119,7 +119,7 @@ "lucide-svelte": "^0.562.0", "mode-watcher": "^1.1.0", "postcss": "^8.5.6", - "svelte": "5.53.5", + "svelte": "5.55.7", "svelte-check": "^4.3.5", "svelte-easy-crop": "^5.0.0", "tailwind-merge": "^3.4.0", @@ -137,6 +137,6 @@ "@codemirror/search": "6.6.0", "@lezer/common": "1.5.0", "@lezer/highlight": "1.2.3", - "devalue": "5.6.4" + "devalue": "5.8.1" } } diff --git a/src/app.css b/src/app.css index b4ca927..fbc230d 100644 --- a/src/app.css +++ b/src/app.css @@ -74,6 +74,30 @@ html { max-width: calc(90px * var(--grid-font-size-scale, 1)) !important; } +/* Scrollbar theming */ +* { + scrollbar-color: hsl(var(--border) / 0.5) transparent; + scrollbar-width: thin; +} + +::-webkit-scrollbar { + width: 8px; + height: 8px; +} + +::-webkit-scrollbar-track { + background: transparent; +} + +::-webkit-scrollbar-thumb { + background: hsl(var(--border) / 0.5); + border-radius: 4px; +} + +::-webkit-scrollbar-thumb:hover { + background: hsl(var(--border) / 0.7); +} + :root { --font-sans: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; diff --git a/src/app.d.ts b/src/app.d.ts index b79d95c..4fb1653 100644 --- a/src/app.d.ts +++ b/src/app.d.ts @@ -3,11 +3,12 @@ import type { AuthenticatedUser } from '$lib/server/auth'; -// Build-time constants injected by Vite -declare const __BUILD_DATE__: string | null; -declare const __BUILD_COMMIT__: string | null; - declare global { + // Build-time constants injected by Vite + const __APP_VERSION__: string | null; + const __BUILD_DATE__: string | null; + const __BUILD_COMMIT__: string | null; + namespace App { // interface Error {} interface Locals { diff --git a/src/lib/components/CodeEditor.svelte b/src/lib/components/CodeEditor.svelte index e36595f..6999f7e 100644 --- a/src/lib/components/CodeEditor.svelte +++ b/src/lib/components/CodeEditor.svelte @@ -405,7 +405,7 @@ new RegExp(`(? p.test(line)); diff --git a/src/lib/components/ScanTab.svelte b/src/lib/components/ScanTab.svelte index c79af14..68c83d8 100644 --- a/src/lib/components/ScanTab.svelte +++ b/src/lib/components/ScanTab.svelte @@ -38,6 +38,7 @@ imageName: string; envId?: number | null; autoStart?: boolean; + activeScanner?: 'grype' | 'trivy'; onComplete?: (results: ScanResult[]) => void; onError?: (error: string) => void; onStatusChange?: (status: ScanStatus) => void; @@ -47,6 +48,7 @@ imageName, envId = null, autoStart = false, + activeScanner = $bindable<'grype' | 'trivy'>('grype'), onComplete, onError, onStatusChange @@ -362,7 +364,7 @@ {:else}
- +
{/if} diff --git a/src/lib/components/app-sidebar.svelte b/src/lib/components/app-sidebar.svelte index a045e68..8110a34 100644 --- a/src/lib/components/app-sidebar.svelte +++ b/src/lib/components/app-sidebar.svelte @@ -27,6 +27,10 @@ import { licenseStore } from '$lib/stores/license'; import { authStore, hasAnyAccess } from '$lib/stores/auth'; 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; import type { Permissions } from '$lib/stores/auth'; @@ -155,6 +159,25 @@ + +
+ + + + {appVersion} + + + +
+
fnsys/dockhand:{appVersion}
+ {#if buildCommit} +
Commit: {buildCommit.slice(0, 7)}
+ {/if} +
+
+
+
+ {#if $authStore.authEnabled && $authStore.authenticated && $authStore.user} diff --git a/src/lib/data/changelog.json b/src/lib/data/changelog.json index 9c69f0d..a7ab23d 100644 --- a/src/lib/data/changelog.json +++ b/src/lib/data/changelog.json @@ -1,4 +1,28 @@ [ + { + "version": "1.0.29", + "date": "2026-05-17", + "changes": [ + { "type": "feature", "text": "optionally display internal (exposed) container ports alongside published ports (#193)" }, + { "type": "feature", "text": "show app version in sidebar with build info tooltip (#209)" }, + { "type": "feature", "text": "central label management — rename or delete labels across all environments (#661)" }, + { "type": "feature", "text": "find next available host port when creating or editing containers (#116)" }, + { "type": "feature", "text": "theme-aware scrollbar styling — scrollbars adapt to dark/light mode and color palettes (#462)" }, + { "type": "fix", "text": "update buttons (single, selected, and all) now respect the \"confirm dangerous actions\" setting (#638, #751)" }, + { "type": "feature", "text": "custom URL labels - dockhand.url or dockhand.port.{port}.url to add links alongside container ports (#266)" }, + { "type": "feature", "text": "generate and copy token for Hawser Standard mode with run command hint (#337)" }, + { "type": "fix", "text": "environment stack directory not cleaned up when environment is deleted (#1023)" }, + { "type": "feature", "text": "toggle to hide timestamps and container name prefix in log viewer (#124)" }, + { "type": "fix", "text": "Podman containers health status not showing (#737)" }, + { "type": "fix", "text": "containers with exit code 0 (init/migration) no longer cause stack \"partial\" status (#1026)" }, + { "type": "fix", "text": "stats stream 400 on reconnect by skipping overlapping fetches (#1044)" }, + { "type": "fix", "text": "env var validation false positive for values containing $ followed by text (#1048)" }, + { "type": "fix", "text": "git-repos directory not cleaned up when environment is deleted (#1049)" }, + { "type": "fix", "text": "webhook secret auto-generated when left empty despite hint saying otherwise (#1050)" }, + { "type": "feature", "text": "scan reports — combined or individual Grype/Trivy (#1056)" } + ], + "imageTag": "fnsys/dockhand:v1.0.29" + }, { "version": "1.0.28", "date": "2026-05-09", diff --git a/src/lib/data/dependencies.json b/src/lib/data/dependencies.json index 7edfff6..1ebf349 100644 --- a/src/lib/data/dependencies.json +++ b/src/lib/data/dependencies.json @@ -5,12 +5,6 @@ "license": "MIT", "repository": "https://github.com/codemirror/autocomplete" }, - { - "name": "@codemirror/commands", - "version": "6.10.0", - "license": "MIT", - "repository": "https://github.com/codemirror/commands" - }, { "name": "@codemirror/commands", "version": "6.10.1", @@ -79,7 +73,7 @@ }, { "name": "@codemirror/lint", - "version": "6.9.2", + "version": "6.9.5", "license": "MIT", "repository": "https://github.com/codemirror/lint" }, @@ -145,7 +139,7 @@ }, { "name": "@lezer/css", - "version": "1.3.0", + "version": "1.3.3", "license": "MIT", "repository": "https://github.com/lezer-parser/css" }, @@ -157,7 +151,7 @@ }, { "name": "@lezer/html", - "version": "1.3.12", + "version": "1.3.13", "license": "MIT", "repository": "https://github.com/lezer-parser/html" }, @@ -175,13 +169,13 @@ }, { "name": "@lezer/lr", - "version": "1.4.4", + "version": "1.4.8", "license": "MIT", "repository": "https://github.com/lezer-parser/lr" }, { "name": "@lezer/markdown", - "version": "1.6.1", + "version": "1.6.3", "license": "MIT", "repository": "https://github.com/lezer-parser/markdown" }, @@ -199,7 +193,7 @@ }, { "name": "@lezer/yaml", - "version": "1.0.3", + "version": "1.0.4", "license": "MIT", "repository": "https://github.com/lezer-parser/yaml" }, @@ -227,9 +221,15 @@ "license": "MIT", "repository": "https://github.com/simonepri/phc-format" }, + { + "name": "@rollup/rollup-darwin-arm64", + "version": "4.60.0", + "license": "MIT", + "repository": "https://github.com/rollup/rollup" + }, { "name": "@sveltejs/acorn-typescript", - "version": "1.0.8", + "version": "1.0.9", "license": "MIT", "repository": "https://github.com/sveltejs/acorn-typescript" }, @@ -257,9 +257,15 @@ "license": "MIT", "repository": "https://github.com/DefinitelyTyped/DefinitelyTyped" }, + { + "name": "@typescript-eslint/types", + "version": "8.58.0", + "license": "MIT", + "repository": "https://github.com/typescript-eslint/typescript-eslint" + }, { "name": "acorn", - "version": "8.15.0", + "version": "8.16.0", "license": "MIT", "repository": "https://github.com/acornjs/acorn" }, @@ -313,7 +319,7 @@ }, { "name": "better-sqlite3", - "version": "11.10.0", + "version": "11.7.0", "license": "MIT", "repository": "https://github.com/WiseLibs/better-sqlite3" }, @@ -365,12 +371,6 @@ "license": "MIT", "repository": "https://github.com/lukeed/clsx" }, - { - "name": "codemirror", - "version": "6.0.2", - "license": "MIT", - "repository": "https://github.com/codemirror/basic-setup" - }, { "name": "color-convert", "version": "2.0.1", @@ -425,12 +425,6 @@ "license": "Apache-2.0", "repository": "https://github.com/lovell/detect-libc" }, - { - "name": "devalue", - "version": "5.6.3", - "license": "MIT", - "repository": "https://github.com/sveltejs/devalue" - }, { "name": "devalue", "version": "5.6.4", @@ -445,13 +439,13 @@ }, { "name": "dockhand", - "version": "1.0.18", + "version": "1.0.27", "license": "UNLICENSED", "repository": null }, { "name": "drizzle-orm", - "version": "0.45.1", + "version": "0.45.2", "license": "Apache-2.0", "repository": "https://github.com/drizzle-team/drizzle-orm" }, @@ -475,7 +469,7 @@ }, { "name": "esrap", - "version": "2.2.3", + "version": "2.2.4", "license": "MIT", "repository": "https://github.com/sveltejs/esrap" }, @@ -485,6 +479,18 @@ "license": "(MIT OR WTFPL)", "repository": "https://github.com/ralphtheninja/expand-template" }, + { + "name": "fast-xml-builder", + "version": "1.1.4", + "license": "MIT", + "repository": "https://github.com/NaturalIntelligence/fast-xml-builder" + }, + { + "name": "fast-xml-parser", + "version": "5.5.8", + "license": "MIT", + "repository": "https://github.com/NaturalIntelligence/fast-xml-parser" + }, { "name": "file-uri-to-path", "version": "1.0.0", @@ -503,6 +509,12 @@ "license": "MIT", "repository": "https://github.com/mafintosh/fs-constants" }, + { + "name": "fsevents", + "version": "2.3.3", + "license": "MIT", + "repository": "https://github.com/fsevents/fsevents" + }, { "name": "get-caller-file", "version": "2.0.5", @@ -601,13 +613,13 @@ }, { "name": "node-abi", - "version": "3.87.0", + "version": "3.89.0", "license": "MIT", "repository": "https://github.com/electron/node-abi" }, { "name": "node-addon-api", - "version": "8.5.0", + "version": "8.7.0", "license": "MIT", "repository": "https://github.com/nodejs/node-addon-api" }, @@ -619,7 +631,7 @@ }, { "name": "nodemailer", - "version": "7.0.12", + "version": "8.0.5", "license": "MIT-0", "repository": "https://github.com/nodemailer/nodemailer" }, @@ -659,6 +671,12 @@ "license": "MIT", "repository": "https://github.com/sindresorhus/path-exists" }, + { + "name": "path-expression-matcher", + "version": "1.2.0", + "license": "MIT", + "repository": "https://github.com/NaturalIntelligence/path-expression-matcher" + }, { "name": "pngjs", "version": "5.0.0", @@ -679,7 +697,7 @@ }, { "name": "pump", - "version": "3.0.3", + "version": "3.0.4", "license": "MIT", "repository": "https://github.com/mafintosh/pump" }, @@ -719,6 +737,12 @@ "license": "ISC", "repository": "https://github.com/yargs/require-main-filename" }, + { + "name": "rollup", + "version": "4.60.0", + "license": "MIT", + "repository": "https://github.com/rollup/rollup" + }, { "name": "runed", "version": "0.28.0", @@ -785,6 +809,12 @@ "license": "MIT", "repository": "https://github.com/sindresorhus/strip-json-comments" }, + { + "name": "strnum", + "version": "2.2.2", + "license": "MIT", + "repository": "https://github.com/NaturalIntelligence/strnum" + }, { "name": "style-mod", "version": "4.1.3", @@ -797,12 +827,6 @@ "license": "MIT", "repository": "https://github.com/sveltejs/svelte" }, - { - "name": "svelte-dnd-action", - "version": "0.9.69", - "license": "MIT", - "repository": "https://github.com/isaacHagoel/svelte-dnd-action" - }, { "name": "svelte-sonner", "version": "1.0.7", @@ -833,6 +857,12 @@ "license": "Apache-2.0", "repository": "https://github.com/mikeal/tunnel-agent" }, + { + "name": "undici", + "version": "7.24.5", + "license": "MIT", + "repository": "https://github.com/nodejs/undici" + }, { "name": "undici-types", "version": "7.16.0", @@ -853,7 +883,7 @@ }, { "name": "webidl-conversions", - "version": "8.0.0", + "version": "8.0.1", "license": "BSD-2-Clause", "repository": "https://github.com/jsdom/webidl-conversions" }, @@ -883,7 +913,7 @@ }, { "name": "ws", - "version": "8.19.0", + "version": "8.18.0", "license": "MIT", "repository": "https://github.com/websockets/ws" }, diff --git a/src/lib/server/container-labels.ts b/src/lib/server/container-labels.ts index 28ea05f..da9f127 100644 --- a/src/lib/server/container-labels.ts +++ b/src/lib/server/container-labels.ts @@ -5,6 +5,8 @@ * - dockhand.update=false — Skip this container during auto-updates and batch updates * - dockhand.hidden=true — Hide this container from the Dockhand UI * - dockhand.notify=false — Suppress notifications for this container's events + * - dockhand.url= — Custom clickable URL displayed alongside container ports + * - dockhand.port..url= — Override the click URL for a specific published port * * 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). @@ -15,6 +17,7 @@ export const DOCKHAND_LABELS = { UPDATE: 'dockhand.update', HIDDEN: 'dockhand.hidden', NOTIFY: 'dockhand.notify', + URL: 'dockhand.url', } as const; const TRUTHY_VALUES = new Set(['true', 'yes', '1']); @@ -72,6 +75,15 @@ export function isNotifyDisabledByLabel(labels: Record | undefin return value === false; // explicitly disabled } +/** + * Get the custom URL from dockhand.url label. + * Returns the URL string if set, or undefined. + */ +export function getCustomUrl(labels: Record | undefined | null): string | undefined { + const value = getLabel(labels, DOCKHAND_LABELS.URL); + return value?.trim() || undefined; +} + /** * Extract all Dockhand label states from a container's labels. * Useful for including in API responses so the frontend knows about label overrides. @@ -80,10 +92,12 @@ export function getDockhandLabels(labels: Record | undefined | n updateDisabled: boolean; hidden: boolean; notifyDisabled: boolean; + customUrl?: string; } { return { updateDisabled: isUpdateDisabledByLabel(labels), hidden: isHiddenByLabel(labels), notifyDisabled: isNotifyDisabledByLabel(labels), + customUrl: getCustomUrl(labels), }; } diff --git a/src/lib/server/docker.ts b/src/lib/server/docker.ts index e8ca5da..88ec7ba 100644 --- a/src/lib/server/docker.ts +++ b/src/lib/server/docker.ts @@ -1046,6 +1046,7 @@ export interface ContainerInfo { networks: { [networkName: string]: { ipAddress: string } }; health?: string; restartCount: number; + exitCode?: number; mounts: Array<{ type: string; source: string; destination: string; mode: string; rw: boolean }>; labels: { [key: string]: string }; command: string; @@ -1081,6 +1082,39 @@ export async function listContainers(all = true, envId?: number | null): Promise }) ); + // Fetch health status via inspect for containers with healthchecks where Status + // string doesn't include health info (Podman compat API omits it from the list endpoint). + // Skip entirely if any container already has health in Status (Docker includes it natively). + const healthStatuses = new Map(); + const healthRegex = /\((healthy|unhealthy|health:\s*starting)\)/i; + const anyHasHealthInStatus = containers.some(c => healthRegex.test(c.Status || '')); + if (!anyHasHealthInStatus) { + // Single API call to find which containers have healthchecks configured + try { + const healthFiltered = await dockerJsonRequest( + '/containers/json?filters=' + encodeURIComponent(JSON.stringify({ health: ['healthy', 'unhealthy', 'starting'] })), + {}, envId + ); + const healthIds = new Set((healthFiltered || []).map((c: any) => c.Id)); + // Only inspect those specific containers (typically very few) + if (healthIds.size > 0) { + await Promise.all( + [...healthIds].map(async (id) => { + try { + const inspect = await inspectContainer(id, envId); + const h = inspect.State?.Health?.Status; + if (h) healthStatuses.set(id, h); + } catch { + // Ignore errors + } + }) + ); + } + } catch { + // Ignore - health filter may not be supported + } + } + return containers.map((container) => { // Extract network info with IP addresses const networks: { [networkName: string]: { ipAddress: string } } = {}; @@ -1103,14 +1137,25 @@ export async function listContainers(all = true, envId?: number | null): Promise // Extract health status from Status string // Docker formats: "(healthy)", "(unhealthy)", "(health: starting)" + // Podman compat API omits health from Status string, so fall back to inspect data let health: string | undefined; const healthMatch = container.Status?.match(/\((healthy|unhealthy|health:\s*starting)\)/i); if (healthMatch) { const matched = healthMatch[1].toLowerCase(); // Normalize "health: starting" to just "starting" health = matched.includes('starting') ? 'starting' : matched; + } else { + const inspectHealth = healthStatuses.get(container.Id); + if (inspectHealth) { + health = inspectHealth === 'health: starting' ? 'starting' : inspectHealth; + } } + // Parse exit code from Status string (e.g., "Exited (0) 5 minutes ago") + const exitCode = container.State === 'exited' + ? parseInt(container.Status?.match(/\((\d+)\)/)?.[1] ?? '-1') + : undefined; + return { id: container.Id, name: container.Names[0]?.replace(/^\//, '') || 'unnamed', @@ -1122,6 +1167,7 @@ export async function listContainers(all = true, envId?: number | null): Promise networks, health, restartCount: restartCounts.get(container.Id) || 0, + exitCode, mounts, labels: container.Labels || {}, command: container.Command || '', diff --git a/src/lib/server/git.ts b/src/lib/server/git.ts index 2751210..494cb37 100644 --- a/src/lib/server/git.ts +++ b/src/lib/server/git.ts @@ -109,6 +109,10 @@ if (!existsSync(GIT_REPOS_DIR)) { mkdirSync(GIT_REPOS_DIR, { recursive: true }); } +export function getGitReposDir(): string { + return GIT_REPOS_DIR; +} + /** * Redact all env var values for safe logging. Only key names are preserved. */ diff --git a/src/lib/server/scheduler/tasks/env-update-check.ts b/src/lib/server/scheduler/tasks/env-update-check.ts index 5d832ca..5d89228 100644 --- a/src/lib/server/scheduler/tasks/env-update-check.ts +++ b/src/lib/server/scheduler/tasks/env-update-check.ts @@ -204,7 +204,7 @@ export async function runEnvUpdateCheckJob( .map(u => { const currentShort = u.currentDigest?.substring(0, 12) || 'unknown'; const newShort = u.newDigest?.substring(0, 12) || 'unknown'; - return `- ${u.containerName} (${u.imageName})\n ${currentShort}... -> ${newShort}...`; + return `- ${u.containerName} (${u.imageName}): ${currentShort} → ${newShort}`; }) .join('\n'); diff --git a/src/lib/server/stacks.ts b/src/lib/server/stacks.ts index 5cbf54c..aeaacd0 100644 --- a/src/lib/server/stacks.ts +++ b/src/lib/server/stacks.ts @@ -77,7 +77,9 @@ export interface ContainerDetail { networks: Array<{ name: string; ipAddress: string }>; volumeCount: number; restartCount: number; + exitCode?: number; created: number; + labels: Record; } /** @@ -1545,6 +1547,12 @@ export async function listComposeStacks(envId?: number | null): Promise { const stackContainers = containers.filter((c) => containerIds.has(c.id)); const runningCount = stackContainers.filter((c) => c.state === 'running').length; + // Containers that exited with code 0 are "completed" (e.g., init/migration containers) + // and should not count against stack health + const completedCount = stackContainers.filter((c) => + c.state === 'exited' && c.exitCode === 0 + ).length; + const activeTotal = stackContainers.length - completedCount; const containerDetails: ContainerDetail[] = stackContainers .map((c) => { @@ -1580,7 +1588,9 @@ export async function listComposeStacks(envId?: number | null): Promise a.service.localeCompare(b.service)); @@ -1590,11 +1600,13 @@ export async function listComposeStacks(envId?: number | null): Promise= activeTotal + ? 'running' + : runningCount === 0 + ? 'stopped' + : 'partial' }; }); diff --git a/src/lib/stores/label-colors.ts b/src/lib/stores/label-colors.ts new file mode 100644 index 0000000..8f1af93 --- /dev/null +++ b/src/lib/stores/label-colors.ts @@ -0,0 +1,28 @@ +import { writable, get } from 'svelte/store'; +import { browser } from '$app/environment'; + +const store = writable>({}); +let loaded = false; + +async function load() { + if (!browser || loaded) return; + loaded = true; + try { + const res = await fetch('/api/labels'); + if (res.ok) { + const data = await res.json(); + store.set(data.colors || {}); + } + } catch { + // ignore + } +} + +export const labelColorOverrides = { + subscribe: store.subscribe, + load, + reload: async () => { + loaded = false; + await load(); + } +}; diff --git a/src/lib/stores/settings.ts b/src/lib/stores/settings.ts index ef0b7a2..b6102de 100644 --- a/src/lib/stores/settings.ts +++ b/src/lib/stores/settings.ts @@ -30,6 +30,7 @@ export interface AppSettings { eventPollInterval: number; metricsCollectionInterval: number; compactPorts: boolean; + showExposedPorts: boolean; formatLogTimestamps: boolean; externalStackPaths: string[]; primaryStackLocation: string | null; @@ -62,6 +63,7 @@ const DEFAULT_SETTINGS: AppSettings = { eventPollInterval: 60000, metricsCollectionInterval: 30000, compactPorts: false, + showExposedPorts: false, formatLogTimestamps: false, externalStackPaths: [], primaryStackLocation: null, @@ -125,6 +127,7 @@ function createSettingsStore() { eventPollInterval: settings.eventPollInterval ?? DEFAULT_SETTINGS.eventPollInterval, metricsCollectionInterval: settings.metricsCollectionInterval ?? DEFAULT_SETTINGS.metricsCollectionInterval, compactPorts: settings.compactPorts ?? DEFAULT_SETTINGS.compactPorts, + showExposedPorts: settings.showExposedPorts ?? DEFAULT_SETTINGS.showExposedPorts, formatLogTimestamps: settings.formatLogTimestamps ?? DEFAULT_SETTINGS.formatLogTimestamps, externalStackPaths: settings.externalStackPaths ?? DEFAULT_SETTINGS.externalStackPaths, primaryStackLocation: settings.primaryStackLocation ?? DEFAULT_SETTINGS.primaryStackLocation, @@ -174,6 +177,7 @@ function createSettingsStore() { eventPollInterval: updatedSettings.eventPollInterval ?? DEFAULT_SETTINGS.eventPollInterval, metricsCollectionInterval: updatedSettings.metricsCollectionInterval ?? DEFAULT_SETTINGS.metricsCollectionInterval, compactPorts: updatedSettings.compactPorts ?? DEFAULT_SETTINGS.compactPorts, + showExposedPorts: updatedSettings.showExposedPorts ?? DEFAULT_SETTINGS.showExposedPorts, formatLogTimestamps: updatedSettings.formatLogTimestamps ?? DEFAULT_SETTINGS.formatLogTimestamps, externalStackPaths: updatedSettings.externalStackPaths ?? DEFAULT_SETTINGS.externalStackPaths, primaryStackLocation: updatedSettings.primaryStackLocation ?? DEFAULT_SETTINGS.primaryStackLocation, @@ -361,6 +365,13 @@ function createSettingsStore() { return newSettings; }); }, + setShowExposedPorts: (value: boolean) => { + update((current) => { + const newSettings = { ...current, showExposedPorts: value }; + saveSettings({ showExposedPorts: value }); + return newSettings; + }); + }, setFormatLogTimestamps: (value: boolean) => { update((current) => { const newSettings = { ...current, formatLogTimestamps: value }; diff --git a/src/lib/types.ts b/src/lib/types.ts index af6261c..841d553 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -36,6 +36,7 @@ export interface ContainerInfo { * - null/undefined: Regular container */ systemContainer?: SystemContainerType | null; + exitCode?: number; } export interface ImageInfo { @@ -128,6 +129,7 @@ export interface StackContainer { volumeCount: number; restartCount: number; created: number; + labels: Record; } export interface ComposeStackInfo { diff --git a/src/lib/utils/label-colors.ts b/src/lib/utils/label-colors.ts index c050986..42c37cc 100644 --- a/src/lib/utils/label-colors.ts +++ b/src/lib/utils/label-colors.ts @@ -50,10 +50,21 @@ function hashString(str: string): number { return Math.abs(hash); } +/** + * Convert a hex color to rgba with alpha for backgrounds + */ +export function hexToRgba(hex: string, alpha: number = 0.15): string { + const r = parseInt(hex.slice(1, 3), 16); + const g = parseInt(hex.slice(3, 5), 16); + const b = parseInt(hex.slice(5, 7), 16); + return `rgba(${r}, ${g}, ${b}, ${alpha})`; +} + /** * Get the primary color for a label (for text/borders) */ -export function getLabelColor(label: string): string { +export function getLabelColor(label: string, customColors?: Record): string { + if (customColors?.[label]) return customColors[label]; const index = hashString(label) % LABEL_COLORS.length; return LABEL_COLORS[index]; } @@ -61,7 +72,8 @@ export function getLabelColor(label: string): string { /** * Get the background color for a label (lighter, with transparency) */ -export function getLabelBgColor(label: string): string { +export function getLabelBgColor(label: string, customColors?: Record): string { + if (customColors?.[label]) return hexToRgba(customColors[label]); const index = hashString(label) % LABEL_BG_COLORS.length; return LABEL_BG_COLORS[index]; } @@ -69,7 +81,10 @@ export function getLabelBgColor(label: string): string { /** * Get both colors for a label as an object */ -export function getLabelColors(label: string): { color: string; bgColor: string } { +export function getLabelColors(label: string, customColors?: Record): { color: string; bgColor: string } { + if (customColors?.[label]) { + return { color: customColors[label], bgColor: hexToRgba(customColors[label]) }; + } const index = hashString(label) % LABEL_COLORS.length; return { color: LABEL_COLORS[index], @@ -77,6 +92,24 @@ export function getLabelColors(label: string): { color: string; bgColor: string }; } +/** + * The available color palette for the color picker (6 columns x 6 rows) + */ +export const COLOR_PALETTE = [ + // Row 1: 400 (lighter) + '#f87171', '#fb923c', '#facc15', '#4ade80', '#2dd4bf', '#60a5fa', + // Row 2: 500 (standard) + '#ef4444', '#f97316', '#eab308', '#22c55e', '#14b8a6', '#3b82f6', + // Row 3: 600 (darker) + '#dc2626', '#ea580c', '#ca8a04', '#16a34a', '#0d9488', '#2563eb', + // Row 4: violet/pink/cyan/lime/indigo/fuchsia 400 + '#a78bfa', '#f472b6', '#22d3ee', '#a3e635', '#818cf8', '#e879f9', + // Row 5: violet/pink/cyan/lime/indigo/fuchsia 500 + '#8b5cf6', '#ec4899', '#06b6d4', '#84cc16', '#6366f1', '#d946ef', + // Row 6: neutral tones + '#78716c', '#a1a1aa', '#94a3b8', '#737373', '#57534e', '#44403c' +]; + /** * Maximum number of labels allowed per environment */ diff --git a/src/lib/utils/port-format.ts b/src/lib/utils/port-format.ts index 427fc5d..446c0d9 100644 --- a/src/lib/utils/port-format.ts +++ b/src/lib/utils/port-format.ts @@ -3,6 +3,7 @@ export interface PortMapping { privatePort: number; display: string; isRange?: boolean; + exposed?: boolean; } interface PortInfo { @@ -62,6 +63,41 @@ export function formatPorts(ports: PortInfo[] | undefined | null): PortMapping[] return result; } +/** + * Extract exposed-only (internal) ports that have no published host mapping. + * These are ports with PrivatePort but no PublicPort in Docker's port list. + */ +export function formatExposedPorts(ports: PortInfo[] | undefined | null): PortMapping[] { + if (!ports || ports.length === 0) return []; + + // Collect published private ports to exclude them + const publishedPrivate = new Set(); + for (const p of ports) { + if (p.PublicPort || p.publicPort) { + publishedPrivate.add(p.PrivatePort || p.privatePort || 0); + } + } + + const seen = new Set(); + return ports + .filter(p => { + const priv = p.PrivatePort || p.privatePort; + const pub = p.PublicPort || p.publicPort; + // Only exposed (no public mapping) and not already shown as published + return priv && !pub && !publishedPrivate.has(priv); + }) + .map(p => { + const priv = (p.PrivatePort || p.privatePort)!; + return { publicPort: 0, privatePort: priv, display: String(priv), exposed: true }; + }) + .filter(p => { + if (seen.has(p.privatePort)) return false; + seen.add(p.privatePort); + return true; + }) + .sort((a, b) => a.privatePort - b.privatePort); +} + function flushRange(items: PortMapping[], start: number, end: number, result: PortMapping[]) { const rangeLen = end - start + 1; if (rangeLen >= 3) { diff --git a/src/routes/+page.svelte b/src/routes/+page.svelte index 36ae05c..1881f4a 100644 --- a/src/routes/+page.svelte +++ b/src/routes/+page.svelte @@ -20,6 +20,7 @@ import { IsMobile } from '$lib/hooks/is-mobile.svelte'; import type { EnvironmentStats } from './api/dashboard/stats/+server'; import { getLabelColor, getLabelBgColor } from '$lib/utils/label-colors'; + import { labelColorOverrides } from '$lib/stores/label-colors'; import { Input } from '$lib/components/ui/input'; import MultiSelectFilter from '$lib/components/MultiSelectFilter.svelte'; import { appSettings } from '$lib/stores/settings'; @@ -358,12 +359,18 @@ } } + let statsStreamFetching = false; + async function fetchStatsStreaming(isRefresh = false) { + // Skip if previous fetch is still in-flight + if (statsStreamFetching) return; + // Abort any previous streaming request if (abortController) { abortController.abort(); } abortController = new AbortController(); + statsStreamFetching = true; // Set up connection timeout const timeoutController = new AbortController(); @@ -622,6 +629,7 @@ } finally { initialLoading = false; refreshing = false; + statsStreamFetching = false; } } @@ -931,8 +939,9 @@ // Chrome 77+ Page Lifecycle API - fires when frozen tab is resumed document.addEventListener('resume', handleVisibilityChange); - // Load label filter from localStorage + // Load label filter and custom label colors loadLabelFilter(); + labelColorOverrides.load(); // Load preferences first await dashboardPreferences.load(); @@ -1021,7 +1030,7 @@ type="button" class="px-2.5 py-1 text-xs font-medium rounded transition-colors border" style={isSelected - ? `background-color: ${getLabelBgColor(label)}; border-color: ${getLabelColor(label)}; color: ${getLabelColor(label)};` + ? `background-color: ${getLabelBgColor(label, $labelColorOverrides)}; border-color: ${getLabelColor(label, $labelColorOverrides)}; color: ${getLabelColor(label, $labelColorOverrides)};` : `background-color: transparent; border-color: hsl(var(--border)); color: hsl(var(--muted-foreground));`} onclick={() => toggleLabel(label)} > diff --git a/src/routes/api/dashboard/stats/stream/+server.ts b/src/routes/api/dashboard/stats/stream/+server.ts index 48c73c7..36214e9 100644 --- a/src/routes/api/dashboard/stats/stream/+server.ts +++ b/src/routes/api/dashboard/stats/stream/+server.ts @@ -303,7 +303,7 @@ async function getEnvironmentStatsProgressive( envStats.containers.total = containers.length; envStats.containers.running = containers.filter((c: any) => c.state === 'running').length; - envStats.containers.stopped = containers.filter((c: any) => c.state === 'exited').length; + envStats.containers.stopped = containers.filter((c: any) => c.state === 'exited' && c.exitCode !== 0).length; envStats.containers.paused = containers.filter((c: any) => c.state === 'paused').length; envStats.containers.restarting = containers.filter((c: any) => c.state === 'restarting').length; envStats.containers.unhealthy = containers.filter((c: any) => c.health === 'unhealthy').length; diff --git a/src/routes/api/environments/[id]/+server.ts b/src/routes/api/environments/[id]/+server.ts index c1b7f80..2f41ecb 100644 --- a/src/routes/api/environments/[id]/+server.ts +++ b/src/routes/api/environments/[id]/+server.ts @@ -1,8 +1,11 @@ import { json } from '@sveltejs/kit'; +import { join } from 'path'; +import { existsSync, rmSync } from 'fs'; import type { RequestHandler } from './$types'; import { getEnvironment, updateEnvironment, deleteEnvironment, getEnvironmentPublicIps, setEnvironmentPublicIp, deleteEnvironmentPublicIp, deleteEnvUpdateCheckSettings, deleteImagePruneSettings, getGitStacksForEnvironmentOnly, deleteGitStack } from '$lib/server/db'; import { clearDockerClientCache } from '$lib/server/docker'; -import { deleteGitStackFiles } from '$lib/server/git'; +import { deleteGitStackFiles, getGitReposDir } from '$lib/server/git'; +import { getStacksDir } from '$lib/server/stacks'; import { authorize } from '$lib/server/authorize'; import { auditEnvironment } from '$lib/server/audit'; import { refreshSubprocessEnvironments } from '$lib/server/subprocess-manager'; @@ -182,6 +185,28 @@ export const DELETE: RequestHandler = async (event) => { await deleteImagePruneSettings(id); unregisterSchedule(id, 'image_prune'); + // Clean up stack directory for this environment + try { + const stacksDir = getStacksDir(); + const envStackDir = join(stacksDir, env.name); + if (existsSync(envStackDir)) { + rmSync(envStackDir, { recursive: true, force: true }); + } + } catch (err) { + console.error(`Failed to clean up stack directory for environment "${env.name}":`, err); + } + + // Clean up git-repos directory for this environment + try { + const gitReposDir = getGitReposDir(); + const envGitDir = join(gitReposDir, env.name); + if (existsSync(envGitDir)) { + rmSync(envGitDir, { recursive: true, force: true }); + } + } catch (err) { + console.error(`Failed to clean up git-repos directory for environment "${env.name}":`, err); + } + // Notify event collectors to stop collecting from deleted environment refreshSubprocessEnvironments(); diff --git a/src/routes/api/git/stacks/+server.ts b/src/routes/api/git/stacks/+server.ts index d067db6..29f9320 100644 --- a/src/routes/api/git/stacks/+server.ts +++ b/src/routes/api/git/stacks/+server.ts @@ -13,7 +13,6 @@ import { import { deployGitStack } from '$lib/server/git'; import { authorize } from '$lib/server/authorize'; import { registerSchedule } from '$lib/server/scheduler'; -import { secureRandomBytes } from '$lib/server/crypto-fallback'; import { auditGitStack } from '$lib/server/audit'; import { createJobResponse } from '$lib/server/sse'; @@ -110,12 +109,6 @@ export const POST: RequestHandler = async (event) => { } } - // Generate webhook secret if webhook is enabled - let webhookSecret = data.webhookSecret; - if (data.webhookEnabled && !webhookSecret) { - webhookSecret = secureRandomBytes(32).toString('hex'); - } - const gitStack = await createGitStack({ stackName: trimmedStackName, environmentId: data.environmentId || null, @@ -126,7 +119,7 @@ export const POST: RequestHandler = async (event) => { autoUpdateSchedule: data.autoUpdateSchedule || 'daily', autoUpdateCron: data.autoUpdateCron || '0 3 * * *', webhookEnabled: data.webhookEnabled || false, - webhookSecret: webhookSecret, + webhookSecret: data.webhookSecret || null, contextDir: data.contextDir || null, buildOnDeploy: data.buildOnDeploy ?? false, noBuildCache: data.noBuildCache ?? false, diff --git a/src/routes/api/labels/+server.ts b/src/routes/api/labels/+server.ts new file mode 100644 index 0000000..2fea43c --- /dev/null +++ b/src/routes/api/labels/+server.ts @@ -0,0 +1,190 @@ +import { json } from '@sveltejs/kit'; +import type { RequestHandler } from './$types'; +import { getEnvironments, updateEnvironment, getSetting, setSetting } from '$lib/server/db'; +import { authorize } from '$lib/server/authorize'; +import { parseLabels, serializeLabels, MAX_LABELS } from '$lib/utils/label-colors'; + +async function getLabelColors(): Promise> { + const colors = await getSetting('label_colors'); + return (colors && typeof colors === 'object' && !Array.isArray(colors)) ? colors : {}; +} + +/** + * GET /api/labels — returns all unique labels with usage counts, environment details, and custom colors + */ +export const GET: RequestHandler = async ({ cookies }) => { + const auth = await authorize(cookies); + if (auth.authEnabled && !await auth.can('environments', 'view')) { + return json({ error: 'Permission denied' }, { status: 403 }); + } + + try { + const environments = await getEnvironments(); + const customColors = await getLabelColors(); + + const labelMap = new Map(); + + for (const env of environments) { + const labels = parseLabels(env.labels as string | null); + for (const label of labels) { + if (!labelMap.has(label)) { + labelMap.set(label, []); + } + labelMap.get(label)!.push({ envId: env.id, envName: env.name }); + } + } + + const result = Array.from(labelMap.entries()) + .map(([label, envs]) => ({ + label, + environments: envs, + count: envs.length, + color: customColors[label] || null + })) + .sort((a, b) => a.label.localeCompare(b.label)); + + return json({ labels: result, colors: customColors }); + } catch (error) { + console.error('Failed to get labels:', error); + return json({ error: 'Failed to get labels' }, { status: 500 }); + } +}; + +/** + * POST /api/labels — bulk label operations + * Body: { action: 'rename', oldLabel: string, newLabel: string } + * | { action: 'delete', label: string } + * | { action: 'add', label: string, environmentIds: number[] } + * | { action: 'set-color', label: string, color: string | null } + */ +export const POST: RequestHandler = async ({ request, cookies }) => { + const auth = await authorize(cookies); + if (auth.authEnabled && !await auth.can('environments', 'edit')) { + return json({ error: 'Permission denied' }, { status: 403 }); + } + + try { + const body = await request.json(); + const { action } = body; + + if (action === 'rename') { + const { oldLabel, newLabel } = body; + if (!oldLabel || !newLabel || typeof oldLabel !== 'string' || typeof newLabel !== 'string') { + return json({ error: 'oldLabel and newLabel are required' }, { status: 400 }); + } + const trimmed = newLabel.trim(); + if (!trimmed) { + return json({ error: 'New label cannot be empty' }, { status: 400 }); + } + + const environments = await getEnvironments(); + let affected = 0; + + for (const env of environments) { + const labels = parseLabels(env.labels as string | null); + const idx = labels.indexOf(oldLabel); + if (idx === -1) continue; + + if (labels.includes(trimmed)) { + labels.splice(idx, 1); + } else { + labels[idx] = trimmed; + } + + await updateEnvironment(env.id, { labels: serializeLabels(labels) }); + affected++; + } + + // Migrate custom color from old label to new + const customColors = await getLabelColors(); + if (customColors[oldLabel]) { + if (!customColors[trimmed]) { + customColors[trimmed] = customColors[oldLabel]; + } + delete customColors[oldLabel]; + await setSetting('label_colors', customColors); + } + + return json({ success: true, affected }); + } + + if (action === 'delete') { + const { label } = body; + if (!label || typeof label !== 'string') { + return json({ error: 'label is required' }, { status: 400 }); + } + + const environments = await getEnvironments(); + let affected = 0; + + for (const env of environments) { + const labels = parseLabels(env.labels as string | null); + const idx = labels.indexOf(label); + if (idx === -1) continue; + + labels.splice(idx, 1); + await updateEnvironment(env.id, { labels: serializeLabels(labels) }); + affected++; + } + + // Remove custom color + const customColors = await getLabelColors(); + if (customColors[label]) { + delete customColors[label]; + await setSetting('label_colors', customColors); + } + + return json({ success: true, affected }); + } + + if (action === 'add') { + const { label, environmentIds } = body; + if (!label || typeof label !== 'string' || !label.trim()) { + return json({ error: 'label is required' }, { status: 400 }); + } + if (!Array.isArray(environmentIds) || environmentIds.length === 0) { + return json({ error: 'environmentIds array is required' }, { status: 400 }); + } + + const trimmed = label.trim(); + const environments = await getEnvironments(); + const targetIds = new Set(environmentIds.map(Number)); + let affected = 0; + + for (const env of environments) { + if (!targetIds.has(env.id)) continue; + const labels = parseLabels(env.labels as string | null); + if (labels.includes(trimmed)) continue; + if (labels.length >= MAX_LABELS) continue; + + labels.push(trimmed); + await updateEnvironment(env.id, { labels: serializeLabels(labels) }); + affected++; + } + + return json({ success: true, affected }); + } + + if (action === 'set-color') { + const { label, color } = body; + if (!label || typeof label !== 'string') { + return json({ error: 'label is required' }, { status: 400 }); + } + + const customColors = await getLabelColors(); + if (color && typeof color === 'string' && /^#[0-9a-fA-F]{6}$/.test(color)) { + customColors[label] = color; + } else { + delete customColors[label]; + } + await setSetting('label_colors', customColors); + + return json({ success: true }); + } + + return json({ error: 'Invalid action. Use "rename", "delete", "add", or "set-color"' }, { status: 400 }); + } catch (error) { + console.error('Failed to manage labels:', error); + return json({ error: 'Failed to manage labels' }, { status: 500 }); + } +}; diff --git a/src/routes/api/settings/general/+server.ts b/src/routes/api/settings/general/+server.ts index 894ac02..68795a9 100644 --- a/src/routes/api/settings/general/+server.ts +++ b/src/routes/api/settings/general/+server.ts @@ -74,6 +74,8 @@ export interface GeneralSettings { editorFont: string; // Compact ports compactPorts: boolean; + // Show exposed (internal) ports + showExposedPorts: boolean; // Log timestamp formatting formatLogTimestamps: boolean; // External stack paths @@ -104,6 +106,7 @@ const DEFAULT_SETTINGS: Omit { terminalFont, editorFont, compactPorts, + showExposedPorts, formatLogTimestamps, externalStackPaths, primaryStackLocation, @@ -223,6 +227,7 @@ export const GET: RequestHandler = async ({ cookies }) => { getSetting('theme_terminal_font'), getSetting('theme_editor_font'), getSetting('compact_ports'), + getSetting('show_exposed_ports'), getSetting('format_log_timestamps'), getExternalStackPaths(), getPrimaryStackLocation(), @@ -262,6 +267,7 @@ export const GET: RequestHandler = async ({ cookies }) => { terminalFont: terminalFont ?? DEFAULT_SETTINGS.terminalFont, editorFont: editorFont ?? DEFAULT_SETTINGS.editorFont, compactPorts: compactPorts ?? DEFAULT_SETTINGS.compactPorts, + showExposedPorts: showExposedPorts ?? DEFAULT_SETTINGS.showExposedPorts, formatLogTimestamps: formatLogTimestamps ?? DEFAULT_SETTINGS.formatLogTimestamps, externalStackPaths, primaryStackLocation, @@ -286,7 +292,7 @@ export const POST: RequestHandler = async ({ request, cookies }) => { try { const body = await request.json(); - const { confirmDestructive, showStoppedContainers, highlightUpdates, timeFormat, dateFormat, downloadFormat, defaultGrypeArgs, defaultTrivyArgs, scheduleRetentionDays, eventRetentionDays, scheduleCleanupCron, eventCleanupCron, scheduleCleanupEnabled, eventCleanupEnabled, scannerCleanupCron, scannerCleanupEnabled, logBufferSizeKb, defaultTimezone, eventCollectionMode, eventPollInterval, metricsCollectionInterval, lightTheme, darkTheme, font, fontSize, gridFontSize, terminalFont, editorFont, compactPorts, formatLogTimestamps, externalStackPaths, primaryStackLocation, defaultGrypeImage, defaultTrivyImage, defaultComposeTemplate, labelFilterMode } = body; + const { confirmDestructive, showStoppedContainers, highlightUpdates, timeFormat, dateFormat, downloadFormat, defaultGrypeArgs, defaultTrivyArgs, scheduleRetentionDays, eventRetentionDays, scheduleCleanupCron, eventCleanupCron, scheduleCleanupEnabled, eventCleanupEnabled, scannerCleanupCron, scannerCleanupEnabled, logBufferSizeKb, defaultTimezone, eventCollectionMode, eventPollInterval, metricsCollectionInterval, lightTheme, darkTheme, font, fontSize, gridFontSize, terminalFont, editorFont, compactPorts, showExposedPorts, formatLogTimestamps, externalStackPaths, primaryStackLocation, defaultGrypeImage, defaultTrivyImage, defaultComposeTemplate, labelFilterMode } = body; if (confirmDestructive !== undefined) { await setSetting('confirm_destructive', confirmDestructive); @@ -390,6 +396,9 @@ export const POST: RequestHandler = async ({ request, cookies }) => { if (compactPorts !== undefined) { await setSetting('compact_ports', compactPorts); } + if (showExposedPorts !== undefined) { + await setSetting('show_exposed_ports', showExposedPorts); + } if (formatLogTimestamps !== undefined) { await setSetting('format_log_timestamps', formatLogTimestamps); } @@ -451,6 +460,7 @@ export const POST: RequestHandler = async ({ request, cookies }) => { terminalFontVal, editorFontVal, compactPortsVal, + showExposedPortsVal, formatLogTimestampsVal, externalStackPathsVal, primaryStackLocationVal, @@ -488,6 +498,7 @@ export const POST: RequestHandler = async ({ request, cookies }) => { getSetting('theme_terminal_font'), getSetting('theme_editor_font'), getSetting('compact_ports'), + getSetting('show_exposed_ports'), getSetting('format_log_timestamps'), getExternalStackPaths(), getPrimaryStackLocation(), @@ -527,6 +538,7 @@ export const POST: RequestHandler = async ({ request, cookies }) => { terminalFont: terminalFontVal ?? DEFAULT_SETTINGS.terminalFont, editorFont: editorFontVal ?? DEFAULT_SETTINGS.editorFont, compactPorts: compactPortsVal ?? DEFAULT_SETTINGS.compactPorts, + showExposedPorts: showExposedPortsVal ?? DEFAULT_SETTINGS.showExposedPorts, formatLogTimestamps: formatLogTimestampsVal ?? DEFAULT_SETTINGS.formatLogTimestamps, externalStackPaths: externalStackPathsVal, primaryStackLocation: primaryStackLocationVal, diff --git a/src/routes/api/stacks/[name]/env/validate/+server.ts b/src/routes/api/stacks/[name]/env/validate/+server.ts index 1c81b3f..7dbab30 100644 --- a/src/routes/api/stacks/[name]/env/validate/+server.ts +++ b/src/routes/api/stacks/[name]/env/validate/+server.ts @@ -60,7 +60,8 @@ function extractComposeVars(yaml: string): { required: string[]; optional: strin // Also match $VAR_NAME (simple variable substitution) // Use negative lookbehind (?('idle'); + // Update confirmation + let confirmUpdateAll = $state(false); + let confirmUpdateId = $state(null); + let confirmUpdateSelected = $state(false); + // Update check state let updateCheckStatus = $state<'idle' | 'checking' | 'found' | 'none' | 'error'>('idle'); let updateCheckProgress = $state({ checked: 0, total: 0 }); @@ -1446,22 +1452,35 @@ {/if} {#if updatableContainersCount > 0} - - + {#snippet children({ open })} + + + {/snippet} + {/if} {#if $canAccess('containers', 'remove')} {/if} {#if selectedHaveUpdates} - + {#snippet children({ open })} + + + Update {selectedWithUpdatesCount} + + {/snippet} + {/if} {#if bulkActionInProgress} @@ -1873,24 +1902,41 @@ {primaryIp} {/if} {:else if column.id === 'ports'} - {#if ports.length > 0} + {@const exposedPorts = $appSettings.showExposedPorts ? formatExposedPorts(container.ports) : []} + {@const customUrl = container.labels?.['dockhand.url']?.trim() || null} + {#if ports.length > 0 || exposedPorts.length > 0 || customUrl} {@const compactPorts = $appSettings.compactPorts} {@const displayPorts = compactPorts && ports.length > 1 ? [ports[0]] : ports} {@const remainingCount = ports.length - 1}
+ {#if customUrl} + e.stopPropagation()} + class="inline-flex items-center gap-0.5 text-xs bg-primary/10 hover:bg-primary/20 text-primary px-1 py-0.5 rounded transition-colors shrink-0" + title="Open {customUrl} in new tab" + > + + {customUrl.replace(/^https?:\/\//, '')} + + + {/if} {#each displayPorts as port} - {@const url = currentEnvDetails ? getPortUrl(port.publicPort) : null} + {@const portUrl = container.labels?.[`dockhand.port.${port.publicPort}.url`]?.trim() || null} + {@const url = portUrl || (currentEnvDetails ? getPortUrl(port.publicPort) : null)} {#if url} e.stopPropagation()} - class="inline-flex items-center gap-0.5 text-xs bg-muted hover:bg-blue-500/20 hover:text-blue-500 px-1 py-0.5 rounded transition-colors shrink-0" + class="inline-flex items-center gap-0.5 text-xs {portUrl ? 'bg-primary/10 hover:bg-primary/20 text-primary' : 'bg-muted hover:bg-blue-500/20 hover:text-blue-500'} px-1 py-0.5 rounded transition-colors shrink-0" title="Open {url} in new tab" > {port.display} - + {:else} {port.display} @@ -1902,6 +1948,9 @@ title={ports.map(p => p.display).join(', ')} >+{remainingCount} {/if} + {#each exposedPorts as port} + {port.display} + {/each}
{:else} - @@ -1947,14 +1996,19 @@ {:else if column.id === 'actions'}
{#if containersWithUpdatesSet.has(container.id) && !container.systemContainer} - + {#snippet children({ open })} + + {/snippet} + {/if} {#if !container.systemContainer} {#if container.state === 'running' || container.state === 'restarting'} diff --git a/src/routes/containers/ContainerInspectModal.svelte b/src/routes/containers/ContainerInspectModal.svelte index 8c2063b..7c183bd 100644 --- a/src/routes/containers/ContainerInspectModal.svelte +++ b/src/routes/containers/ContainerInspectModal.svelte @@ -4,7 +4,7 @@ import * as Tabs from '$lib/components/ui/tabs'; import { Button } from '$lib/components/ui/button'; import { Badge } from '$lib/components/ui/badge'; - import { Loader2, Box, Info, Layers, Cpu, MemoryStick, HardDrive, Network, Shield, Settings2, Code, Copy, Check, XCircle, Activity, Wifi, Pencil, RefreshCw, X, FolderOpen, Moon, Tags, ExternalLink, Gpu } from 'lucide-svelte'; + import { Loader2, Box, Info, Layers, Cpu, MemoryStick, HardDrive, Network, Shield, Settings2, Code, Copy, Check, XCircle, Activity, Wifi, Pencil, RefreshCw, X, FolderOpen, Moon, Tags, ExternalLink, Gpu, Globe } from 'lucide-svelte'; import * as Tooltip from '$lib/components/ui/tooltip'; import { copyToClipboard } from '$lib/utils/clipboard'; import { Input } from '$lib/components/ui/input'; @@ -875,13 +875,30 @@ {#if containerData.NetworkSettings?.Ports && Object.keys(containerData.NetworkSettings.Ports).length > 0} + {@const inspectCustomUrl = containerData.Config?.Labels?.['dockhand.url']?.trim() || null}

Port mappings

+ {#if inspectCustomUrl} + + {/if} {#each Object.entries(containerData.NetworkSettings.Ports) as [containerPort, hostBindings]} {#if hostBindings && hostBindings.length > 0} {#each hostBindings as binding} - {@const url = getPortUrl(parseInt(binding.HostPort))} + {@const portUrlOverride = containerData.Config?.Labels?.[`dockhand.port.${binding.HostPort}.url`]?.trim() || null} + {@const url = portUrlOverride || getPortUrl(parseInt(binding.HostPort))}
{#if url} {/each} {:else} -
- exposed - {containerPort} +
+ exposed + {containerPort}
{/if} {/each} diff --git a/src/routes/containers/ContainerSettingsTab.svelte b/src/routes/containers/ContainerSettingsTab.svelte index e2013c2..42c62ee 100644 --- a/src/routes/containers/ContainerSettingsTab.svelte +++ b/src/routes/containers/ContainerSettingsTab.svelte @@ -5,7 +5,9 @@ import { Button } from '$lib/components/ui/button'; import { Checkbox } from '$lib/components/ui/checkbox'; import { TogglePill, ToggleGroup } from '$lib/components/ui/toggle-pill'; - import { Plus, Trash2, Settings2, RefreshCw, Network, X, Ban, RotateCw, AlertTriangle, PauseCircle, Share2, Server, CircleOff, ChevronDown, ChevronRight, Cpu, Shield, HeartPulse, Wifi, HardDrive, Lock, Loader2, CheckCircle2, Package, Gpu } from 'lucide-svelte'; + import { Plus, Trash2, Settings2, RefreshCw, Network, X, Ban, RotateCw, AlertTriangle, PauseCircle, Share2, Server, CircleOff, ChevronDown, ChevronRight, Cpu, Shield, HeartPulse, Wifi, HardDrive, Lock, Loader2, CheckCircle2, Package, Gpu, Search } from 'lucide-svelte'; + import * as Tooltip from '$lib/components/ui/tooltip'; + import { currentEnvironment } from '$lib/stores/environment'; import { Badge } from '$lib/components/ui/badge'; import AutoUpdateSettings from './AutoUpdateSettings.svelte'; import type { VulnerabilityCriteria } from '$lib/components/VulnerabilityCriteriaSelector.svelte'; @@ -280,6 +282,46 @@ let gpuDeviceIdInput = $state(''); let customRuntimeInput = $state(''); + // Find free port + let findingFreePort = $state(false); + + async function findFreePort(index: number) { + findingFreePort = true; + try { + const envParam = $currentEnvironment ? `?env=${$currentEnvironment.id}` : ''; + const res = await fetch(`/api/containers${envParam}`); + if (!res.ok) return; + + const containers: any[] = await res.json(); + const usedPorts = new Set(); + for (const c of containers) { + if (c.ports) { + for (const p of c.ports) { + const pub = p.PublicPort || p.publicPort; + if (pub) usedPorts.add(pub); + } + } + } + // Also consider ports already typed in the form + for (let i = 0; i < portMappings.length; i++) { + if (i !== index && portMappings[i].hostPort) { + usedPorts.add(parseInt(portMappings[i].hostPort)); + } + } + + const startFrom = parseInt(portMappings[index].hostPort) || 8080; + let port = startFrom; + while (usedPorts.has(port) && port < 65535) port++; + if (port <= 65535) { + portMappings[index].hostPort = String(port); + } + } catch { + // Silently fail + } finally { + findingFreePort = false; + } + } + // Helper functions for form function addPortMapping() { portMappings = [...portMappings, { hostPort: '', containerPort: '', protocol: 'tcp' }]; @@ -856,9 +898,22 @@
{#each portMappings as mapping, index}
-
+
Host +
Container @@ -882,6 +937,10 @@
{/each}
+

+ + Hover the host port field and click the search icon to find the next available port. Only checks Docker-published ports. +

diff --git a/src/routes/dashboard/EnvironmentListView.svelte b/src/routes/dashboard/EnvironmentListView.svelte index 08a48a0..3107898 100644 --- a/src/routes/dashboard/EnvironmentListView.svelte +++ b/src/routes/dashboard/EnvironmentListView.svelte @@ -3,6 +3,7 @@ import { whale } from '@lucide/lab'; import EnvironmentIcon from '$lib/components/EnvironmentIcon.svelte'; import { getLabelColors } from '$lib/utils/label-colors'; + import { labelColorOverrides } from '$lib/stores/label-colors'; import { DataGrid } from '$lib/components/data-grid'; import type { DataGridRowState, DataGridSortState } from '$lib/components/data-grid/types'; import type { ColumnConfig } from '$lib/types'; @@ -277,7 +278,7 @@ {#if s?.labels && s.labels.length > 0}
{#each s.labels as label} - {@const colors = getLabelColors(label)} + {@const colors = getLabelColors(label, $labelColorOverrides)} import { getLabelColors } from '$lib/utils/label-colors'; + import { labelColorOverrides } from '$lib/stores/label-colors'; interface Props { labels?: string[]; @@ -14,7 +15,7 @@ {#if compact}
{#each labels as label} - {@const colors = getLabelColors(label)} + {@const colors = getLabelColors(label, $labelColorOverrides)} {label} @@ -24,7 +25,7 @@ {:else if unified}
{#each labels as label} - {@const colors = getLabelColors(label)} + {@const colors = getLabelColors(label, $labelColorOverrides)} {label} @@ -34,7 +35,7 @@ {:else}
{#each labels as label} - {@const colors = getLabelColors(label)} + {@const colors = getLabelColors(label, $labelColorOverrides)} {label} diff --git a/src/routes/images/ImageScanModal.svelte b/src/routes/images/ImageScanModal.svelte index 5dff2de..11a9593 100644 --- a/src/routes/images/ImageScanModal.svelte +++ b/src/routes/images/ImageScanModal.svelte @@ -2,6 +2,7 @@ import * as Dialog from '$lib/components/ui/dialog'; 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 { currentEnvironment } from '$lib/stores/environment'; import ScanTab from '$lib/components/ScanTab.svelte'; @@ -25,6 +26,15 @@ let scanResults = $state([]); let duration = $state(0); let hasStarted = $state(false); + let activeScanner = $state<'grype' | 'trivy'>('grype'); + let exportGrype = $state(true); + let exportTrivy = $state(true); + const hasBothScanners = $derived(scanResults.length > 1); + const exportResults = $derived( + scanResults.filter(r => + (r.scanner === 'grype' && exportGrype) || (r.scanner === 'trivy' && exportTrivy) + ) + ); $effect(() => { if (open && imageName && !hasStarted) { @@ -86,105 +96,131 @@ } function exportToCSV() { - const activeResult = scanResults[0]; - if (!activeResult) return; + if (exportResults.length === 0) return; - const headers = ['CVE ID', 'Severity', 'Package', 'Installed Version', 'Fixed Version', 'Description', 'Link']; - const rows = activeResult.vulnerabilities.map(v => [ - v.id, - v.severity, - v.package, - v.version, - v.fixedVersion || '', - (v.description || '').replace(/"/g, '""'), - v.link || '' - ]); + const headers = ['Scanner', 'CVE ID', 'Severity', 'Package', 'Installed Version', 'Fixed Version', 'Description', 'Link']; + const rows: string[][] = []; + for (const result of exportResults) { + for (const v of result.vulnerabilities) { + rows.push([ + result.scanner, + v.id, + v.severity, + v.package, + v.version, + v.fixedVersion || '', + (v.description || '').replace(/"/g, '""'), + v.link || '' + ]); + } + } const csvContent = [ headers.join(','), ...rows.map(row => row.map(cell => `"${cell}"`).join(',')) ].join('\n'); - const filename = `vuln-report-${sanitizeFilename(imageName)}-${activeResult.scanner}-${new Date().toISOString().split('T')[0]}.csv`; + const scannerSuffix = exportResults.length > 1 ? 'combined' : exportResults[0].scanner; + const filename = `vuln-report-${sanitizeFilename(imageName)}-${scannerSuffix}-${new Date().toISOString().split('T')[0]}.csv`; downloadFile(csvContent, filename, 'text/csv'); } function exportToMarkdown() { - const activeResult = scanResults[0]; - if (!activeResult) return; - - const summaryParts = []; - if (activeResult.summary.critical > 0) summaryParts.push(`**${activeResult.summary.critical} Critical**`); - if (activeResult.summary.high > 0) summaryParts.push(`**${activeResult.summary.high} High**`); - if (activeResult.summary.medium > 0) summaryParts.push(`${activeResult.summary.medium} Medium`); - if (activeResult.summary.low > 0) summaryParts.push(`${activeResult.summary.low} Low`); - if (activeResult.summary.negligible > 0) summaryParts.push(`${activeResult.summary.negligible} Negligible`); - if (activeResult.summary.unknown > 0) summaryParts.push(`${activeResult.summary.unknown} Unknown`); + if (exportResults.length === 0) return; let md = `# Vulnerability Scan Report\n\n`; md += `**Image:** \`${imageName}\`\n\n`; - md += `**Scanner:** ${activeResult.scanner === 'grype' ? 'Grype (Anchore)' : 'Trivy (Aqua Security)'}\n\n`; - md += `**Duration:** ${formatDuration(activeResult.scanDuration || duration)}\n\n`; - md += `## Summary\n\n`; - md += summaryParts.length > 0 ? summaryParts.join(' | ') : 'No vulnerabilities found'; - md += `\n\n**Total:** ${activeResult.vulnerabilities.length} vulnerabilities\n\n`; - if (activeResult.vulnerabilities.length > 0) { - const bySeverity: Record = {}; - for (const vuln of activeResult.vulnerabilities) { - const sev = vuln.severity.toLowerCase(); - if (!bySeverity[sev]) bySeverity[sev] = []; - bySeverity[sev].push(vuln); + for (const result of exportResults) { + const scannerLabel = result.scanner === 'grype' ? 'Grype (Anchore)' : 'Trivy (Aqua Security)'; + + if (exportResults.length > 1) { + md += `---\n\n# ${scannerLabel}\n\n`; + } else { + md += `**Scanner:** ${scannerLabel}\n\n`; } + md += `**Duration:** ${formatDuration(result.scanDuration || duration)}\n\n`; - const severityOrder = ['critical', 'high', 'medium', 'low', 'negligible', 'unknown']; + const summaryParts = []; + if (result.summary.critical > 0) summaryParts.push(`**${result.summary.critical} Critical**`); + if (result.summary.high > 0) summaryParts.push(`**${result.summary.high} High**`); + if (result.summary.medium > 0) summaryParts.push(`${result.summary.medium} Medium`); + if (result.summary.low > 0) summaryParts.push(`${result.summary.low} Low`); + if (result.summary.negligible > 0) summaryParts.push(`${result.summary.negligible} Negligible`); + if (result.summary.unknown > 0) summaryParts.push(`${result.summary.unknown} Unknown`); - for (const severity of severityOrder) { - const vulns = bySeverity[severity]; - if (!vulns || vulns.length === 0) continue; + md += `## Summary\n\n`; + md += summaryParts.length > 0 ? summaryParts.join(' | ') : 'No vulnerabilities found'; + md += `\n\n**Total:** ${result.vulnerabilities.length} vulnerabilities\n\n`; - md += `## ${severity.charAt(0).toUpperCase() + severity.slice(1)} (${vulns.length})\n\n`; + if (result.vulnerabilities.length > 0) { + const bySeverity: Record = {}; + for (const vuln of result.vulnerabilities) { + const sev = vuln.severity.toLowerCase(); + if (!bySeverity[sev]) bySeverity[sev] = []; + bySeverity[sev].push(vuln); + } - for (const vuln of vulns) { - md += `### ${vuln.id}\n\n`; - md += `- **Package:** \`${vuln.package}\`\n`; - md += `- **Installed:** \`${vuln.version}\`\n`; - if (vuln.fixedVersion) { - md += `- **Fixed in:** \`${vuln.fixedVersion}\`\n`; - } else { - md += `- **Fixed in:** *No fix available*\n`; + const severityOrder = ['critical', 'high', 'medium', 'low', 'negligible', 'unknown']; + + for (const severity of severityOrder) { + const vulns = bySeverity[severity]; + if (!vulns || vulns.length === 0) continue; + + md += `## ${severity.charAt(0).toUpperCase() + severity.slice(1)} (${vulns.length})\n\n`; + + for (const vuln of vulns) { + md += `### ${vuln.id}\n\n`; + md += `- **Package:** \`${vuln.package}\`\n`; + md += `- **Installed:** \`${vuln.version}\`\n`; + if (vuln.fixedVersion) { + md += `- **Fixed in:** \`${vuln.fixedVersion}\`\n`; + } else { + md += `- **Fixed in:** *No fix available*\n`; + } + if (vuln.link) { + md += `- **Reference:** [${vuln.id}](${vuln.link})\n`; + } + if (vuln.description) { + md += `\n${vuln.description}\n`; + } + md += `\n`; } - if (vuln.link) { - md += `- **Reference:** [${vuln.id}](${vuln.link})\n`; - } - if (vuln.description) { - md += `\n${vuln.description}\n`; - } - md += `\n`; } } } md += `---\n\n*Report generated by Dockhand*\n`; - const filename = `vuln-report-${sanitizeFilename(imageName)}-${activeResult.scanner}-${new Date().toISOString().split('T')[0]}.md`; + const scannerSuffix = exportResults.length > 1 ? 'combined' : exportResults[0].scanner; + const filename = `vuln-report-${sanitizeFilename(imageName)}-${scannerSuffix}-${new Date().toISOString().split('T')[0]}.md`; downloadFile(md, filename, 'text/markdown'); } function exportToJSON() { - const activeResult = scanResults[0]; - if (!activeResult) return; + if (exportResults.length === 0) return; - const report = { - image: imageName, - scanner: activeResult.scanner, - scanDuration: activeResult.scanDuration || duration, - summary: activeResult.summary, - vulnerabilities: activeResult.vulnerabilities - }; + const report = exportResults.length === 1 + ? { + image: imageName, + scanner: exportResults[0].scanner, + scanDuration: exportResults[0].scanDuration || duration, + summary: exportResults[0].summary, + vulnerabilities: exportResults[0].vulnerabilities + } + : { + image: imageName, + scanners: exportResults.map(r => ({ + scanner: r.scanner, + scanDuration: r.scanDuration || duration, + summary: r.summary, + vulnerabilities: r.vulnerabilities + })) + }; const jsonContent = JSON.stringify(report, null, 2); - const filename = `vuln-report-${sanitizeFilename(imageName)}-${activeResult.scanner}-${new Date().toISOString().split('T')[0]}.json`; + const scannerSuffix = exportResults.length > 1 ? 'combined' : exportResults[0].scanner; + const filename = `vuln-report-${sanitizeFilename(imageName)}-${scannerSuffix}-${new Date().toISOString().split('T')[0]}.json`; downloadFile(jsonContent, filename, 'application/json'); } @@ -229,6 +265,7 @@ {imageName} envId={effectiveEnvId} autoStart={hasStarted} + bind:activeScanner onComplete={handleScanComplete} onError={handleScanError} onStatusChange={handleStatusChange} @@ -253,15 +290,28 @@ {/snippet} - + {#if hasBothScanners} +
+ + +
+ + {/if} + Markdown report (.md) - + CSV spreadsheet (.csv) - + JSON data (.json) diff --git a/src/routes/images/ScanResultsView.svelte b/src/routes/images/ScanResultsView.svelte index 0aac3f9..c085729 100644 --- a/src/routes/images/ScanResultsView.svelte +++ b/src/routes/images/ScanResultsView.svelte @@ -29,11 +29,13 @@ interface Props { results: ScanResult[]; compact?: boolean; + activeScanner?: 'grype' | 'trivy'; } - let { results, compact = false }: Props = $props(); + let { results, compact = false, activeScanner = $bindable<'grype' | 'trivy'>(results[0]?.scanner || 'grype') }: Props = $props(); - let activeTab = $state<'grype' | 'trivy'>(results[0]?.scanner || 'grype'); + // Use activeScanner directly as the tab state + let activeTab = $derived(activeScanner); let expandedVulns = $state>(new Set()); let sortBy = $state<'severity' | 'id' | 'package'>('severity'); let sortDir = $state<'asc' | 'desc'>('asc'); @@ -112,7 +114,7 @@ {#each results as r} + + + + + + + + + + + {/if} +
+ generatedStandardToken = null} /> + {#if generatedStandardToken} +
+

+ + Copy this token now — it won't be shown again! +

+
+ + + +
+
+ {:else} +

Enter a token manually or generate one. Set the same token as TOKEN env var on your Hawser agent.

+ {/if}
- Run Hawser agent on the target host: hawser --port {formPort} +
+ Run Hawser agent on the target host: +
+ {formHawserToken ? `TOKEN=${formHawserToken} ` : ''}hawser standard --port {formPort} + {#if formHawserToken} + + {/if} +
+
{/if} diff --git a/src/routes/settings/environments/EnvironmentsTab.svelte b/src/routes/settings/environments/EnvironmentsTab.svelte index 998822d..51f22fd 100644 --- a/src/routes/settings/environments/EnvironmentsTab.svelte +++ b/src/routes/settings/environments/EnvironmentsTab.svelte @@ -31,6 +31,7 @@ import { canAccess } from '$lib/stores/auth'; import EnvironmentIcon from '$lib/components/EnvironmentIcon.svelte'; import { getLabelColors } from '$lib/utils/label-colors'; + import { labelColorOverrides } from '$lib/stores/label-colors'; import EnvironmentModal from './EnvironmentModal.svelte'; import { environments as environmentsStore } from '$lib/stores/environment'; import { dashboardData } from '$lib/stores/dashboard'; @@ -112,6 +113,9 @@ [...new Set(environments.flatMap(env => env.labels || []))].sort() ); + // Load custom label colors + $effect(() => { labelColorOverrides.load(); }); + // === Environment Functions === async function fetchEnvironments() { envLoading = true; @@ -424,7 +428,7 @@ {#if env.labels && env.labels.length > 0}
{#each env.labels as label} - {@const colors = getLabelColors(label)} + {@const colors = getLabelColors(label, $labelColorOverrides)}

Show first port with +N count instead of all ports

+
+
+ + + + + + +

Shows internal container ports (from EXPOSE directives) that are not published to the host. These appear in the container list with an amber badge to distinguish them from published port mappings.

+
+
+ { + appSettings.setShowExposedPorts(checked); + toast.success(checked ? 'Showing exposed ports in container list' : 'Exposed ports hidden from container list'); + }} + disabled={!$canAccess('settings', 'edit')} + /> +
+

Display internal container ports in the container list grid

+
diff --git a/src/routes/settings/labels/LabelsTab.svelte b/src/routes/settings/labels/LabelsTab.svelte new file mode 100644 index 0000000..c49e38a --- /dev/null +++ b/src/routes/settings/labels/LabelsTab.svelte @@ -0,0 +1,525 @@ + + +
+ + +
+
+ +
+ Environment labels + Manage labels across all environments. Renaming or deleting a label applies to every environment using it. +
+
+
+ {#if !loading} + {labels.length} label{labels.length !== 1 ? 's' : ''} + {/if} + +
+
+
+ + {#if loading} +
+ + Loading labels... +
+ {:else if labels.length === 0} +
+ +

No labels found

+

Click "Add label" to create one and assign it to environments

+
+ {:else} + + + + Label + Color + Environments + Used by + Actions + + + + {#each labels as info} + {@const colors = getColors(info.label)} + + + + {info.label} + + + + colorPopoverLabel = open ? info.label : null}> + + + + +
+

Pick a color

+
+ {#each COLOR_PALETTE as color} + + {/each} +
+ {#if customColors[info.label]} + + {/if} +
+
+
+
+ + {info.count} + + +
+ {#each info.environments as env} + + + {env.envName} + + {/each} +
+
+ +
+ + + + + Rename across all environments + + handleDelete(info)} + onOpenChange={(open) => confirmDeleteLabel = open ? info.label : null} + disabled={!$canAccess('environments', 'edit')} + > + {#snippet children({ open })} + + {/snippet} + +
+
+
+ {/each} +
+
+ {/if} +
+
+
+ + + + + + Rename label + + {#if renameTarget} + This will rename "{renameTarget.label}" across {renameTarget.count} environment{renameTarget.count !== 1 ? 's' : ''}. + {/if} + + + {#if renameTarget} + {@const currentColors = getColors(renameTarget.label)} +
+
+ + + {renameTarget.label} + +
+
+ + { if (e.key === 'Enter' && newLabelName.trim()) handleRename(); }} + /> +
+ {#if newLabelName.trim() && newLabelName.trim() !== renameTarget.label} + {@const newColors = getColors(newLabelName.trim())} +
+ Preview: + + {newLabelName.trim()} + +
+ {/if} + {#if labels.some(l => l.label === newLabelName.trim() && l.label !== renameTarget?.label)} +
+ + Label "{newLabelName.trim()}" already exists. Environments with both labels will be merged. +
+ {/if} +
+ {/if} + + + + +
+
+ + + + + + Add label + Create a new label and assign it to one or more environments. + +
+
+ + { if (e.key === 'Enter' && addLabelName.trim() && addSelectedEnvIds.length > 0) handleAdd(); }} + /> + {#if addLabelName.trim()} + {@const previewColors = getColors(addLabelName.trim())} +
+ Preview: + + {addLabelName.trim()} + +
+ {/if} + {#if addLabelName.trim() && labels.some(l => l.label === addLabelName.trim())} +
+ + This label already exists. It will be added to the selected environments that don't have it yet. +
+ {/if} +
+
+
+ +
+ + +
+
+
+ {#each addEnvOptions as env} + + {/each} + {#if addEnvOptions.length === 0} +

No environments available

+ {/if} +
+

{addSelectedEnvIds.length > 0 ? `${addSelectedEnvIds.length} environment${addSelectedEnvIds.length !== 1 ? 's' : ''} selected` : '\u00A0'}

+
+
+ + + + +
+
diff --git a/src/routes/stacks/+page.svelte b/src/routes/stacks/+page.svelte index 2d64ad0..fde99c7 100644 --- a/src/routes/stacks/+page.svelte +++ b/src/routes/stacks/+page.svelte @@ -14,7 +14,7 @@ import * as Tooltip from '$lib/components/ui/tooltip'; import * as Popover from '$lib/components/ui/popover'; import MultiSelectFilter from '$lib/components/MultiSelectFilter.svelte'; - import { Play, Square, Trash2, Plus, ArrowBigDown, Search, Pencil, ExternalLink, GitBranch, RefreshCw, Loader2, FileCode, FileText, FileOutput, Box, RotateCcw, ScrollText, Terminal, Eye, Network, HardDrive, Heart, HeartPulse, HeartOff, ChevronsUpDown, ChevronsDownUp, Rocket, AlertTriangle, X, Layers, Pause, CircleDashed, Skull, FolderOpen, Variable, Clock, RotateCw, Import, Ship, Cable, LayoutPanelLeft, Rows3, GripVertical } from 'lucide-svelte'; + import { Play, Square, Trash2, Plus, ArrowBigDown, Search, Pencil, ExternalLink, GitBranch, RefreshCw, Loader2, FileCode, FileText, FileOutput, Box, RotateCcw, ScrollText, Terminal, Eye, Network, HardDrive, Heart, HeartPulse, HeartOff, ChevronsUpDown, ChevronsDownUp, Rocket, AlertTriangle, X, Layers, Pause, CircleDashed, Skull, FolderOpen, Variable, Clock, RotateCw, Import, Ship, Cable, LayoutPanelLeft, Rows3, GripVertical, Globe } from 'lucide-svelte'; import { formatPorts } from '$lib/utils/port-format'; import ConfirmPopover from '$lib/components/ConfirmPopover.svelte'; import BatchOperationModal from '$lib/components/BatchOperationModal.svelte'; @@ -136,10 +136,15 @@ // Fetch container stats let statsAbortController: AbortController | null = null; + let statsFetching = false; + async function fetchStats() { - // Abort any previous in-flight stream + // Skip if previous fetch is still in-flight + if (statsFetching) return; + statsAbortController?.abort(); statsAbortController = new AbortController(); + statsFetching = true; try { const response = await fetch( @@ -202,6 +207,8 @@ if (error?.name !== 'AbortError') { console.error('Failed to fetch container stats:', error); } + } finally { + statsFetching = false; } } @@ -2015,22 +2022,38 @@ {/key} {/if}
+ + {#if container.labels?.['dockhand.url']?.trim()} + e.stopPropagation()} + class="inline-flex items-center gap-0.5 px-1.5 py-0.5 rounded bg-primary/10 text-primary hover:bg-primary/20 transition-colors" + title="Open {container.labels['dockhand.url'].trim()} in new tab" + > + + {container.labels['dockhand.url'].trim().replace(/^https?:\/\//, '')} + + + {/if} {#if container.ports.length > 0} {@const mappedPorts = formatPorts(container.ports)} {#each mappedPorts as port} - {@const url = getPortUrl(port.publicPort)} + {@const portUrl = container.labels?.[`dockhand.port.${port.publicPort}.url`]?.trim() || null} + {@const url = portUrl || getPortUrl(port.publicPort)} {#if url} e.stopPropagation()} - class="inline-flex items-center gap-0.5 px-1.5 py-0.5 rounded bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200 hover:bg-blue-200 dark:hover:bg-blue-800 transition-colors" + class="inline-flex items-center gap-0.5 px-1.5 py-0.5 rounded {portUrl ? 'bg-primary/10 text-primary hover:bg-primary/20' : 'bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200 hover:bg-blue-200 dark:hover:bg-blue-800'} transition-colors" title="Open {url} in new tab" > {port.display} - + {:else} diff --git a/src/routes/stacks/GitStackModal.svelte b/src/routes/stacks/GitStackModal.svelte index 7ec3dac..b0e1588 100644 --- a/src/routes/stacks/GitStackModal.svelte +++ b/src/routes/stacks/GitStackModal.svelte @@ -943,14 +943,18 @@ {/if} {/if} - + + + + + Generate secret +
{#if !gitStack}