mirror of
https://github.com/Finsys/dockhand.git
synced 2026-06-17 19:09:33 +03:00
1.0.30
This commit is contained in:
+1
-1
@@ -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" \
|
||||
|
||||
+4
-4
@@ -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",
|
||||
|
||||
@@ -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)
|
||||
|
||||
+28
@@ -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 */
|
||||
}
|
||||
|
||||
@@ -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<string, string> = {
|
||||
remove: 'removing',
|
||||
|
||||
@@ -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`;
|
||||
|
||||
@@ -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)) {
|
||||
|
||||
@@ -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<FontSize>('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)) {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -7,6 +7,7 @@
|
||||
* - 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
|
||||
* - dockhand.order=<int> — 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<string, string> | 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<string, string> | 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.
|
||||
|
||||
@@ -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<string> {
|
||||
export async function getContainerLogs(id: string, tail: number | 'all' = 100, envId?: number | null, since?: string, until?: string): Promise<string> {
|
||||
// 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<string, string> = (oldImageInspect as any)?.Config?.Labels || {};
|
||||
const newImageLabels: Record<string, string> = (newImageInspect as any)?.Config?.Labels || {};
|
||||
const containerLabels: Record<string, string> = 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.
|
||||
|
||||
@@ -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<string, string> {
|
||||
const result: Record<string, string> = {};
|
||||
|
||||
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;
|
||||
}
|
||||
+27
-23
@@ -224,6 +224,20 @@ async function buildGitEnv(credential: GitCredential | null): Promise<GitEnv> {
|
||||
// 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/<pid>/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/<pid>/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)) {
|
||||
|
||||
@@ -311,21 +311,22 @@ async function sendTelegram(appriseUrl: string, payload: NotificationPayload): P
|
||||
|
||||
// Gotify
|
||||
async function sendGotify(appriseUrl: string, payload: NotificationPayload): Promise<NotificationResult> {
|
||||
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
|
||||
})
|
||||
});
|
||||
|
||||
|
||||
@@ -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<Record<string, string>> {
|
||||
const files: Record<string, string> = {};
|
||||
let totalSize = 0;
|
||||
const skipped: string[] = [];
|
||||
|
||||
async function scanDir(currentPath: string, relativePath: string = ''): Promise<void> {
|
||||
const entries = readdirSync(currentPath, { withFileTypes: true });
|
||||
@@ -243,7 +250,21 @@ async function readDirFilesAsMap(dirPath: string): Promise<Record<string, string
|
||||
if (entry.name === '.git') continue;
|
||||
await scanDir(fullPath, relPath);
|
||||
} else if (entry.isFile()) {
|
||||
const fileSize = statSync(fullPath).size;
|
||||
|
||||
if (fileSize > 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<Record<string, string
|
||||
}
|
||||
|
||||
await scanDir(dirPath);
|
||||
|
||||
if (skipped.length > 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<ComposeS
|
||||
labels: c.labels || {}
|
||||
};
|
||||
})
|
||||
.sort((a, b) => 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,
|
||||
|
||||
@@ -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}`;
|
||||
}
|
||||
@@ -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]}`;
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
export function wrapHtmlLines(html: string): string {
|
||||
return html
|
||||
.split('\n')
|
||||
.map((line) => `<div class="log-line">${line || ' '}</div>`)
|
||||
.join('');
|
||||
}
|
||||
@@ -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) ---
|
||||
|
||||
@@ -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<string, { HostIp?: string; HostPort: string }> {
|
||||
const result: Record<string, { HostIp?: string; HostPort: string }> = {};
|
||||
|
||||
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}`;
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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<Response> {
|
||||
async function handleEdgeLogsStream(containerId: string, tail: string, environmentId: number, since?: string, until?: string): Promise<Response> {
|
||||
// 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();
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
@@ -138,7 +138,7 @@ interface EdgeContainerLogSource {
|
||||
/**
|
||||
* Handle merged logs streaming for Hawser Edge connections
|
||||
*/
|
||||
async function handleEdgeMergedLogs(containerIds: string[], tail: string, environmentId: number): Promise<Response> {
|
||||
async function handleEdgeMergedLogs(containerIds: string[], tail: string, environmentId: number, since?: string, until?: string): Promise<Response> {
|
||||
// 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') {
|
||||
|
||||
+1
-6
@@ -18,12 +18,7 @@ function parseEnvFile(content: string): Record<string, string> {
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
+106
-111
@@ -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<Map<string, Set<string>>>(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}
|
||||
<div class="flex {compactPorts ? 'flex-nowrap' : 'flex-wrap'} gap-1">
|
||||
{#if customUrl}
|
||||
{#if parsedUrl}
|
||||
<a
|
||||
href={customUrl}
|
||||
href={parsedUrl.url}
|
||||
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"
|
||||
title="Open {parsedUrl.url} in new tab"
|
||||
>
|
||||
<Globe class="w-2.5 h-2.5" />
|
||||
<span class="max-w-[120px] truncate">{customUrl.replace(/^https?:\/\//, '')}</span>
|
||||
<span class="max-w-[120px] truncate">{parsedUrl.name || parsedUrl.url.replace(/^https?:\/\//, '')}</span>
|
||||
<ExternalLink class="w-2.5 h-2.5 opacity-60" />
|
||||
</a>
|
||||
{/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}
|
||||
<a
|
||||
@@ -2010,101 +2004,6 @@
|
||||
{/snippet}
|
||||
</ConfirmPopover>
|
||||
{/if}
|
||||
{#if !container.systemContainer}
|
||||
{#if container.state === 'running' || container.state === 'restarting'}
|
||||
{#if $canAccess('containers', 'stop')}
|
||||
<ConfirmPopover
|
||||
open={confirmStopId === container.id}
|
||||
action="Stop"
|
||||
itemType="container"
|
||||
itemName={container.name}
|
||||
title="Stop"
|
||||
onConfirm={() => stopContainer(container.id)}
|
||||
onOpenChange={(open) => confirmStopId = open ? container.id : null}
|
||||
>
|
||||
{#snippet children({ open })}
|
||||
<Square class="w-3 h-3 {open ? 'text-destructive' : 'text-muted-foreground hover:text-destructive'} {stoppingId === container.id ? 'animate-pulse text-destructive' : ''}" />
|
||||
{/snippet}
|
||||
</ConfirmPopover>
|
||||
{#if container.state === 'running'}
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => pauseContainer(container.id)}
|
||||
title="Pause"
|
||||
class="p-0.5 rounded hover:bg-muted transition-colors opacity-70 hover:opacity-100 cursor-pointer"
|
||||
>
|
||||
<Pause class="w-3 h-3 text-muted-foreground hover:text-yellow-500" />
|
||||
</button>
|
||||
{/if}
|
||||
{/if}
|
||||
{:else if container.state === 'paused'}
|
||||
{#if $canAccess('containers', 'start')}
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => unpauseContainer(container.id)}
|
||||
title="Unpause"
|
||||
class="p-0.5 rounded hover:bg-muted transition-colors opacity-70 hover:opacity-100 cursor-pointer"
|
||||
>
|
||||
<Play class="w-3 h-3 text-muted-foreground hover:text-green-500" />
|
||||
</button>
|
||||
{/if}
|
||||
{:else}
|
||||
{#if $canAccess('containers', 'start')}
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => startContainer(container.id)}
|
||||
title="Start"
|
||||
class="p-0.5 rounded hover:bg-muted transition-colors opacity-70 hover:opacity-100 cursor-pointer"
|
||||
>
|
||||
<Play class="w-3 h-3 text-muted-foreground hover:text-green-500" />
|
||||
</button>
|
||||
{/if}
|
||||
{/if}
|
||||
{#if $canAccess('containers', 'restart')}
|
||||
<ConfirmPopover
|
||||
open={confirmRestartId === container.id}
|
||||
action="Restart"
|
||||
itemType="container"
|
||||
itemName={container.name}
|
||||
title="Restart"
|
||||
variant="secondary"
|
||||
onConfirm={() => restartContainer(container.id)}
|
||||
onOpenChange={(open) => confirmRestartId = open ? container.id : null}
|
||||
>
|
||||
{#snippet children({ open })}
|
||||
<RotateCw class="w-3 h-3 {open ? 'text-foreground' : 'text-muted-foreground hover:text-foreground'} {restartingId === container.id ? 'animate-spin text-foreground' : ''}" />
|
||||
{/snippet}
|
||||
</ConfirmPopover>
|
||||
{/if}
|
||||
{/if}
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => inspectContainer(container)}
|
||||
title="View details"
|
||||
class="p-0.5 rounded hover:bg-muted transition-colors opacity-70 hover:opacity-100 cursor-pointer"
|
||||
>
|
||||
<Eye class="w-3 h-3 text-muted-foreground hover:text-foreground" />
|
||||
</button>
|
||||
{#if container.state === 'running' && $canAccess('containers', 'exec')}
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => browseFiles(container)}
|
||||
title="Browse files"
|
||||
class="p-0.5 rounded hover:bg-muted transition-colors opacity-70 hover:opacity-100 cursor-pointer"
|
||||
>
|
||||
<FolderOpen class="w-3 h-3 text-muted-foreground hover:text-foreground" />
|
||||
</button>
|
||||
{/if}
|
||||
{#if $canAccess('containers', 'create')}
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => editContainer(container.id)}
|
||||
title="Edit"
|
||||
class="p-0.5 rounded hover:bg-muted transition-colors opacity-70 hover:opacity-100 cursor-pointer"
|
||||
>
|
||||
<Pencil class="w-3 h-3 text-muted-foreground hover:text-foreground" />
|
||||
</button>
|
||||
{/if}
|
||||
{#if $canAccess('containers', 'logs')}
|
||||
{#if hasActiveLogs(container.id)}
|
||||
<button
|
||||
@@ -2247,6 +2146,102 @@
|
||||
</Popover.Root>
|
||||
{/if}
|
||||
{/if}
|
||||
{#if container.state === 'running' && $canAccess('containers', 'exec')}
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => browseFiles(container)}
|
||||
title="Browse files"
|
||||
class="p-0.5 rounded hover:bg-muted transition-colors opacity-70 hover:opacity-100 cursor-pointer"
|
||||
>
|
||||
<FolderOpen class="w-3 h-3 text-muted-foreground hover:text-foreground" />
|
||||
</button>
|
||||
{/if}
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => inspectContainer(container)}
|
||||
title="View details"
|
||||
class="p-0.5 rounded hover:bg-muted transition-colors opacity-70 hover:opacity-100 cursor-pointer"
|
||||
>
|
||||
<Eye class="w-3 h-3 text-muted-foreground hover:text-foreground" />
|
||||
</button>
|
||||
{#if $canAccess('containers', 'create')}
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => editContainer(container.id)}
|
||||
title="Edit"
|
||||
class="p-0.5 rounded hover:bg-muted transition-colors opacity-70 hover:opacity-100 cursor-pointer"
|
||||
>
|
||||
<Pencil class="w-3 h-3 text-muted-foreground hover:text-foreground" />
|
||||
</button>
|
||||
{/if}
|
||||
{#if !container.systemContainer}
|
||||
{#if container.state === 'paused'}
|
||||
{#if $canAccess('containers', 'start')}
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => unpauseContainer(container.id)}
|
||||
title="Unpause"
|
||||
class="p-0.5 rounded hover:bg-muted transition-colors opacity-70 hover:opacity-100 cursor-pointer"
|
||||
>
|
||||
<Play class="w-3 h-3 text-muted-foreground hover:text-green-500" />
|
||||
</button>
|
||||
{/if}
|
||||
{:else if container.state !== 'running' && container.state !== 'restarting'}
|
||||
{#if $canAccess('containers', 'start')}
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => startContainer(container.id)}
|
||||
title="Start"
|
||||
class="p-0.5 rounded hover:bg-muted transition-colors opacity-70 hover:opacity-100 cursor-pointer"
|
||||
>
|
||||
<Play class="w-3 h-3 text-muted-foreground hover:text-green-500" />
|
||||
</button>
|
||||
{/if}
|
||||
{/if}
|
||||
{#if $canAccess('containers', 'restart')}
|
||||
<ConfirmPopover
|
||||
open={confirmRestartId === container.id}
|
||||
action="Restart"
|
||||
itemType="container"
|
||||
itemName={container.name}
|
||||
title="Restart"
|
||||
variant="secondary"
|
||||
onConfirm={() => restartContainer(container.id)}
|
||||
onOpenChange={(open) => confirmRestartId = open ? container.id : null}
|
||||
>
|
||||
{#snippet children({ open })}
|
||||
<RotateCw class="w-3 h-3 {open ? 'text-foreground' : 'text-muted-foreground hover:text-foreground'} {restartingId === container.id ? 'animate-spin text-foreground' : ''}" />
|
||||
{/snippet}
|
||||
</ConfirmPopover>
|
||||
{/if}
|
||||
{#if container.state === 'running' || container.state === 'restarting'}
|
||||
{#if container.state === 'running'}
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => pauseContainer(container.id)}
|
||||
title="Pause"
|
||||
class="p-0.5 rounded hover:bg-muted transition-colors opacity-70 hover:opacity-100 cursor-pointer"
|
||||
>
|
||||
<Pause class="w-3 h-3 text-muted-foreground hover:text-yellow-500" />
|
||||
</button>
|
||||
{/if}
|
||||
{#if $canAccess('containers', 'stop')}
|
||||
<ConfirmPopover
|
||||
open={confirmStopId === container.id}
|
||||
action="Stop"
|
||||
itemType="container"
|
||||
itemName={container.name}
|
||||
title="Stop"
|
||||
onConfirm={() => stopContainer(container.id)}
|
||||
onOpenChange={(open) => confirmStopId = open ? container.id : null}
|
||||
>
|
||||
{#snippet children({ open })}
|
||||
<Square class="w-3 h-3 {open ? 'text-destructive' : 'text-muted-foreground hover:text-destructive'} {stoppingId === container.id ? 'animate-pulse text-destructive' : ''}" />
|
||||
{/snippet}
|
||||
</ConfirmPopover>
|
||||
{/if}
|
||||
{/if}
|
||||
{/if}
|
||||
{#if !container.systemContainer && $canAccess('containers', 'remove')}
|
||||
<ConfirmPopover
|
||||
open={confirmDeleteId === container.id}
|
||||
|
||||
@@ -4,9 +4,13 @@
|
||||
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, Globe } 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, Link, Unlink } from 'lucide-svelte';
|
||||
import * as Select from '$lib/components/ui/select';
|
||||
import { toast } from 'svelte-sonner';
|
||||
import * as Tooltip from '$lib/components/ui/tooltip';
|
||||
import { copyToClipboard } from '$lib/utils/clipboard';
|
||||
import { parseCustomUrl } from '$lib/utils/custom-url';
|
||||
import { formatBytes } from '$lib/utils/format';
|
||||
import { Input } from '$lib/components/ui/input';
|
||||
import { Label } from '$lib/components/ui/label';
|
||||
import { currentEnvironment, appendEnvParam, environments } from '$lib/stores/environment';
|
||||
@@ -93,6 +97,92 @@
|
||||
|
||||
let editInputRef: HTMLInputElement | null = null;
|
||||
|
||||
// Network attach/detach state
|
||||
interface NetworkListItem {
|
||||
id: string;
|
||||
name: string;
|
||||
driver: string;
|
||||
}
|
||||
let availableNetworks = $state<NetworkListItem[]>([]);
|
||||
let selectedNetwork = $state<string | undefined>(undefined);
|
||||
let networkConnecting = $state(false);
|
||||
let networkDisconnecting = $state<string | null>(null);
|
||||
let networksLoading = $state(false);
|
||||
|
||||
const connectedNetworkNames = $derived(
|
||||
containerData?.NetworkSettings?.Networks
|
||||
? new Set(Object.keys(containerData.NetworkSettings.Networks))
|
||||
: new Set<string>()
|
||||
);
|
||||
|
||||
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}
|
||||
|
||||
<!-- Networks -->
|
||||
{#if containerData.NetworkSettings?.Networks && Object.keys(containerData.NetworkSettings.Networks).length > 0}
|
||||
<div class="space-y-2">
|
||||
<h3 class="text-sm font-semibold">Connected networks</h3>
|
||||
<div class="space-y-2">
|
||||
<h3 class="text-sm font-semibold">Connected networks</h3>
|
||||
{#if containerData.NetworkSettings?.Networks && Object.keys(containerData.NetworkSettings.Networks).length > 0}
|
||||
<div class="space-y-2">
|
||||
{#each Object.entries(containerData.NetworkSettings.Networks) as [networkName, networkData]}
|
||||
{@const netData = networkData as any}
|
||||
<div class="p-3 border border-border rounded-lg space-y-2">
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="font-medium text-sm">{networkName}</span>
|
||||
<Badge variant="secondary" class="text-xs">{networkData.NetworkID?.slice(0, 12)}</Badge>
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="font-medium text-sm">{networkName}</span>
|
||||
<Badge variant="secondary" class="text-xs">{netData.NetworkID?.slice(0, 12)}</Badge>
|
||||
</div>
|
||||
{#if containerData.State?.Running}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
class="h-6 px-2 text-xs text-destructive hover:text-destructive hover:bg-destructive/10"
|
||||
disabled={networkDisconnecting === networkName}
|
||||
onclick={() => disconnectFromNetwork(netData.NetworkID, networkName)}
|
||||
>
|
||||
{#if networkDisconnecting === networkName}
|
||||
<Loader2 class="w-3 h-3 mr-1 animate-spin" />
|
||||
{:else}
|
||||
<Unlink class="w-3 h-3 mr-1" />
|
||||
{/if}
|
||||
Leave
|
||||
</Button>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="grid grid-cols-2 lg:grid-cols-4 gap-2 text-xs">
|
||||
{#if networkData.IPAddress}
|
||||
@@ -870,26 +980,74 @@
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
{:else}
|
||||
<p class="text-xs text-muted-foreground">No networks connected.</p>
|
||||
{/if}
|
||||
|
||||
<!-- Join network dropdown -->
|
||||
{#if containerData.State?.Running}
|
||||
<div class="flex items-center gap-2 pt-1">
|
||||
<Select.Root type="single" bind:value={selectedNetwork}>
|
||||
<Select.Trigger class="flex-1 h-8 text-xs">
|
||||
{#if selectedNetwork}
|
||||
{@const net = unconnectedNetworks.find(n => n.id === selectedNetwork)}
|
||||
<span class="flex items-center gap-2">
|
||||
<Network class="w-3 h-3 text-muted-foreground" />
|
||||
{net?.name || 'Unknown'}
|
||||
<Badge variant="outline" class="text-[10px] px-1 py-0">{net?.driver}</Badge>
|
||||
</span>
|
||||
{:else}
|
||||
<span class="text-muted-foreground">
|
||||
{networksLoading ? 'Loading networks...' : unconnectedNetworks.length > 0 ? 'Join a network...' : 'No networks available'}
|
||||
</span>
|
||||
{/if}
|
||||
</Select.Trigger>
|
||||
<Select.Content>
|
||||
{#each unconnectedNetworks as network}
|
||||
<Select.Item value={network.id}>
|
||||
<span class="flex items-center gap-2">
|
||||
<Network class="w-3 h-3 text-muted-foreground" />
|
||||
{network.name}
|
||||
<Badge variant="outline" class="text-[10px] px-1 py-0 ml-auto">{network.driver}</Badge>
|
||||
</span>
|
||||
</Select.Item>
|
||||
{/each}
|
||||
</Select.Content>
|
||||
</Select.Root>
|
||||
<Button
|
||||
size="sm"
|
||||
class="h-8"
|
||||
disabled={!selectedNetwork || networkConnecting}
|
||||
onclick={connectToNetwork}
|
||||
>
|
||||
{#if networkConnecting}
|
||||
<Loader2 class="w-3.5 h-3.5 mr-1 animate-spin" />
|
||||
{:else}
|
||||
<Link class="w-3.5 h-3.5 mr-1" />
|
||||
{/if}
|
||||
Join
|
||||
</Button>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Ports -->
|
||||
{#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'])}
|
||||
<div class="space-y-2">
|
||||
<h3 class="text-sm font-semibold">Port mappings</h3>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
{#if inspectCustomUrl}
|
||||
{#if inspectParsedUrl}
|
||||
<div class="flex items-center gap-2 text-xs p-2 bg-primary/10 rounded">
|
||||
<a
|
||||
href={inspectCustomUrl}
|
||||
href={inspectParsedUrl.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="inline-flex items-center gap-1 text-primary hover:underline"
|
||||
title="Open {inspectCustomUrl}"
|
||||
title="Open {inspectParsedUrl.url}"
|
||||
>
|
||||
<Globe class="w-3 h-3" />
|
||||
<span>{inspectCustomUrl.replace(/^https?:\/\//, '')}</span>
|
||||
<span>{inspectParsedUrl.name || inspectParsedUrl.url.replace(/^https?:\/\//, '')}</span>
|
||||
<ExternalLink class="w-3 h-3 opacity-60" />
|
||||
</a>
|
||||
</div>
|
||||
@@ -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))}
|
||||
<div class="flex items-center gap-2 text-xs p-2 bg-muted rounded">
|
||||
{#if url}
|
||||
<a
|
||||
|
||||
@@ -5,7 +5,8 @@
|
||||
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, Search } 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, CircleHelp } from 'lucide-svelte';
|
||||
import { parseHostPort, validatePort, validateIp, formatHostPort, expandPortBindings } from '$lib/utils/port-parse';
|
||||
import * as Tooltip from '$lib/components/ui/tooltip';
|
||||
import { currentEnvironment } from '$lib/stores/environment';
|
||||
import { Badge } from '$lib/components/ui/badge';
|
||||
@@ -305,15 +306,19 @@
|
||||
// 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 p = parseHostPort(portMappings[i].hostPort);
|
||||
const num = parseInt(p.hostPort);
|
||||
if (!isNaN(num)) usedPorts.add(num);
|
||||
}
|
||||
}
|
||||
|
||||
const startFrom = parseInt(portMappings[index].hostPort) || 8080;
|
||||
const currentParsed = parseHostPort(portMappings[index].hostPort);
|
||||
const startFrom = parseInt(currentParsed.hostPort) || 8080;
|
||||
let port = startFrom;
|
||||
while (usedPorts.has(port) && port < 65535) port++;
|
||||
if (port <= 65535) {
|
||||
portMappings[index].hostPort = String(port);
|
||||
// Preserve IP prefix if present
|
||||
portMappings[index].hostPort = formatHostPort(currentParsed.hostIp, String(port));
|
||||
}
|
||||
} catch {
|
||||
// Silently fail
|
||||
@@ -897,28 +902,33 @@
|
||||
|
||||
<div class="space-y-2">
|
||||
{#each portMappings as mapping, index}
|
||||
<div class="flex gap-2 items-center">
|
||||
<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>
|
||||
<Input bind:value={mapping.containerPort} type="number" class="h-9" />
|
||||
</div>
|
||||
{@const parsed = parseHostPort(mapping.hostPort)}
|
||||
{@const hostPortError = validatePort(parsed.hostPort)}
|
||||
{@const hostIpError = validateIp(parsed.hostIp)}
|
||||
{@const containerPortError = validatePort(mapping.containerPort)}
|
||||
<div class="flex flex-col gap-1">
|
||||
<div class="flex gap-2 items-center">
|
||||
<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="text" placeholder="e.g. 8080 or 127.0.0.1:8080" class="h-9 {(hostPortError || hostIpError) && mapping.hostPort ? 'border-destructive' : ''}" />
|
||||
<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>
|
||||
<Input bind:value={mapping.containerPort} type="text" placeholder="e.g. 8080 or 8000-8005" class="h-9 {containerPortError && mapping.containerPort ? 'border-destructive' : ''}" />
|
||||
</div>
|
||||
<ToggleGroup
|
||||
value={mapping.protocol}
|
||||
options={protocolOptions}
|
||||
@@ -935,11 +945,30 @@
|
||||
<Trash2 class="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
{#if (hostPortError && mapping.hostPort) || (hostIpError && mapping.hostPort) || (containerPortError && mapping.containerPort)}
|
||||
<p class="text-xs text-destructive pl-1">{hostIpError || hostPortError || containerPortError}</p>
|
||||
{/if}
|
||||
</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 class="text-xs text-muted-foreground flex items-center gap-1.5">
|
||||
<Tooltip.Provider>
|
||||
<Tooltip.Root>
|
||||
<Tooltip.Trigger>
|
||||
<CircleHelp class="w-3.5 h-3.5 text-muted-foreground/70 cursor-help shrink-0" />
|
||||
</Tooltip.Trigger>
|
||||
<Tooltip.Content class="max-w-xs text-xs" side="right">
|
||||
<p class="font-medium mb-1">Supported host port formats:</p>
|
||||
<ul class="space-y-0.5 text-muted-foreground">
|
||||
<li><code class="text-foreground">8080</code> — bind to all interfaces</li>
|
||||
<li><code class="text-foreground">127.0.0.1:8080</code> — bind to specific IP</li>
|
||||
<li><code class="text-foreground">8000-8005</code> — port range (container port must also be a range)</li>
|
||||
<li>Leave host port empty for random allocation</li>
|
||||
</ul>
|
||||
</Tooltip.Content>
|
||||
</Tooltip.Root>
|
||||
</Tooltip.Provider>
|
||||
Hover the host port field and click the search icon to find the next available port.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -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<string, { HostIp?: string; HostPort: string }> = {};
|
||||
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
|
||||
|
||||
@@ -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<string, { HostIp?: string; HostPort: string }> = {};
|
||||
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
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<script lang="ts">
|
||||
import { Cpu, MemoryStick, Loader2 } from 'lucide-svelte';
|
||||
import { formatBytes } from '$lib/utils/format';
|
||||
|
||||
interface Metrics {
|
||||
cpuPercent?: number;
|
||||
@@ -29,15 +30,6 @@
|
||||
// Only show skeleton if loading AND we don't have metrics yet
|
||||
const showSkeleton = $derived(loading && !hasMetrics);
|
||||
|
||||
function formatBytes(bytes: number): string {
|
||||
if (!Number.isFinite(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));
|
||||
if (i < 0 || i >= sizes.length) return '0 B';
|
||||
return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + ' ' + sizes[i];
|
||||
}
|
||||
|
||||
function getProgressColor(percent: number): string {
|
||||
if (percent >= 90) return 'bg-red-500';
|
||||
if (percent >= 70) return 'bg-amber-500';
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<script lang="ts">
|
||||
import { Cpu } from 'lucide-svelte';
|
||||
import { formatBytes } from '$lib/utils/format';
|
||||
import { Chart, Svg, Area } from 'layerchart';
|
||||
import { scaleTime } from 'd3-scale';
|
||||
|
||||
@@ -31,15 +32,6 @@
|
||||
(Number.isFinite(metrics.cpuPercent) || Number.isFinite(metrics.memoryPercent))
|
||||
);
|
||||
|
||||
function formatBytes(bytes: number): string {
|
||||
if (!Number.isFinite(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));
|
||||
if (i < 0 || i >= sizes.length) return '0 B';
|
||||
return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + ' ' + sizes[i];
|
||||
}
|
||||
|
||||
function getProgressColor(percent: number): string {
|
||||
if (percent >= 90) return 'bg-red-500';
|
||||
if (percent >= 70) return 'bg-amber-500';
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<script lang="ts">
|
||||
import { HardDrive, Image, Database, Box, Hammer, Loader2 } from 'lucide-svelte';
|
||||
import { formatBytes } from '$lib/utils/format';
|
||||
import { Chart, Svg, Pie, Arc } from 'layerchart';
|
||||
|
||||
interface Props {
|
||||
@@ -34,14 +35,6 @@
|
||||
].filter(d => d.value > 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];
|
||||
}
|
||||
|
||||
function getPercentage(value: number): number {
|
||||
if (totalSize === 0) return 0;
|
||||
return (value / totalSize) * 100;
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
import * as Select from '$lib/components/ui/select';
|
||||
import { Trash2, Upload, RefreshCw, Play, Search, Layers, Server, ShieldCheck, CheckSquare, Square, Tag, Check, XCircle, Icon, AlertTriangle, X, Images, Copy, Download, ChevronRight, ChevronDown, Loader2, ArrowUp, ArrowDown, ArrowUpDown, CircleDashed, CircleDot, Circle, Filter } from 'lucide-svelte';
|
||||
import { broom, whale } from '@lucide/lab';
|
||||
import { formatBytes } from '$lib/utils/format';
|
||||
import * as Tooltip from '$lib/components/ui/tooltip';
|
||||
import { copyToClipboard } from '$lib/utils/clipboard';
|
||||
import ConfirmPopover from '$lib/components/ConfirmPopover.svelte';
|
||||
@@ -110,7 +111,7 @@
|
||||
let sortDirection = $state<SortDirection>('desc');
|
||||
|
||||
// Filter state
|
||||
type UsageFilter = 'all' | 'in-use' | 'unused';
|
||||
type UsageFilter = 'all' | 'in-use' | 'unused' | 'some-unused';
|
||||
let usageFilter = $state<UsageFilter>('all');
|
||||
|
||||
// Expanded rows state
|
||||
@@ -292,8 +293,17 @@
|
||||
// Apply usage filter
|
||||
if (usageFilter !== 'all') {
|
||||
filtered = filtered.filter(group => {
|
||||
const isInUse = group.containers > 0;
|
||||
return usageFilter === 'in-use' ? isInUse : !isInUse;
|
||||
if (usageFilter === 'in-use') {
|
||||
return group.containers > 0;
|
||||
}
|
||||
if (usageFilter === 'some-unused') {
|
||||
// Only images that have BOTH used and unused tags
|
||||
return group.containers > 0 && group.tags.some(t => t.containers === 0);
|
||||
}
|
||||
// 'unused' includes fully unused AND images with some unused tags
|
||||
const fullyUnused = group.containers === 0;
|
||||
const someUnused = group.tags.length > 1 && group.tags.some(t => t.containers === 0);
|
||||
return fullyUnused || someUnused;
|
||||
});
|
||||
}
|
||||
|
||||
@@ -633,14 +643,6 @@
|
||||
return `${(mb / 1024).toFixed(2)} GB`;
|
||||
}
|
||||
|
||||
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];
|
||||
}
|
||||
|
||||
function formatImageDate(timestamp: number): string {
|
||||
return formatDate(new Date(timestamp * 1000));
|
||||
}
|
||||
@@ -734,6 +736,9 @@
|
||||
{:else if usageFilter === 'in-use'}
|
||||
<CircleDot class="w-3.5 h-3.5 mr-1.5 text-emerald-500 shrink-0" />
|
||||
<span>In use</span>
|
||||
{:else if usageFilter === 'some-unused'}
|
||||
<CircleDot class="w-3.5 h-3.5 mr-1.5 text-amber-500 shrink-0" />
|
||||
<span>Some unused</span>
|
||||
{:else}
|
||||
<Circle class="w-3.5 h-3.5 mr-1.5 text-muted-foreground shrink-0" />
|
||||
<span>Unused</span>
|
||||
@@ -748,6 +753,10 @@
|
||||
<CircleDot class="w-4 h-4 mr-2 text-emerald-500" />
|
||||
In use
|
||||
</Select.Item>
|
||||
<Select.Item value="some-unused">
|
||||
<CircleDot class="w-4 h-4 mr-2 text-amber-500" />
|
||||
Some unused
|
||||
</Select.Item>
|
||||
<Select.Item value="unused">
|
||||
<Circle class="w-4 h-4 mr-2 text-muted-foreground" />
|
||||
Unused
|
||||
|
||||
@@ -183,7 +183,7 @@
|
||||
|
||||
<!-- Layer Stack with Expandable Details -->
|
||||
<div class="space-y-1">
|
||||
<h3 class="sticky top-0 z-10 bg-background text-sm font-semibold mb-2 pb-2 flex items-center gap-2">
|
||||
<h3 class="text-sm font-semibold mb-2 pb-2 flex items-center gap-2">
|
||||
<Layers class="w-4 h-4" />
|
||||
Layer stack (bottom to top) - click to expand
|
||||
</h3>
|
||||
|
||||
+123
-11
@@ -10,7 +10,9 @@
|
||||
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, Clock, Tag } 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, Hash } from 'lucide-svelte';
|
||||
import { wrapHtmlLines } from '$lib/utils/log-lines';
|
||||
import LogTimeRangeFilter from './LogTimeRangeFilter.svelte';
|
||||
import { copyToClipboard } from '$lib/utils/clipboard';
|
||||
import PageHeader from '$lib/components/PageHeader.svelte';
|
||||
import TerminalPanel from '../terminal/TerminalPanel.svelte';
|
||||
@@ -36,6 +38,7 @@ import type { FavoriteGroup } from '../api/preferences/favorite-groups/+server';
|
||||
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 showLineNumbers = $state(typeof localStorage !== 'undefined' && localStorage.getItem('dockhand-log-line-numbers') === 'true');
|
||||
let darkMode = $state(true);
|
||||
let layoutMode = $state<'single' | 'multi' | 'grouped'>('multi');
|
||||
let streamingEnabled = $state(true);
|
||||
@@ -72,6 +75,47 @@ import type { FavoriteGroup } from '../api/preferences/favorite-groups/+server';
|
||||
// RAF-based auto-scroll
|
||||
let scrollRafPending = false;
|
||||
|
||||
// Tail count and since filter
|
||||
const tailOptions = [
|
||||
{ value: '100', label: '100' },
|
||||
{ value: '500', label: '500' },
|
||||
{ value: '1000', label: '1K' },
|
||||
{ value: '5000', label: '5K' },
|
||||
{ value: '10000', label: '10K' },
|
||||
{ value: 'all', label: 'All' }
|
||||
];
|
||||
let tailCount = $state('500');
|
||||
let sinceDate = $state('');
|
||||
let sinceTime = $state('');
|
||||
let untilDate = $state('');
|
||||
let untilTime = $state('');
|
||||
function getTimestamp(date: string, time: string, defaultTime: string): string {
|
||||
if (!date) return '';
|
||||
const dateStr = time ? `${date}T${time}` : `${date}T${defaultTime}`;
|
||||
const ts = Math.floor(new Date(dateStr).getTime() / 1000);
|
||||
return isNaN(ts) ? '' : String(ts);
|
||||
}
|
||||
|
||||
function getSinceParam(): string {
|
||||
return getTimestamp(sinceDate, sinceTime, '00:00:00');
|
||||
}
|
||||
|
||||
function getUntilParam(): string {
|
||||
return getTimestamp(untilDate, untilTime, '23:59:59');
|
||||
}
|
||||
|
||||
function reloadAllLogs() {
|
||||
logs = '';
|
||||
pendingText = '';
|
||||
mergedLogs = []; mergedHtml = '';
|
||||
if (layoutMode === 'grouped') {
|
||||
if (streamingEnabled) startGroupedStreaming();
|
||||
} else if (selectedContainer) {
|
||||
if (streamingEnabled) startStreaming();
|
||||
else fetchLogs();
|
||||
}
|
||||
}
|
||||
|
||||
// Flush pending logs to state (called on timer)
|
||||
function flushPendingLogs() {
|
||||
if (pendingLogs.length === 0) {
|
||||
@@ -172,6 +216,7 @@ import type { FavoriteGroup } from '../api/preferences/favorite-groups/+server';
|
||||
fontSize: number;
|
||||
autoScroll: boolean;
|
||||
streamingEnabled: boolean;
|
||||
tailCount: string;
|
||||
// Selection state (depends on mode)
|
||||
selectedContainerId: string | null; // for single/multi mode
|
||||
selectedContainerIds: string[]; // for grouped mode
|
||||
@@ -201,6 +246,7 @@ import type { FavoriteGroup } from '../api/preferences/favorite-groups/+server';
|
||||
fontSize,
|
||||
autoScroll,
|
||||
streamingEnabled,
|
||||
tailCount,
|
||||
selectedContainerId: selectedContainer?.id ?? null,
|
||||
selectedContainerIds: Array.from(selectedContainerIds),
|
||||
stackName
|
||||
@@ -411,6 +457,7 @@ import type { FavoriteGroup } from '../api/preferences/favorite-groups/+server';
|
||||
if (savedUiState.fontSize !== undefined) fontSize = savedUiState.fontSize;
|
||||
if (savedUiState.autoScroll !== undefined) autoScroll = savedUiState.autoScroll;
|
||||
if (savedUiState.streamingEnabled !== undefined) streamingEnabled = savedUiState.streamingEnabled;
|
||||
if (savedUiState.tailCount !== undefined) tailCount = savedUiState.tailCount;
|
||||
initialStateLoaded = true;
|
||||
|
||||
// Fetch data for this environment
|
||||
@@ -796,12 +843,15 @@ import type { FavoriteGroup } from '../api/preferences/favorite-groups/+server';
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchLogs(tail: number = 500) {
|
||||
async function fetchLogs(tail?: number | string) {
|
||||
if (!selectedContainer) return;
|
||||
const t = tail ?? tailCount;
|
||||
|
||||
loading = true;
|
||||
try {
|
||||
const response = await fetch(appendEnvParam(`/api/containers/${selectedContainer.id}/logs?tail=${tail}`, envId));
|
||||
const since = getSinceParam();
|
||||
const until = getUntilParam();
|
||||
const response = await fetch(appendEnvParam(`/api/containers/${selectedContainer.id}/logs?tail=${t}${since ? `&since=${since}` : ''}${until ? `&until=${until}` : ''}`, envId));
|
||||
const data = await response.json();
|
||||
logs = data.logs || 'No logs available';
|
||||
scrollToBottom(true); // Force scroll on initial load
|
||||
@@ -819,7 +869,7 @@ import type { FavoriteGroup } from '../api/preferences/favorite-groups/+server';
|
||||
|
||||
// For stopped containers, just fetch logs once - no streaming
|
||||
if (selectedContainer.state !== 'running') {
|
||||
fetchLogs(500);
|
||||
fetchLogs();
|
||||
isConnected = false;
|
||||
connectionError = null;
|
||||
return;
|
||||
@@ -831,7 +881,9 @@ import type { FavoriteGroup } from '../api/preferences/favorite-groups/+server';
|
||||
const containerId = selectedContainer.id; // Capture for closure
|
||||
|
||||
try {
|
||||
const url = appendEnvParam(`/api/containers/${containerId}/logs/stream?tail=500`, envId);
|
||||
const since = getSinceParam();
|
||||
const until = getUntilParam();
|
||||
const url = appendEnvParam(`/api/containers/${containerId}/logs/stream?tail=${tailCount}${since ? `&since=${since}` : ''}${until ? `&until=${until}` : ''}`, envId);
|
||||
eventSource = new EventSource(url);
|
||||
|
||||
eventSource.addEventListener('connected', () => {
|
||||
@@ -917,7 +969,8 @@ import type { FavoriteGroup } from '../api/preferences/favorite-groups/+server';
|
||||
if (!container) continue;
|
||||
|
||||
try {
|
||||
const response = await fetch(appendEnvParam(`/api/containers/${containerId}/logs?tail=100`, envId));
|
||||
const since = getSinceParam();
|
||||
const response = await fetch(appendEnvParam(`/api/containers/${containerId}/logs?tail=${tailCount}${since ? `&since=${since}` : ''}`, envId));
|
||||
const data = await response.json();
|
||||
const containerName = container.name;
|
||||
const color = getContainerColor(containerId);
|
||||
@@ -1003,7 +1056,9 @@ import type { FavoriteGroup } from '../api/preferences/favorite-groups/+server';
|
||||
|
||||
try {
|
||||
const containerIdsParam = runningIds.join(',');
|
||||
const url = appendEnvParam(`/api/logs/merged?containers=${containerIdsParam}&tail=100`, envId);
|
||||
const since = getSinceParam();
|
||||
const until = getUntilParam();
|
||||
const url = appendEnvParam(`/api/logs/merged?containers=${containerIdsParam}&tail=${tailCount}${since ? `&since=${since}` : ''}${until ? `&until=${until}` : ''}`, envId);
|
||||
eventSource = new EventSource(url);
|
||||
|
||||
eventSource.addEventListener('connected', (event) => {
|
||||
@@ -2035,8 +2090,29 @@ import type { FavoriteGroup } from '../api/preferences/favorite-groups/+server';
|
||||
<ArrowDownToLine class="w-3 h-3" />
|
||||
<span>Auto-scroll</span>
|
||||
</button>
|
||||
<!-- Tail lines selector -->
|
||||
<Select.Root type="single" value={tailCount} onValueChange={(v) => { tailCount = v; saveState(); reloadAllLogs(); }}>
|
||||
<Select.Trigger class="!h-7 w-[52px] text-xs px-2 [&_svg]:size-3 {darkMode ? 'bg-zinc-800 border-zinc-700 text-zinc-300' : 'bg-white border-gray-300 text-gray-700'}" title="Number of log lines to load">
|
||||
<span>{tailOptions.find(o => o.value === tailCount)?.label ?? tailCount}</span>
|
||||
</Select.Trigger>
|
||||
<Select.Content>
|
||||
{#each tailOptions as opt}
|
||||
<Select.Item value={opt.value} label={opt.label} class="pe-2 [&>span:first-child]:hidden">{opt.label} lines</Select.Item>
|
||||
{/each}
|
||||
</Select.Content>
|
||||
</Select.Root>
|
||||
<!-- Time range filter -->
|
||||
<LogTimeRangeFilter
|
||||
bind:sinceDate
|
||||
bind:sinceTime
|
||||
bind:untilDate
|
||||
bind:untilTime
|
||||
{darkMode}
|
||||
onApply={reloadAllLogs}
|
||||
onClear={reloadAllLogs}
|
||||
/>
|
||||
<Select.Root type="single" value={String(fontSize)} onValueChange={(v) => { fontSize = Number(v); saveState(); }}>
|
||||
<Select.Trigger class="!h-5 !py-0 w-14 text-xs px-1.5 [&_svg]:size-3 {darkMode ? 'bg-zinc-800 border-zinc-700 text-zinc-300' : 'bg-white border-gray-300 text-gray-700'}">
|
||||
<Select.Trigger class="!h-7 w-14 text-xs px-2 [&_svg]:size-3 {darkMode ? 'bg-zinc-800 border-zinc-700 text-zinc-300' : 'bg-white border-gray-300 text-gray-700'}">
|
||||
<span>{fontSize}px</span>
|
||||
</Select.Trigger>
|
||||
<Select.Content>
|
||||
@@ -2067,6 +2143,13 @@ import type { FavoriteGroup } from '../api/preferences/favorite-groups/+server';
|
||||
>
|
||||
<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={() => { showLineNumbers = !showLineNumbers; localStorage.setItem('dockhand-log-line-numbers', String(showLineNumbers)); }}
|
||||
class="p-1 rounded transition-colors {showLineNumbers ? (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={showLineNumbers ? 'Hide line numbers' : 'Show line numbers'}
|
||||
>
|
||||
<Hash class="w-3 h-3 transition-colors {showLineNumbers ? (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'}" />
|
||||
@@ -2133,7 +2216,7 @@ import type { FavoriteGroup } from '../api/preferences/favorite-groups/+server';
|
||||
<RefreshCw class="w-8 h-8 animate-spin {darkMode ? 'text-zinc-400' : 'text-gray-500'}" />
|
||||
</div>
|
||||
{/if}
|
||||
<pre class="font-mono {wordWrap ? 'whitespace-pre-wrap' : 'whitespace-pre'} {darkMode ? 'text-zinc-50' : 'text-gray-900'}" style="font-size: {fontSize}px;">{@html formattedMergedHtml()}</pre>
|
||||
<pre class="font-mono {wordWrap ? 'whitespace-pre-wrap' : 'whitespace-pre'} {showLineNumbers ? 'show-line-numbers' : ''} {darkMode ? 'text-zinc-50' : 'text-gray-900'}" style="font-size: {fontSize}px;">{@html wrapHtmlLines(formattedMergedHtml())}</pre>
|
||||
</div>
|
||||
{/if}
|
||||
{:else if !selectedContainer}
|
||||
@@ -2227,8 +2310,29 @@ import type { FavoriteGroup } from '../api/preferences/favorite-groups/+server';
|
||||
<ArrowDownToLine class="w-3 h-3" />
|
||||
<span>Auto-scroll</span>
|
||||
</button>
|
||||
<!-- Tail lines selector -->
|
||||
<Select.Root type="single" value={tailCount} onValueChange={(v) => { tailCount = v; saveState(); reloadAllLogs(); }}>
|
||||
<Select.Trigger class="!h-7 w-[52px] text-xs px-2 [&_svg]:size-3 {darkMode ? 'bg-zinc-800 border-zinc-700 text-zinc-300' : 'bg-white border-gray-300 text-gray-700'}" title="Number of log lines to load">
|
||||
<span>{tailOptions.find(o => o.value === tailCount)?.label ?? tailCount}</span>
|
||||
</Select.Trigger>
|
||||
<Select.Content>
|
||||
{#each tailOptions as opt}
|
||||
<Select.Item value={opt.value} label={opt.label} class="pe-2 [&>span:first-child]:hidden">{opt.label} lines</Select.Item>
|
||||
{/each}
|
||||
</Select.Content>
|
||||
</Select.Root>
|
||||
<!-- Time range filter -->
|
||||
<LogTimeRangeFilter
|
||||
bind:sinceDate
|
||||
bind:sinceTime
|
||||
bind:untilDate
|
||||
bind:untilTime
|
||||
{darkMode}
|
||||
onApply={reloadAllLogs}
|
||||
onClear={reloadAllLogs}
|
||||
/>
|
||||
<Select.Root type="single" value={String(fontSize)} onValueChange={(v) => { fontSize = Number(v); saveState(); }}>
|
||||
<Select.Trigger class="!h-5 !py-0 w-14 text-xs px-1.5 [&_svg]:size-3 {darkMode ? 'bg-zinc-800 border-zinc-700 text-zinc-300' : 'bg-white border-gray-300 text-gray-700'}">
|
||||
<Select.Trigger class="!h-7 w-14 text-xs px-2 [&_svg]:size-3 {darkMode ? 'bg-zinc-800 border-zinc-700 text-zinc-300' : 'bg-white border-gray-300 text-gray-700'}">
|
||||
<span>{fontSize}px</span>
|
||||
</Select.Trigger>
|
||||
<Select.Content>
|
||||
@@ -2261,6 +2365,14 @@ import type { FavoriteGroup } from '../api/preferences/favorite-groups/+server';
|
||||
>
|
||||
<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>
|
||||
<!-- Line numbers -->
|
||||
<button
|
||||
onclick={() => { showLineNumbers = !showLineNumbers; localStorage.setItem('dockhand-log-line-numbers', String(showLineNumbers)); }}
|
||||
class="p-1 rounded transition-colors {showLineNumbers ? (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={showLineNumbers ? 'Hide line numbers' : 'Show line numbers'}
|
||||
>
|
||||
<Hash class="w-3 h-3 transition-colors {showLineNumbers ? (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}
|
||||
@@ -2364,7 +2476,7 @@ import type { FavoriteGroup } from '../api/preferences/favorite-groups/+server';
|
||||
</div>
|
||||
{:else}
|
||||
<div bind:this={logsRef} class="flex-1 overflow-auto p-4">
|
||||
<pre class="font-mono {wordWrap ? 'whitespace-pre-wrap' : 'whitespace-pre'} {darkMode ? 'text-zinc-50' : 'text-gray-900'}" style="font-size: {fontSize}px;">{@html highlightedLogs()}</pre>
|
||||
<pre class="font-mono {wordWrap ? 'whitespace-pre-wrap' : 'whitespace-pre'} {showLineNumbers ? 'show-line-numbers' : ''} {darkMode ? 'text-zinc-50' : 'text-gray-900'}" style="font-size: {fontSize}px;">{@html wrapHtmlLines(highlightedLogs())}</pre>
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
|
||||
@@ -0,0 +1,155 @@
|
||||
<script lang="ts">
|
||||
import { CalendarClock, X } from 'lucide-svelte';
|
||||
import * as Popover from '$lib/components/ui/popover';
|
||||
import { Calendar } from '$lib/components/ui/calendar';
|
||||
import { CalendarDate, today, getLocalTimeZone } from '@internationalized/date';
|
||||
|
||||
interface Props {
|
||||
sinceDate: string;
|
||||
sinceTime: string;
|
||||
untilDate: string;
|
||||
untilTime: string;
|
||||
darkMode: boolean;
|
||||
onApply: () => void;
|
||||
onClear: () => void;
|
||||
}
|
||||
|
||||
let {
|
||||
sinceDate = $bindable(),
|
||||
sinceTime = $bindable(),
|
||||
untilDate = $bindable(),
|
||||
untilTime = $bindable(),
|
||||
darkMode,
|
||||
onApply,
|
||||
onClear
|
||||
}: Props = $props();
|
||||
|
||||
let open = $state(false);
|
||||
let picking = $state<'from' | 'to'>('from');
|
||||
|
||||
const hasFilter = $derived(sinceDate || untilDate);
|
||||
|
||||
function formatLabel(): string {
|
||||
const parts: string[] = [];
|
||||
if (sinceDate) parts.push(sinceDate.slice(5) + ' ' + (sinceTime || '00:00'));
|
||||
if (untilDate) parts.push(untilDate.slice(5) + ' ' + (untilTime || '23:59'));
|
||||
if (parts.length === 2) return parts.join(' → ');
|
||||
if (sinceDate) return parts[0] + ' →';
|
||||
return '→ ' + parts[0];
|
||||
}
|
||||
|
||||
function parseDate(dateStr: string): CalendarDate | undefined {
|
||||
if (!dateStr) return undefined;
|
||||
const [y, m, d] = dateStr.split('-').map(Number);
|
||||
return new CalendarDate(y, m, d);
|
||||
}
|
||||
|
||||
function toDateString(date: CalendarDate): string {
|
||||
return `${date.year}-${String(date.month).padStart(2, '0')}-${String(date.day).padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
const sinceCalValue = $derived(parseDate(sinceDate));
|
||||
const untilCalValue = $derived(parseDate(untilDate));
|
||||
|
||||
function onFromSelect(value: any) {
|
||||
if (!value) return;
|
||||
sinceDate = toDateString(value);
|
||||
// Auto-switch to "To" picker
|
||||
picking = 'to';
|
||||
// If both dates are set, auto-apply
|
||||
if (untilDate) {
|
||||
open = false;
|
||||
onApply();
|
||||
}
|
||||
}
|
||||
|
||||
function onToSelect(value: any) {
|
||||
if (!value) return;
|
||||
untilDate = toDateString(value);
|
||||
// If both dates are set, auto-apply and close
|
||||
if (sinceDate) {
|
||||
open = false;
|
||||
onApply();
|
||||
}
|
||||
}
|
||||
|
||||
function clear() {
|
||||
sinceDate = '';
|
||||
sinceTime = '';
|
||||
untilDate = '';
|
||||
untilTime = '';
|
||||
open = false;
|
||||
onClear();
|
||||
}
|
||||
|
||||
function apply() {
|
||||
open = false;
|
||||
onApply();
|
||||
}
|
||||
</script>
|
||||
|
||||
<Popover.Root bind:open onOpenChange={(v) => { if (v) picking = sinceDate && !untilDate ? 'to' : 'from'; }}>
|
||||
<Popover.Trigger
|
||||
class="flex items-center gap-1.5 px-2 py-1 rounded text-xs transition-colors {hasFilter ? (darkMode ? 'bg-amber-500/20 ring-1 ring-amber-500/50 text-amber-400' : 'bg-amber-500/30 ring-1 ring-amber-600/50 text-amber-700') : darkMode ? 'text-zinc-500 hover:text-zinc-300 hover:bg-zinc-800' : 'text-gray-500 hover:text-gray-700 hover:bg-gray-300'}"
|
||||
title={hasFilter ? `Time range: ${formatLabel()}` : 'Filter logs by time range'}
|
||||
>
|
||||
<CalendarClock class="w-3 h-3" />
|
||||
{#if hasFilter}<span class="tabular-nums">{formatLabel()}</span>{/if}
|
||||
</Popover.Trigger>
|
||||
<Popover.Content class="w-auto p-0 z-[200]" align="start">
|
||||
<div class="flex flex-col">
|
||||
<!-- From/To tabs -->
|
||||
<div class="flex border-b border-border">
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => picking = 'from'}
|
||||
class="flex-1 px-3 py-1.5 text-xs font-medium transition-colors {picking === 'from' ? 'text-foreground border-b-2 border-primary' : 'text-muted-foreground hover:text-foreground'}"
|
||||
>
|
||||
From{#if sinceDate}<span class="ml-1 text-muted-foreground">({sinceDate.slice(5)})</span>{/if}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => picking = 'to'}
|
||||
class="flex-1 px-3 py-1.5 text-xs font-medium transition-colors {picking === 'to' ? 'text-foreground border-b-2 border-primary' : 'text-muted-foreground hover:text-foreground'}"
|
||||
>
|
||||
To{#if untilDate}<span class="ml-1 text-muted-foreground">({untilDate.slice(5)})</span>{/if}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Calendar -->
|
||||
{#if picking === 'from'}
|
||||
<Calendar
|
||||
type="single"
|
||||
value={sinceCalValue}
|
||||
onValueChange={onFromSelect}
|
||||
class="p-2"
|
||||
/>
|
||||
<div class="px-3 pb-2">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-xs text-muted-foreground">Time:</span>
|
||||
<input type="time" bind:value={sinceTime} class="h-7 rounded border border-border px-2 text-xs bg-background text-foreground [color-scheme:dark] focus:outline-none focus:ring-1 focus:ring-ring" />
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
<Calendar
|
||||
type="single"
|
||||
value={untilCalValue}
|
||||
onValueChange={onToSelect}
|
||||
class="p-2"
|
||||
/>
|
||||
<div class="px-3 pb-2">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-xs text-muted-foreground">Time:</span>
|
||||
<input type="time" bind:value={untilTime} class="h-7 rounded border border-border px-2 text-xs bg-background text-foreground [color-scheme:dark] focus:outline-none focus:ring-1 focus:ring-ring" />
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Actions -->
|
||||
<div class="flex items-center gap-2 px-3 pb-2">
|
||||
<button onclick={apply} class="px-2 py-1 rounded text-xs bg-primary text-primary-foreground hover:bg-primary/90" disabled={!sinceDate && !untilDate}>Apply</button>
|
||||
<button onclick={clear} class="px-2 py-1 rounded text-xs bg-muted text-muted-foreground hover:bg-muted/80">Clear</button>
|
||||
</div>
|
||||
</div>
|
||||
</Popover.Content>
|
||||
</Popover.Root>
|
||||
@@ -1,5 +1,6 @@
|
||||
<script lang="ts">
|
||||
import { RefreshCw, Copy, Download, WrapText, ArrowDownToLine, Search, ChevronUp, ChevronDown, X, Type, Eraser, Filter } from 'lucide-svelte';
|
||||
import { RefreshCw, Copy, Download, WrapText, ArrowDownToLine, Search, ChevronUp, ChevronDown, X, Type, Eraser, Filter, Hash } from 'lucide-svelte';
|
||||
import { wrapHtmlLines } from '$lib/utils/log-lines';
|
||||
import { copyToClipboard } from '$lib/utils/clipboard';
|
||||
import * as Select from '$lib/components/ui/select';
|
||||
import { appSettings, formatLogTimestamps } from '$lib/stores/settings';
|
||||
@@ -37,6 +38,7 @@
|
||||
|
||||
let logsRef: HTMLDivElement;
|
||||
let wordWrap = $state(true);
|
||||
let showLineNumbers = $state(typeof window !== 'undefined' && localStorage.getItem('dockhand-log-line-numbers') === 'true');
|
||||
let fontSize = $state(12);
|
||||
|
||||
// RAF-based auto-scroll
|
||||
@@ -252,6 +254,14 @@
|
||||
>
|
||||
<WrapText class="w-3 h-3 transition-colors {wordWrap ? 'text-amber-400' : 'text-zinc-500 hover:text-zinc-300'}" />
|
||||
</button>
|
||||
<!-- Line numbers -->
|
||||
<button
|
||||
onclick={() => { showLineNumbers = !showLineNumbers; localStorage.setItem('dockhand-log-line-numbers', String(showLineNumbers)); }}
|
||||
class="p-1 rounded hover:bg-zinc-800 transition-colors {showLineNumbers ? 'bg-amber-500/20 ring-1 ring-amber-500/50' : ''}"
|
||||
title={showLineNumbers ? 'Hide line numbers' : 'Show line numbers'}
|
||||
>
|
||||
<Hash class="w-3 h-3 transition-colors {showLineNumbers ? 'text-amber-400' : 'text-zinc-500 hover:text-zinc-300'}" />
|
||||
</button>
|
||||
<!-- Search -->
|
||||
{#if logSearchActive}
|
||||
<div class="flex items-center gap-1 bg-zinc-800 rounded px-1.5 py-0.5">
|
||||
@@ -333,7 +343,7 @@
|
||||
<!-- Logs content -->
|
||||
<div bind:this={logsRef} class="flex-1 overflow-auto p-4">
|
||||
{#if logs}
|
||||
<pre class="text-zinc-50 {wordWrap ? 'whitespace-pre-wrap' : 'whitespace-pre'}" style="font-size: {fontSize}px; font-family: {terminalFontFamily()};">{@html highlightedLogs()}</pre>
|
||||
<pre class="text-zinc-50 {wordWrap ? 'whitespace-pre-wrap' : 'whitespace-pre'} {showLineNumbers ? 'show-line-numbers' : ''}" style="font-size: {fontSize}px; font-family: {terminalFontFamily()};">{@html wrapHtmlLines(highlightedLogs())}</pre>
|
||||
{:else if loading}
|
||||
<div class="flex items-center justify-center h-full text-muted-foreground">
|
||||
<RefreshCw class="w-5 h-5 animate-spin mr-2" />
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
<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, Clock, Tag } from 'lucide-svelte';
|
||||
import { X, GripHorizontal, RefreshCw, Copy, Download, WrapText, ArrowDownToLine, Search, ChevronUp, ChevronDown, Sun, Moon, Wifi, WifiOff, Pause, Play, Eraser, Filter, Clock, Tag, Hash } from 'lucide-svelte';
|
||||
import { wrapHtmlLines } from '$lib/utils/log-lines';
|
||||
import LogTimeRangeFilter from './LogTimeRangeFilter.svelte';
|
||||
import { copyToClipboard } from '$lib/utils/clipboard';
|
||||
import * as Select from '$lib/components/ui/select';
|
||||
import { appSettings, formatLogTimestamps } from '$lib/stores/settings';
|
||||
@@ -31,6 +33,7 @@
|
||||
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);
|
||||
let showLineNumbers = $state(false);
|
||||
|
||||
// SSE Streaming state
|
||||
let streamingEnabled = $state(true);
|
||||
@@ -61,6 +64,46 @@
|
||||
let logSearchInputRef: HTMLInputElement | undefined;
|
||||
|
||||
const fontSizeOptions = [10, 12, 14, 16];
|
||||
const tailOptions = [
|
||||
{ value: '100', label: '100' },
|
||||
{ value: '500', label: '500' },
|
||||
{ value: '1000', label: '1K' },
|
||||
{ value: '5000', label: '5K' },
|
||||
{ value: '10000', label: '10K' },
|
||||
{ value: 'all', label: 'All' }
|
||||
];
|
||||
|
||||
// Tail count and time range filter
|
||||
let tailCount = $state('500');
|
||||
let sinceDate = $state('');
|
||||
let sinceTime = $state('');
|
||||
let untilDate = $state('');
|
||||
let untilTime = $state('');
|
||||
function getTimestamp(date: string, time: string, defaultTime: string): string {
|
||||
if (!date) return '';
|
||||
const dateStr = time ? `${date}T${time}` : `${date}T${defaultTime}`;
|
||||
const ts = Math.floor(new Date(dateStr).getTime() / 1000);
|
||||
return isNaN(ts) ? '' : String(ts);
|
||||
}
|
||||
|
||||
function getSinceParam(): string {
|
||||
return getTimestamp(sinceDate, sinceTime, '00:00:00');
|
||||
}
|
||||
|
||||
function getUntilParam(): string {
|
||||
return getTimestamp(untilDate, untilTime, '23:59:59');
|
||||
}
|
||||
|
||||
function reloadLogs() {
|
||||
logs = '';
|
||||
pendingText = '';
|
||||
if (streamingEnabled && containerId && visible) {
|
||||
loading = true;
|
||||
startStreaming();
|
||||
} else {
|
||||
fetchLogs();
|
||||
}
|
||||
}
|
||||
|
||||
// Get terminal font family from theme preferences
|
||||
let terminalFontFamily = $derived(() => {
|
||||
@@ -101,6 +144,8 @@
|
||||
if (settings.autoScroll !== undefined) autoScroll = settings.autoScroll;
|
||||
if (settings.streamingEnabled !== undefined) streamingEnabled = settings.streamingEnabled;
|
||||
if (settings.logSearchFilterMode !== undefined) logSearchFilterMode = settings.logSearchFilterMode;
|
||||
if (settings.tailCount !== undefined) tailCount = settings.tailCount;
|
||||
if (settings.showLineNumbers !== undefined) showLineNumbers = settings.showLineNumbers;
|
||||
} catch {
|
||||
// Ignore parse errors
|
||||
}
|
||||
@@ -117,7 +162,9 @@
|
||||
fontSize,
|
||||
autoScroll,
|
||||
streamingEnabled,
|
||||
logSearchFilterMode
|
||||
logSearchFilterMode,
|
||||
tailCount,
|
||||
showLineNumbers
|
||||
}));
|
||||
}
|
||||
}
|
||||
@@ -202,7 +249,9 @@
|
||||
const currentContainerId = containerId; // Capture for closure
|
||||
|
||||
try {
|
||||
const url = appendEnvParam(`/api/containers/${currentContainerId}/logs/stream?tail=500`, envId);
|
||||
const since = getSinceParam();
|
||||
const until = getUntilParam();
|
||||
const url = appendEnvParam(`/api/containers/${currentContainerId}/logs/stream?tail=${tailCount}${since ? `&since=${since}` : ''}${until ? `&until=${until}` : ''}`, envId);
|
||||
eventSource = new EventSource(url);
|
||||
|
||||
eventSource.addEventListener('connected', () => {
|
||||
@@ -412,7 +461,9 @@
|
||||
loading = true;
|
||||
connectionError = null;
|
||||
try {
|
||||
const response = await fetch(appendEnvParam(`/api/containers/${containerId}/logs?tail=500`, envId));
|
||||
const since = getSinceParam();
|
||||
const until = getUntilParam();
|
||||
const response = await fetch(appendEnvParam(`/api/containers/${containerId}/logs?tail=${tailCount}${since ? `&since=${since}` : ''}${until ? `&until=${until}` : ''}`, envId));
|
||||
const data = await response.json();
|
||||
if (!response.ok) {
|
||||
logs = `Failed to fetch logs: ${data.error || response.statusText}`;
|
||||
@@ -708,6 +759,27 @@
|
||||
<Play class="w-3 h-3" />
|
||||
{/if}
|
||||
</button>
|
||||
<!-- Tail lines selector -->
|
||||
<Select.Root type="single" value={tailCount} onValueChange={(v) => { tailCount = v; saveSettings(); reloadLogs(); }}>
|
||||
<Select.Trigger size="sm" class="!h-auto !py-0.5 w-[52px] text-xs px-1.5 {darkMode ? 'bg-zinc-800 border-zinc-700 text-zinc-300' : 'bg-white border-gray-300 text-gray-700'} [&_svg]:size-3" title="Number of log lines to load">
|
||||
<span>{tailOptions.find(o => o.value === tailCount)?.label ?? tailCount}</span>
|
||||
</Select.Trigger>
|
||||
<Select.Content>
|
||||
{#each tailOptions as opt}
|
||||
<Select.Item value={opt.value} label={opt.label}>{opt.label} lines</Select.Item>
|
||||
{/each}
|
||||
</Select.Content>
|
||||
</Select.Root>
|
||||
<!-- Time range filter -->
|
||||
<LogTimeRangeFilter
|
||||
bind:sinceDate
|
||||
bind:sinceTime
|
||||
bind:untilDate
|
||||
bind:untilTime
|
||||
{darkMode}
|
||||
onApply={reloadLogs}
|
||||
onClear={reloadLogs}
|
||||
/>
|
||||
<!-- Auto-scroll button -->
|
||||
<button
|
||||
onclick={toggleAutoScroll}
|
||||
@@ -718,7 +790,7 @@
|
||||
</button>
|
||||
<!-- Font size -->
|
||||
<Select.Root type="single" value={String(fontSize)} onValueChange={(v) => updateFontSize(Number(v))}>
|
||||
<Select.Trigger size="sm" class="!h-5 !py-0 w-14 text-xs px-1.5 {darkMode ? 'bg-zinc-800 border-zinc-700 text-zinc-300' : 'bg-white border-gray-300 text-gray-700'} [&_svg]:size-3">
|
||||
<Select.Trigger size="sm" class="!h-auto !py-0.5 w-14 text-xs px-1.5 {darkMode ? 'bg-zinc-800 border-zinc-700 text-zinc-300' : 'bg-white border-gray-300 text-gray-700'} [&_svg]:size-3">
|
||||
<span>{fontSize}px</span>
|
||||
</Select.Trigger>
|
||||
<Select.Content>
|
||||
@@ -751,6 +823,14 @@
|
||||
>
|
||||
<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>
|
||||
<!-- Line numbers -->
|
||||
<button
|
||||
onclick={() => { showLineNumbers = !showLineNumbers; saveSettings(); }}
|
||||
class="p-1 rounded transition-colors {showLineNumbers ? (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={showLineNumbers ? 'Hide line numbers' : 'Show line numbers'}
|
||||
>
|
||||
<Hash class="w-3 h-3 transition-colors {showLineNumbers ? (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}
|
||||
@@ -854,7 +934,7 @@
|
||||
<!-- Logs content -->
|
||||
<div bind:this={logsRef} class="flex-1 overflow-auto p-3">
|
||||
{#if logs}
|
||||
<pre class="logs-fade-in {wordWrap ? 'whitespace-pre-wrap' : 'whitespace-pre'} {darkMode ? 'text-zinc-50' : 'text-gray-900'}" style="font-size: {fontSize}px; font-family: {terminalFontFamily()};">{@html highlightedLogs()}</pre>
|
||||
<pre class="logs-fade-in {wordWrap ? 'whitespace-pre-wrap' : 'whitespace-pre'} {showLineNumbers ? 'show-line-numbers' : ''} {darkMode ? 'text-zinc-50' : 'text-gray-900'}" style="font-size: {fontSize}px; font-family: {terminalFontFamily()};">{@html wrapHtmlLines(highlightedLogs())}</pre>
|
||||
{:else if loading}
|
||||
<p class="text-xs {darkMode ? 'text-zinc-500' : 'text-gray-500'}">Connecting to log stream...</p>
|
||||
{:else}
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
import ConfirmPopover from '$lib/components/ConfirmPopover.svelte';
|
||||
import { toast } from 'svelte-sonner';
|
||||
import { whale } from '@lucide/lab';
|
||||
import { formatBytes } from '$lib/utils/format';
|
||||
import * as Dialog from '$lib/components/ui/dialog';
|
||||
import { Label } from '$lib/components/ui/label';
|
||||
import { Badge } from '$lib/components/ui/badge';
|
||||
@@ -380,12 +381,9 @@
|
||||
}
|
||||
}
|
||||
|
||||
function formatBytes(bytes?: number): string {
|
||||
function formatRegistryBytes(bytes?: number): string {
|
||||
if (!bytes) return '-';
|
||||
if (bytes < 1024) return `${bytes} B`;
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
||||
if (bytes < 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
||||
return `${(bytes / (1024 * 1024 * 1024)).toFixed(2)} GB`;
|
||||
return formatBytes(bytes);
|
||||
}
|
||||
|
||||
function formatDate(dateStr?: string): string {
|
||||
@@ -683,7 +681,7 @@
|
||||
</div>
|
||||
</td>
|
||||
<td class="py-1 px-2 pr-4 text-muted-foreground whitespace-nowrap">
|
||||
{formatBytes(tag.size)}
|
||||
{formatRegistryBytes(tag.size)}
|
||||
</td>
|
||||
<td class="py-1 px-2 pr-4 text-muted-foreground whitespace-nowrap">
|
||||
{formatDate(tag.lastUpdated)}
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
import * as Tabs from '$lib/components/ui/tabs';
|
||||
import { onMount, onDestroy } from 'svelte';
|
||||
import { licenseStore } from '$lib/stores/license';
|
||||
import { formatBytes } from '$lib/utils/format';
|
||||
import { browser } from '$app/environment';
|
||||
import LicenseModal from './LicenseModal.svelte';
|
||||
import PrivacyModal from './PrivacyModal.svelte';
|
||||
@@ -403,11 +404,6 @@
|
||||
}, 800);
|
||||
}
|
||||
|
||||
function formatBytes(bytes: number): string {
|
||||
const gb = bytes / (1024 * 1024 * 1024);
|
||||
return `${gb.toFixed(1)} GB`;
|
||||
}
|
||||
|
||||
async function fetchSystemInfo() {
|
||||
try {
|
||||
const [systemRes, hostRes] = await Promise.all([
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
import VulnerabilityCriteriaSelector, { type VulnerabilityCriteria } from '$lib/components/VulnerabilityCriteriaSelector.svelte';
|
||||
import { CircleFadingArrowUp, CircleArrowUp, RefreshCw, Info, Trash2 } from 'lucide-svelte';
|
||||
import { formatDateTime } from '$lib/stores/settings';
|
||||
import { formatBytes } from '$lib/utils/format';
|
||||
|
||||
interface Props {
|
||||
// Update check settings
|
||||
@@ -43,14 +44,6 @@
|
||||
timezone = $bindable()
|
||||
}: Props = $props();
|
||||
|
||||
// Format bytes to human-readable string
|
||||
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(2))} ${sizes[i]}`;
|
||||
}
|
||||
</script>
|
||||
|
||||
<!-- Scheduled Update Check Section -->
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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<string | null>(null);
|
||||
let confirmRestartContainerId = $state<string | null>(null);
|
||||
let confirmPauseContainerId = $state<string | null>(null);
|
||||
let confirmRemoveContainerId = $state<string | null>(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 @@
|
||||
<div class="p-1">
|
||||
<Loader2 class="w-3 h-3 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
{: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')}
|
||||
<button
|
||||
type="button"
|
||||
onclick={(e) => { e.stopPropagation(); startStack(stack.name); }}
|
||||
title="Start"
|
||||
class="p-1 rounded hover:bg-muted transition-colors opacity-70 hover:opacity-100 cursor-pointer"
|
||||
>
|
||||
<Play class="w-3 h-3 text-muted-foreground hover:text-green-500" />
|
||||
</button>
|
||||
{/if}
|
||||
{:else}
|
||||
{#if $canAccess('stacks', 'restart')}
|
||||
<Popover.Root open={restartPopoverOpen[stack.name] ?? false} onOpenChange={(v) => restartPopoverOpen[stack.name] = v}>
|
||||
<Popover.Trigger asChild>
|
||||
@@ -1861,17 +1892,6 @@
|
||||
{/snippet}
|
||||
</ConfirmPopover>
|
||||
{/if}
|
||||
{:else}
|
||||
{#if $canAccess('stacks', 'start')}
|
||||
<button
|
||||
type="button"
|
||||
onclick={(e) => { e.stopPropagation(); startStack(stack.name); }}
|
||||
title="Start"
|
||||
class="p-1 rounded hover:bg-muted transition-colors opacity-70 hover:opacity-100 cursor-pointer"
|
||||
>
|
||||
<Play class="w-3 h-3 text-muted-foreground hover:text-green-500" />
|
||||
</button>
|
||||
{/if}
|
||||
{/if}
|
||||
{/if}
|
||||
{#if $canAccess('stacks', 'stop') && stack.status !== 'created' && stack.status !== 'not deployed'}
|
||||
@@ -2023,25 +2043,29 @@
|
||||
{/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 parseCustomUrl(container.labels?.['dockhand.url'])}
|
||||
{@const stackParsedUrl = parseCustomUrl(container.labels?.['dockhand.url'])}
|
||||
{#if stackParsedUrl}
|
||||
<a
|
||||
href={stackParsedUrl.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-primary/10 text-primary hover:bg-primary/20 transition-colors"
|
||||
title="Open {stackParsedUrl.url} in new tab"
|
||||
>
|
||||
<Globe class="w-2.5 h-2.5" />
|
||||
<span class="max-w-[120px] truncate">{stackParsedUrl.name || stackParsedUrl.url.replace(/^https?:\/\//, '')}</span>
|
||||
<ExternalLink class="w-2.5 h-2.5 opacity-60" />
|
||||
</a>
|
||||
{/if}
|
||||
{/if}
|
||||
<!-- Clickable ports with range collapsing -->
|
||||
{#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}
|
||||
<a
|
||||
@@ -2147,6 +2171,29 @@
|
||||
{#if isLoading}
|
||||
<Loader2 class="w-3.5 h-3.5 animate-spin text-muted-foreground" />
|
||||
{:else}
|
||||
{#if container.state === 'paused'}
|
||||
{#if $canAccess('containers', 'unpause')}
|
||||
<button
|
||||
type="button"
|
||||
title="Unpause"
|
||||
onclick={(e) => unpauseContainer(container.id, e)}
|
||||
class="p-1 rounded hover:bg-muted transition-colors opacity-70 hover:opacity-100 cursor-pointer"
|
||||
>
|
||||
<Play class="w-3.5 h-3.5 text-muted-foreground hover:text-emerald-500" />
|
||||
</button>
|
||||
{/if}
|
||||
{:else if container.state !== 'running'}
|
||||
{#if $canAccess('containers', 'start')}
|
||||
<button
|
||||
type="button"
|
||||
title="Start"
|
||||
onclick={(e) => startContainer(container.id, e)}
|
||||
class="p-1 rounded hover:bg-muted transition-colors opacity-70 hover:opacity-100 cursor-pointer"
|
||||
>
|
||||
<Play class="w-3.5 h-3.5 text-muted-foreground hover:text-emerald-500" />
|
||||
</button>
|
||||
{/if}
|
||||
{/if}
|
||||
{#if container.state === 'running'}
|
||||
{#if $canAccess('containers', 'restart')}
|
||||
<ConfirmPopover
|
||||
@@ -2193,30 +2240,23 @@
|
||||
{/snippet}
|
||||
</ConfirmPopover>
|
||||
{/if}
|
||||
{:else if container.state === 'paused'}
|
||||
{#if $canAccess('containers', 'unpause')}
|
||||
<button
|
||||
type="button"
|
||||
title="Unpause"
|
||||
onclick={(e) => unpauseContainer(container.id, e)}
|
||||
class="p-1 rounded hover:bg-muted transition-colors opacity-70 hover:opacity-100 cursor-pointer"
|
||||
>
|
||||
<Play class="w-3.5 h-3.5 text-muted-foreground hover:text-emerald-500" />
|
||||
</button>
|
||||
{/if}
|
||||
{:else}
|
||||
{#if $canAccess('containers', 'start')}
|
||||
<button
|
||||
type="button"
|
||||
title="Start"
|
||||
onclick={(e) => startContainer(container.id, e)}
|
||||
class="p-1 rounded hover:bg-muted transition-colors opacity-70 hover:opacity-100 cursor-pointer"
|
||||
>
|
||||
<Play class="w-3.5 h-3.5 text-muted-foreground hover:text-emerald-500" />
|
||||
</button>
|
||||
{/if}
|
||||
{/if}
|
||||
{/if}
|
||||
{#if $canAccess('containers', 'remove')}
|
||||
<ConfirmPopover
|
||||
open={confirmRemoveContainerId === container.id}
|
||||
action="Remove"
|
||||
itemType="container"
|
||||
itemName={container.service}
|
||||
title="Remove"
|
||||
onConfirm={() => removeContainer(container.id)}
|
||||
onOpenChange={(open) => confirmRemoveContainerId = open ? container.id : null}
|
||||
>
|
||||
{#snippet children({ open })}
|
||||
<Trash2 class="w-3.5 h-3.5 {open ? 'text-destructive' : 'text-muted-foreground hover:text-destructive'}" />
|
||||
{/snippet}
|
||||
</ConfirmPopover>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user