1.0.30
This commit is contained in:
49 files changed
+1414
-492
No files matched your search
@@ -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}`;
|
||||
}
|
||||
Reference in new issue
Block a user