1.0.29
This commit is contained in:
44 files changed
+1611
-165
No files matched your search
+2
-2
@@ -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
@@ -1,3 +1,3 @@
|
||||
module github.com/Finsys/dockhand/collector
|
||||
|
||||
go 1.25.9
|
||||
go 1.25.10
|
||||
+4
-4
@@ -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
@@ -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;
|
||||
|
||||
|
||||
Vendored
+5
-4
@@ -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 {
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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),
|
||||
};
|
||||
}
|
||||
@@ -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 || '',
|
||||
|
||||
@@ -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');
|
||||
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
};
|
||||
@@ -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 };
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
*/
|
||||
|
||||
@@ -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
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
};
|
||||
@@ -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
@@ -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)) {
|
||||
|
||||
@@ -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}"
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
@@ -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">
|
||||
|
||||
@@ -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}
|
||||
|
||||
Reference in new issue
Block a user