This commit is contained in:
jarek committed 2026-05-17 08:02:31 +02:00
1 parent d9054ff347
commit e7100f8926
44 files changed
+1611 -165

No files matched your search

+2 -2
View File
@@ -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/
+1 -1
View File
@@ -1 +1 @@
v1.0.28
v1.0.29
+1 -1
View File
@@ -1,3 +1,3 @@
module github.com/Finsys/dockhand/collector
go 1.25.9
go 1.25.10
+4 -4
View File
@@ -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"
}
}
+24
View File
@@ -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;
+5 -4
View File
@@ -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 {
+1 -1
View File
@@ -405,7 +405,7 @@
new RegExp(`(?<!\\$)\\$\\{${marker.name}\\?`),
new RegExp(`(?<!\\$)\\$\\{${marker.name}:\\+`),
new RegExp(`(?<!\\$)\\$\\{${marker.name}\\+`),
new RegExp(`(?<!\\$)\\$${marker.name}(?![a-zA-Z0-9_])`)
new RegExp(`(?<![A-Za-z0-9\\$])\\$${marker.name}(?![a-zA-Z0-9_])`)
];
const hasVariable = varPatterns.some(p => p.test(line));
+3 -1
View File
@@ -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}
<!-- Scan Results -->
<div class="flex-1 min-h-0 overflow-auto">
<ScanResultsView {results} />
<ScanResultsView {results} bind:activeScanner />
</div>
{/if}
</div>
+23
View File
@@ -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 @@
</Sidebar.Group>
</Sidebar.Content>
<!-- Version (expanded sidebar only) -->
<div class="group-data-[state=collapsed]:hidden px-3 py-2 mt-auto text-center">
<Tooltip.Root>
<Tooltip.Trigger>
<span class="text-[10px] text-muted-foreground/60 hover:text-muted-foreground transition-colors cursor-default">
{appVersion}
</span>
</Tooltip.Trigger>
<Tooltip.Content side="top" align="start" sideOffset={8} class="text-xs">
<div class="space-y-0.5">
<div class="flex items-center gap-1.5"><svg class="w-4 h-4 shrink-0" viewBox="0 0 24 18" fill="currentColor"><path d="M23.76 8.68c-.26-.18-.86-.58-1.53-.58-.24 0-.48.04-.72.12-.12-.84-.68-1.56-1.34-2.14l-.28-.22-.24.26c-.28.34-.48.72-.56 1.14-.1.42-.06.82.1 1.2-.42.22-.88.36-1.32.42-.24.04-.48.06-.72.06H.78a.77.77 0 0 0-.78.78c-.02 1.46.22 2.9.72 4.24.56 1.44 1.4 2.5 2.5 3.16 1.26.74 3.32 1.16 5.64 1.16.98 0 2-.1 2.98-.3a11.5 11.5 0 0 0 3.3-1.3 9.67 9.67 0 0 0 2.54-2.34c1.16-1.42 1.86-3.02 2.34-4.38h.2c1.22 0 1.98-.48 2.4-.9.28-.26.5-.58.64-.94l.08-.24-.28-.2zM2.74 8.84H4.7c.1 0 .18-.08.18-.18V7.02c0-.1-.08-.18-.18-.18H2.74c-.1 0-.18.08-.18.18v1.64c0 .1.08.18.18.18zm2.72 0h1.96c.1 0 .18-.08.18-.18V7.02c0-.1-.08-.18-.18-.18H5.46c-.1 0-.18.08-.18.18v1.64c0 .1.08.18.18.18zm2.76 0h1.96c.1 0 .18-.08.18-.18V7.02c0-.1-.08-.18-.18-.18H8.22c-.1 0-.18.08-.18.18v1.64c0 .1.08.18.18.18zm2.76 0h1.96c.1 0 .18-.08.18-.18V7.02c0-.1-.08-.18-.18-.18h-1.96c-.1 0-.18.08-.18.18v1.64c0 .1.08.18.18.18zM5.46 6.2h1.96c.1 0 .18-.08.18-.18V4.38c0-.1-.08-.18-.18-.18H5.46c-.1 0-.18.08-.18.18v1.64c0 .1.08.18.18.18zm2.76 0h1.96c.1 0 .18-.08.18-.18V4.38c0-.1-.08-.18-.18-.18H8.22c-.1 0-.18.08-.18.18v1.64c0 .1.08.18.18.18zm2.76 0h1.96c.1 0 .18-.08.18-.18V4.38c0-.1-.08-.18-.18-.18h-1.96c-.1 0-.18.08-.18.18v1.64c0 .1.08.18.18.18zm0-2.64h1.96c.1 0 .18-.08.18-.18V1.74c0-.1-.08-.18-.18-.18h-1.96c-.1 0-.18.08-.18.18v1.64c0 .1.08.18.18.18zm2.76 5.28h1.96c.1 0 .18-.08.18-.18V7.02c0-.1-.08-.18-.18-.18h-1.96c-.1 0-.18.08-.18.18v1.64c0 .1.08.18.18.18z"/></svg><span class="font-mono">fnsys/dockhand:{appVersion}</span></div>
{#if buildCommit}
<div>Commit: <span class="font-mono">{buildCommit.slice(0, 7)}</span></div>
{/if}
</div>
</Tooltip.Content>
</Tooltip.Root>
</div>
<!-- User info footer (only when auth is enabled) -->
{#if $authStore.authEnabled && $authStore.authenticated && $authStore.user}
<Sidebar.Footer class="border-t">
+24
View File
@@ -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",
+72 -42
View File
@@ -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"
},
+14
View File
@@ -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=<url> — Custom clickable URL displayed alongside container ports
* - dockhand.port.<hostPort>.url=<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<string, string> | 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<string, string> | 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<string, string> | undefined | n
updateDisabled: boolean;
hidden: boolean;
notifyDisabled: boolean;
customUrl?: string;
} {
return {
updateDisabled: isUpdateDisabledByLabel(labels),
hidden: isHiddenByLabel(labels),
notifyDisabled: isNotifyDisabledByLabel(labels),
customUrl: getCustomUrl(labels),
};
}
+46
View File
@@ -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<string, string>();
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<any[]>(
'/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,13 +1137,24 @@ 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,
@@ -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 || '',
+4
View File
@@ -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.
*/
@@ -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');
+14 -2
View File
@@ -77,7 +77,9 @@ export interface ContainerDetail {
networks: Array<{ name: string; ipAddress: string }>;
volumeCount: number;
restartCount: number;
exitCode?: number;
created: number;
labels: Record<string, string>;
}
/**
@@ -1545,6 +1547,12 @@ export async function listComposeStacks(envId?: number | null): Promise<ComposeS
const result: ComposeStackInfo[] = Array.from(stacks.entries()).map(([name, containerIds]) => {
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<ComposeS
networks,
volumeCount,
restartCount: c.restartCount || 0,
created: c.created
exitCode: c.exitCode,
created: c.created,
labels: c.labels || {}
};
})
.sort((a, b) => a.service.localeCompare(b.service));
@@ -1590,7 +1600,9 @@ export async function listComposeStacks(envId?: number | null): Promise<ComposeS
containers: Array.from(containerIds),
containerDetails,
status:
runningCount === stackContainers.length
activeTotal === 0
? 'stopped'
: runningCount >= activeTotal
? 'running'
: runningCount === 0
? 'stopped'
+28
View File
@@ -0,0 +1,28 @@
import { writable, get } from 'svelte/store';
import { browser } from '$app/environment';
const store = writable<Record<string, string>>({});
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();
}
};
+11
View File
@@ -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 };
+2
View File
@@ -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<string, string>;
}
export interface ComposeStackInfo {
+36 -3
View File
@@ -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, string>): 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, string>): 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<string, string>): { 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
*/
+36
View File
@@ -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<number>();
for (const p of ports) {
if (p.PublicPort || p.publicPort) {
publishedPrivate.add(p.PrivatePort || p.privatePort || 0);
}
}
const seen = new Set<number>();
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) {
+11 -2
View File
@@ -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)}
>
@@ -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;
+26 -1
View File
@@ -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();
+1 -8
View File
@@ -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,
+190
View File
@@ -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<Record<string, string>> {
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<string, { envId: number; envName: string }[]>();
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 });
}
};
+13 -1
View File
@@ -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<GeneralSettings, 'scheduleRetentionDays' | 'eventRe
eventPollInterval: 60000,
metricsCollectionInterval: 30000,
compactPorts: false,
showExposedPorts: false,
formatLogTimestamps: false,
lightTheme: 'default',
darkTheme: 'default',
@@ -186,6 +189,7 @@ export const GET: RequestHandler = async ({ cookies }) => {
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,
+2 -1
View File
@@ -60,7 +60,8 @@ function extractComposeVars(yaml: string): { required: string[]; optional: strin
// Also match $VAR_NAME (simple variable substitution)
// Use negative lookbehind (?<!\$) to skip escaped $$
const simpleRegex = /(?<!\$)\$([A-Za-z_][A-Za-z0-9_]*)(?![{A-Za-z0-9_])/g;
// Also require $ to not be preceded by an alphanumeric char (avoids matching inside values like "1$abc")
const simpleRegex = /(?<![A-Za-z0-9\$])\$([A-Za-z_][A-Za-z0-9_]*)(?![{A-Za-z0-9_])/g;
while ((match = simpleRegex.exec(line)) !== null) {
const varName = match[1];
if (!required.includes(varName) && !optional.includes(varName)) {
+72 -18
View File
@@ -12,7 +12,7 @@
import * as Select from '$lib/components/ui/select';
import * as Tooltip from '$lib/components/ui/tooltip';
import ConfirmPopover from '$lib/components/ConfirmPopover.svelte';
import { formatPorts, type PortMapping } from '$lib/utils/port-format';
import { formatPorts, formatExposedPorts } from '$lib/utils/port-format';
import MultiSelectFilter from '$lib/components/MultiSelectFilter.svelte';
import PageHeader from '$lib/components/PageHeader.svelte';
import { Badge } from '$lib/components/ui/badge';
@@ -37,6 +37,7 @@
ArrowDown,
Search,
ExternalLink,
Globe,
LayoutPanelLeft,
Rows3,
GripVertical,
@@ -264,6 +265,11 @@
let confirmPrune = $state(false);
let pruneStatus = $state<'idle' | 'pruning' | 'success' | 'error'>('idle');
// Update confirmation
let confirmUpdateAll = $state(false);
let confirmUpdateId = $state<string | null>(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,12 +1452,23 @@
{/if}
</Button>
{#if updatableContainersCount > 0}
<ConfirmPopover
open={confirmUpdateAll}
action="Update"
itemType="all {updatableContainersCount} containers"
confirmText="Update all"
variant="default"
title="Update all containers with available updates"
position="left"
onConfirm={updateAllContainers}
onOpenChange={(open) => confirmUpdateAll = open}
unstyled
>
{#snippet children({ open })}
<Button
size="sm"
variant="outline"
onclick={updateAllContainers}
class="border-amber-500/40 text-amber-600 hover:bg-amber-500/10 hover:border-amber-500"
title="Update all containers with available updates"
>
<CircleArrowUp class="w-3.5 h-3.5" />
Update all ({updatableContainersCount})
@@ -1462,6 +1479,8 @@
title="Dismiss all update indicators"
>×</button>
</Button>
{/snippet}
</ConfirmPopover>
{/if}
{#if $canAccess('containers', 'remove')}
<ConfirmPopover
@@ -1632,16 +1651,26 @@
</ConfirmPopover>
{/if}
{#if selectedHaveUpdates}
<button
type="button"
class="inline-flex items-center gap-1 px-1.5 py-0 rounded border border-amber-500/40 text-amber-600 hover:border-amber-500 hover:shadow transition-all cursor-pointer {bulkActionInProgress ? 'opacity-50' : ''}"
onclick={updateSelectedContainers}
disabled={bulkActionInProgress}
<ConfirmPopover
open={confirmUpdateSelected}
action="Update"
itemType="{selectedWithUpdatesCount} selected container(s)"
confirmText="Update"
variant="default"
title="Update selected containers to latest image"
onConfirm={updateSelectedContainers}
onOpenChange={(open) => confirmUpdateSelected = open}
unstyled
>
{#snippet children({ open })}
<span
class="inline-flex items-center gap-1 px-1.5 py-0 rounded border border-amber-500/40 text-amber-600 hover:border-amber-500 hover:shadow transition-all cursor-pointer {bulkActionInProgress ? 'opacity-50' : ''}"
>
<CircleArrowUp class="w-3 h-3" />
Update {selectedWithUpdatesCount}
</button>
</span>
{/snippet}
</ConfirmPopover>
{/if}
{#if bulkActionInProgress}
<CircleArrowUp class="w-3 h-3 animate-spin ml-1" />
@@ -1873,24 +1902,41 @@
<code class="text-xs">{primaryIp}</code>
{/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}
<div class="flex {compactPorts ? 'flex-nowrap' : 'flex-wrap'} gap-1">
{#if customUrl}
<a
href={customUrl}
target="_blank"
rel="noopener noreferrer"
onclick={(e) => 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"
>
<Globe class="w-2.5 h-2.5" />
<span class="max-w-[120px] truncate">{customUrl.replace(/^https?:\/\//, '')}</span>
<ExternalLink class="w-2.5 h-2.5 opacity-60" />
</a>
{/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}
<a
href={url}
target="_blank"
rel="noopener noreferrer"
onclick={(e) => 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"
>
<code>{port.display}</code>
<ExternalLink class="w-2.5 h-2.5 text-muted-foreground" />
<ExternalLink class="w-2.5 h-2.5 {portUrl ? 'opacity-60' : 'text-muted-foreground'}" />
</a>
{:else}
<code class="text-xs bg-muted px-1 py-0.5 rounded shrink-0">{port.display}</code>
@@ -1902,6 +1948,9 @@
title={ports.map(p => p.display).join(', ')}
>+{remainingCount}</span>
{/if}
{#each exposedPorts as port}
<code class="text-xs bg-amber-500/10 text-amber-600 dark:text-amber-400 border border-amber-500/20 px-1 py-0.5 rounded shrink-0" title="Exposed (internal) port">{port.display}</code>
{/each}
</div>
{:else}
<span class="text-gray-400 dark:text-gray-600 text-xs">-</span>
@@ -1947,14 +1996,19 @@
{:else if column.id === 'actions'}
<div class="relative flex gap-0.5 justify-end">
{#if containersWithUpdatesSet.has(container.id) && !container.systemContainer}
<button
type="button"
onclick={() => updateSingleContainer(container.id, container.name)}
<ConfirmPopover
open={confirmUpdateId === container.id}
action="Update"
itemType="container"
itemName={container.name}
title="Update available - click to update"
class="p-0.5 rounded hover:bg-muted transition-colors cursor-pointer"
onConfirm={() => updateSingleContainer(container.id, container.name)}
onOpenChange={(open) => confirmUpdateId = open ? container.id : null}
>
{#snippet children({ open })}
<CircleArrowUp class="w-3 h-3 text-amber-500 {$appSettings.highlightUpdates ? 'glow-amber' : ''}" />
</button>
{/snippet}
</ConfirmPopover>
{/if}
{#if !container.systemContainer}
{#if container.state === 'running' || container.state === 'restarting'}
@@ -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 @@
<!-- Ports -->
{#if containerData.NetworkSettings?.Ports && Object.keys(containerData.NetworkSettings.Ports).length > 0}
{@const inspectCustomUrl = containerData.Config?.Labels?.['dockhand.url']?.trim() || null}
<div class="space-y-2">
<h3 class="text-sm font-semibold">Port mappings</h3>
<div class="flex flex-wrap gap-2">
{#if inspectCustomUrl}
<div class="flex items-center gap-2 text-xs p-2 bg-primary/10 rounded">
<a
href={inspectCustomUrl}
target="_blank"
rel="noopener noreferrer"
class="inline-flex items-center gap-1 text-primary hover:underline"
title="Open {inspectCustomUrl}"
>
<Globe class="w-3 h-3" />
<span>{inspectCustomUrl.replace(/^https?:\/\//, '')}</span>
<ExternalLink class="w-3 h-3 opacity-60" />
</a>
</div>
{/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))}
<div class="flex items-center gap-2 text-xs p-2 bg-muted rounded">
{#if url}
<a
@@ -902,9 +919,9 @@
</div>
{/each}
{:else}
<div class="flex items-center gap-2 text-xs p-2 bg-muted rounded">
<code class="text-muted-foreground">exposed</code>
<code>{containerPort}</code>
<div class="flex items-center gap-2 text-xs p-2 bg-amber-500/10 border border-amber-500/20 rounded">
<code class="text-amber-600 dark:text-amber-400">exposed</code>
<code class="text-amber-600 dark:text-amber-400">{containerPort}</code>
</div>
{/if}
{/each}
@@ -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<number>();
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 @@
<div class="space-y-2">
{#each portMappings as mapping, index}
<div class="flex gap-2 items-center">
<div class="flex-1 relative">
<div class="flex-1 relative group/port">
<span class="absolute -top-2 left-2 text-2xs text-muted-foreground bg-background px-1">Host</span>
<Input bind:value={mapping.hostPort} type="number" class="h-9" />
<button
type="button"
class="absolute right-1.5 top-1/2 -translate-y-1/2 text-muted-foreground/50 hover:text-primary transition-colors opacity-0 group-hover/port:opacity-100"
onclick={() => findFreePort(index)}
disabled={findingFreePort}
title="Find next available Docker port"
>
{#if findingFreePort}
<Loader2 class="w-3.5 h-3.5 animate-spin" />
{:else}
<Search class="w-3.5 h-3.5" />
{/if}
</button>
</div>
<div class="flex-1 relative">
<span class="absolute -top-2 left-2 text-2xs text-muted-foreground bg-background px-1">Container</span>
@@ -882,6 +937,10 @@
</div>
{/each}
</div>
<p class="text-2xs text-muted-foreground/60 flex items-center gap-1">
<Search class="w-3 h-3" />
Hover the host port field and click the search icon to find the next available port. Only checks Docker-published ports.
</p>
</div>
<!-- Volume Mappings -->
@@ -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}
<div class="flex flex-wrap gap-1">
{#each s.labels as label}
{@const colors = getLabelColors(label)}
{@const colors = getLabelColors(label, $labelColorOverrides)}
<span
class="px-1.5 py-0.5 text-[11px] rounded-sm font-medium leading-tight whitespace-nowrap"
style="background-color: {colors.bgColor}; color: {colors.color}"
+4 -3
View File
@@ -1,5 +1,6 @@
<script lang="ts">
import { getLabelColors } from '$lib/utils/label-colors';
import { labelColorOverrides } from '$lib/stores/label-colors';
interface Props {
labels?: string[];
@@ -14,7 +15,7 @@
{#if compact}
<div class="flex flex-wrap gap-0.5 pl-6 mt-0.5">
{#each labels as label}
{@const colors = getLabelColors(label)}
{@const colors = getLabelColors(label, $labelColorOverrides)}
<span class="px-1.5 py-0.5 text-[11px] rounded-sm font-medium leading-tight"
style="background-color: {colors.bgColor}; color: {colors.color}">
{label}
@@ -24,7 +25,7 @@
{:else if unified}
<div class="flex flex-wrap gap-0.5 px-4 -mt-0.5 mb-1">
{#each labels as label}
{@const colors = getLabelColors(label)}
{@const colors = getLabelColors(label, $labelColorOverrides)}
<span class="px-1.5 py-0.5 text-[11px] rounded-sm font-medium leading-tight"
style="background-color: {colors.bgColor}; color: {colors.color}">
{label}
@@ -34,7 +35,7 @@
{:else}
<div class="flex flex-wrap gap-0.5 pl-6 pr-4 -mt-8 -mb-4">
{#each labels as label}
{@const colors = getLabelColors(label)}
{@const colors = getLabelColors(label, $labelColorOverrides)}
<span class="px-1.5 py-0.5 text-[11px] rounded-sm font-medium leading-tight"
style="background-color: {colors.bgColor}; color: {colors.color}">
{label}
+83 -33
View File
@@ -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<ScanResult[]>([]);
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,11 +96,14 @@
}
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 => [
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,
@@ -99,39 +112,50 @@
(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`;
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 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`);
md += `## Summary\n\n`;
md += summaryParts.length > 0 ? summaryParts.join(' | ') : 'No vulnerabilities found';
md += `\n\n**Total:** ${activeResult.vulnerabilities.length} vulnerabilities\n\n`;
md += `\n\n**Total:** ${result.vulnerabilities.length} vulnerabilities\n\n`;
if (activeResult.vulnerabilities.length > 0) {
const bySeverity: Record<string, typeof activeResult.vulnerabilities> = {};
for (const vuln of activeResult.vulnerabilities) {
if (result.vulnerabilities.length > 0) {
const bySeverity: Record<string, typeof result.vulnerabilities> = {};
for (const vuln of result.vulnerabilities) {
const sev = vuln.severity.toLowerCase();
if (!bySeverity[sev]) bySeverity[sev] = [];
bySeverity[sev].push(vuln);
@@ -164,27 +188,39 @@
}
}
}
}
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 = {
const report = exportResults.length === 1
? {
image: imageName,
scanner: activeResult.scanner,
scanDuration: activeResult.scanDuration || duration,
summary: activeResult.summary,
vulnerabilities: activeResult.vulnerabilities
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}
</DropdownMenu.Trigger>
<DropdownMenu.Content align="start">
<DropdownMenu.Item onclick={exportToMarkdown}>
{#if hasBothScanners}
<div class="px-2 py-1.5 flex flex-col gap-1">
<label class="flex items-center gap-2 text-xs cursor-pointer">
<Checkbox bind:checked={exportGrype} disabled={!exportTrivy} />
Grype
</label>
<label class="flex items-center gap-2 text-xs cursor-pointer">
<Checkbox bind:checked={exportTrivy} disabled={!exportGrype} />
Trivy
</label>
</div>
<DropdownMenu.Separator />
{/if}
<DropdownMenu.Item onclick={exportToMarkdown} disabled={exportResults.length === 0}>
<FileText class="w-4 h-4 mr-2 text-blue-500" />
Markdown report (.md)
</DropdownMenu.Item>
<DropdownMenu.Item onclick={exportToCSV}>
<DropdownMenu.Item onclick={exportToCSV} disabled={exportResults.length === 0}>
<FileSpreadsheet class="w-4 h-4 mr-2 text-green-500" />
CSV spreadsheet (.csv)
</DropdownMenu.Item>
<DropdownMenu.Item onclick={exportToJSON}>
<DropdownMenu.Item onclick={exportToJSON} disabled={exportResults.length === 0}>
<FileText class="w-4 h-4 mr-2 text-amber-500" />
JSON data (.json)
</DropdownMenu.Item>
+5 -3
View File
@@ -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<Set<string>>(new Set());
let sortBy = $state<'severity' | 'id' | 'package'>('severity');
let sortDir = $state<'asc' | 'desc'>('asc');
@@ -112,7 +114,7 @@
{#each results as r}
<button
class="px-3 py-1.5 text-xs font-medium border-b-2 transition-colors cursor-pointer {activeTab === r.scanner ? 'border-primary text-foreground' : 'border-transparent text-muted-foreground hover:text-foreground'}"
onclick={() => activeTab = r.scanner}
onclick={() => activeScanner = r.scanner}
>
{r.scanner === 'grype' ? 'Grype' : 'Trivy'}
{#if r.summary.critical > 0 || r.summary.high > 0}
+45 -6
View File
@@ -10,7 +10,7 @@
import * as Select from '$lib/components/ui/select';
import { Checkbox } from '$lib/components/ui/checkbox';
import { ToggleGroup } from '$lib/components/ui/toggle-pill';
import { RefreshCw, Search, ChevronDown, ChevronUp, Unplug, Copy, Download, WrapText, ArrowDownToLine, X, Sun, Moon, LayoutList, Square, Box, Wifi, WifiOff, Pause, Play, ScrollText, Star, GripVertical, Layers, Check, FolderHeart, Save, Trash2, MoreHorizontal, Eraser, Filter, GripHorizontal, Terminal, ArrowDown, ArrowRight } from 'lucide-svelte';
import { RefreshCw, Search, ChevronDown, ChevronUp, Unplug, Copy, Download, WrapText, ArrowDownToLine, X, Sun, Moon, LayoutList, Square, Box, Wifi, WifiOff, Pause, Play, ScrollText, Star, GripVertical, Layers, Check, FolderHeart, Save, Trash2, MoreHorizontal, Eraser, Filter, GripHorizontal, Terminal, ArrowDown, ArrowRight, Clock, Tag } from 'lucide-svelte';
import { copyToClipboard } from '$lib/utils/clipboard';
import PageHeader from '$lib/components/PageHeader.svelte';
import TerminalPanel from '../terminal/TerminalPanel.svelte';
@@ -34,6 +34,8 @@ import type { FavoriteGroup } from '../api/preferences/favorite-groups/+server';
let autoScroll = $state(true);
let fontSize = $state(12);
let wordWrap = $state(true);
let showTimestamps = $state(typeof localStorage !== 'undefined' ? localStorage.getItem('dockhand-log-timestamps') !== 'false' : true);
let showContainerName = $state(typeof localStorage !== 'undefined' ? localStorage.getItem('dockhand-log-container-name') !== 'false' : true);
let darkMode = $state(true);
let layoutMode = $state<'single' | 'multi' | 'grouped'>('multi');
let streamingEnabled = $state(true);
@@ -843,9 +845,9 @@ import type { FavoriteGroup } from '../api/preferences/favorite-groups/+server';
try {
const data = JSON.parse(event.data);
if (data.text) {
// Add container name prefix to each line if available
// Add container name prefix to each line if available and enabled
let text = data.text;
if (data.containerName) {
if (data.containerName && showContainerName) {
const lines = text.split('\n');
text = lines.map((line: string, i: number) => {
if (line === '' && i === lines.length - 1) return line;
@@ -853,8 +855,10 @@ import type { FavoriteGroup } from '../api/preferences/favorite-groups/+server';
return `[${data.containerName}] ${line}`;
}).join('\n');
}
// Format timestamps if enabled
if ($appSettings.formatLogTimestamps) {
// Strip or format timestamps
if (!showTimestamps) {
text = text.replace(/^(\[.*?\] )?\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?Z /gm, '$1');
} else if ($appSettings.formatLogTimestamps) {
text = formatLogTimestamps(text);
}
// Buffer text and schedule flush
@@ -1041,7 +1045,12 @@ import type { FavoriteGroup } from '../api/preferences/favorite-groups/+server';
if (data.text) {
// Use consistent color based on position in all selected containers
const color = getContainerColor(data.containerId);
const logText = $appSettings.formatLogTimestamps ? formatLogTimestamps(data.text) : data.text;
let logText = data.text;
if (!showTimestamps) {
logText = logText.replace(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?Z /gm, '');
} else if ($appSettings.formatLogTimestamps) {
logText = formatLogTimestamps(logText);
}
// Add to pending batch instead of updating state immediately
pendingLogs.push({
containerId: data.containerId,
@@ -2044,6 +2053,20 @@ import type { FavoriteGroup } from '../api/preferences/favorite-groups/+server';
<WrapText class="w-3 h-3" />
<span>Wrap</span>
</button>
<button
onclick={() => { showTimestamps = !showTimestamps; localStorage.setItem('dockhand-log-timestamps', String(showTimestamps)); }}
class="p-1 rounded transition-colors {showTimestamps ? (darkMode ? 'bg-amber-500/20 ring-1 ring-amber-500/50' : 'bg-amber-500/30 ring-1 ring-amber-600/50') : ''} {darkMode ? 'hover:bg-zinc-800' : 'hover:bg-gray-200'}"
title={showTimestamps ? 'Hide timestamps' : 'Show timestamps'}
>
<Clock class="w-3 h-3 transition-colors {showTimestamps ? (darkMode ? 'text-amber-400' : 'text-amber-700') : darkMode ? 'text-zinc-500 hover:text-zinc-300' : 'text-gray-500 hover:text-gray-700'}" />
</button>
<button
onclick={() => { showContainerName = !showContainerName; localStorage.setItem('dockhand-log-container-name', String(showContainerName)); }}
class="p-1 rounded transition-colors {showContainerName ? (darkMode ? 'bg-amber-500/20 ring-1 ring-amber-500/50' : 'bg-amber-500/30 ring-1 ring-amber-600/50') : ''} {darkMode ? 'hover:bg-zinc-800' : 'hover:bg-gray-200'}"
title={showContainerName ? 'Hide container name prefix' : 'Show container name prefix'}
>
<Tag class="w-3 h-3 transition-colors {showContainerName ? (darkMode ? 'text-amber-400' : 'text-amber-700') : darkMode ? 'text-zinc-500 hover:text-zinc-300' : 'text-gray-500 hover:text-gray-700'}" />
</button>
<button onclick={toggleTheme} class="p-1 rounded transition-colors {darkMode ? 'hover:bg-zinc-800' : 'hover:bg-gray-200'}" title={darkMode ? 'Switch to light mode' : 'Switch to dark mode'}>
{#if darkMode}
<Sun class="w-3 h-3 {darkMode ? 'text-zinc-500 hover:text-zinc-300' : 'text-gray-500 hover:text-gray-700'}" />
@@ -2222,6 +2245,22 @@ import type { FavoriteGroup } from '../api/preferences/favorite-groups/+server';
<WrapText class="w-3 h-3" />
<span>Wrap</span>
</button>
<!-- Timestamps toggle -->
<button
onclick={() => { showTimestamps = !showTimestamps; localStorage.setItem('dockhand-log-timestamps', String(showTimestamps)); }}
class="p-1 rounded transition-colors {showTimestamps ? (darkMode ? 'bg-amber-500/20 ring-1 ring-amber-500/50' : 'bg-amber-500/30 ring-1 ring-amber-600/50') : ''} {darkMode ? 'hover:bg-zinc-800' : 'hover:bg-gray-200'}"
title={showTimestamps ? 'Hide timestamps' : 'Show timestamps'}
>
<Clock class="w-3 h-3 transition-colors {showTimestamps ? (darkMode ? 'text-amber-400' : 'text-amber-700') : darkMode ? 'text-zinc-500 hover:text-zinc-300' : 'text-gray-500 hover:text-gray-700'}" />
</button>
<!-- Container name toggle -->
<button
onclick={() => { showContainerName = !showContainerName; localStorage.setItem('dockhand-log-container-name', String(showContainerName)); }}
class="p-1 rounded transition-colors {showContainerName ? (darkMode ? 'bg-amber-500/20 ring-1 ring-amber-500/50' : 'bg-amber-500/30 ring-1 ring-amber-600/50') : ''} {darkMode ? 'hover:bg-zinc-800' : 'hover:bg-gray-200'}"
title={showContainerName ? 'Hide container name prefix' : 'Show container name prefix'}
>
<Tag class="w-3 h-3 transition-colors {showContainerName ? (darkMode ? 'text-amber-400' : 'text-amber-700') : darkMode ? 'text-zinc-500 hover:text-zinc-300' : 'text-gray-500 hover:text-gray-700'}" />
</button>
<!-- Theme toggle -->
<button
onclick={toggleTheme}
+25 -4
View File
@@ -1,6 +1,6 @@
<script lang="ts">
import { onMount, onDestroy, tick } from 'svelte';
import { X, GripHorizontal, RefreshCw, Copy, Download, WrapText, ArrowDownToLine, Search, ChevronUp, ChevronDown, Sun, Moon, Wifi, WifiOff, Pause, Play, Eraser, Filter } from 'lucide-svelte';
import { X, GripHorizontal, RefreshCw, Copy, Download, WrapText, ArrowDownToLine, Search, ChevronUp, ChevronDown, Sun, Moon, Wifi, WifiOff, Pause, Play, Eraser, Filter, Clock, Tag } from 'lucide-svelte';
import { copyToClipboard } from '$lib/utils/clipboard';
import * as Select from '$lib/components/ui/select';
import { appSettings, formatLogTimestamps } from '$lib/stores/settings';
@@ -29,6 +29,8 @@
let autoScroll = $state(true);
let wordWrap = $state(true);
let fontSize = $state(12);
let showTimestamps = $state(typeof localStorage !== 'undefined' ? localStorage.getItem('dockhand-log-timestamps') !== 'false' : true);
let showContainerName = $state(typeof localStorage !== 'undefined' ? localStorage.getItem('dockhand-log-container-name') !== 'false' : true);
// SSE Streaming state
let streamingEnabled = $state(true);
@@ -215,9 +217,9 @@
try {
const data = JSON.parse(event.data);
if (data.text) {
// Add container name prefix to each line if available
// Add container name prefix to each line if available and enabled
let text = data.text;
if (data.containerName) {
if (data.containerName && showContainerName) {
const lines = text.split('\n');
text = lines.map((line: string, i: number) => {
if (line === '' && i === lines.length - 1) return line;
@@ -540,7 +542,10 @@
// Highlighted logs with search matches and ANSI color support
let highlightedLogs = $derived(() => {
let text = logs || '';
if ($appSettings.formatLogTimestamps) {
if (!showTimestamps) {
// Strip ISO 8601 timestamps from start of each line (Docker log format)
text = text.replace(/^(\[.*?\] )?\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?Z /gm, '$1');
} else if ($appSettings.formatLogTimestamps) {
text = formatLogTimestamps(text);
}
@@ -730,6 +735,22 @@
>
<WrapText class="w-3 h-3 transition-colors {wordWrap ? (darkMode ? 'text-amber-400' : 'text-amber-700') : darkMode ? 'text-zinc-500 hover:text-zinc-300' : 'text-gray-500 hover:text-gray-700'}" />
</button>
<!-- Timestamps toggle -->
<button
onclick={() => { showTimestamps = !showTimestamps; localStorage.setItem('dockhand-log-timestamps', String(showTimestamps)); }}
class="p-1 rounded transition-colors {showTimestamps ? (darkMode ? 'bg-amber-500/20 ring-1 ring-amber-500/50' : 'bg-amber-500/30 ring-1 ring-amber-600/50') : ''} {darkMode ? 'hover:bg-zinc-800' : 'hover:bg-gray-300'}"
title={showTimestamps ? 'Hide timestamps' : 'Show timestamps'}
>
<Clock class="w-3 h-3 transition-colors {showTimestamps ? (darkMode ? 'text-amber-400' : 'text-amber-700') : darkMode ? 'text-zinc-500 hover:text-zinc-300' : 'text-gray-500 hover:text-gray-700'}" />
</button>
<!-- Container name toggle -->
<button
onclick={() => { showContainerName = !showContainerName; localStorage.setItem('dockhand-log-container-name', String(showContainerName)); }}
class="p-1 rounded transition-colors {showContainerName ? (darkMode ? 'bg-amber-500/20 ring-1 ring-amber-500/50' : 'bg-amber-500/30 ring-1 ring-amber-600/50') : ''} {darkMode ? 'hover:bg-zinc-800' : 'hover:bg-gray-300'}"
title={showContainerName ? 'Hide container name prefix' : 'Show container name prefix'}
>
<Tag class="w-3 h-3 transition-colors {showContainerName ? (darkMode ? 'text-amber-400' : 'text-amber-700') : darkMode ? 'text-zinc-500 hover:text-zinc-300' : 'text-gray-500 hover:text-gray-700'}" />
</button>
<!-- Theme toggle -->
<button
onclick={toggleTheme}
+11 -1
View File
@@ -15,13 +15,15 @@
Crown,
Users,
Info,
GitBranch
GitBranch,
Tags
} from 'lucide-svelte';
import PageHeader from '$lib/components/PageHeader.svelte';
// Import tab components
import GeneralTab from './general/GeneralTab.svelte';
import EnvironmentsTab from './environments/EnvironmentsTab.svelte';
import LabelsTab from './labels/LabelsTab.svelte';
import RegistriesTab from './registries/RegistriesTab.svelte';
import GitTab from './git/GitTab.svelte';
import ConfigSetsTab from './config-sets/ConfigSetsTab.svelte';
@@ -55,6 +57,10 @@
<Globe class="w-4 h-4" />
Environments
</Tabs.Trigger>
<Tabs.Trigger value="labels" class="flex-1 flex items-center justify-center gap-1.5">
<Tags class="w-4 h-4" />
Labels
</Tabs.Trigger>
<Tabs.Trigger value="registries" class="flex-1 flex items-center justify-center gap-1.5">
<Download class="w-4 h-4" />
Registries
@@ -93,6 +99,10 @@
{#if activeTab === 'environments'}<EnvironmentsTab {editEnvId} {newEnv} />{/if}
</Tabs.Content>
<Tabs.Content value="labels" class="flex-1 min-h-0 overflow-y-auto">
{#if activeTab === 'labels'}<LabelsTab />{/if}
</Tabs.Content>
<Tabs.Content value="registries" class="flex-1 min-h-0 overflow-y-auto">
{#if activeTab === 'registries'}<RegistriesTab />{/if}
</Tabs.Content>
@@ -81,6 +81,7 @@
import { licenseStore } from '$lib/stores/license';
import { formatDateTime, formatDate } from '$lib/stores/settings';
import { getLabelColor, getLabelBgColor, parseLabels, MAX_LABELS } from '$lib/utils/label-colors';
import { labelColorOverrides } from '$lib/stores/label-colors';
import EventTypesEditor from './EventTypesEditor.svelte';
import UpdatesTab from './tabs/UpdatesTab.svelte';
import ActivityTab from './tabs/ActivityTab.svelte';
@@ -531,6 +532,7 @@
// Reset token state for this environment (important when switching between envs)
hawserToken = null;
generatedToken = null;
generatedStandardToken = null;
pendingToken = null;
// Load scanner settings, notifications, update check settings, image prune settings, and timezone
loadScannerSettings(environment.id);
@@ -576,6 +578,7 @@
envNotifLoading = false;
hawserToken = null;
generatedToken = null;
generatedStandardToken = null;
pendingToken = null;
// Reset update check settings
updateCheckEnabled = false;
@@ -603,6 +606,8 @@
// Track which environment was initialized to avoid repeated resets
let lastInitializedEnvId = $state<number | null | undefined>(undefined);
$effect(() => { labelColorOverrides.load(); });
// Reset form when modal opens OR when environment prop changes (for edit mode)
$effect(() => {
if (open) {
@@ -626,6 +631,16 @@
pendingToken = base64.replace(/\+/g, '-').replace(/\//g, '_').replace(/=/g, '');
}
let generatedStandardToken = $state<string | null>(null);
function generateStandardToken() {
const array = new Uint8Array(32);
crypto.getRandomValues(array);
const base64 = btoa(String.fromCharCode(...array));
formHawserToken = base64.replace(/\+/g, '-').replace(/\//g, '_').replace(/=/g, '');
generatedStandardToken = formHawserToken;
}
// === Test Connection ===
async function testConnection() {
testingConnection = true;
@@ -1516,7 +1531,7 @@
<Badge
variant="secondary"
class="gap-1 pr-1 rounded-md"
style="background-color: {getLabelBgColor(label)}; border-color: {getLabelColor(label)}; color: {getLabelColor(label)};"
style="background-color: {getLabelBgColor(label, $labelColorOverrides)}; border-color: {getLabelColor(label, $labelColorOverrides)}; color: {getLabelColor(label, $labelColorOverrides)};"
>
{label}
<button
@@ -1554,7 +1569,7 @@
{#if showLabelDropdown && filteredLabelSuggestions.length > 0}
<div class="absolute z-50 w-full mt-1 bg-popover border rounded-md shadow-md max-h-48 overflow-y-auto">
{#each filteredLabelSuggestions as suggestion}
{@const colors = { color: getLabelColor(suggestion), bgColor: getLabelBgColor(suggestion) }}
{@const colors = { color: getLabelColor(suggestion, $labelColorOverrides), bgColor: getLabelBgColor(suggestion, $labelColorOverrides) }}
<button
type="button"
class="w-full px-3 py-1.5 text-left text-sm hover:bg-accent flex items-center gap-2"
@@ -1923,13 +1938,91 @@
</div>
{/if}
<div class="space-y-2">
<div class="flex items-center justify-between">
<Label for="edit-env-hawser-token">Agent token (optional)</Label>
<Input id="edit-env-hawser-token" type="password" bind:value={formHawserToken} placeholder="Token for agent authentication" />
<p class="text-xs text-muted-foreground">If the Hawser agent is configured with TOKEN, enter it here.</p>
{#if !formHawserToken}
<Button
variant="outline"
size="sm"
class="h-7 text-xs"
onclick={generateStandardToken}
>
<Key class="w-3 h-3" />
Generate
</Button>
{/if}
</div>
<Input id="edit-env-hawser-token" type="password" bind:value={formHawserToken} placeholder="Token for agent authentication" oninput={() => generatedStandardToken = null} />
{#if generatedStandardToken}
<div class="p-3 bg-amber-50 dark:bg-amber-950/30 border border-amber-300 dark:border-amber-700 rounded-md space-y-2">
<p class="text-xs font-medium text-amber-700 dark:text-amber-400 flex items-center gap-1">
<AlertTriangle class="w-3 h-3" />
Copy this token now — it won't be shown again!
</p>
<div class="flex gap-2">
<Input
type="text"
value={generatedStandardToken}
readonly
class="font-mono text-xs flex-1"
/>
<Button variant="outline" size="sm" onclick={() => copyToken(generatedStandardToken!)}>
{#if copySuccess === 'error'}
<Tooltip.Root open>
<Tooltip.Trigger>
<XCircle class="w-4 h-4 text-red-500" />
</Tooltip.Trigger>
<Tooltip.Content>Copy requires HTTPS</Tooltip.Content>
</Tooltip.Root>
{:else if copySuccess === 'ok'}
<Check class="w-4 h-4 text-green-500" />
{:else}
<Copy class="w-4 h-4" />
{/if}
</Button>
<Button variant="outline" size="sm" onclick={() => { formHawserToken = ''; generateStandardToken(); }}>
<RefreshCw class="w-4 h-4" />
</Button>
</div>
</div>
{:else}
<p class="text-xs text-muted-foreground">Enter a token manually or generate one. Set the same token as <code class="bg-muted px-1 rounded">TOKEN</code> env var on your Hawser agent.</p>
{/if}
</div>
<div class="text-xs text-muted-foreground bg-muted/50 rounded-md p-2 flex items-start gap-2">
<Info class="w-3 h-3 mt-0.5 shrink-0" />
<span>Run Hawser agent on the target host: <code class="bg-muted px-1 rounded">hawser --port {formPort}</code></span>
<div class="space-y-1 flex-1">
<span>Run Hawser agent on the target host:</span>
<div class="flex items-start gap-1.5">
<code class="bg-muted px-1.5 py-0.5 rounded break-all flex-1">{formHawserToken ? `TOKEN=${formHawserToken} ` : ''}hawser standard --port {formPort}</code>
{#if formHawserToken}
<button
class="shrink-0 p-0.5 rounded hover:bg-muted transition-colors"
onclick={() => {
const cmd = `${formHawserToken ? `TOKEN=${formHawserToken} ` : ''}hawser standard --port ${formPort}`;
copyToClipboard(cmd).then(ok => {
copyCmdSuccess = ok ? 'ok' : 'error';
setTimeout(() => { copyCmdSuccess = null; }, 2000);
});
}}
title="Copy command"
>
{#if copyCmdSuccess === 'error'}
<Tooltip.Root open>
<Tooltip.Trigger>
<XCircle class="w-3 h-3 text-red-500" />
</Tooltip.Trigger>
<Tooltip.Content>Copy requires HTTPS</Tooltip.Content>
</Tooltip.Root>
{:else if copyCmdSuccess === 'ok'}
<Check class="w-3 h-3 text-green-600" />
{:else}
<Copy class="w-3 h-3" />
{/if}
</button>
{/if}
</div>
</div>
</div>
{/if}
@@ -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}
<div class="flex flex-wrap gap-1">
{#each env.labels as label}
{@const colors = getLabelColors(label)}
{@const colors = getLabelColors(label, $labelColorOverrides)}
<span
class="px-1.5 py-0.5 text-2xs rounded font-medium"
style="background-color: {colors.bgColor}; color: {colors.color}"
@@ -21,6 +21,7 @@
let showStoppedContainers = $derived($appSettings.showStoppedContainers);
let highlightUpdates = $derived($appSettings.highlightUpdates);
let compactPorts = $derived($appSettings.compactPorts);
let showExposedPorts = $derived($appSettings.showExposedPorts);
let timeFormat = $derived($appSettings.timeFormat);
let dateFormat = $derived($appSettings.dateFormat);
let downloadFormat = $derived($appSettings.downloadFormat);
@@ -292,6 +293,28 @@ services:
</div>
<p class="text-xs text-muted-foreground">Show first port with +N count instead of all ports</p>
</div>
<div class="space-y-1">
<div class="flex items-center gap-3">
<Label>Show exposed ports</Label>
<Tooltip.Root>
<Tooltip.Trigger>
<HelpCircle class="w-3.5 h-3.5 text-muted-foreground" />
</Tooltip.Trigger>
<Tooltip.Content side="top" class="max-w-xs">
<p>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.</p>
</Tooltip.Content>
</Tooltip.Root>
<TogglePill
checked={showExposedPorts}
onchange={(checked) => {
appSettings.setShowExposedPorts(checked);
toast.success(checked ? 'Showing exposed ports in container list' : 'Exposed ports hidden from container list');
}}
disabled={!$canAccess('settings', 'edit')}
/>
</div>
<p class="text-xs text-muted-foreground">Display internal container ports in the container list grid</p>
</div>
<div class="space-y-1">
<div class="flex items-center gap-3">
<Label>Time format</Label>
+525
View File
@@ -0,0 +1,525 @@
<script lang="ts">
import * as Card from '$lib/components/ui/card';
import * as Table from '$lib/components/ui/table';
import * as Dialog from '$lib/components/ui/dialog';
import * as Tooltip from '$lib/components/ui/tooltip';
import * as Popover from '$lib/components/ui/popover';
import { Button } from '$lib/components/ui/button';
import { Input } from '$lib/components/ui/input';
import { Label } from '$lib/components/ui/label';
import { Badge } from '$lib/components/ui/badge';
import { Checkbox } from '$lib/components/ui/checkbox';
import ConfirmPopover from '$lib/components/ConfirmPopover.svelte';
import { Tags, Pencil, Trash2, Loader2, Globe, AlertTriangle, Plus, Palette, RotateCcw } from 'lucide-svelte';
import { getLabelColors, COLOR_PALETTE, hexToRgba } from '$lib/utils/label-colors';
import { canAccess } from '$lib/stores/auth';
import { toast } from 'svelte-sonner';
interface LabelInfo {
label: string;
count: number;
color: string | null;
environments: { envId: number; envName: string }[];
}
interface EnvOption {
id: number;
name: string;
}
let labels = $state<LabelInfo[]>([]);
let customColors = $state<Record<string, string>>({});
let loading = $state(true);
// Rename dialog state
let showRenameDialog = $state(false);
let renameTarget = $state<LabelInfo | null>(null);
let newLabelName = $state('');
let renaming = $state(false);
// Add dialog state
let showAddDialog = $state(false);
let addLabelName = $state('');
let addEnvOptions = $state<EnvOption[]>([]);
let addSelectedEnvIds = $state<number[]>([]);
let adding = $state(false);
// Delete confirm state
let confirmDeleteLabel = $state<string | null>(null);
// Color popover state
let colorPopoverLabel = $state<string | null>(null);
$effect(() => {
fetchLabels();
});
async function fetchLabels() {
loading = true;
try {
const res = await fetch('/api/labels');
if (res.ok) {
const data = await res.json();
labels = data.labels;
customColors = data.colors || {};
}
} catch (e) {
console.error('Failed to fetch labels:', e);
} finally {
loading = false;
}
}
async function fetchEnvironments() {
try {
const res = await fetch('/api/environments');
if (res.ok) {
const envs = await res.json();
addEnvOptions = envs.map((e: any) => ({ id: e.id, name: e.name }));
}
} catch {
// ignore
}
}
function getColors(label: string) {
return getLabelColors(label, customColors);
}
function openRenameDialog(info: LabelInfo) {
renameTarget = info;
newLabelName = info.label;
showRenameDialog = true;
}
async function openAddDialog() {
addLabelName = '';
addSelectedEnvIds = [];
await fetchEnvironments();
showAddDialog = true;
}
async function handleRename() {
if (!renameTarget || !newLabelName.trim()) return;
if (newLabelName.trim() === renameTarget.label) {
showRenameDialog = false;
return;
}
renaming = true;
try {
const res = await fetch('/api/labels', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
action: 'rename',
oldLabel: renameTarget.label,
newLabel: newLabelName.trim()
})
});
if (res.ok) {
const data = await res.json();
toast.success(`Renamed "${renameTarget.label}" to "${newLabelName.trim()}" across ${data.affected} environment${data.affected !== 1 ? 's' : ''}`);
showRenameDialog = false;
await fetchLabels();
} else {
const err = await res.json();
toast.error(err.error || 'Failed to rename label');
}
} catch {
toast.error('Failed to rename label');
} finally {
renaming = false;
}
}
async function handleAdd() {
if (!addLabelName.trim() || addSelectedEnvIds.length === 0) return;
adding = true;
try {
const res = await fetch('/api/labels', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
action: 'add',
label: addLabelName.trim(),
environmentIds: addSelectedEnvIds
})
});
if (res.ok) {
const data = await res.json();
toast.success(`Added "${addLabelName.trim()}" to ${data.affected} environment${data.affected !== 1 ? 's' : ''}`);
showAddDialog = false;
await fetchLabels();
} else {
const err = await res.json();
toast.error(err.error || 'Failed to add label');
}
} catch {
toast.error('Failed to add label');
} finally {
adding = false;
}
}
async function handleDelete(info: LabelInfo) {
try {
const res = await fetch('/api/labels', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ action: 'delete', label: info.label })
});
if (res.ok) {
const data = await res.json();
toast.success(`Removed "${info.label}" from ${data.affected} environment${data.affected !== 1 ? 's' : ''}`);
await fetchLabels();
} else {
const err = await res.json();
toast.error(err.error || 'Failed to delete label');
}
} catch {
toast.error('Failed to delete label');
}
}
async function setColor(label: string, color: string | null) {
try {
const res = await fetch('/api/labels', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ action: 'set-color', label, color })
});
if (res.ok) {
if (color) {
customColors = { ...customColors, [label]: color };
} else {
const { [label]: _, ...rest } = customColors;
customColors = rest;
}
colorPopoverLabel = null;
toast.success(color ? `Color set for "${label}"` : `Color reset for "${label}"`);
}
} catch {
toast.error('Failed to set color');
}
}
function toggleEnvSelection(envId: number) {
if (addSelectedEnvIds.includes(envId)) {
addSelectedEnvIds = addSelectedEnvIds.filter(id => id !== envId);
} else {
addSelectedEnvIds = [...addSelectedEnvIds, envId];
}
}
function selectAllEnvs() {
addSelectedEnvIds = addEnvOptions.map(e => e.id);
}
function deselectAllEnvs() {
addSelectedEnvIds = [];
}
</script>
<div class="p-4 space-y-4">
<Card.Root>
<Card.Header class="pb-3">
<div class="flex items-center justify-between">
<div class="flex items-center gap-2">
<Tags class="w-5 h-5 text-muted-foreground" />
<div>
<Card.Title class="text-base">Environment labels</Card.Title>
<Card.Description>Manage labels across all environments. Renaming or deleting a label applies to every environment using it.</Card.Description>
</div>
</div>
<div class="flex items-center gap-2">
{#if !loading}
<Badge variant="secondary" class="text-xs">{labels.length} label{labels.length !== 1 ? 's' : ''}</Badge>
{/if}
<Button
size="sm"
variant="outline"
onclick={openAddDialog}
disabled={!$canAccess('environments', 'edit')}
class="h-7 text-xs"
>
<Plus class="w-3.5 h-3.5" />
Add label
</Button>
</div>
</div>
</Card.Header>
<Card.Content>
{#if loading}
<div class="flex items-center justify-center py-8 text-muted-foreground">
<Loader2 class="w-5 h-5 animate-spin mr-2" />
Loading labels...
</div>
{:else if labels.length === 0}
<div class="flex flex-col items-center justify-center py-8 text-muted-foreground gap-2">
<Tags class="w-8 h-8 opacity-50" />
<p class="text-sm">No labels found</p>
<p class="text-xs">Click "Add label" to create one and assign it to environments</p>
</div>
{:else}
<Table.Root>
<Table.Header>
<Table.Row>
<Table.Head class="w-[200px]">Label</Table.Head>
<Table.Head class="w-[60px] text-center">Color</Table.Head>
<Table.Head class="w-[80px] text-center">Environments</Table.Head>
<Table.Head>Used by</Table.Head>
<Table.Head class="w-[100px] text-right">Actions</Table.Head>
</Table.Row>
</Table.Header>
<Table.Body>
{#each labels as info}
{@const colors = getColors(info.label)}
<Table.Row>
<Table.Cell>
<span
class="px-2 py-0.5 text-xs rounded font-medium"
style="background-color: {colors.bgColor}; color: {colors.color}"
>
{info.label}
</span>
</Table.Cell>
<Table.Cell class="text-center">
<Popover.Root open={colorPopoverLabel === info.label} onOpenChange={(open) => colorPopoverLabel = open ? info.label : null}>
<Popover.Trigger>
<button
type="button"
class="w-5 h-5 rounded border border-border hover:ring-2 hover:ring-primary/30 transition-all"
style="background-color: {colors.color}"
title="Change color"
></button>
</Popover.Trigger>
<Popover.Content class="w-auto p-3" align="start">
<div class="space-y-2">
<p class="text-xs font-medium text-muted-foreground">Pick a color</p>
<div class="grid grid-cols-6 gap-1">
{#each COLOR_PALETTE as color}
<button
type="button"
class="w-5 h-5 rounded border transition-all {color === customColors[info.label] ? 'ring-2 ring-primary ring-offset-1 ring-offset-background' : 'border-transparent hover:ring-2 hover:ring-muted-foreground/30'}"
style="background-color: {color}"
onclick={() => setColor(info.label, color)}
></button>
{/each}
</div>
{#if customColors[info.label]}
<button
type="button"
class="flex items-center gap-1 text-xs text-muted-foreground hover:text-foreground transition-colors w-full justify-center pt-1"
onclick={() => setColor(info.label, null)}
>
<RotateCcw class="w-3 h-3" />
Reset to default
</button>
{/if}
</div>
</Popover.Content>
</Popover.Root>
</Table.Cell>
<Table.Cell class="text-center">
<Badge variant="outline" class="text-xs">{info.count}</Badge>
</Table.Cell>
<Table.Cell>
<div class="flex flex-wrap gap-1">
{#each info.environments as env}
<span class="inline-flex items-center gap-0.5 text-xs text-muted-foreground bg-muted px-1.5 py-0.5 rounded">
<Globe class="w-3 h-3" />
{env.envName}
</span>
{/each}
</div>
</Table.Cell>
<Table.Cell class="text-right">
<div class="flex items-center justify-end gap-1">
<Tooltip.Root>
<Tooltip.Trigger>
<Button
variant="ghost"
size="icon"
class="h-7 w-7"
onclick={() => openRenameDialog(info)}
disabled={!$canAccess('environments', 'edit')}
>
<Pencil class="w-3.5 h-3.5" />
</Button>
</Tooltip.Trigger>
<Tooltip.Content>Rename across all environments</Tooltip.Content>
</Tooltip.Root>
<ConfirmPopover
open={confirmDeleteLabel === info.label}
action="Remove"
itemType="label"
itemName={info.label}
confirmText="Remove"
position="left"
onConfirm={() => handleDelete(info)}
onOpenChange={(open) => confirmDeleteLabel = open ? info.label : null}
disabled={!$canAccess('environments', 'edit')}
>
{#snippet children({ open })}
<Trash2 class="w-3.5 h-3.5 {open ? 'text-destructive' : 'text-muted-foreground hover:text-destructive'}" />
{/snippet}
</ConfirmPopover>
</div>
</Table.Cell>
</Table.Row>
{/each}
</Table.Body>
</Table.Root>
{/if}
</Card.Content>
</Card.Root>
</div>
<!-- Rename Dialog -->
<Dialog.Root bind:open={showRenameDialog}>
<Dialog.Content class="max-w-md">
<Dialog.Header>
<Dialog.Title>Rename label</Dialog.Title>
<Dialog.Description>
{#if renameTarget}
This will rename "{renameTarget.label}" across {renameTarget.count} environment{renameTarget.count !== 1 ? 's' : ''}.
{/if}
</Dialog.Description>
</Dialog.Header>
{#if renameTarget}
{@const currentColors = getColors(renameTarget.label)}
<div class="space-y-4 py-2">
<div class="space-y-2">
<Label>Current name</Label>
<span
class="inline-block px-2 py-0.5 text-xs rounded font-medium"
style="background-color: {currentColors.bgColor}; color: {currentColors.color}"
>
{renameTarget.label}
</span>
</div>
<div class="space-y-2">
<Label for="new-label-name">New name</Label>
<Input
id="new-label-name"
bind:value={newLabelName}
placeholder="Enter new label name"
onkeydown={(e) => { if (e.key === 'Enter' && newLabelName.trim()) handleRename(); }}
/>
</div>
{#if newLabelName.trim() && newLabelName.trim() !== renameTarget.label}
{@const newColors = getColors(newLabelName.trim())}
<div class="flex items-center gap-2 text-xs text-muted-foreground">
<span>Preview:</span>
<span
class="px-2 py-0.5 rounded font-medium"
style="background-color: {newColors.bgColor}; color: {newColors.color}"
>
{newLabelName.trim()}
</span>
</div>
{/if}
{#if labels.some(l => l.label === newLabelName.trim() && l.label !== renameTarget?.label)}
<div class="flex items-center gap-1.5 text-xs text-amber-500">
<AlertTriangle class="w-3.5 h-3.5" />
<span>Label "{newLabelName.trim()}" already exists. Environments with both labels will be merged.</span>
</div>
{/if}
</div>
{/if}
<Dialog.Footer>
<Button variant="outline" onclick={() => showRenameDialog = false}>Cancel</Button>
<Button
onclick={handleRename}
disabled={renaming || !newLabelName.trim() || newLabelName.trim() === renameTarget?.label}
>
{#if renaming}
<Loader2 class="w-4 h-4 mr-2 animate-spin" />
{/if}
Rename
</Button>
</Dialog.Footer>
</Dialog.Content>
</Dialog.Root>
<!-- Add Label Dialog -->
<Dialog.Root bind:open={showAddDialog}>
<Dialog.Content class="max-w-md">
<Dialog.Header>
<Dialog.Title>Add label</Dialog.Title>
<Dialog.Description>Create a new label and assign it to one or more environments.</Dialog.Description>
</Dialog.Header>
<div class="space-y-4 py-2">
<div class="space-y-2">
<Label for="add-label-name">Label name</Label>
<Input
id="add-label-name"
bind:value={addLabelName}
placeholder="e.g. production, staging, critical"
onkeydown={(e) => { if (e.key === 'Enter' && addLabelName.trim() && addSelectedEnvIds.length > 0) handleAdd(); }}
/>
{#if addLabelName.trim()}
{@const previewColors = getColors(addLabelName.trim())}
<div class="flex items-center gap-2 text-xs text-muted-foreground">
<span>Preview:</span>
<span
class="px-2 py-0.5 rounded font-medium"
style="background-color: {previewColors.bgColor}; color: {previewColors.color}"
>
{addLabelName.trim()}
</span>
</div>
{/if}
{#if addLabelName.trim() && labels.some(l => l.label === addLabelName.trim())}
<div class="flex items-center gap-1.5 text-xs text-amber-500">
<AlertTriangle class="w-3.5 h-3.5" />
<span>This label already exists. It will be added to the selected environments that don't have it yet.</span>
</div>
{/if}
</div>
<div class="space-y-2">
<div class="flex items-center justify-between">
<Label>Environments</Label>
<div class="flex gap-2">
<button type="button" class="text-2xs text-primary hover:underline" onclick={selectAllEnvs}>Select all</button>
<button type="button" class="text-2xs text-muted-foreground hover:underline" onclick={deselectAllEnvs}>Clear</button>
</div>
</div>
<div class="max-h-48 overflow-y-auto border rounded-md p-2 space-y-1">
{#each addEnvOptions as env}
<label class="flex items-center gap-2 px-2 py-1 rounded hover:bg-muted cursor-pointer text-sm">
<Checkbox
checked={addSelectedEnvIds.includes(env.id)}
onCheckedChange={() => toggleEnvSelection(env.id)}
/>
<Globe class="w-3.5 h-3.5 text-muted-foreground" />
{env.name}
</label>
{/each}
{#if addEnvOptions.length === 0}
<p class="text-xs text-muted-foreground text-center py-2">No environments available</p>
{/if}
</div>
<p class="text-xs text-muted-foreground h-4">{addSelectedEnvIds.length > 0 ? `${addSelectedEnvIds.length} environment${addSelectedEnvIds.length !== 1 ? 's' : ''} selected` : '\u00A0'}</p>
</div>
</div>
<Dialog.Footer>
<Button variant="outline" onclick={() => showAddDialog = false}>Cancel</Button>
<Button
onclick={handleAdd}
disabled={adding || !addLabelName.trim() || addSelectedEnvIds.length === 0}
>
{#if adding}
<Loader2 class="w-4 h-4 mr-2 animate-spin" />
{/if}
Add label
</Button>
</Dialog.Footer>
</Dialog.Content>
</Dialog.Root>
+28 -5
View File
@@ -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}
<div class="flex flex-wrap gap-1.5 mb-2 text-2xs">
<!-- Custom URL from dockhand.url label -->
{#if container.labels?.['dockhand.url']?.trim()}
<a
href={container.labels['dockhand.url'].trim()}
target="_blank"
rel="noopener noreferrer"
onclick={(e) => 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"
>
<Globe class="w-2.5 h-2.5" />
<span class="max-w-[120px] truncate">{container.labels['dockhand.url'].trim().replace(/^https?:\/\//, '')}</span>
<ExternalLink class="w-2.5 h-2.5 opacity-60" />
</a>
{/if}
<!-- Clickable ports with range collapsing -->
{#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}
<a
href={url}
target="_blank"
rel="noopener noreferrer"
onclick={(e) => 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"
>
<code>{port.display}</code>
<ExternalLink class="w-2.5 h-2.5" />
<ExternalLink class="w-2.5 h-2.5 {portUrl ? 'opacity-60' : ''}" />
</a>
{:else}
<span 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">
+6 -2
View File
@@ -943,14 +943,18 @@
{/if}
</Button>
{/if}
<Tooltip.Root>
<Tooltip.Trigger>
<Button
variant="outline"
size="sm"
onclick={() => formWebhookSecret = generateWebhookSecret()}
title="Generate new secret"
>
<RefreshCcw class="w-4 h-4" />
<Key class="w-4 h-4" />
</Button>
</Tooltip.Trigger>
<Tooltip.Content>Generate secret</Tooltip.Content>
</Tooltip.Root>
</div>
</div>
{#if !gitStack}