From c8b3acc07e0547eb7a9d39908d9f13789c2c4d87 Mon Sep 17 00:00:00 2001 From: jarek Date: Sat, 30 May 2026 08:42:21 +0200 Subject: [PATCH] 1.0.30 --- Dockerfile | 2 +- VERSION | 2 +- package.json | 8 +- server.js | 2 +- src/app.css | 28 +++ src/lib/components/BatchOperationModal.svelte | 9 +- src/lib/components/PullTab.svelte | 7 +- src/lib/components/StackEnvVarsPanel.svelte | 7 +- src/lib/components/host-info.svelte | 9 +- src/lib/data/changelog.json | 24 ++ src/lib/data/dependencies.json | 114 ++++----- src/lib/server/container-env-merge.ts | 34 +++ src/lib/server/container-labels.ts | 13 ++ src/lib/server/docker.ts | 40 +++- src/lib/server/env-parser.ts | 25 ++ src/lib/server/git.ts | 50 ++-- src/lib/server/notifications.ts | 9 +- src/lib/server/stacks.ts | 38 ++- src/lib/utils/custom-url.ts | 29 +++ src/lib/utils/format.ts | 6 + src/lib/utils/log-lines.ts | 6 + src/lib/utils/notification-parsers.ts | 26 ++- src/lib/utils/port-parse.ts | 166 ++++++++++++++ .../api/containers/[id]/logs/+server.ts | 6 +- .../containers/[id]/logs/stream/+server.ts | 10 +- src/routes/api/debug/memory/+server.ts | 11 +- src/routes/api/environments/[id]/+server.ts | 13 +- src/routes/api/images/scan/+server.ts | 13 +- src/routes/api/logs/merged/+server.ts | 10 +- src/routes/api/stacks/[name]/env/+server.ts | 7 +- src/routes/containers/+page.svelte | 217 +++++++++--------- .../containers/ContainerInspectModal.svelte | 204 ++++++++++++++-- .../containers/ContainerSettingsTab.svelte | 87 ++++--- .../containers/CreateContainerModal.svelte | 10 +- .../containers/EditContainerModal.svelte | 25 +- .../dashboard-cpu-memory-bars.svelte | 10 +- .../dashboard-cpu-memory-charts.svelte | 10 +- .../dashboard/dashboard-disk-usage.svelte | 9 +- src/routes/images/+page.svelte | 31 ++- src/routes/images/ImageLayersView.svelte | 2 +- src/routes/logs/+page.svelte | 134 ++++++++++- src/routes/logs/LogTimeRangeFilter.svelte | 155 +++++++++++++ src/routes/logs/LogViewer.svelte | 14 +- src/routes/logs/LogsPanel.svelte | 92 +++++++- src/routes/registry/+page.svelte | 10 +- src/routes/settings/about/AboutTab.svelte | 6 +- .../environments/tabs/UpdatesTab.svelte | 9 +- .../notifications/NotificationModal.svelte | 3 +- src/routes/stacks/+page.svelte | 154 ++++++++----- 49 files changed, 1414 insertions(+), 492 deletions(-) create mode 100644 src/lib/server/container-env-merge.ts create mode 100644 src/lib/server/env-parser.ts create mode 100644 src/lib/utils/custom-url.ts create mode 100644 src/lib/utils/format.ts create mode 100644 src/lib/utils/log-lines.ts create mode 100644 src/lib/utils/port-parse.ts create mode 100644 src/routes/logs/LogTimeRangeFilter.svelte diff --git a/Dockerfile b/Dockerfile index 79d34ec..8d79c08 100644 --- a/Dockerfile +++ b/Dockerfile @@ -37,7 +37,7 @@ RUN APKO_ARCH=$([ "$TARGETARCH" = "arm64" ] && echo "aarch64" || echo "x86_64") " - busybox" \ " - tzdata" \ " - docker-cli" \ - " - docker-compose=5.1.3-r3" \ + " - docker-compose=5.1.4-r3" \ " - docker-cli-buildx" \ " - sqlite" \ " - postgresql-client" \ diff --git a/VERSION b/VERSION index 385844c..e3083d1 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -v1.0.29 +v1.0.30 diff --git a/package.json b/package.json index acf0e53..61096ee 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "dockhand", "private": true, - "version": "1.0.29", + "version": "1.0.30", "type": "module", "scripts": { "dev": "npx vite dev", @@ -88,7 +88,7 @@ "rollup": "4.60.0", "svelte-sonner": "1.0.7", "undici": "7.24.5", - "ws": "8.18.0" + "ws": "8.20.1" }, "devDependencies": { "@internationalized/date": "^3.10.1", @@ -96,7 +96,7 @@ "@lucide/svelte": "^0.562.0", "@playwright/test": "1.57.0", "@sveltejs/adapter-node": "^5.2.0", - "@sveltejs/kit": "2.50.0", + "@sveltejs/kit": "2.60.1", "@sveltejs/vite-plugin-svelte": "6.2.4", "@tailwindcss/vite": "^4.1.18", "@types/better-sqlite3": "^7.6.12", @@ -119,7 +119,7 @@ "lucide-svelte": "^0.562.0", "mode-watcher": "^1.1.0", "postcss": "^8.5.6", - "svelte": "5.55.7", + "svelte": "5.55.9", "svelte-check": "^4.3.5", "svelte-easy-crop": "^5.0.0", "tailwind-merge": "^3.4.0", diff --git a/server.js b/server.js index 12ece69..300c6d3 100644 --- a/server.js +++ b/server.js @@ -191,7 +191,7 @@ async function handleTerminalConnection(ws, url, connId) { }; if (target.tls.ca) tlsOpts.ca = [target.tls.ca, ...rootCertificates]; if (target.tls.cert) tlsOpts.cert = [target.tls.cert]; - if (target.tls.key) tlsOpts.key = [target.tls.key]; + if (target.tls.key) tlsOpts.key = target.tls.key; dockerStream = tlsConnect(tlsOpts); } else { // Plain HTTP (direct TCP or hawser-standard) diff --git a/src/app.css b/src/app.css index fbc230d..fc3f5c2 100644 --- a/src/app.css +++ b/src/app.css @@ -1777,3 +1777,31 @@ html { .ansi-dim { opacity: 0.7; } .ansi-italic { font-style: italic; } .ansi-underline { text-decoration: underline; } + +/* Log line numbers */ +.log-line { + min-height: 1.2em; +} +pre.show-line-numbers { + counter-reset: log-line; +} +pre.show-line-numbers .log-line { + counter-increment: log-line; + padding-left: 4.5em; + position: relative; +} +pre.show-line-numbers .log-line::before { + content: counter(log-line); + position: absolute; + left: 0; + width: 3.5em; + text-align: right; + padding-right: 0.75em; + user-select: none; + color: rgb(113 113 122); /* zinc-500 */ + border-right: 1px solid rgb(63 63 70); /* zinc-700 */ +} +:where(.light, .light *) pre.show-line-numbers .log-line::before { + color: rgb(156 163 175); /* gray-400 */ + border-right-color: rgb(209 213 219); /* gray-300 */ +} diff --git a/src/lib/components/BatchOperationModal.svelte b/src/lib/components/BatchOperationModal.svelte index 67b66db..fb3ca8b 100644 --- a/src/lib/components/BatchOperationModal.svelte +++ b/src/lib/components/BatchOperationModal.svelte @@ -4,14 +4,7 @@ import { Progress } from '$lib/components/ui/progress'; import { Check, X, Loader2, Circle, Ban } from 'lucide-svelte'; import { onDestroy } from 'svelte'; - - function formatBytes(bytes: number): string { - if (bytes === 0) return '0 B'; - const k = 1024; - const sizes = ['B', 'KB', 'MB', 'GB', 'TB']; - const i = Math.floor(Math.log(bytes) / Math.log(k)); - return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + ' ' + sizes[i]; - } + import { formatBytes } from '$lib/utils/format'; const progressText: Record = { remove: 'removing', diff --git a/src/lib/components/PullTab.svelte b/src/lib/components/PullTab.svelte index 41ad9ad..d6c7f58 100644 --- a/src/lib/components/PullTab.svelte +++ b/src/lib/components/PullTab.svelte @@ -9,6 +9,7 @@ import { onMount } from 'svelte'; import { appendEnvParam } from '$lib/stores/environment'; import { watchJob } from '$lib/utils/sse-fetch'; + import { formatBytes } from '$lib/utils/format'; interface LayerProgress { id: string; @@ -98,12 +99,6 @@ localStorage.setItem('logTheme', logDarkMode ? 'dark' : 'light'); } - function formatBytes(bytes: number): string { - if (bytes < 1024) return `${bytes} B`; - if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`; - return `${(bytes / (1024 * 1024)).toFixed(1)} MB`; - } - function formatDuration(ms: number): string { if (ms < 1000) return `${ms}ms`; return `${(ms / 1000).toFixed(1)}s`; diff --git a/src/lib/components/StackEnvVarsPanel.svelte b/src/lib/components/StackEnvVarsPanel.svelte index 0590ab1..59e4ab5 100644 --- a/src/lib/components/StackEnvVarsPanel.svelte +++ b/src/lib/components/StackEnvVarsPanel.svelte @@ -114,12 +114,7 @@ } const key = trimmed.slice(0, eqIndex).trim(); - let value = trimmed.slice(eqIndex + 1); - - if ((value.startsWith('"') && value.endsWith('"')) || - (value.startsWith("'") && value.endsWith("'"))) { - value = value.slice(1, -1); - } + const value = trimmed.slice(eqIndex + 1); if (key) { if (!/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(key)) { diff --git a/src/lib/components/host-info.svelte b/src/lib/components/host-info.svelte index 746e243..88479d6 100644 --- a/src/lib/components/host-info.svelte +++ b/src/lib/components/host-info.svelte @@ -9,6 +9,7 @@ import { toast } from 'svelte-sonner'; import { themeStore, type FontSize } from '$lib/stores/theme'; import { getTimeFormat } from '$lib/stores/settings'; + import { formatBytes } from '$lib/utils/format'; // Font size scaling for header let fontSize = $state('normal'); @@ -218,14 +219,6 @@ (diskUsage.Volumes?.reduce((sum: number, v: any) => sum + (v.UsageData?.Size || 0), 0) || 0); }); - function formatBytes(bytes: number): string { - if (bytes === 0) return '0 B'; - const k = 1024; - const sizes = ['B', 'KB', 'MB', 'GB', 'TB']; - const i = Math.floor(Math.log(bytes) / Math.log(k)); - return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + ' ' + sizes[i]; - } - async function switchEnvironment(envId: number) { // Don't switch if already on this environment if (Number(envId) === Number(currentEnvId)) { diff --git a/src/lib/data/changelog.json b/src/lib/data/changelog.json index a7ab23d..e2f4f6c 100644 --- a/src/lib/data/changelog.json +++ b/src/lib/data/changelog.json @@ -1,4 +1,28 @@ [ + { + "version": "1.0.30", + "date": "2026-05-30", + "changes": [ + { "type": "feature", "text": "time range filter for log viewer — filter logs by From/To date and time (#1068)" }, + { "type": "feature", "text": "configurable tail line count in log viewer — choose from 100 to all lines (#1066)" }, + { "type": "feature", "text": "toggleable line numbers in log viewer (#1067)" }, + { "type": "feature", "text": "\"some unused\" image filter — show images with both used and unused tags for selective cleanup (#621)" }, + { "type": "feature", "text": "IP binding and port ranges in container port mappings (#581)" }, + { "type": "feature", "text": "remove individual containers directly from stacks page (#576)" }, + { "type": "fix", "text": "scan cache lookup by tag name never matched — results now resolved via image digest (#1064)" }, + { "type": "fix", "text": "image-baked env vars not updated during auto-update container recreation (#1061)" }, + { "type": "fix", "text": "git stack deploy via Hawser fails with \"Invalid string length\" when repo has large files (#1040)" }, + { "type": "feature", "text": "Gotify notification priority via URL query param — gotify://host/token?priority=5 (#1033)" }, + { "type": "fix", "text": "consistent action button order across container and stack views (#1079)" }, + { "type": "feature", "text": "named custom URL labels — dockhand.url=[Name](https://...) markdown syntax (#1065)" }, + { "type": "fix", "text": "HTTPS git credentials no longer leaked in process arguments (#1081)" }, + { "type": "feature", "text": "bump Docker Compose to 5.1.4 (GHSA-pmwq-pjrm-6p5r)" }, + { "type": "feature", "text": "dockhand.order label to control container display order within stacks (#847)" }, + { "type": "feature", "text": "live network attach/detach for running containers — join or leave Docker networks without restarting (#1051)" }, + { "type": "fix", "text": "environment variable values with nested quotes progressively corrupted on each save (#1036, #1086)" } + ], + "imageTag": "fnsys/dockhand:v1.0.30" + }, { "version": "1.0.29", "date": "2026-05-17", diff --git a/src/lib/data/dependencies.json b/src/lib/data/dependencies.json index 1ebf349..7edfff6 100644 --- a/src/lib/data/dependencies.json +++ b/src/lib/data/dependencies.json @@ -5,6 +5,12 @@ "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", @@ -73,7 +79,7 @@ }, { "name": "@codemirror/lint", - "version": "6.9.5", + "version": "6.9.2", "license": "MIT", "repository": "https://github.com/codemirror/lint" }, @@ -139,7 +145,7 @@ }, { "name": "@lezer/css", - "version": "1.3.3", + "version": "1.3.0", "license": "MIT", "repository": "https://github.com/lezer-parser/css" }, @@ -151,7 +157,7 @@ }, { "name": "@lezer/html", - "version": "1.3.13", + "version": "1.3.12", "license": "MIT", "repository": "https://github.com/lezer-parser/html" }, @@ -169,13 +175,13 @@ }, { "name": "@lezer/lr", - "version": "1.4.8", + "version": "1.4.4", "license": "MIT", "repository": "https://github.com/lezer-parser/lr" }, { "name": "@lezer/markdown", - "version": "1.6.3", + "version": "1.6.1", "license": "MIT", "repository": "https://github.com/lezer-parser/markdown" }, @@ -193,7 +199,7 @@ }, { "name": "@lezer/yaml", - "version": "1.0.4", + "version": "1.0.3", "license": "MIT", "repository": "https://github.com/lezer-parser/yaml" }, @@ -221,15 +227,9 @@ "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.9", + "version": "1.0.8", "license": "MIT", "repository": "https://github.com/sveltejs/acorn-typescript" }, @@ -257,15 +257,9 @@ "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.16.0", + "version": "8.15.0", "license": "MIT", "repository": "https://github.com/acornjs/acorn" }, @@ -319,7 +313,7 @@ }, { "name": "better-sqlite3", - "version": "11.7.0", + "version": "11.10.0", "license": "MIT", "repository": "https://github.com/WiseLibs/better-sqlite3" }, @@ -371,6 +365,12 @@ "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,6 +425,12 @@ "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", @@ -439,13 +445,13 @@ }, { "name": "dockhand", - "version": "1.0.27", + "version": "1.0.18", "license": "UNLICENSED", "repository": null }, { "name": "drizzle-orm", - "version": "0.45.2", + "version": "0.45.1", "license": "Apache-2.0", "repository": "https://github.com/drizzle-team/drizzle-orm" }, @@ -469,7 +475,7 @@ }, { "name": "esrap", - "version": "2.2.4", + "version": "2.2.3", "license": "MIT", "repository": "https://github.com/sveltejs/esrap" }, @@ -479,18 +485,6 @@ "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", @@ -509,12 +503,6 @@ "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", @@ -613,13 +601,13 @@ }, { "name": "node-abi", - "version": "3.89.0", + "version": "3.87.0", "license": "MIT", "repository": "https://github.com/electron/node-abi" }, { "name": "node-addon-api", - "version": "8.7.0", + "version": "8.5.0", "license": "MIT", "repository": "https://github.com/nodejs/node-addon-api" }, @@ -631,7 +619,7 @@ }, { "name": "nodemailer", - "version": "8.0.5", + "version": "7.0.12", "license": "MIT-0", "repository": "https://github.com/nodemailer/nodemailer" }, @@ -671,12 +659,6 @@ "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", @@ -697,7 +679,7 @@ }, { "name": "pump", - "version": "3.0.4", + "version": "3.0.3", "license": "MIT", "repository": "https://github.com/mafintosh/pump" }, @@ -737,12 +719,6 @@ "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", @@ -809,12 +785,6 @@ "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", @@ -827,6 +797,12 @@ "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", @@ -857,12 +833,6 @@ "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", @@ -883,7 +853,7 @@ }, { "name": "webidl-conversions", - "version": "8.0.1", + "version": "8.0.0", "license": "BSD-2-Clause", "repository": "https://github.com/jsdom/webidl-conversions" }, @@ -913,7 +883,7 @@ }, { "name": "ws", - "version": "8.18.0", + "version": "8.19.0", "license": "MIT", "repository": "https://github.com/websockets/ws" }, diff --git a/src/lib/server/container-env-merge.ts b/src/lib/server/container-env-merge.ts new file mode 100644 index 0000000..7353498 --- /dev/null +++ b/src/lib/server/container-env-merge.ts @@ -0,0 +1,34 @@ +/** + * Merge container env vars with new image env vars during auto-update. + * Image-baked vars get updated to the new image's values. + * User-set vars (not present in old image) are preserved. + * Env vars removed from the new image are dropped. + */ +export function mergeImageEnvVars( + containerEnv: string[], + oldImageEnv: string[], + newImageEnv: string[] +): string[] { + const getKey = (entry: string) => entry.split('=')[0]; + const oldImageKeys = new Set(oldImageEnv.map(getKey)); + + const merged: string[] = []; + + // Keep user-set env vars (key not present in old image) + for (const entry of containerEnv) { + if (!oldImageKeys.has(getKey(entry))) { + merged.push(entry); + } + } + + // Add all new image env vars (updates changed values, adds new ones) + for (const entry of newImageEnv) { + const key = getKey(entry); + // Skip if user already set this key (user wins) + if (!merged.some(e => getKey(e) === key)) { + merged.push(entry); + } + } + + return merged; +} diff --git a/src/lib/server/container-labels.ts b/src/lib/server/container-labels.ts index da9f127..d510847 100644 --- a/src/lib/server/container-labels.ts +++ b/src/lib/server/container-labels.ts @@ -7,6 +7,7 @@ * - dockhand.notify=false — Suppress notifications for this container's events * - dockhand.url= — Custom clickable URL displayed alongside container ports * - dockhand.port..url= — Override the click URL for a specific published port + * - dockhand.order= — Controls display order within a stack (lower = first, default 0) * * 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). @@ -18,6 +19,7 @@ export const DOCKHAND_LABELS = { HIDDEN: 'dockhand.hidden', NOTIFY: 'dockhand.notify', URL: 'dockhand.url', + ORDER: 'dockhand.order', } as const; const TRUTHY_VALUES = new Set(['true', 'yes', '1']); @@ -84,6 +86,17 @@ export function getCustomUrl(labels: Record | undefined | null): return value?.trim() || undefined; } +/** + * Get the sort order value from dockhand.order label. + * Returns the parsed integer, or 0 for missing/invalid values. + */ +export function getOrderValue(labels: Record | undefined | null): number { + const value = getLabel(labels, DOCKHAND_LABELS.ORDER); + if (value == null) return 0; + const parsed = parseInt(value.trim(), 10); + return Number.isNaN(parsed) ? 0 : parsed; +} + /** * Extract all Dockhand label states from a container's labels. * Useful for including in API responses so the frontend knows about label overrides. diff --git a/src/lib/server/docker.ts b/src/lib/server/docker.ts index 88ec7ba..1f74f5e 100644 --- a/src/lib/server/docker.ts +++ b/src/lib/server/docker.ts @@ -6,6 +6,7 @@ */ import { homedir } from 'node:os'; +import { mergeImageEnvVars } from './container-env-merge'; import { existsSync, mkdirSync, rmSync, readdirSync } from 'node:fs'; import { join, resolve } from 'node:path'; import * as http from 'node:http'; @@ -1215,13 +1216,26 @@ export async function renameContainer(id: string, newName: string, envId?: numbe await assertDockerResponse(response); } -export async function getContainerLogs(id: string, tail = 100, envId?: number | null): Promise { +export async function getContainerLogs(id: string, tail: number | 'all' = 100, envId?: number | null, since?: string, until?: string): Promise { // Check if container has TTY enabled const info = await inspectContainer(id, envId); const hasTty = info.Config?.Tty ?? false; + let query = `stdout=true&stderr=true×tamps=true`; + if (tail === 'all') { + query += `&tail=all`; + } else { + query += `&tail=${tail}`; + } + if (since) { + query += `&since=${since}`; + } + if (until) { + query += `&until=${until}`; + } + const response = await dockerFetch( - `/containers/${id}/logs?stdout=true&stderr=true&tail=${tail}×tamps=true`, + `/containers/${id}/logs?${query}`, {}, envId ); @@ -1887,15 +1901,17 @@ export async function recreateContainerFromInspect( HostConfig: hostConfig }; - // 4a. Update image-embedded labels to match the new image. - // Docker's create API uses exactly the labels you pass, ignoring the new image's - // embedded labels. We inspect both old and new images to distinguish image-origin - // labels from user-set labels, then merge accordingly. + // 4a. Update image-embedded labels and env vars to match the new image. + // Docker's create API uses exactly the labels/env you pass, ignoring the new image's + // embedded values. We inspect both old and new images to distinguish image-origin + // values from user-set values, then merge accordingly. try { const [oldImageInspect, newImageInspect] = await Promise.all([ inspectImage(config.Image, envId), inspectImage(newImage, envId) ]); + + // Merge labels const oldImageLabels: Record = (oldImageInspect as any)?.Config?.Labels || {}; const newImageLabels: Record = (newImageInspect as any)?.Config?.Labels || {}; const containerLabels: Record = createConfig.Labels || {}; @@ -1916,9 +1932,17 @@ export async function recreateContainerFromInspect( createConfig.Labels = mergedLabels; log?.(`Updated image labels: ${Object.keys(newImageLabels).length} from new image, ${Object.keys(mergedLabels).length} total`); + + // Merge env vars (same logic: image-baked vars get updated, user-set vars preserved) + const oldImageEnv: string[] = (oldImageInspect as any)?.Config?.Env || []; + const newImageEnv: string[] = (newImageInspect as any)?.Config?.Env || []; + const containerEnv: string[] = createConfig.Env || []; + + createConfig.Env = mergeImageEnvVars(containerEnv, oldImageEnv, newImageEnv); + log?.(`Updated image env vars: ${newImageEnv.length} from new image, ${createConfig.Env.length} total`); } catch (e) { - log?.(`Warning: could not update image labels: ${e}`); - // Fall through with old labels — non-fatal + log?.(`Warning: could not update image labels/env: ${e}`); + // Fall through with old values — non-fatal } // Strip default MemorySwappiness — Podman + cgroupv2 rejects it. diff --git a/src/lib/server/env-parser.ts b/src/lib/server/env-parser.ts new file mode 100644 index 0000000..73d7336 --- /dev/null +++ b/src/lib/server/env-parser.ts @@ -0,0 +1,25 @@ +/** + * Parse .env file content into key-value pairs. + * Preserves values exactly as written — no quote stripping. + * Docker Compose handles its own quote interpretation at runtime. + */ +export function parseEnvVars(content: string): Record { + const result: Record = {}; + + for (const line of content.split('\n')) { + const trimmed = line.trim(); + if (!trimmed || trimmed.startsWith('#')) continue; + + const eqIndex = trimmed.indexOf('='); + if (eqIndex === -1) continue; + + const key = trimmed.substring(0, eqIndex).trim(); + const value = trimmed.substring(eqIndex + 1).trim(); + + if (/^[A-Za-z_][A-Za-z0-9_]*$/.test(key)) { + result[key] = value; + } + } + + return result; +} diff --git a/src/lib/server/git.ts b/src/lib/server/git.ts index 494cb37..564a96b 100644 --- a/src/lib/server/git.ts +++ b/src/lib/server/git.ts @@ -224,6 +224,20 @@ async function buildGitEnv(credential: GitCredential | null): Promise { // Ensure current UID is resolvable for SSH/git operations await ensurePasswdEntry(env); + // For HTTPS password/token auth, inject credentials via http.extraHeader env vars + // instead of embedding them in the URL (which leaks via /proc//cmdline, #1081). + // Uses GIT_CONFIG_COUNT mechanism (git >= 2.31) to set Authorization header. + if (credential?.authType === 'password' && (credential.username || credential.password)) { + const token = credential.password || ''; + const username = credential.username || ''; + // Use Basic auth (base64 of username:password) — works with GitHub PATs, + // GitLab tokens, Gitea tokens, and standard username/password combos. + const basicAuth = Buffer.from(`${username}:${token}`).toString('base64'); + env.GIT_CONFIG_COUNT = '1'; + env.GIT_CONFIG_KEY_0 = 'http.extraHeader'; + env.GIT_CONFIG_VALUE_0 = `Authorization: Basic ${basicAuth}`; + } + if (credential?.authType === 'ssh' && credential.sshPrivateKey) { // Write SSH key to /tmp instead of data volume — some filesystems (TrueNAS ZFS, // NFS, CIFS) silently ignore chmod, leaving the key group-readable (e.g. 0670). @@ -278,24 +292,20 @@ function cleanupSshKey(credential: GitCredential | null): void { } function buildRepoUrl(url: string, credential: GitCredential | null): string { - // For SSH URLs or no auth, return as-is - if (!credential || credential.authType !== 'password' || url.startsWith('git@')) { - return url; - } - - // For HTTPS with password auth, embed credentials - try { - const parsed = new URL(url); - if (credential.username) { - parsed.username = credential.username; + // Never embed credentials in the URL — they leak via /proc//cmdline (see #1081). + // HTTPS credentials are injected via GIT_CONFIG_COUNT env vars in buildGitEnv(). + // Strip any existing credentials from the URL for safety. + if (credential?.authType === 'password' && !url.startsWith('git@')) { + try { + const parsed = new URL(url); + parsed.username = ''; + parsed.password = ''; + return parsed.toString(); + } catch { + return url; } - if (credential.password) { - parsed.password = credential.password; - } - return parsed.toString(); - } catch { - return url; } + return url; } async function execGit(args: string[], cwd: string, env: GitEnv): Promise<{ stdout: string; stderr: string; code: number }> { @@ -1447,13 +1457,7 @@ export function parseEnvFileContent(content: string, stackName?: string): Record } const key = trimmed.substring(0, eqIndex).trim(); - let value = trimmed.substring(eqIndex + 1).trim(); - - // Handle quoted values - if ((value.startsWith('"') && value.endsWith('"')) || - (value.startsWith("'") && value.endsWith("'"))) { - value = value.slice(1, -1); - } + const value = trimmed.substring(eqIndex + 1).trim(); // Only add if key is valid env var name if (/^[A-Za-z_][A-Za-z0-9_]*$/.test(key)) { diff --git a/src/lib/server/notifications.ts b/src/lib/server/notifications.ts index 06921b3..d9b5b98 100644 --- a/src/lib/server/notifications.ts +++ b/src/lib/server/notifications.ts @@ -311,21 +311,22 @@ async function sendTelegram(appriseUrl: string, payload: NotificationPayload): P // Gotify async function sendGotify(appriseUrl: string, payload: NotificationPayload): Promise { - const url = buildGotifyUrl(appriseUrl); - if (!url) { + const parsed = buildGotifyUrl(appriseUrl); + if (!parsed) { return { success: false, error: 'Invalid Gotify URL format. Expected: gotify://hostname/token' }; } const titleWithEnv = payload.environmentName ? `${payload.title} [${payload.environmentName}]` : payload.title; + const defaultPriority = payload.type === 'error' ? 8 : payload.type === 'warning' ? 5 : 2; try { - const response = await fetch(url, { + const response = await fetch(parsed.url, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ title: titleWithEnv, message: payload.message, - priority: payload.type === 'error' ? 8 : payload.type === 'warning' ? 5 : 2 + priority: parsed.priority ?? defaultPriority }) }); diff --git a/src/lib/server/stacks.ts b/src/lib/server/stacks.ts index aeaacd0..d3506e3 100644 --- a/src/lib/server/stacks.ts +++ b/src/lib/server/stacks.ts @@ -31,6 +31,7 @@ import { unregisterSchedule } from './scheduler'; import { deleteGitStackFiles, parseEnvFileContent } from './git'; import { cleanPem } from '$lib/utils/pem'; import { rewriteComposeVolumePaths, getHostDataDir } from './host-path'; +import { getOrderValue } from './container-labels'; // ============================================================================= // TYPES @@ -229,8 +230,14 @@ function collectProcess(proc: ChildProcess): Promise<{ exitCode: number; stdout: * Used to send files to Hawser for remote deployments. * Binary files are base64-encoded with a "base64:" prefix to preserve all bytes. */ +// Max file size: 10 MB per file, 256 MB total payload +const MAX_FILE_SIZE = 10 * 1024 * 1024; +const MAX_TOTAL_SIZE = 256 * 1024 * 1024; + async function readDirFilesAsMap(dirPath: string): Promise> { const files: Record = {}; + let totalSize = 0; + const skipped: string[] = []; async function scanDir(currentPath: string, relativePath: string = ''): Promise { const entries = readdirSync(currentPath, { withFileTypes: true }); @@ -243,7 +250,21 @@ async function readDirFilesAsMap(dirPath: string): Promise MAX_FILE_SIZE) { + skipped.push(`${relPath} (${(fileSize / 1024 / 1024).toFixed(1)} MB)`); + continue; + } + + if (totalSize + fileSize > MAX_TOTAL_SIZE) { + skipped.push(`${relPath} (would exceed ${MAX_TOTAL_SIZE / 1024 / 1024} MB total limit)`); + continue; + } + const bytes = readFileSync(fullPath); + totalSize += fileSize; + if (isBinaryContent(bytes)) { files[relPath] = `base64:${bytes.toString('base64')}`; } else { @@ -254,6 +275,11 @@ async function readDirFilesAsMap(dirPath: string): Promise 0) { + console.log(`[readDirFilesAsMap] Skipped ${skipped.length} file(s) exceeding size limits: ${skipped.join(', ')}`); + } + return files; } @@ -1346,10 +1372,13 @@ async function executeComposeViaHawser( } } catch (err: any) { console.log(`${logPrefix} EXCEPTION in executeComposeViaHawser:`, err.message); + const isStringLength = err.message?.includes('Invalid string length'); return { success: false, output: '', - error: `Failed to ${operation} via Hawser: ${err.message}` + error: isStringLength + ? `Stack files too large to send via Hawser. The repository may contain large binary files. Consider using a .dockerignore or moving large files out of the compose directory.` + : `Failed to ${operation} via Hawser: ${err.message}` }; } } @@ -1593,7 +1622,12 @@ export async function listComposeStacks(envId?: number | null): Promise a.service.localeCompare(b.service)); + .sort((a, b) => { + const orderA = getOrderValue(a.labels); + const orderB = getOrderValue(b.labels); + if (orderA !== orderB) return orderA - orderB; + return a.service.localeCompare(b.service); + }); return { name, diff --git a/src/lib/utils/custom-url.ts b/src/lib/utils/custom-url.ts new file mode 100644 index 0000000..ef3567e --- /dev/null +++ b/src/lib/utils/custom-url.ts @@ -0,0 +1,29 @@ +export interface ParsedCustomUrl { + url: string; + name?: string; +} + +/** + * Parse a custom URL value that may be in markdown link format. + * Supports: + * - Plain URL: "https://example.com" → { url: "https://example.com" } + * - Markdown link: "[My App](https://example.com)" → { url: "https://example.com", name: "My App" } + * Returns null if value is empty/missing. + */ +export function parseCustomUrl(value: string | undefined | null): ParsedCustomUrl | null { + if (!value) return null; + const trimmed = value.trim(); + if (!trimmed) return null; + + const match = trimmed.match(/^\[([^\]]+)\]\(([^)]+)\)$/); + if (match) { + return { name: match[1].trim(), url: ensureProtocol(match[2].trim()) }; + } + + return { url: ensureProtocol(trimmed) }; +} + +function ensureProtocol(url: string): string { + if (/^https?:\/\//i.test(url)) return url; + return `https://${url}`; +} diff --git a/src/lib/utils/format.ts b/src/lib/utils/format.ts new file mode 100644 index 0000000..8811638 --- /dev/null +++ b/src/lib/utils/format.ts @@ -0,0 +1,6 @@ +export function formatBytes(bytes: number, decimals = 1): string { + if (bytes === 0) return '0 B'; + const units = ['B', 'KB', 'MB', 'GB', 'TB']; + const i = Math.floor(Math.log(bytes) / Math.log(1024)); + return `${(bytes / Math.pow(1024, i)).toFixed(i === 0 ? 0 : decimals)} ${units[i]}`; +} diff --git a/src/lib/utils/log-lines.ts b/src/lib/utils/log-lines.ts new file mode 100644 index 0000000..ddbdfb0 --- /dev/null +++ b/src/lib/utils/log-lines.ts @@ -0,0 +1,6 @@ +export function wrapHtmlLines(html: string): string { + return html + .split('\n') + .map((line) => `
${line || ' '}
`) + .join(''); +} diff --git a/src/lib/utils/notification-parsers.ts b/src/lib/utils/notification-parsers.ts index 44468a3..3417cf3 100644 --- a/src/lib/utils/notification-parsers.ts +++ b/src/lib/utils/notification-parsers.ts @@ -22,15 +22,35 @@ export function parseTelegramUrl(url: string): { botToken: string; chatId: strin // --- Gotify --- -export function buildGotifyUrl(appriseUrl: string): string | null { - const match = appriseUrl.match(/^gotifys?:\/\/([^/]+)\/(.+)/); +export function buildGotifyUrl(appriseUrl: string): { url: string; priority?: number } | null { + // Strip query params before parsing path + const qIdx = appriseUrl.indexOf('?'); + const baseUrl = qIdx >= 0 ? appriseUrl.substring(0, qIdx) : appriseUrl; + const queryStr = qIdx >= 0 ? appriseUrl.substring(qIdx + 1) : ''; + + const match = baseUrl.match(/^gotifys?:\/\/([^/]+)\/(.+)/); if (!match) return null; const [, hostname, pathPart] = match; const protocol = appriseUrl.startsWith('gotifys') ? 'https' : 'http'; const lastSlash = pathPart.lastIndexOf('/'); const subpath = lastSlash >= 0 ? pathPart.substring(0, lastSlash) : ''; const token = lastSlash >= 0 ? pathPart.substring(lastSlash + 1) : pathPart; - return `${protocol}://${hostname}${subpath ? '/' + subpath : ''}/message?token=${token}`; + + // Parse priority from query params + let priority: number | undefined; + if (queryStr) { + const params = new URLSearchParams(queryStr); + const p = params.get('priority'); + if (p) { + const num = parseInt(p); + if (!isNaN(num) && num >= 0 && num <= 10) priority = num; + } + } + + return { + url: `${protocol}://${hostname}${subpath ? '/' + subpath : ''}/message?token=${token}`, + priority + }; } // --- Workflows (Microsoft Power Automate) --- diff --git a/src/lib/utils/port-parse.ts b/src/lib/utils/port-parse.ts new file mode 100644 index 0000000..49c8dc9 --- /dev/null +++ b/src/lib/utils/port-parse.ts @@ -0,0 +1,166 @@ +/** + * Parse Docker port mapping syntax from UI inputs. + * + * Supported host port formats: + * "8080" -> { hostIp: '', hostPort: '8080' } + * "127.0.0.1:8080" -> { hostIp: '127.0.0.1', hostPort: '8080' } + * "::1:8080" -> { hostIp: '::1', hostPort: '8080' } + * "[::1]:8080" -> { hostIp: '::1', hostPort: '8080' } + * "" -> { hostIp: '', hostPort: '' } (empty = random) + * + * Supported container port formats: + * "8080" -> single port + * "8000-8005" -> port range + */ + +export interface ParsedHostPort { + hostIp: string; + hostPort: string; +} + +/** + * Parse a host port string that may include an IP binding. + * Returns the IP and port separately. + */ +export function parseHostPort(input: string): ParsedHostPort { + const trimmed = input.trim(); + if (!trimmed) return { hostIp: '', hostPort: '' }; + + // Check for bracketed IPv6: [::1]:8080 + const bracketMatch = trimmed.match(/^\[([^\]]+)\]:(.+)$/); + if (bracketMatch) { + return { hostIp: bracketMatch[1], hostPort: bracketMatch[2] }; + } + + // Count colons to distinguish IPv4:port from IPv6 + const colons = (trimmed.match(/:/g) || []).length; + + if (colons === 0) { + // Just a port number or range: "8080" or "8000-8005" + return { hostIp: '', hostPort: trimmed }; + } + + if (colons === 1) { + // IPv4:port -> "127.0.0.1:8080" + const lastColon = trimmed.lastIndexOf(':'); + return { + hostIp: trimmed.substring(0, lastColon), + hostPort: trimmed.substring(lastColon + 1) + }; + } + + // Multiple colons — ambiguous between IPv6 address and IPv6:port. + // Use bracket notation [::1]:8080 for IPv6 with port. + // Bare multi-colon input is treated as IPv6 address with no port. + return { hostIp: trimmed, hostPort: '' }; +} + +/** + * Validate a port number or range string. + * Returns error message or empty string if valid. + */ +export function validatePort(port: string): string { + if (!port) return ''; // Empty is valid (means random allocation) + + // Port range: "8000-8005" + const rangeMatch = port.match(/^(\d+)-(\d+)$/); + if (rangeMatch) { + const start = parseInt(rangeMatch[1]); + const end = parseInt(rangeMatch[2]); + if (start < 1 || start > 65535) return `Port ${start} out of range (1-65535)`; + if (end < 1 || end > 65535) return `Port ${end} out of range (1-65535)`; + if (start >= end) return 'Range start must be less than end'; + return ''; + } + + // Single port + if (!/^\d+$/.test(port)) return 'Invalid port number'; + const num = parseInt(port); + if (num < 1 || num > 65535) return 'Port out of range (1-65535)'; + return ''; +} + +/** + * Validate an IP address (IPv4 or IPv6). + * Returns error message or empty string if valid. + */ +export function validateIp(ip: string): string { + if (!ip) return ''; // Empty is valid (bind to all interfaces) + + // Basic IPv4 check + if (/^\d{1,3}(\.\d{1,3}){3}$/.test(ip)) { + const parts = ip.split('.').map(Number); + if (parts.every(p => p >= 0 && p <= 255)) return ''; + return 'Invalid IPv4 address'; + } + + // IPv6 (simplified check — accept common forms) + if (/^[0-9a-fA-F:]+$/.test(ip)) return ''; + + return 'Invalid IP address'; +} + +/** + * Expand port range mappings into individual Docker API port bindings. + * + * Input: hostPort="8000-8005", containerPort="9000-9005", protocol="tcp", hostIp="" + * Output: { "9000/tcp": { HostPort: "8000" }, "9001/tcp": { HostPort: "8001" }, ... } + */ +export function expandPortBindings( + hostPort: string, + containerPort: string, + protocol: string, + hostIp: string +): Record { + const result: Record = {}; + + const hostRange = parseRange(hostPort); + const containerRange = parseRange(containerPort); + + if (hostRange && containerRange) { + // Both are ranges — must be same length + const len = Math.min(hostRange.length, containerRange.length); + for (let i = 0; i < len; i++) { + const key = `${containerRange[i]}/${protocol}`; + const binding: { HostIp?: string; HostPort: string } = { HostPort: String(hostRange[i]) }; + if (hostIp) binding.HostIp = hostIp; + result[key] = binding; + } + } else if (containerRange) { + // Container is range, host is single port or empty + for (const cp of containerRange) { + const key = `${cp}/${protocol}`; + const binding: { HostIp?: string; HostPort: string } = { HostPort: hostPort || '0' }; + if (hostIp) binding.HostIp = hostIp; + result[key] = binding; + } + } else { + // Single port mapping + const key = `${containerPort}/${protocol}`; + const binding: { HostIp?: string; HostPort: string } = { HostPort: hostPort || '0' }; + if (hostIp) binding.HostIp = hostIp; + result[key] = binding; + } + + return result; +} + +function parseRange(port: string): number[] | null { + const match = port.match(/^(\d+)-(\d+)$/); + if (!match) return null; + const start = parseInt(match[1]); + const end = parseInt(match[2]); + const result: number[] = []; + for (let i = start; i <= end; i++) result.push(i); + return result; +} + +/** + * Format a parsed host port back to display string. + */ +export function formatHostPort(hostIp: string, hostPort: string): string { + if (!hostIp) return hostPort; + // IPv6 needs brackets + if (hostIp.includes(':')) return `[${hostIp}]:${hostPort}`; + return `${hostIp}:${hostPort}`; +} diff --git a/src/routes/api/containers/[id]/logs/+server.ts b/src/routes/api/containers/[id]/logs/+server.ts index d069328..ce60cd0 100644 --- a/src/routes/api/containers/[id]/logs/+server.ts +++ b/src/routes/api/containers/[id]/logs/+server.ts @@ -10,7 +10,9 @@ export const GET: RequestHandler = async ({ params, url, cookies }) => { const auth = await authorize(cookies); - const tail = parseInt(url.searchParams.get('tail') || '100'); + const tail = url.searchParams.get('tail') || '100'; + const since = url.searchParams.get('since') || undefined; + const until = url.searchParams.get('until') || undefined; const envId = url.searchParams.get('env'); const envIdNum = envId ? parseInt(envId) : undefined; @@ -20,7 +22,7 @@ export const GET: RequestHandler = async ({ params, url, cookies }) => { } try { - const logs = await getContainerLogs(params.id, tail, envIdNum); + const logs = await getContainerLogs(params.id, tail === 'all' ? 'all' : parseInt(tail), envIdNum, since, until); return json({ logs }); } catch (error: any) { console.error('Error getting container logs:', error?.message || error, error?.stack); diff --git a/src/routes/api/containers/[id]/logs/stream/+server.ts b/src/routes/api/containers/[id]/logs/stream/+server.ts index 8936bc7..10cb980 100644 --- a/src/routes/api/containers/[id]/logs/stream/+server.ts +++ b/src/routes/api/containers/[id]/logs/stream/+server.ts @@ -90,7 +90,7 @@ function parseDockerFrame(buffer: Buffer, offset: number): { type: number; size: /** * Handle logs streaming for Hawser Edge connections */ -async function handleEdgeLogsStream(containerId: string, tail: string, environmentId: number): Promise { +async function handleEdgeLogsStream(containerId: string, tail: string, environmentId: number, since?: string, until?: string): Promise { // Check if edge agent is connected if (!isEdgeConnected(environmentId)) { return new Response(JSON.stringify({ error: 'Edge agent not connected' }), { @@ -115,7 +115,7 @@ async function handleEdgeLogsStream(containerId: string, tail: string, environme // Ignore - default to demux mode } - const logsPath = `/containers/${containerId}/logs?stdout=true&stderr=true&follow=true&tail=${tail}×tamps=true`; + const logsPath = `/containers/${containerId}/logs?stdout=true&stderr=true&follow=true×tamps=true&tail=${tail}${since ? `&since=${since}` : ''}${until ? `&until=${until}` : ''}`; let controllerClosed = false; let cancelStream: (() => void) | null = null; @@ -262,6 +262,8 @@ export const GET: RequestHandler = async ({ params, url, cookies }) => { const containerId = params.id; const tail = url.searchParams.get('tail') || '100'; + const since = url.searchParams.get('since') || ''; + const until = url.searchParams.get('until') || ''; const envId = url.searchParams.get('env'); const envIdNum = envId ? parseInt(envId) : undefined; @@ -277,7 +279,7 @@ export const GET: RequestHandler = async ({ params, url, cookies }) => { // Handle Hawser Edge mode separately if (config.type === 'hawser-edge') { - return handleEdgeLogsStream(containerId, tail, config.environmentId!); + return handleEdgeLogsStream(containerId, tail, config.environmentId!, since, until); } // First, check if container has TTY enabled and get container name @@ -311,7 +313,7 @@ export const GET: RequestHandler = async ({ params, url, cookies }) => { } // Build the logs URL with follow=true for streaming - const logsPath = `/containers/${containerId}/logs?stdout=true&stderr=true&follow=true&tail=${tail}×tamps=true`; + const logsPath = `/containers/${containerId}/logs?stdout=true&stderr=true&follow=true×tamps=true&tail=${tail}${since ? `&since=${since}` : ''}${until ? `&until=${until}` : ''}`; let controllerClosed = false; let abortController: AbortController | null = new AbortController(); diff --git a/src/routes/api/debug/memory/+server.ts b/src/routes/api/debug/memory/+server.ts index 765968e..281aeae 100644 --- a/src/routes/api/debug/memory/+server.ts +++ b/src/routes/api/debug/memory/+server.ts @@ -13,6 +13,7 @@ import type { RequestHandler } from './$types'; import v8 from 'node:v8'; import os from 'node:os'; import { getRssStats, dumpHeapSnapshot, listHeapSnapshots } from '$lib/server/rss-tracker'; +import { formatBytes } from '$lib/utils/format'; // Track startup time and initial RSS for growth rate calculation const startupTime = Date.now(); @@ -99,16 +100,6 @@ export const GET: RequestHandler = async ({ url }) => { }); }; -function formatBytes(bytes: number): string { - if (bytes === 0) return '0 B'; - const sign = bytes < 0 ? '-' : ''; - const abs = Math.abs(bytes); - if (abs < 1024) return `${sign}${abs} B`; - if (abs < 1024 * 1024) return `${sign}${(abs / 1024).toFixed(1)} KB`; - if (abs < 1024 * 1024 * 1024) return `${sign}${(abs / (1024 * 1024)).toFixed(1)} MB`; - return `${sign}${(abs / (1024 * 1024 * 1024)).toFixed(2)} GB`; -} - function formatUptime(ms: number): string { const seconds = Math.floor(ms / 1000); const minutes = Math.floor(seconds / 60); diff --git a/src/routes/api/environments/[id]/+server.ts b/src/routes/api/environments/[id]/+server.ts index 2f41ecb..b6a41d4 100644 --- a/src/routes/api/environments/[id]/+server.ts +++ b/src/routes/api/environments/[id]/+server.ts @@ -138,6 +138,9 @@ export const DELETE: RequestHandler = async (event) => { try { const id = parseInt(params.id); + if (isNaN(id) || id <= 0) { + return json({ error: 'Invalid environment ID' }, { status: 400 }); + } // Get environment name before deletion for audit log const env = await getEnvironment(id); @@ -145,6 +148,11 @@ export const DELETE: RequestHandler = async (event) => { return json({ error: 'Environment not found' }, { status: 404 }); } + // Safety: never delete directories if env name is empty/whitespace + if (!env.name?.trim()) { + return json({ error: 'Cannot delete environment with empty name' }, { status: 500 }); + } + // Close Edge connection if this is a Hawser Edge environment // This rejects any pending requests and closes the WebSocket closeEdgeConnection(id); @@ -186,10 +194,11 @@ export const DELETE: RequestHandler = async (event) => { unregisterSchedule(id, 'image_prune'); // Clean up stack directory for this environment + // Safety: only delete subdirectory named after the env, never the parent try { const stacksDir = getStacksDir(); const envStackDir = join(stacksDir, env.name); - if (existsSync(envStackDir)) { + if (envStackDir !== stacksDir && envStackDir.startsWith(stacksDir) && existsSync(envStackDir)) { rmSync(envStackDir, { recursive: true, force: true }); } } catch (err) { @@ -200,7 +209,7 @@ export const DELETE: RequestHandler = async (event) => { try { const gitReposDir = getGitReposDir(); const envGitDir = join(gitReposDir, env.name); - if (existsSync(envGitDir)) { + if (envGitDir !== gitReposDir && envGitDir.startsWith(gitReposDir) && existsSync(envGitDir)) { rmSync(envGitDir, { recursive: true, force: true }); } } catch (err) { diff --git a/src/routes/api/images/scan/+server.ts b/src/routes/api/images/scan/+server.ts index 0cb9164..3ce268e 100644 --- a/src/routes/api/images/scan/+server.ts +++ b/src/routes/api/images/scan/+server.ts @@ -1,6 +1,7 @@ import { json, type RequestHandler } from '@sveltejs/kit'; import { scanImage, type ScanProgress, type ScanResult } from '$lib/server/scanner'; import { saveVulnerabilityScan, getLatestScanForImage } from '$lib/server/db'; +import { inspectImage } from '$lib/server/docker'; import { authorize } from '$lib/server/authorize'; import { createJobResponse } from '$lib/server/sse'; @@ -97,8 +98,16 @@ export const GET: RequestHandler = async ({ url, cookies }) => { } try { - // Note: getLatestScanForImage signature is (imageId, scanner, environmentId) - const result = await getLatestScanForImage(imageName, scanner, envId); + // Resolve tag to SHA256 ID for reliable cache lookup + let imageId = imageName; + try { + const info = await inspectImage(imageName, envId) as any; + if (info.Id) imageId = info.Id; + } catch { + // Fall back to name if inspect fails + } + + const result = await getLatestScanForImage(imageId, scanner, envId); if (!result) { return json({ found: false }); } diff --git a/src/routes/api/logs/merged/+server.ts b/src/routes/api/logs/merged/+server.ts index 29a42b9..4387032 100644 --- a/src/routes/api/logs/merged/+server.ts +++ b/src/routes/api/logs/merged/+server.ts @@ -138,7 +138,7 @@ interface EdgeContainerLogSource { /** * Handle merged logs streaming for Hawser Edge connections */ -async function handleEdgeMergedLogs(containerIds: string[], tail: string, environmentId: number): Promise { +async function handleEdgeMergedLogs(containerIds: string[], tail: string, environmentId: number, since?: string, until?: string): Promise { // Check if edge agent is connected if (!isEdgeConnected(environmentId)) { return new Response(JSON.stringify({ error: 'Edge agent not connected' }), { @@ -197,7 +197,7 @@ async function handleEdgeMergedLogs(containerIds: string[], tail: string, enviro }; // Start log stream for this container via Edge - const logsPath = `/containers/${containerId}/logs?stdout=true&stderr=true&follow=true&tail=${tail}×tamps=true`; + const logsPath = `/containers/${containerId}/logs?stdout=true&stderr=true&follow=true×tamps=true&tail=${tail}${since ? `&since=${since}` : ''}${until ? `&until=${until}` : ''}`; const { cancel } = sendEdgeStreamRequest( environmentId, @@ -362,6 +362,8 @@ export const GET: RequestHandler = async ({ url, cookies }) => { // Parse container IDs from comma-separated list const containerIds = url.searchParams.get('containers')?.split(',').filter(Boolean) || []; const tail = url.searchParams.get('tail') || '100'; + const since = url.searchParams.get('since') || ''; + const until = url.searchParams.get('until') || ''; const envId = url.searchParams.get('env'); const envIdNum = envId ? parseInt(envId) : undefined; @@ -386,7 +388,7 @@ export const GET: RequestHandler = async ({ url, cookies }) => { // Handle Hawser Edge mode separately if (config.type === 'hawser-edge') { - return handleEdgeMergedLogs(containerIds, tail, config.environmentId!); + return handleEdgeMergedLogs(containerIds, tail, config.environmentId!, since, until); } let controllerClosed = false; @@ -449,7 +451,7 @@ export const GET: RequestHandler = async ({ url, cookies }) => { const hasTty = info.Config?.Tty ?? false; // Start log stream for this container - const logsPath = `/containers/${containerId}/logs?stdout=true&stderr=true&follow=true&tail=${tail}×tamps=true`; + const logsPath = `/containers/${containerId}/logs?stdout=true&stderr=true&follow=true×tamps=true&tail=${tail}${since ? `&since=${since}` : ''}${until ? `&until=${until}` : ''}`; let logsResponse: Response; if (config.type === 'socket') { diff --git a/src/routes/api/stacks/[name]/env/+server.ts b/src/routes/api/stacks/[name]/env/+server.ts index df03759..51cf066 100644 --- a/src/routes/api/stacks/[name]/env/+server.ts +++ b/src/routes/api/stacks/[name]/env/+server.ts @@ -18,12 +18,7 @@ function parseEnvFile(content: string): Record { const eqIndex = trimmed.indexOf('='); if (eqIndex > 0) { const key = trimmed.substring(0, eqIndex).trim(); - let value = trimmed.substring(eqIndex + 1); - // Remove surrounding quotes if present - if ((value.startsWith('"') && value.endsWith('"')) || - (value.startsWith("'") && value.endsWith("'"))) { - value = value.slice(1, -1); - } + const value = trimmed.substring(eqIndex + 1); result[key] = value; } } diff --git a/src/routes/containers/+page.svelte b/src/routes/containers/+page.svelte index 3c90d41..9ae68ce 100644 --- a/src/routes/containers/+page.svelte +++ b/src/routes/containers/+page.svelte @@ -13,6 +13,7 @@ import * as Tooltip from '$lib/components/ui/tooltip'; import ConfirmPopover from '$lib/components/ConfirmPopover.svelte'; import { formatPorts, formatExposedPorts } from '$lib/utils/port-format'; + import { formatBytes } from '$lib/utils/format'; import MultiSelectFilter from '$lib/components/MultiSelectFilter.svelte'; import PageHeader from '$lib/components/PageHeader.svelte'; import { Badge } from '$lib/components/ui/badge'; @@ -86,6 +87,7 @@ import { watchJob } from '$lib/utils/sse-fetch'; import { ipToNumber } from '$lib/utils/ip'; import { formatHostPortUrl } from '$lib/utils/url'; + import { parseCustomUrl } from '$lib/utils/custom-url'; import { detectShells, getBestShell, hasAvailableShell, USER_OPTIONS, getSavedUser, saveUserForContainer, getCustomUsers, removeCustomUser, type ShellDetectionResult } from '$lib/utils/shell-detection'; import { DataGrid } from '$lib/components/data-grid'; import type { ColumnConfig } from '$lib/types'; @@ -94,15 +96,6 @@ // Track change detection for stat highlighting (UI-only, stays in component) let changedFields = $state>>(new Map()); - // Format bytes to human readable - function formatBytes(bytes: number, decimals = 1): string { - if (bytes === 0) return '0B'; - const k = 1024; - const sizes = ['B', 'K', 'M', 'G', 'T']; - const i = Math.floor(Math.log(bytes) / Math.log(k)); - return parseFloat((bytes / Math.pow(k, i)).toFixed(decimals)) + sizes[i]; - } - type SortField = 'name' | 'image' | 'state' | 'health' | 'uptime' | 'stack' | 'ip' | 'cpu' | 'memory' | 'ports'; type SortDirection = 'asc' | 'desc'; @@ -1903,28 +1896,29 @@ {/if} {:else if column.id === 'ports'} {@const exposedPorts = $appSettings.showExposedPorts ? formatExposedPorts(container.ports) : []} - {@const customUrl = container.labels?.['dockhand.url']?.trim() || null} - {#if ports.length > 0 || exposedPorts.length > 0 || customUrl} + {@const parsedUrl = parseCustomUrl(container.labels?.['dockhand.url'])} + {#if ports.length > 0 || exposedPorts.length > 0 || parsedUrl} {@const compactPorts = $appSettings.compactPorts} {@const displayPorts = compactPorts && ports.length > 1 ? [ports[0]] : ports} {@const remainingCount = ports.length - 1}
- {#if customUrl} + {#if parsedUrl} 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" + title="Open {parsedUrl.url} in new tab" > - {customUrl.replace(/^https?:\/\//, '')} + {parsedUrl.name || parsedUrl.url.replace(/^https?:\/\//, '')} {/if} {#each displayPorts as port} - {@const portUrl = container.labels?.[`dockhand.port.${port.publicPort}.url`]?.trim() || null} + {@const portParsed = parseCustomUrl(container.labels?.[`dockhand.port.${port.publicPort}.url`])} + {@const portUrl = portParsed?.url || null} {@const url = portUrl || (currentEnvDetails ? getPortUrl(port.publicPort) : null)} {#if url} {/if} - {#if !container.systemContainer} - {#if container.state === 'running' || container.state === 'restarting'} - {#if $canAccess('containers', 'stop')} - stopContainer(container.id)} - onOpenChange={(open) => confirmStopId = open ? container.id : null} - > - {#snippet children({ open })} - - {/snippet} - - {#if container.state === 'running'} - - {/if} - {/if} - {:else if container.state === 'paused'} - {#if $canAccess('containers', 'start')} - - {/if} - {:else} - {#if $canAccess('containers', 'start')} - - {/if} - {/if} - {#if $canAccess('containers', 'restart')} - restartContainer(container.id)} - onOpenChange={(open) => confirmRestartId = open ? container.id : null} - > - {#snippet children({ open })} - - {/snippet} - - {/if} - {/if} - - {#if container.state === 'running' && $canAccess('containers', 'exec')} - - {/if} - {#if $canAccess('containers', 'create')} - - {/if} {#if $canAccess('containers', 'logs')} {#if hasActiveLogs(container.id)} + {/if} + + {#if $canAccess('containers', 'create')} + + {/if} + {#if !container.systemContainer} + {#if container.state === 'paused'} + {#if $canAccess('containers', 'start')} + + {/if} + {:else if container.state !== 'running' && container.state !== 'restarting'} + {#if $canAccess('containers', 'start')} + + {/if} + {/if} + {#if $canAccess('containers', 'restart')} + restartContainer(container.id)} + onOpenChange={(open) => confirmRestartId = open ? container.id : null} + > + {#snippet children({ open })} + + {/snippet} + + {/if} + {#if container.state === 'running' || container.state === 'restarting'} + {#if container.state === 'running'} + + {/if} + {#if $canAccess('containers', 'stop')} + stopContainer(container.id)} + onOpenChange={(open) => confirmStopId = open ? container.id : null} + > + {#snippet children({ open })} + + {/snippet} + + {/if} + {/if} + {/if} {#if !container.systemContainer && $canAccess('containers', 'remove')} ([]); + let selectedNetwork = $state(undefined); + let networkConnecting = $state(false); + let networkDisconnecting = $state(null); + let networksLoading = $state(false); + + const connectedNetworkNames = $derived( + containerData?.NetworkSettings?.Networks + ? new Set(Object.keys(containerData.NetworkSettings.Networks)) + : new Set() + ); + + const unconnectedNetworks = $derived( + availableNetworks.filter(n => !connectedNetworkNames.has(n.name)) + ); + + async function fetchNetworks() { + networksLoading = true; + try { + const envId = $currentEnvironment?.id ?? null; + const response = await fetch(appendEnvParam('/api/networks', envId)); + if (response.ok) { + availableNetworks = await response.json(); + } + } catch (err) { + console.error('Failed to fetch networks:', err); + } finally { + networksLoading = false; + } + } + + async function connectToNetwork() { + if (!selectedNetwork || !containerId) return; + networkConnecting = true; + try { + const envId = $currentEnvironment?.id ?? null; + const response = await fetch(appendEnvParam(`/api/networks/${selectedNetwork}/connect`, envId), { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ containerId, containerName: displayName }) + }); + if (response.ok) { + const net = availableNetworks.find(n => n.id === selectedNetwork); + toast.success(`Connected to ${net?.name || 'network'}`); + selectedNetwork = undefined; + await fetchContainerInspect(); + } else { + const data = await response.json(); + toast.error(data.details || 'Failed to connect to network'); + } + } catch (err) { + toast.error('Failed to connect to network'); + } finally { + networkConnecting = false; + } + } + + async function disconnectFromNetwork(networkId: string, networkName: string) { + networkDisconnecting = networkName; + try { + const envId = $currentEnvironment?.id ?? null; + const response = await fetch(appendEnvParam(`/api/networks/${networkId}/disconnect`, envId), { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ containerId, containerName: displayName }) + }); + if (response.ok) { + toast.success(`Disconnected from ${networkName}`); + await fetchContainerInspect(); + } else { + const data = await response.json(); + toast.error(data.details || 'Failed to disconnect from network'); + } + } catch (err) { + toast.error('Failed to disconnect from network'); + } finally { + networkDisconnecting = null; + } + } + // Current environment details for port URL generation const currentEnvDetails = $derived($environments.find(e => e.id === $currentEnvironment?.id) ?? null); @@ -203,6 +293,13 @@ } }); + // Fetch available networks when network tab is selected + $effect(() => { + if (open && activeTab === 'network') { + fetchNetworks(); + } + }); + // Reset when modal closes $effect(() => { if (!open) { @@ -223,6 +320,8 @@ displayName = ''; isEditing = false; editName = ''; + availableNetworks = []; + selectedNetwork = undefined; } }); @@ -340,14 +439,6 @@ return formatDateTime(dateString); } - function formatBytes(bytes: number): string { - if (!bytes || bytes === 0) return '0 B'; - const k = 1024; - const sizes = ['B', 'KB', 'MB', 'GB', 'TB']; - const i = Math.floor(Math.log(bytes) / Math.log(k)); - return `${(bytes / Math.pow(k, i)).toFixed(i > 1 ? 2 : 0)} ${sizes[i]}`; - } - function formatMemory(bytes: number): string { if (!bytes) return 'unlimited'; const mb = bytes / (1024 * 1024); @@ -825,15 +916,34 @@ {/if} - {#if containerData.NetworkSettings?.Networks && Object.keys(containerData.NetworkSettings.Networks).length > 0} -
-

Connected networks

+
+

Connected networks

+ {#if containerData.NetworkSettings?.Networks && Object.keys(containerData.NetworkSettings.Networks).length > 0}
{#each Object.entries(containerData.NetworkSettings.Networks) as [networkName, networkData]} + {@const netData = networkData as any}
- {networkName} - {networkData.NetworkID?.slice(0, 12)} +
+ {networkName} + {netData.NetworkID?.slice(0, 12)} +
+ {#if containerData.State?.Running} + + {/if}
{#if networkData.IPAddress} @@ -870,26 +980,74 @@
{/each}
-
- {/if} + {:else} +

No networks connected.

+ {/if} + + + {#if containerData.State?.Running} +
+ + + {#if selectedNetwork} + {@const net = unconnectedNetworks.find(n => n.id === selectedNetwork)} + + + {net?.name || 'Unknown'} + {net?.driver} + + {:else} + + {networksLoading ? 'Loading networks...' : unconnectedNetworks.length > 0 ? 'Join a network...' : 'No networks available'} + + {/if} + + + {#each unconnectedNetworks as network} + + + + {network.name} + {network.driver} + + + {/each} + + + +
+ {/if} +
{#if containerData.NetworkSettings?.Ports && Object.keys(containerData.NetworkSettings.Ports).length > 0} - {@const inspectCustomUrl = containerData.Config?.Labels?.['dockhand.url']?.trim() || null} + {@const inspectParsedUrl = parseCustomUrl(containerData.Config?.Labels?.['dockhand.url'])}

Port mappings

- {#if inspectCustomUrl} + {#if inspectParsedUrl} @@ -897,8 +1055,8 @@ {#each Object.entries(containerData.NetworkSettings.Ports) as [containerPort, hostBindings]} {#if hostBindings && hostBindings.length > 0} {#each hostBindings as binding} - {@const portUrlOverride = containerData.Config?.Labels?.[`dockhand.port.${binding.HostPort}.url`]?.trim() || null} - {@const url = portUrlOverride || getPortUrl(parseInt(binding.HostPort))} + {@const portParsedOverride = parseCustomUrl(containerData.Config?.Labels?.[`dockhand.port.${binding.HostPort}.url`])} + {@const url = portParsedOverride?.url || getPortUrl(parseInt(binding.HostPort))} diff --git a/src/routes/containers/CreateContainerModal.svelte b/src/routes/containers/CreateContainerModal.svelte index c7b5b07..3f82210 100644 --- a/src/routes/containers/CreateContainerModal.svelte +++ b/src/routes/containers/CreateContainerModal.svelte @@ -10,6 +10,7 @@ import ScanTab from '$lib/components/ScanTab.svelte'; import type { ScanResult } from '$lib/components/ScanTab.svelte'; import ContainerSettingsTab from './ContainerSettingsTab.svelte'; + import { parseHostPort, expandPortBindings } from '$lib/utils/port-parse'; import type { VulnerabilityCriteria } from '$lib/components/VulnerabilityCriteriaSelector.svelte'; // Parse shell command respecting quotes @@ -364,12 +365,13 @@ loading = true; try { - const ports: any = {}; + const ports: Record = {}; portMappings - .filter((p) => p.containerPort && p.hostPort) + .filter((p) => p.containerPort) .forEach((p) => { - const key = `${p.containerPort}/${p.protocol}`; - ports[key] = { HostPort: String(p.hostPort) }; + const parsed = parseHostPort(p.hostPort); + const bindings = expandPortBindings(parsed.hostPort, p.containerPort, p.protocol, parsed.hostIp); + Object.assign(ports, bindings); }); const volumeBinds = volumeMappings diff --git a/src/routes/containers/EditContainerModal.svelte b/src/routes/containers/EditContainerModal.svelte index e2d818b..a82b4ad 100644 --- a/src/routes/containers/EditContainerModal.svelte +++ b/src/routes/containers/EditContainerModal.svelte @@ -6,6 +6,8 @@ import { focusFirstInput } from '$lib/utils'; import ContainerSettingsTab from './ContainerSettingsTab.svelte'; import type { VulnerabilityCriteria } from '$lib/components/VulnerabilityCriteriaSelector.svelte'; + import { parseHostPort, expandPortBindings, formatHostPort } from '$lib/utils/port-parse'; + import { formatBytes } from '$lib/utils/format'; // Parse shell command respecting quotes function parseShellCommand(cmd: string): string[] { @@ -337,14 +339,6 @@ } } - function formatBytes(bytes: number): string { - if (bytes === 0) return ''; - const k = 1024; - const sizes = ['B', 'KB', 'MB', 'GB', 'TB']; - const i = Math.floor(Math.log(bytes) / Math.log(k)); - return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + sizes[i]; - } - async function loadContainerData() { loadingData = true; try { @@ -372,14 +366,16 @@ networkMode = 'bridge'; } - // Parse port mappings + // Parse port mappings (include HostIp if present) const ports = data.HostConfig.PortBindings || {}; portMappings = Object.keys(ports).length > 0 ? Object.entries(ports).map(([containerPort, bindings]: [string, any]) => { const [port, protocol] = containerPort.split('/'); + const hostIp = bindings[0]?.HostIp || ''; + const hostPort = bindings[0]?.HostPort || ''; return { containerPort: port, - hostPort: bindings[0]?.HostPort || '', + hostPort: formatHostPort(hostIp, hostPort), protocol: protocol || 'tcp' }; }) @@ -860,12 +856,13 @@ if (containerConfigChanged) { statusMessage = 'Updating container...'; - const ports: any = {}; + const ports: Record = {}; portMappings - .filter((p) => p.containerPort && p.hostPort) + .filter((p) => p.containerPort) .forEach((p) => { - const key = `${p.containerPort}/${p.protocol}`; - ports[key] = { HostPort: String(p.hostPort) }; + const parsed = parseHostPort(p.hostPort); + const bindings = expandPortBindings(parsed.hostPort, p.containerPort, p.protocol, parsed.hostIp); + Object.assign(ports, bindings); }); const volumeBinds = volumeMappings diff --git a/src/routes/dashboard/dashboard-cpu-memory-bars.svelte b/src/routes/dashboard/dashboard-cpu-memory-bars.svelte index 315fa12..28331ad 100644 --- a/src/routes/dashboard/dashboard-cpu-memory-bars.svelte +++ b/src/routes/dashboard/dashboard-cpu-memory-bars.svelte @@ -1,5 +1,6 @@ + + { if (v) picking = sinceDate && !untilDate ? 'to' : 'from'; }}> + + + {#if hasFilter}{formatLabel()}{/if} + + +
+ +
+ + +
+ + + {#if picking === 'from'} + +
+
+ Time: + +
+
+ {:else} + +
+
+ Time: + +
+
+ {/if} + + +
+ + +
+
+
+
diff --git a/src/routes/logs/LogViewer.svelte b/src/routes/logs/LogViewer.svelte index ccdf8ee..c7e7c4b 100644 --- a/src/routes/logs/LogViewer.svelte +++ b/src/routes/logs/LogViewer.svelte @@ -1,5 +1,6 @@ diff --git a/src/routes/settings/notifications/NotificationModal.svelte b/src/routes/settings/notifications/NotificationModal.svelte index 376681d..e29e997 100644 --- a/src/routes/settings/notifications/NotificationModal.svelte +++ b/src/routes/settings/notifications/NotificationModal.svelte @@ -416,13 +416,14 @@ id="notif-apprise-urls" bind:value={formAppriseUrls} placeholder="gotify://hostname/app-token +gotifys://hostname/app-token?priority=5 discord://webhook_id/webhook_token slack://token_a/token_b/token_c mmost://hostname/webhook-token tgram://bot_token/chat_id tgram://bot_token/chat_id:topic_id ntfy://my-topic -ntfy://host/topic?auth=base64token +ntfy://host/topic?auth=base64token&priority=3 ntfys://host/topic?auth=base64token pushover://user_key/api_token workflows://hostname/workflow/signature diff --git a/src/routes/stacks/+page.svelte b/src/routes/stacks/+page.svelte index fde99c7..c2f9207 100644 --- a/src/routes/stacks/+page.svelte +++ b/src/routes/stacks/+page.svelte @@ -13,9 +13,11 @@ import { Checkbox } from '$lib/components/ui/checkbox'; import * as Tooltip from '$lib/components/ui/tooltip'; import * as Popover from '$lib/components/ui/popover'; + import { formatBytes } from '$lib/utils/format'; 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, Globe } from 'lucide-svelte'; import { formatPorts } from '$lib/utils/port-format'; + import { parseCustomUrl } from '$lib/utils/custom-url'; import ConfirmPopover from '$lib/components/ConfirmPopover.svelte'; import BatchOperationModal from '$lib/components/BatchOperationModal.svelte'; import type { ComposeStackInfo, ContainerStats } from '$lib/types'; @@ -124,15 +126,6 @@ return networks[0]?.ipAddress || '-'; } - // Helper: format bytes to human readable - function formatBytes(bytes: number, decimals = 1): string { - if (bytes === 0) return '0B'; - const k = 1024; - const sizes = ['B', 'K', 'M', 'G', 'T']; - const i = Math.floor(Math.log(bytes) / Math.log(k)); - return parseFloat((bytes / Math.pow(k, i)).toFixed(decimals)) + sizes[i]; - } - // Fetch container stats let statsAbortController: AbortController | null = null; @@ -438,6 +431,7 @@ let confirmStopContainerId = $state(null); let confirmRestartContainerId = $state(null); let confirmPauseContainerId = $state(null); + let confirmRemoveContainerId = $state(null); // Operation error state (for stack and container operations) let operationError = $state<{ id: string; title: string; message: string } | null>(null); @@ -1165,6 +1159,32 @@ } } + async function removeContainer(containerId: string) { + operationError = null; + containerActionLoading = containerId; + try { + const response = await fetch(appendEnvParam(`/api/containers/${containerId}?force=true`, envId), { method: 'DELETE' }); + if (!response.ok) { + const data = await response.json(); + const errorMsg = data.error || 'Failed to remove container'; + operationError = { id: containerId, message: errorMsg }; + toast.error(errorMsg); + clearErrorAfterDelay(containerId); + return; + } + toast.success('Container removed'); + await fetchStacks(); + } catch (error) { + console.error('Failed to remove container:', error); + const errorMsg = error instanceof Error ? error.message : 'Failed to remove container'; + operationError = { id: containerId, message: errorMsg }; + toast.error(errorMsg); + clearErrorAfterDelay(containerId); + } finally { + containerActionLoading = null; + } + } + function inspectContainer(containerId: string, containerName: string) { inspectContainerId = containerId; inspectContainerName = containerName; @@ -1810,7 +1830,18 @@
- {:else if stack.status === 'running' || stack.status === 'partial' || stack.status === 'restarting'} + {:else if stack.status !== 'running' && stack.status !== 'partial' && stack.status !== 'restarting'} + {#if $canAccess('stacks', 'start')} + + {/if} + {:else} {#if $canAccess('stacks', 'restart')} restartPopoverOpen[stack.name] = v}> @@ -1861,17 +1892,6 @@ {/snippet} {/if} - {:else} - {#if $canAccess('stacks', 'start')} - - {/if} {/if} {/if} {#if $canAccess('stacks', 'stop') && stack.status !== 'created' && stack.status !== 'not deployed'} @@ -2023,25 +2043,29 @@ {/if}
- {#if container.labels?.['dockhand.url']?.trim()} - e.stopPropagation()} - class="inline-flex items-center gap-0.5 px-1.5 py-0.5 rounded bg-primary/10 text-primary hover:bg-primary/20 transition-colors" - title="Open {container.labels['dockhand.url'].trim()} in new tab" - > - - {container.labels['dockhand.url'].trim().replace(/^https?:\/\//, '')} - - + {#if parseCustomUrl(container.labels?.['dockhand.url'])} + {@const stackParsedUrl = parseCustomUrl(container.labels?.['dockhand.url'])} + {#if stackParsedUrl} + 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 {stackParsedUrl.url} in new tab" + > + + {stackParsedUrl.name || stackParsedUrl.url.replace(/^https?:\/\//, '')} + + + {/if} {/if} {#if container.ports.length > 0} {@const mappedPorts = formatPorts(container.ports)} {#each mappedPorts as port} - {@const portUrl = container.labels?.[`dockhand.port.${port.publicPort}.url`]?.trim() || null} + {@const portParsed = parseCustomUrl(container.labels?.[`dockhand.port.${port.publicPort}.url`])} + {@const portUrl = portParsed?.url || null} {@const url = portUrl || getPortUrl(port.publicPort)} {#if url} {:else} + {#if container.state === 'paused'} + {#if $canAccess('containers', 'unpause')} + + {/if} + {:else if container.state !== 'running'} + {#if $canAccess('containers', 'start')} + + {/if} + {/if} {#if container.state === 'running'} {#if $canAccess('containers', 'restart')} {/if} - {:else if container.state === 'paused'} - {#if $canAccess('containers', 'unpause')} - - {/if} - {:else} - {#if $canAccess('containers', 'start')} - - {/if} {/if} {/if} + {#if $canAccess('containers', 'remove')} + removeContainer(container.id)} + onOpenChange={(open) => confirmRemoveContainerId = open ? container.id : null} + > + {#snippet children({ open })} + + {/snippet} + + {/if}