Compare commits

...

18 Commits

Author SHA1 Message Date
jarek d9054ff347 1.0.28 2026-05-09 10:13:53 +02:00
jarek 002d969a5d 1.0.28 2026-05-09 09:55:18 +02:00
Juha Kovanen 91ef3e3c9b fix: prevent WebSocket connection drops on hawser handler errors
Wrap globalThis.__hawserHandleMessage in try-catch to prevent unhandled
promise rejections from closing WebSocket connections abruptly.

Previously, if the async handler threw an error, the WebSocket would close
with code 1006 (abnormal closure) without proper error logging. This caused
connections to die after 1-2 seconds, triggering rapid reconnection storms
and preventing stats from being retrieved.

The inner try-catch ensures handler errors are logged but don't close the
connection, allowing the agent to recover and continue processing messages.
2026-05-05 13:12:50 +02:00
jarek 7e3797cbfe 1.0.27 2026-04-26 08:01:32 +02:00
Ivan Kara ccfda4c054 Adjust uploaded files permission 2026-04-19 18:56:01 +02:00
Sebastiaan Lokhorst 28a6211457 Add Apprise workflows:// notification format
For sending messages to e.g. Microsoft Teams
2026-04-19 16:44:41 +02:00
Dennis Braun 7c123833b5 fix: avoid duplicate volume binds during recreate 2026-04-19 16:26:56 +02:00
YewFence a1def17750 chore: delete the unnecessary functions called 2026-04-19 16:22:01 +02:00
YewFence 94657735fb feat: mirror Dockhand's ExtraHosts into scanner and self-update sidecar containers
Add `extraHosts` option to `runContainer` and `runContainerWithStreaming` so arbitrary `HostConfig.ExtraHosts` entries can be passed when spawning containers.

Expose `getOwnExtraHosts()` from `host-path.ts` and forward the cached entries into scanner and self-updater containers, ensuring custom host aliases (e.g. internal registry hostnames) are available inside those sidecars without additional user configuration.
2026-04-19 16:22:01 +02:00
Penlane 74741d2a01 fix: improve canvas resize 2026-04-19 16:18:48 +02:00
Penlane 94591fef48 feat: include NetworkGraph in Networks page 2026-04-19 16:18:48 +02:00
Penlane 44b06e8fc6 feat: add NetworkGraphModal 2026-04-19 16:18:48 +02:00
Penlane e35d485ae9 feat: add NetworkGraphViewer 2026-04-19 16:18:48 +02:00
FlyingT f27c0b066f Update README.md 2026-04-19 16:08:18 +02:00
FlyingT 4840ac024d Add files via upload 2026-04-19 16:08:18 +02:00
FlyingT d3aacfa94b Add files via upload 2026-04-19 16:08:18 +02:00
GiulioSavini 8671dfaf32 fix: allow 6-field cron expressions with seconds
The cron editor rejected sub-minute expressions like `*/30 * * * * *`
because validation required exactly 5 fields. Now accepts both 5-field
(standard) and 6-field (with seconds) cron expressions.

Also fixes schedule type auto-detection to correctly fall back to
'custom' for 6-field expressions instead of misinterpreting field
positions.

Fixes #819
2026-04-19 16:01:50 +02:00
Tim Huge d10f6dfd6d Fix: Remove Telegram link preview 2026-04-19 15:57:52 +02:00
87 changed files with 9492 additions and 492 deletions
+1 -1
View File
@@ -37,7 +37,7 @@ RUN APKO_ARCH=$([ "$TARGETARCH" = "arm64" ] && echo "aarch64" || echo "x86_64")
" - busybox" \
" - tzdata" \
" - docker-cli" \
" - docker-compose=5.1.3-r0" \
" - docker-compose=5.1.3-r2" \
" - docker-cli-buildx" \
" - sqlite" \
" - postgresql-client" \
+6
View File
@@ -36,6 +36,12 @@ Dockhand is a modern, efficient Docker management application providing real-tim
- **Database**: SQLite or PostgreSQL via Drizzle ORM
- **Docker**: direct docker API calls.
## Screenshots
| Light Mode | Dark Mode |
| --- | --- |
| <img src="docs/dashboard1.webp" width="600" alt="Dashboard 1 Light"> | <img src="docs/dashboard2.webp" width="600" alt="Dashboard 2 Dark"> |
| <img src="docs/dashboard3.webp" width="600" alt="Dashboard 3 Light"> | <img src="docs/dashboard4.webp" width="600" alt="Dashboard 4 Dark"> |
## License
Dockhand is licensed under the [Business Source License 1.1](LICENSE.txt) (BSL 1.1).
+1 -1
View File
@@ -1 +1 @@
v1.0.26
v1.0.28
+1 -1
View File
@@ -421,7 +421,7 @@ func (m *manager) collectMetrics(env *environment) {
sCtx, sCancel := context.WithTimeout(env.ctx, 10*time.Second)
defer sCancel()
sResp, sErr := env.doRequest(sCtx, "GET", fmt.Sprintf("/containers/%s/stats?stream=false&one-shot=true", id))
sResp, sErr := env.doRequest(sCtx, "GET", fmt.Sprintf("/containers/%s/stats?stream=false", id))
if sErr != nil {
return
}
+1 -1
View File
@@ -10,7 +10,7 @@ PGID=${PGID:-1001}
export BODY_SIZE_LIMIT=${BODY_SIZE_LIMIT:-2G}
# Default command (--expose-gc allows forced GC from /api/debug/memory?gc=true)
# Custom CA: set NODE_EXTRA_CA_CERTS=/path/to/ca.crt (appends to built-in CAs)
# Custom CA: set NODE_EXTRA_CA_CERTS=/path/to/ca.crt (appends to built-in CAs, git ops auto-merge with system CAs)
# Enterprise (system CA store): set NODE_OPTIONS="--use-openssl-ca"
if [ "$MEMORY_MONITOR" = "true" ]; then
DEFAULT_CMD="node --dns-result-order=ipv4first --no-network-family-autoselection --expose-gc /app/server.js"
Binary file not shown.

After

Width:  |  Height:  |  Size: 292 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 224 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 283 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 281 KiB

@@ -0,0 +1,2 @@
ALTER TABLE "git_stacks" ADD COLUMN "context_dir" text;--> statement-breakpoint
ALTER TABLE "git_stacks" ADD COLUMN "no_build_cache" boolean DEFAULT false;
File diff suppressed because it is too large Load Diff
+7
View File
@@ -43,6 +43,13 @@
"when": 1775312212996,
"tag": "0005_add_api_tokens",
"breakpoints": true
},
{
"idx": 6,
"version": "7",
"when": 1777220350655,
"tag": "0006_add_git_stack_context_dir",
"breakpoints": true
}
]
}
@@ -0,0 +1,2 @@
ALTER TABLE `git_stacks` ADD `context_dir` text;--> statement-breakpoint
ALTER TABLE `git_stacks` ADD `no_build_cache` integer DEFAULT false;
File diff suppressed because it is too large Load Diff
+7
View File
@@ -43,6 +43,13 @@
"when": 1775311743346,
"tag": "0005_add_api_tokens",
"breakpoints": true
},
{
"idx": 6,
"version": "6",
"when": 1777220350655,
"tag": "0006_add_git_stack_context_dir",
"breakpoints": true
}
]
}
+2 -2
View File
@@ -1,7 +1,7 @@
{
"name": "dockhand",
"private": true,
"version": "1.0.26",
"version": "1.0.28",
"type": "module",
"scripts": {
"dev": "npx vite dev",
@@ -78,7 +78,7 @@
"cronstrue": "3.9.0",
"devalue": "5.6.4",
"drizzle-orm": "0.45.2",
"fast-xml-parser": "5.5.8",
"fast-xml-parser": "5.7.3",
"js-yaml": "4.1.1",
"ldapts": "8.1.3",
"nodemailer": "8.0.5",
+7 -2
View File
@@ -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)
@@ -430,7 +430,12 @@ function handleHawserConnection(ws, connId, remoteIp) {
// Use the global hawser message handler injected by the SvelteKit app
if (typeof globalThis.__hawserHandleMessage === 'function') {
await globalThis.__hawserHandleMessage(ws, msg, connId, remoteIp);
try {
await globalThis.__hawserHandleMessage(ws, msg, connId, remoteIp);
} catch (handlerError) {
console.error('[Hawser WS] Handler error:', handlerError);
// Don't close connection - let it recover
}
} else {
console.warn('[Hawser WS] No global handler registered');
ws.send(JSON.stringify({ type: 'error', message: 'Server not ready' }));
+5 -4
View File
@@ -200,8 +200,8 @@
* Sync rawContent TO variables.
* Parses raw content for non-secrets, preserves existing secrets.
*/
function syncRawToVariables() {
const { vars, warnings } = parseRawContent(rawContent);
function syncRawToVariables(content?: string) {
const { vars, warnings } = parseRawContent(content ?? rawContent);
parseWarnings = warnings;
// Preserve existing secrets (they're not in rawContent)
@@ -240,8 +240,9 @@
// Form → Text: sync variables to raw (preserves comments)
syncVariablesToRaw();
} else if (newMode === 'form' && viewMode === 'text') {
// Text → Form: sync raw to variables (preserves secrets)
syncRawToVariables();
// Text → Form: use textEditorContent which falls back to generatedRawContent
// when rawContent is empty (fixes vars lost on view switch for git stacks)
syncRawToVariables(textEditorContent);
}
viewMode = newMode;
+1 -1
View File
@@ -62,7 +62,7 @@
<span class="text-muted-foreground font-normal">({release.date})</span>
</h3>
<div class="space-y-1.5 ml-1">
{#each release.changes as change}
{#each [...release.changes].sort((a, b) => a.type === b.type ? 0 : a.type === 'feature' ? -1 : 1) as change}
{@const { icon: Icon, class: iconClass } = getChangeIcon(change.type)}
<div class="flex items-start gap-2">
<Icon class="w-4 h-4 mt-0.5 shrink-0 {iconClass}" />
+4 -12
View File
@@ -19,7 +19,7 @@
// Detect schedule type from cron expression
function detectScheduleType(cron: string): 'daily' | 'weekly' | 'custom' {
const parts = cron.split(' ');
if (parts.length < 5) return 'custom';
if (parts.length !== 5) return 'custom';
const [min, hr, day, month, dow] = parts;
@@ -137,23 +137,15 @@
onchange(newValue);
}
// Validate cron expression
// Validate cron expression (supports 5-field and 6-field with seconds)
function isValidCron(cron: string): boolean {
const parts = cron.trim().split(/\s+/);
if (parts.length !== 5) return false;
const [min, hr, day, month, dow] = parts;
if (parts.length !== 5 && parts.length !== 6) return false;
// Basic pattern validation (number, *, */n, range, list)
const cronFieldPattern = /^(\*|(\*\/\d+)|\d+(-\d+)?(,\d+(-\d+)?)*)$/;
return (
cronFieldPattern.test(min) &&
cronFieldPattern.test(hr) &&
cronFieldPattern.test(day) &&
cronFieldPattern.test(month) &&
cronFieldPattern.test(dow)
);
return parts.every((part) => cronFieldPattern.test(part));
}
// Human-readable description using cronstrue
+1 -1
View File
@@ -10,7 +10,7 @@ export const containerColumns: ColumnConfig[] = [
{ id: 'uptime', label: 'Uptime', sortable: true, sortField: 'uptime', width: 80, minWidth: 60 },
{ id: 'restartCount', label: 'Restarts', width: 70, minWidth: 50 },
{ id: 'cpu', label: 'CPU', sortable: true, sortField: 'cpu', width: 50, minWidth: 40, align: 'right' },
{ id: 'memory', label: 'Memory', sortable: true, sortField: 'memory', width: 60, minWidth: 50, align: 'right' },
{ id: 'memory', label: 'Memory', sortable: true, sortField: 'memory', width: 95, minWidth: 70, align: 'right' },
{ id: 'networkIO', label: 'Net I/O', width: 85, minWidth: 70, align: 'right' },
{ id: 'diskIO', label: 'Disk I/O', width: 85, minWidth: 70, align: 'right' },
{ id: 'ip', label: 'IP', sortable: true, sortField: 'ip', width: 100, minWidth: 80 },
+50 -1
View File
@@ -1,4 +1,53 @@
[
{
"version": "1.0.28",
"date": "2026-05-09",
"changes": [
{ "type": "feature", "text": "context directory for git stacks — reference files from anywhere in the repo (#864)" },
{ "type": "feature", "text": "no-cache build option for git stacks (#880)" },
{ "type": "fix", "text": "env vars lost when switching between raw/form view (#964)" },
{ "type": "fix", "text": "compose name property not respected during stack scan (#922)" },
{ "type": "feature", "text": "editable schedule for scanner cache cleanup (#979)" },
{ "type": "fix", "text": "container labels cannot be deleted (#984)" },
{ "type": "fix", "text": "env var values leaked in deploy logs — now all values are redacted (#985)" },
{ "type": "fix", "text": "volume export keeps helper container alive, preventing volume prune/deletion (#983)" },
{ "type": "fix", "text": "ntfy self-hosted notifications fail when using ?auth= query parameter (#840)" },
{ "type": "fix", "text": "scrollbar appears in dashboard tiles when content overflows (#969)" },
{ "type": "fix", "text": "case-sensitive environment sort order — lowercase names sorted after uppercase (#975)" },
{ "type": "fix", "text": "inaccurate dashboard CPU gauge caused by one-shot stats flag (#932)" },
{ "type": "feature", "text": "ntfy notifications support ?tags=, ?title=, and ?priority= URL query parameters (#689)" },
{ "type": "fix", "text": "stack .env file wiped when saving from graph view (#988)" },
{ "type": "feature", "text": "dismiss update available indicators without updating (#853)" },
{ "type": "feature", "text": "public IP setting available for hawser-edge environments — enables clickable port links (#350)" },
{ "type": "fix", "text": "git stack creation silently destroys existing stacks with the same name (#1001)" },
{ "type": "feature", "text": "static IP/MAC address configuration for containers (#297)" }
],
"imageTag": "fnsys/dockhand:v1.0.28"
},
{
"version": "1.0.27",
"comingSoon": false,
"date": "2026-04-26",
"changes": [
{ "type": "feature", "text": "network graph visualization on networks page (#894, @Penlane)" },
{ "type": "feature", "text": "customizable compose template for new stacks in settings (#632, @oratory)" },
{ "type": "feature", "text": "Microsoft Teams notifications via Power Automate Workflows (#355, @slokhorst)" },
{ "type": "feature", "text": "container label controls: dockhand.update, dockhand.hidden, dockhand.notify (#6, #53, #94, #215)" },
{ "type": "feature", "text": "configurable label filter matching mode (any/all) for environment dashboard (#607)" },
{ "type": "feature", "text": "log search filter mode to hide non-matching lines (#916)" },
{ "type": "feature", "text": "inline terminal on logs page with resizable split layout (#900)" },
{ "type": "fix", "text": "disable Telegram link preview in notifications (#910, @deenle)" },
{ "type": "fix", "text": "cron editor rejects 6-field expressions with seconds (#839, @GiulioSavini)" },
{ "type": "fix", "text": "mirror Dockhand's ExtraHosts into scanner and self-update containers (#836, @YewFence)" },
{ "type": "fix", "text": "duplicate volume binds during container recreate (#765, @itsDNNS)" },
{ "type": "fix", "text": "log timestamp formatting not applied on main logs page (#882)" },
{ "type": "fix", "text": "uploaded files now inherit container user ownership (#732, @ivanjx)" },
{ "type": "fix", "text": "extraneous backslash in Telegram notification environment name (#955)" },
{ "type": "fix", "text": "collapse ports into ranges only if 3 or more consecutive ports" },
{ "type": "fix", "text": "git operations auto-merge system CAs with custom cert (#967)" }
],
"imageTag": "fnsys/dockhand:v1.0.27"
},
{
"version": "1.0.26",
"date": "2026-04-19",
@@ -12,7 +61,7 @@
{ "type": "fix", "text": "clicking stack name toggles stats accordion instead of just opening editor (#628)" },
{ "type": "fix", "text": "scheduled image prune notifications missing environment name (#770)" },
{ "type": "fix", "text": "Gotify, ntfy, Pushover, and webhook notifications missing environment name (#943)" },
{ "type": "fix", "text": "MFA code field not recognized by Bitwarden and other password managers (#566)" }
{ "type": "fix", "text": "MFA code field not recognized by Bitwarden and other password managers (#566)" }
],
"imageTag": "fnsys/dockhand:v1.0.26"
},
+1 -1
View File
@@ -223,7 +223,7 @@ function setSessionCookie(cookies: Cookies, sessionId: string, maxAge: number, r
path: '/',
httpOnly: true, // Prevents XSS attacks from reading cookie
secure: isSecureContext(request), // Protocol-aware: checks x-forwarded-proto or NODE_ENV
sameSite: 'strict', // CSRF protection
sameSite: 'lax', // Lax required for OIDC/SSO cross-site redirects
maxAge: maxAge // Session timeout in seconds
});
}
+89
View File
@@ -0,0 +1,89 @@
/**
* Dockhand Container Label Controls
*
* Docker container labels that control Dockhand behavior:
* - dockhand.update=false Skip this container during auto-updates and batch updates
* - dockhand.hidden=true Hide this container from the Dockhand UI
* - dockhand.notify=false Suppress notifications for this container's events
*
* 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).
*/
/** Recognized Dockhand label keys */
export const DOCKHAND_LABELS = {
UPDATE: 'dockhand.update',
HIDDEN: 'dockhand.hidden',
NOTIFY: 'dockhand.notify',
} as const;
const TRUTHY_VALUES = new Set(['true', 'yes', '1']);
const FALSY_VALUES = new Set(['false', 'no', '0']);
/**
* Parse a label value as a boolean.
* Returns true for: true, TRUE, yes, YES, 1
* Returns false for: false, FALSE, no, NO, 0
* Returns undefined for missing or unrecognized values.
*/
function parseLabelBool(value: string | undefined | null): boolean | undefined {
if (value == null) return undefined;
const normalized = value.trim().toLowerCase();
if (TRUTHY_VALUES.has(normalized)) return true;
if (FALSY_VALUES.has(normalized)) return false;
return undefined;
}
/**
* Get a label value from a Docker labels object.
*/
function getLabel(labels: Record<string, string> | undefined | null, key: string): string | undefined {
if (!labels) return undefined;
return labels[key];
}
/**
* Check if a container should be skipped during auto-updates.
* Returns true if dockhand.update is explicitly set to false/no/0.
* Default (no label): allow updates (opt-out model).
*/
export function isUpdateDisabledByLabel(labels: Record<string, string> | undefined | null): boolean {
const value = parseLabelBool(getLabel(labels, DOCKHAND_LABELS.UPDATE));
return value === false; // explicitly disabled
}
/**
* Check if a container should be hidden from the UI.
* Returns true if dockhand.hidden is explicitly set to true/yes/1.
* Default (no label): visible (opt-out model).
*/
export function isHiddenByLabel(labels: Record<string, string> | undefined | null): boolean {
const value = parseLabelBool(getLabel(labels, DOCKHAND_LABELS.HIDDEN));
return value === true; // explicitly hidden
}
/**
* Check if notifications should be suppressed for this container.
* Returns true if dockhand.notify is explicitly set to false/no/0.
* Default (no label): send notifications (opt-out model).
*/
export function isNotifyDisabledByLabel(labels: Record<string, string> | undefined | null): boolean {
const value = parseLabelBool(getLabel(labels, DOCKHAND_LABELS.NOTIFY));
return value === false; // explicitly disabled
}
/**
* Extract all Dockhand label states from a container's labels.
* Useful for including in API responses so the frontend knows about label overrides.
*/
export function getDockhandLabels(labels: Record<string, string> | undefined | null): {
updateDisabled: boolean;
hidden: boolean;
notifyDisabled: boolean;
} {
return {
updateDisabled: isUpdateDisabledByLabel(labels),
hidden: isHiddenByLabel(labels),
notifyDisabled: isNotifyDisabledByLabel(labels),
};
}
+148 -7
View File
@@ -78,6 +78,7 @@ import {
import type { AllGridPreferences, GridId, GridColumnPreferences } from '$lib/types';
import { encrypt, decrypt } from './encryption.js';
import { parseEnvInterpolation } from './env-interpolation';
// Re-export for backwards compatibility
export { db, isPostgres, isSqlite };
@@ -112,7 +113,7 @@ export function initDatabase() {
// =============================================================================
export async function getEnvironments(): Promise<Environment[]> {
const results = await db.select().from(environments).orderBy(asc(environments.name));
const results = await db.select().from(environments).orderBy(sql`lower(${environments.name})`);
return results.map((e: Environment) => ({
...e,
tlsKey: decrypt(e.tlsKey),
@@ -2066,6 +2067,7 @@ export async function getGitStacksByRepositoryId(repositoryId: number): Promise<
}
export async function deleteGitRepository(id: number): Promise<boolean> {
console.log(`[GitStack] Deleting git repository id=${id} (will cascade-delete git_stacks, set null on stack_sources FKs)`);
await db.delete(gitRepositories).where(eq(gitRepositories.id, id));
return true;
}
@@ -2086,7 +2088,9 @@ export interface GitStackData {
autoUpdateCron: string;
webhookEnabled: boolean;
webhookSecret: string | null;
contextDir: string | null;
buildOnDeploy: boolean;
noBuildCache: boolean;
repullImages: boolean;
forceRedeploy: boolean;
lastSync: string | null;
@@ -2122,7 +2126,9 @@ export async function getGitStacks(environmentId?: number): Promise<GitStackWith
autoUpdateCron: gitStacks.autoUpdateCron,
webhookEnabled: gitStacks.webhookEnabled,
webhookSecret: gitStacks.webhookSecret,
contextDir: gitStacks.contextDir,
buildOnDeploy: gitStacks.buildOnDeploy,
noBuildCache: gitStacks.noBuildCache,
repullImages: gitStacks.repullImages,
forceRedeploy: gitStacks.forceRedeploy,
lastSync: gitStacks.lastSync,
@@ -2153,7 +2159,9 @@ export async function getGitStacks(environmentId?: number): Promise<GitStackWith
autoUpdateCron: gitStacks.autoUpdateCron,
webhookEnabled: gitStacks.webhookEnabled,
webhookSecret: gitStacks.webhookSecret,
contextDir: gitStacks.contextDir,
buildOnDeploy: gitStacks.buildOnDeploy,
noBuildCache: gitStacks.noBuildCache,
repullImages: gitStacks.repullImages,
forceRedeploy: gitStacks.forceRedeploy,
lastSync: gitStacks.lastSync,
@@ -2184,7 +2192,9 @@ export async function getGitStacks(environmentId?: number): Promise<GitStackWith
autoUpdateCron: row.autoUpdateCron,
webhookEnabled: row.webhookEnabled,
webhookSecret: row.webhookSecret,
contextDir: row.contextDir ?? null,
buildOnDeploy: row.buildOnDeploy ?? false,
noBuildCache: row.noBuildCache ?? false,
repullImages: row.repullImages ?? false,
forceRedeploy: row.forceRedeploy ?? false,
lastSync: row.lastSync,
@@ -2217,7 +2227,9 @@ export async function getGitStacksForEnvironmentOnly(environmentId: number): Pro
autoUpdateCron: gitStacks.autoUpdateCron,
webhookEnabled: gitStacks.webhookEnabled,
webhookSecret: gitStacks.webhookSecret,
contextDir: gitStacks.contextDir,
buildOnDeploy: gitStacks.buildOnDeploy,
noBuildCache: gitStacks.noBuildCache,
repullImages: gitStacks.repullImages,
forceRedeploy: gitStacks.forceRedeploy,
lastSync: gitStacks.lastSync,
@@ -2248,7 +2260,9 @@ export async function getGitStacksForEnvironmentOnly(environmentId: number): Pro
autoUpdateCron: row.autoUpdateCron,
webhookEnabled: row.webhookEnabled,
webhookSecret: row.webhookSecret,
contextDir: row.contextDir ?? null,
buildOnDeploy: row.buildOnDeploy ?? false,
noBuildCache: row.noBuildCache ?? false,
repullImages: row.repullImages ?? false,
forceRedeploy: row.forceRedeploy ?? false,
lastSync: row.lastSync,
@@ -2280,7 +2294,9 @@ export async function getGitStack(id: number): Promise<GitStackWithRepo | null>
autoUpdateCron: gitStacks.autoUpdateCron,
webhookEnabled: gitStacks.webhookEnabled,
webhookSecret: gitStacks.webhookSecret,
contextDir: gitStacks.contextDir,
buildOnDeploy: gitStacks.buildOnDeploy,
noBuildCache: gitStacks.noBuildCache,
repullImages: gitStacks.repullImages,
forceRedeploy: gitStacks.forceRedeploy,
lastSync: gitStacks.lastSync,
@@ -2312,7 +2328,9 @@ export async function getGitStack(id: number): Promise<GitStackWithRepo | null>
autoUpdateCron: row.autoUpdateCron,
webhookEnabled: row.webhookEnabled,
webhookSecret: row.webhookSecret,
contextDir: row.contextDir ?? null,
buildOnDeploy: row.buildOnDeploy ?? false,
noBuildCache: row.noBuildCache ?? false,
repullImages: row.repullImages ?? false,
forceRedeploy: row.forceRedeploy ?? false,
lastSync: row.lastSync,
@@ -2344,7 +2362,9 @@ export async function getGitStackByName(stackName: string, environmentId?: numbe
autoUpdateCron: gitStacks.autoUpdateCron,
webhookEnabled: gitStacks.webhookEnabled,
webhookSecret: gitStacks.webhookSecret,
contextDir: gitStacks.contextDir,
buildOnDeploy: gitStacks.buildOnDeploy,
noBuildCache: gitStacks.noBuildCache,
repullImages: gitStacks.repullImages,
forceRedeploy: gitStacks.forceRedeploy,
lastSync: gitStacks.lastSync,
@@ -2381,7 +2401,9 @@ export async function getGitStackByName(stackName: string, environmentId?: numbe
autoUpdateCron: row.autoUpdateCron,
webhookEnabled: row.webhookEnabled,
webhookSecret: row.webhookSecret,
contextDir: row.contextDir ?? null,
buildOnDeploy: row.buildOnDeploy ?? false,
noBuildCache: row.noBuildCache ?? false,
repullImages: row.repullImages ?? false,
forceRedeploy: row.forceRedeploy ?? false,
lastSync: row.lastSync,
@@ -2413,7 +2435,9 @@ export async function getGitStackByWebhookSecret(secret: string): Promise<GitSta
autoUpdateCron: gitStacks.autoUpdateCron,
webhookEnabled: gitStacks.webhookEnabled,
webhookSecret: gitStacks.webhookSecret,
contextDir: gitStacks.contextDir,
buildOnDeploy: gitStacks.buildOnDeploy,
noBuildCache: gitStacks.noBuildCache,
repullImages: gitStacks.repullImages,
forceRedeploy: gitStacks.forceRedeploy,
lastSync: gitStacks.lastSync,
@@ -2445,7 +2469,9 @@ export async function getGitStackByWebhookSecret(secret: string): Promise<GitSta
autoUpdateCron: row.autoUpdateCron,
webhookEnabled: row.webhookEnabled,
webhookSecret: row.webhookSecret,
contextDir: row.contextDir ?? null,
buildOnDeploy: row.buildOnDeploy ?? false,
noBuildCache: row.noBuildCache ?? false,
repullImages: row.repullImages ?? false,
forceRedeploy: row.forceRedeploy ?? false,
lastSync: row.lastSync,
@@ -2475,7 +2501,9 @@ export async function createGitStack(data: {
autoUpdateCron?: string;
webhookEnabled?: boolean;
webhookSecret?: string | null;
contextDir?: string | null;
buildOnDeploy?: boolean;
noBuildCache?: boolean;
repullImages?: boolean;
forceRedeploy?: boolean;
}): Promise<GitStackWithRepo> {
@@ -2485,12 +2513,14 @@ export async function createGitStack(data: {
repositoryId: data.repositoryId,
composePath: data.composePath || 'compose.yaml',
envFilePath: data.envFilePath || null,
contextDir: data.contextDir || null,
autoUpdate: data.autoUpdate || false,
autoUpdateSchedule: data.autoUpdateSchedule || 'daily',
autoUpdateCron: data.autoUpdateCron || '0 3 * * *',
webhookEnabled: data.webhookEnabled || false,
webhookSecret: data.webhookSecret || null,
buildOnDeploy: data.buildOnDeploy ?? false,
noBuildCache: data.noBuildCache ?? false,
repullImages: data.repullImages ?? false,
forceRedeploy: data.forceRedeploy ?? false
}).returning();
@@ -2509,7 +2539,9 @@ export async function updateGitStack(id: number, data: Partial<GitStackData>): P
if (data.autoUpdateCron !== undefined) updateData.autoUpdateCron = data.autoUpdateCron;
if (data.webhookEnabled !== undefined) updateData.webhookEnabled = data.webhookEnabled;
if (data.webhookSecret !== undefined) updateData.webhookSecret = data.webhookSecret;
if (data.contextDir !== undefined) updateData.contextDir = data.contextDir;
if (data.buildOnDeploy !== undefined) updateData.buildOnDeploy = data.buildOnDeploy;
if (data.noBuildCache !== undefined) updateData.noBuildCache = data.noBuildCache;
if (data.repullImages !== undefined) updateData.repullImages = data.repullImages;
if (data.forceRedeploy !== undefined) updateData.forceRedeploy = data.forceRedeploy;
if (data.lastSync !== undefined) updateData.lastSync = data.lastSync;
@@ -2522,6 +2554,7 @@ export async function updateGitStack(id: number, data: Partial<GitStackData>): P
}
export async function deleteGitStack(id: number): Promise<boolean> {
console.log(`[GitStack] Deleting git_stacks row id=${id}`);
await db.delete(gitStacks).where(eq(gitStacks.id, id));
return true;
}
@@ -2546,7 +2579,9 @@ export async function getEnabledAutoUpdateGitStacks(): Promise<GitStackWithRepo[
autoUpdateCron: gitStacks.autoUpdateCron,
webhookEnabled: gitStacks.webhookEnabled,
webhookSecret: gitStacks.webhookSecret,
contextDir: gitStacks.contextDir,
buildOnDeploy: gitStacks.buildOnDeploy,
noBuildCache: gitStacks.noBuildCache,
repullImages: gitStacks.repullImages,
forceRedeploy: gitStacks.forceRedeploy,
lastSync: gitStacks.lastSync,
@@ -2576,7 +2611,9 @@ export async function getEnabledAutoUpdateGitStacks(): Promise<GitStackWithRepo[
autoUpdateCron: row.autoUpdateCron,
webhookEnabled: row.webhookEnabled,
webhookSecret: row.webhookSecret,
contextDir: row.contextDir ?? null,
buildOnDeploy: row.buildOnDeploy ?? false,
noBuildCache: row.noBuildCache ?? false,
repullImages: row.repullImages ?? false,
forceRedeploy: row.forceRedeploy ?? false,
lastSync: row.lastSync,
@@ -2607,7 +2644,9 @@ export async function getAllAutoUpdateGitStacks(): Promise<GitStackWithRepo[]> {
autoUpdateCron: gitStacks.autoUpdateCron,
webhookEnabled: gitStacks.webhookEnabled,
webhookSecret: gitStacks.webhookSecret,
contextDir: gitStacks.contextDir,
buildOnDeploy: gitStacks.buildOnDeploy,
noBuildCache: gitStacks.noBuildCache,
repullImages: gitStacks.repullImages,
forceRedeploy: gitStacks.forceRedeploy,
lastSync: gitStacks.lastSync,
@@ -2636,7 +2675,9 @@ export async function getAllAutoUpdateGitStacks(): Promise<GitStackWithRepo[]> {
autoUpdateCron: row.autoUpdateCron,
webhookEnabled: row.webhookEnabled,
webhookSecret: row.webhookSecret,
contextDir: row.contextDir ?? null,
buildOnDeploy: row.buildOnDeploy ?? false,
noBuildCache: row.noBuildCache ?? false,
repullImages: row.repullImages ?? false,
forceRedeploy: row.forceRedeploy ?? false,
lastSync: row.lastSync,
@@ -2781,11 +2822,21 @@ export async function upsertStackSource(data: {
const existing = await getStackSource(data.stackName, data.environmentId);
if (existing) {
const newRepoId = data.gitRepositoryId || null;
const newStackId = data.gitStackId || null;
const changes: string[] = [];
if (data.sourceType !== existing.sourceType) changes.push(`sourceType: ${existing.sourceType}${data.sourceType}`);
if (newRepoId !== existing.gitRepositoryId) changes.push(`gitRepoId: ${existing.gitRepositoryId}${newRepoId}`);
if (newStackId !== existing.gitStackId) changes.push(`gitStackId: ${existing.gitStackId}${newStackId}`);
if (changes.length > 0) {
console.log(`[GitStack] Updating stack_sources "${data.stackName}" env=${data.environmentId}: ${changes.join(', ')}`);
}
await db.update(stackSources)
.set({
sourceType: data.sourceType,
gitRepositoryId: data.gitRepositoryId || null,
gitStackId: data.gitStackId || null,
gitRepositoryId: newRepoId,
gitStackId: newStackId,
composePath: data.composePath ?? null,
envPath: data.envPath ?? null,
updatedAt: new Date().toISOString()
@@ -2793,6 +2844,7 @@ export async function upsertStackSource(data: {
.where(eq(stackSources.id, existing.id));
return getStackSource(data.stackName, data.environmentId) as Promise<StackSourceData>;
} else {
console.log(`[GitStack] Creating stack_sources "${data.stackName}" env=${data.environmentId} type=${data.sourceType} repoId=${data.gitRepositoryId || null} stackId=${data.gitStackId || null}`);
await db.insert(stackSources).values({
stackName: data.stackName,
environmentId: data.environmentId ?? null,
@@ -2826,6 +2878,7 @@ export async function updateStackSource(
}
export async function deleteStackSource(stackName: string, environmentId?: number | null): Promise<boolean> {
console.log(`[GitStack] Deleting stack_sources "${stackName}" env=${environmentId}`);
// Delete matching record (either with specific envId or NULL)
await db.delete(stackSources)
.where(and(
@@ -3193,14 +3246,16 @@ export async function getAuditLogs(filters: AuditLogFilters = {}): Promise<Audit
// Labels filter - find environments with matching labels first
let labelFilteredEnvIds: number[] | undefined;
if (filters.labels && filters.labels.length > 0) {
// Get environments that have ANY of the specified labels
const labelFilterMode = await getSetting('label_filter_mode') ?? 'any';
const allEnvs = await db.select({ id: environments.id, labels: environments.labels }).from(environments);
labelFilteredEnvIds = allEnvs
.filter(env => {
if (!env.labels) return false;
try {
const envLabels = JSON.parse(env.labels) as string[];
return filters.labels!.some(label => envLabels.includes(label));
return labelFilterMode === 'all'
? filters.labels!.every(label => envLabels.includes(label))
: filters.labels!.some(label => envLabels.includes(label));
} catch {
return false;
}
@@ -3408,14 +3463,16 @@ export async function getContainerEvents(filters: ContainerEventFilters = {}): P
// Labels filter - find environments with matching labels first
let labelFilteredEnvIds: number[] | undefined;
if (filters.labels && filters.labels.length > 0) {
// Get environments that have ANY of the specified labels
const labelFilterMode = await getSetting('label_filter_mode') ?? 'any';
const allEnvs = await db.select({ id: environments.id, labels: environments.labels }).from(environments);
labelFilteredEnvIds = allEnvs
.filter(env => {
if (!env.labels) return false;
try {
const envLabels = JSON.parse(env.labels) as string[];
return filters.labels!.some(label => envLabels.includes(label));
return labelFilterMode === 'all'
? filters.labels!.every(label => envLabels.includes(label))
: filters.labels!.some(label => envLabels.includes(label));
} catch {
return false;
}
@@ -4038,8 +4095,11 @@ const SCHEDULE_CLEANUP_CRON_KEY = 'schedule_cleanup_cron';
const EVENT_CLEANUP_CRON_KEY = 'event_cleanup_cron';
const SCHEDULE_CLEANUP_ENABLED_KEY = 'schedule_cleanup_enabled';
const EVENT_CLEANUP_ENABLED_KEY = 'event_cleanup_enabled';
const SCANNER_CLEANUP_CRON_KEY = 'scanner_cleanup_cron';
const SCANNER_CLEANUP_ENABLED_KEY = 'scanner_cleanup_enabled';
const DEFAULT_SCHEDULE_CLEANUP_CRON = '0 3 * * *'; // Daily at 3 AM
const DEFAULT_EVENT_CLEANUP_CRON = '30 3 * * *'; // Daily at 3:30 AM
const DEFAULT_SCANNER_CLEANUP_CRON = '0 3 * * 0'; // Weekly Sunday at 3 AM
export async function getScheduleRetentionDays(): Promise<number> {
const result = await db.select().from(settings).where(eq(settings.key, SCHEDULE_RETENTION_KEY));
@@ -4173,6 +4233,50 @@ export async function setEventCleanupEnabled(enabled: boolean): Promise<void> {
}
}
export async function getScannerCleanupCron(): Promise<string> {
const result = await db.select().from(settings).where(eq(settings.key, SCANNER_CLEANUP_CRON_KEY));
if (result[0]) {
return result[0].value || DEFAULT_SCANNER_CLEANUP_CRON;
}
return DEFAULT_SCANNER_CLEANUP_CRON;
}
export async function setScannerCleanupCron(cron: string): Promise<void> {
const existing = await db.select().from(settings).where(eq(settings.key, SCANNER_CLEANUP_CRON_KEY));
if (existing.length > 0) {
await db.update(settings)
.set({ value: cron, updatedAt: new Date().toISOString() })
.where(eq(settings.key, SCANNER_CLEANUP_CRON_KEY));
} else {
await db.insert(settings).values({
key: SCANNER_CLEANUP_CRON_KEY,
value: cron
});
}
}
export async function getScannerCleanupEnabled(): Promise<boolean> {
const result = await db.select().from(settings).where(eq(settings.key, SCANNER_CLEANUP_ENABLED_KEY));
if (result[0]) {
return result[0].value === 'true';
}
return true; // Enabled by default
}
export async function setScannerCleanupEnabled(enabled: boolean): Promise<void> {
const existing = await db.select().from(settings).where(eq(settings.key, SCANNER_CLEANUP_ENABLED_KEY));
if (existing.length > 0) {
await db.update(settings)
.set({ value: enabled ? 'true' : 'false', updatedAt: new Date().toISOString() })
.where(eq(settings.key, SCANNER_CLEANUP_ENABLED_KEY));
} else {
await db.insert(settings).values({
key: SCANNER_CLEANUP_ENABLED_KEY,
value: enabled ? 'true' : 'false'
});
}
}
// =============================================================================
// EXTERNAL STACK PATHS
// =============================================================================
@@ -4629,6 +4733,43 @@ export async function getSecretKeyNames(
return new Set(vars.filter(v => v.isSecret).map(v => v.key));
}
/**
* Get the set of env var keys that should be masked in container inspect responses.
* Handles two cases:
* 1. Direct match: env var key == secret key in DB (e.g., DB_PASS=${DB_PASS})
* 2. Interpolation: env var key differs from secret key (e.g., MYSQL_PASSWORD=${db_secret})
* Detected by parsing the compose file for ${variable} references in environment: sections.
*
* @param composeContent - Optional compose file content. If provided, interpolation
* references are parsed to detect secrets injected under different key names.
*/
export async function getSecretKeysToMask(
stackName: string,
environmentId?: number | null,
composeContent?: string | null
): Promise<Set<string>> {
const vars = await getStackEnvVars(stackName, environmentId, true);
const secretKeyNames = new Set(vars.filter(v => v.isSecret).map(v => v.key));
if (secretKeyNames.size === 0) return secretKeyNames;
// If we have compose content, parse interpolation references to find
// container env keys that map to secret interpolation variables.
// e.g., "MYSQL_PASSWORD=${db_secret}" → if db_secret is a secret, mask MYSQL_PASSWORD too.
if (composeContent) {
const interpolated = parseEnvInterpolation(composeContent);
for (const [containerKey, varName] of interpolated) {
if (secretKeyNames.has(varName)) {
secretKeyNames.add(containerKey);
}
}
}
return secretKeyNames;
}
export { parseEnvInterpolation } from './env-interpolation';
/**
* Get count of environment variables for a stack.
* @param stackName - Name of the stack
+2
View File
@@ -315,7 +315,9 @@ export const gitStacks = sqliteTable('git_stacks', {
autoUpdateCron: text('auto_update_cron').default('0 3 * * *'),
webhookEnabled: integer('webhook_enabled', { mode: 'boolean' }).default(false),
webhookSecret: text('webhook_secret'),
contextDir: text('context_dir'), // Working directory relative to repo root (null = compose file's directory)
buildOnDeploy: integer('build_on_deploy', { mode: 'boolean' }).default(false),
noBuildCache: integer('no_build_cache', { mode: 'boolean' }).default(false),
repullImages: integer('repull_images', { mode: 'boolean' }).default(false),
forceRedeploy: integer('force_redeploy', { mode: 'boolean' }).default(false),
lastSync: text('last_sync'),
+2
View File
@@ -318,7 +318,9 @@ export const gitStacks = pgTable('git_stacks', {
autoUpdateCron: text('auto_update_cron').default('0 3 * * *'),
webhookEnabled: boolean('webhook_enabled').default(false),
webhookSecret: text('webhook_secret'),
contextDir: text('context_dir'), // Working directory relative to repo root (null = compose file's directory)
buildOnDeploy: boolean('build_on_deploy').default(false),
noBuildCache: boolean('no_build_cache').default(false),
repullImages: boolean('repull_images').default(false),
forceRedeploy: boolean('force_redeploy').default(false),
lastSync: timestamp('last_sync', { mode: 'string' }),
+72 -30
View File
@@ -14,6 +14,7 @@ import * as tls from 'node:tls';
import { createHash } from 'node:crypto';
import type { Environment } from './db';
import { getStackEnvVarsAsRecord } from './db';
import { getAdditionalVolumeBinds } from './mount-dedupe';
import { isSystemContainer } from './scheduler/tasks/update-utils';
import { deepDiff } from '../utils/diff.js';
@@ -1247,6 +1248,12 @@ export interface CreateContainerOptions {
networkIpv6Address?: string;
/** Gateway priority for the primary network (Docker Engine 28+) */
networkGwPriority?: number;
/** Per-network endpoint configuration (IPv4, IPv6, aliases) */
networkConfigs?: Record<string, {
ipv4Address?: string;
ipv6Address?: string;
aliases?: string[];
}>;
user?: string | null;
privileged?: boolean;
healthcheck?: HealthcheckConfig | null;
@@ -1430,10 +1437,25 @@ export async function createContainer(options: CreateContainerOptions, envId?: n
for (const network of options.networks) {
const isFirstNetwork = network === options.networks[0];
const netCfg = options.networkConfigs?.[network];
const endpointConfig: any = {};
// Apply aliases, static IP, and gateway priority only to the first (primary) network
if (isFirstNetwork) {
// Per-network config from networkConfigs (takes precedence)
if (netCfg) {
if (netCfg.aliases && netCfg.aliases.length > 0) {
endpointConfig.Aliases = netCfg.aliases;
}
if (netCfg.ipv4Address || netCfg.ipv6Address) {
endpointConfig.IPAMConfig = {};
if (netCfg.ipv4Address) {
endpointConfig.IPAMConfig.IPv4Address = netCfg.ipv4Address;
}
if (netCfg.ipv6Address) {
endpointConfig.IPAMConfig.IPv6Address = netCfg.ipv6Address;
}
}
} else if (isFirstNetwork) {
// Backward compat: apply flat fields to first network if no networkConfigs
if (options.networkAliases && options.networkAliases.length > 0) {
endpointConfig.Aliases = options.networkAliases;
}
@@ -1616,9 +1638,22 @@ export async function createContainer(options: CreateContainerOptions, envId?: n
containerConfig.StopTimeout = options.stopTimeout;
}
// MAC address
// MAC address — set both top-level (API <1.44) and endpoint config (API 1.44+)
if (options.macAddress) {
containerConfig.MacAddress = options.macAddress;
// For Docker API 1.44+, MacAddress must be in EndpointConfig
const primaryNetwork = options.networks?.[0] || options.networkMode || 'bridge';
if (containerConfig.NetworkingConfig?.EndpointsConfig?.[primaryNetwork]) {
containerConfig.NetworkingConfig.EndpointsConfig[primaryNetwork].MacAddress = options.macAddress;
} else {
containerConfig.NetworkingConfig = containerConfig.NetworkingConfig || { EndpointsConfig: {} };
containerConfig.NetworkingConfig.EndpointsConfig = containerConfig.NetworkingConfig.EndpointsConfig || {};
containerConfig.NetworkingConfig.EndpointsConfig[primaryNetwork] = {
...containerConfig.NetworkingConfig.EndpointsConfig[primaryNetwork],
MacAddress: options.macAddress
};
}
}
// Extra hosts (/etc/hosts entries)
@@ -1917,20 +1952,7 @@ export async function recreateContainerFromInspect(
}
}
// Preserve anonymous volumes from Mounts not in HostConfig.Binds
const existingBinds = new Set((hostConfig.Binds || []).map((b: string) => {
const parts = b.split(':');
return parts.length >= 2 ? parts[1] : parts[0];
}));
const mounts = inspectData.Mounts || [];
const additionalBinds: string[] = [];
for (const mount of mounts) {
if (mount.Type === 'volume' && mount.Name && mount.Destination) {
if (!existingBinds.has(mount.Destination)) {
additionalBinds.push(`${mount.Name}:${mount.Destination}`);
}
}
}
const additionalBinds = getAdditionalVolumeBinds(hostConfig, inspectData.Mounts || []);
if (additionalBinds.length > 0) {
createConfig.HostConfig = {
...hostConfig,
@@ -2337,11 +2359,15 @@ export async function updateContainer(id: string, options: Partial<CreateContain
const mergedOptions: CreateContainerOptions = {
...existingOptions,
...options,
// Special handling for labels - merge instead of replace to preserve Docker internal labels
labels: {
...existingOptions.labels,
...options.labels
}
// Replace labels, but preserve Docker internal labels (com.docker.*)
labels: options.labels !== undefined
? {
...Object.fromEntries(
Object.entries(existingOptions.labels || {}).filter(([k]) => k.startsWith('com.docker.'))
),
...options.labels
}
: existingOptions.labels
};
// 1. Stop old container
@@ -3856,19 +3882,25 @@ export async function getContainerTop(id: string, envId?: number | null): Promis
export async function execInContainer(
containerId: string,
cmd: string[],
envId?: number | null
envId?: number | null,
user?: string | null
): Promise<string> {
// Create exec instance
const execBody: any = {
Cmd: cmd,
AttachStdout: true,
AttachStderr: true,
Tty: false
};
if (user) {
execBody.User = user;
}
const execCreate = await dockerJsonRequest<{ Id: string }>(
`/containers/${containerId}/exec`,
{
method: 'POST',
body: JSON.stringify({
Cmd: cmd,
AttachStdout: true,
AttachStderr: true,
Tty: false
})
body: JSON.stringify(execBody)
},
envId
);
@@ -3964,6 +3996,7 @@ export async function runContainer(options: {
cmd: string[];
binds?: string[];
env?: string[];
extraHosts?: string[];
name?: string;
envId?: number | null;
}): Promise<{ stdout: string; stderr: string }> {
@@ -3985,6 +4018,10 @@ export async function runContainer(options: {
}
};
if (options.extraHosts && options.extraHosts.length > 0) {
containerConfig.HostConfig.ExtraHosts = options.extraHosts;
}
const createResult = await dockerJsonRequest<{ Id: string }>(
`/containers/create?name=${encodeURIComponent(containerName)}`,
{
@@ -4044,6 +4081,7 @@ export async function runContainerWithStreaming(options: {
cmd: string[];
binds?: string[];
env?: string[];
extraHosts?: string[];
name?: string;
user?: string;
envId?: number | null;
@@ -4071,6 +4109,10 @@ export async function runContainerWithStreaming(options: {
}
};
if (options.extraHosts && options.extraHosts.length > 0) {
containerConfig.HostConfig.ExtraHosts = options.extraHosts;
}
// Set user if specified (needed for rootless Docker socket access)
if (options.user) {
containerConfig.User = options.user;
+36
View File
@@ -0,0 +1,36 @@
/**
* Parse compose YAML to extract environment variable interpolation mappings.
* Returns pairs of [containerEnvKey, interpolationVariable].
*
* Handles patterns:
* - VAR=${ref}
* - VAR=${ref:-default}
* - VAR=${ref:+alt}
* - VAR=${ref?error}
*
* Only extracts from `environment:` sections (list format: `- KEY=value`).
*/
export function parseEnvInterpolation(composeContent: string): Array<[string, string]> {
const results: Array<[string, string]> = [];
// Step 1: Find lines matching `- ENV_KEY=...${...}...`
const linePattern = /^\s*-\s*([A-Za-z_][A-Za-z0-9_]*)=(.*)/gm;
let lineMatch;
while ((lineMatch = linePattern.exec(composeContent)) !== null) {
const containerKey = lineMatch[1];
const valueStr = lineMatch[2];
// Step 2: Extract all ${VAR} references from the value
const varPattern = /\$\{([A-Za-z_][A-Za-z0-9_]*)(?:[:\-\+\?][^}]*)?\}/g;
let varMatch;
while ((varMatch = varPattern.exec(valueStr)) !== null) {
const varName = varMatch[1];
// Only add if names differ — same-name case handled by direct key matching
if (containerKey !== varName) {
results.push([containerKey, varName]);
}
}
}
return results;
}
+128 -32
View File
@@ -16,6 +16,70 @@ import {
} from './db';
import { deployStack, getStackDir } from './stacks';
const MERGED_CA_BUNDLE_PATH = '/tmp/dockhand-merged-ca-bundle.crt';
let mergedCaBundleReady = false;
/**
* Create a merged CA bundle combining system CAs with the custom cert from
* NODE_EXTRA_CA_CERTS. GIT_SSL_CAINFO replaces the default CA store, so without
* merging, public CAs (GitHub, GitLab) break.
*/
function getMergedCaBundlePath(): string {
if (mergedCaBundleReady && existsSync(MERGED_CA_BUNDLE_PATH)) {
console.log(`[Git] Using cached merged CA bundle: ${MERGED_CA_BUNDLE_PATH}`);
return MERGED_CA_BUNDLE_PATH;
}
const customCertPath = process.env.NODE_EXTRA_CA_CERTS!;
console.log(`[Git] NODE_EXTRA_CA_CERTS set to: ${customCertPath}`);
const systemCaPaths = [
process.env.SSL_CERT_FILE,
'/etc/ssl/certs/ca-certificates.crt',
'/etc/pki/tls/certs/ca-bundle.crt',
'/etc/ssl/cert.pem'
];
let systemCaContent = '';
let systemCaSource = '';
for (const caPath of systemCaPaths) {
if (caPath && existsSync(caPath)) {
try {
systemCaContent = readFileSync(caPath, 'utf-8');
systemCaSource = caPath;
console.log(`[Git] Found system CA bundle: ${caPath} (${systemCaContent.split('-----BEGIN CERTIFICATE-----').length - 1} certs)`);
break;
} catch (err) {
console.log(`[Git] Failed to read system CA bundle ${caPath}: ${err}`);
}
}
}
if (!systemCaSource) {
console.log(`[Git] No system CA bundle found, using custom cert only: ${customCertPath}`);
}
try {
const customCaContent = readFileSync(customCertPath, 'utf-8');
const customCertCount = customCaContent.split('-----BEGIN CERTIFICATE-----').length - 1;
console.log(`[Git] Custom CA file contains ${customCertCount} cert(s)`);
const merged = systemCaContent
? systemCaContent.trimEnd() + '\n' + customCaContent.trimEnd() + '\n'
: customCaContent;
writeFileSync(MERGED_CA_BUNDLE_PATH, merged);
mergedCaBundleReady = true;
const totalCerts = merged.split('-----BEGIN CERTIFICATE-----').length - 1;
console.log(`[Git] Created merged CA bundle: ${MERGED_CA_BUNDLE_PATH} (${totalCerts} total certs — system from ${systemCaSource || 'none'} + custom from ${customCertPath})`);
} catch (err) {
console.warn(`[Git] Failed to create merged CA bundle, falling back to custom cert only: ${customCertPath}`, err);
return customCertPath;
}
return MERGED_CA_BUNDLE_PATH;
}
/**
* Collect stdout, stderr and exit code from a spawned process.
*/
@@ -46,21 +110,14 @@ if (!existsSync(GIT_REPOS_DIR)) {
}
/**
* Mask sensitive values in environment variables for safe logging.
* Redact all env var values for safe logging. Only key names are preserved.
*/
function maskSecrets(vars: Record<string, string>): Record<string, string> {
const masked: Record<string, string> = {};
const secretPatterns = /password|secret|token|key|api_key|apikey|auth|credential|private/i;
for (const [key, value] of Object.entries(vars)) {
if (secretPatterns.test(key)) {
masked[key] = '***';
} else if (value.length > 50) {
masked[key] = value.substring(0, 10) + '...(truncated)';
} else {
masked[key] = value;
}
function redactEnvVarsForLog(vars: Record<string, string>): Record<string, string> {
const redacted: Record<string, string> = {};
for (const key of Object.keys(vars)) {
redacted[key] = '***';
}
return masked;
return redacted;
}
function getRepoPath(repoId: number): string {
@@ -153,9 +210,11 @@ async function buildGitEnv(credential: GitCredential | null): Promise<GitEnv> {
SSH_AUTH_SOCK: ''
};
// Pass custom CA certificate to git CLI (NODE_EXTRA_CA_CERTS only affects Node.js)
// Pass custom CA certificate to git CLI (NODE_EXTRA_CA_CERTS only affects Node.js).
// GIT_SSL_CAINFO replaces the default CA store, so we merge system CAs with the
// custom cert so both self-signed repos and public repos (GitHub etc.) work (#967).
if (process.env.NODE_EXTRA_CA_CERTS) {
env.GIT_SSL_CAINFO = process.env.NODE_EXTRA_CA_CERTS;
env.GIT_SSL_CAINFO = getMergedCaBundlePath();
}
// Ensure current UID is resolvable for SSH/git operations
@@ -777,15 +836,15 @@ export async function syncGitStack(stackId: number): Promise<SyncResult> {
// (e.g., config files, scripts, additional env files)
let changedFiles: string[] = [];
if (commitChanged) {
// Get the directory containing the compose file (relative to repo root)
const composeDirRelative = dirname(gitStack.composePath);
console.log(`${logPrefix} Checking for changes in directory: ${composeDirRelative || '(root)'}`);
// Use contextDir if set, otherwise fall back to compose file's directory
const diffDirRelative = gitStack.contextDir || dirname(gitStack.composePath);
console.log(`${logPrefix} Checking for changes in directory: ${diffDirRelative || '(root)'}`);
const diffResult = await getChangedFilesInDir(
repoPath,
previousCommit,
newCommit,
composeDirRelative || '.',
diffDirRelative || '.',
env
);
@@ -827,10 +886,29 @@ export async function syncGitStack(stackId: number): Promise<SyncResult> {
console.log(`${logPrefix} Compose content:`);
console.log(composeContent);
// Determine the compose directory and filename (for copying all files)
const composeDir = dirname(composePath);
const composeFileName = basename(gitStack.composePath); // e.g., "docker-compose.yaml"
console.log(`${logPrefix} Compose directory:`, composeDir);
// Determine the source directory and compose filename
// If contextDir is set, use it as the source directory (relative to repo root)
// and compute composeFileName as relative path from contextDir to compose file
let composeDir: string;
let composeFileName: string;
if (gitStack.contextDir) {
const contextDirAbsolute = resolve(repoPath, gitStack.contextDir);
// Validate: context dir must be within repo
if (!contextDirAbsolute.startsWith(repoPath)) {
throw new Error('Context directory must be within the repository');
}
// Validate: compose file must be within context directory
const relCompose = relative(contextDirAbsolute, composePath);
if (relCompose.startsWith('..')) {
throw new Error('Compose file must be within the context directory');
}
composeDir = contextDirAbsolute;
composeFileName = relCompose; // e.g., "apps/myapp/compose.yaml"
} else {
composeDir = dirname(composePath);
composeFileName = basename(gitStack.composePath); // e.g., "docker-compose.yaml"
}
console.log(`${logPrefix} Source directory (composeDir):`, composeDir);
console.log(`${logPrefix} Compose filename:`, composeFileName);
// Read env file if configured (optional - don't fail if missing)
@@ -932,7 +1010,7 @@ export async function deployGitStack(stackId: number, options?: { force?: boolea
console.log(`${logPrefix} Sync result - env file vars:`, syncResult.envFileVars ? Object.keys(syncResult.envFileVars).length : 0);
if (syncResult.envFileVars && Object.keys(syncResult.envFileVars).length > 0) {
console.log(`${logPrefix} Env file var keys:`, Object.keys(syncResult.envFileVars).join(', '));
console.log(`${logPrefix} Env file vars (masked):`, JSON.stringify(maskSecrets(syncResult.envFileVars), null, 2));
console.log(`${logPrefix} Env file vars (masked):`, JSON.stringify(redactEnvVarsForLog(syncResult.envFileVars), null, 2));
}
// Check if there are changes - skip redeploy if no changes and not forced
@@ -972,6 +1050,7 @@ export async function deployGitStack(stackId: number, options?: { force?: boolea
envFileName: syncResult.envFileName, // Env file relative to compose dir (for --env-file flag, optional)
forceRecreate,
build: gitStack.buildOnDeploy,
noBuildCache: gitStack.noBuildCache,
pullPolicy: gitStack.repullImages ? 'always' : undefined
});
@@ -1148,15 +1227,15 @@ export async function deployGitStackWithProgress(
// Normalize to 7-char short hash for comparison (DB stores 7-char, git returns 40-char)
const commitChanged = previousCommit?.substring(0, 7) !== newCommit.substring(0, 7);
// Check if any files in the compose file's directory have changed
// Check if any files in the context/compose directory have changed
// (for consistency with syncGitStack, though this function always deploys)
if (commitChanged) {
const composeDir = dirname(gitStack.composePath);
const diffDir = gitStack.contextDir || dirname(gitStack.composePath);
const diffResult = await getChangedFilesInDir(
repoPath,
previousCommit,
newCommit,
composeDir || '.',
diffDir || '.',
env
);
updated = diffResult.changed;
@@ -1177,8 +1256,24 @@ export async function deployGitStackWithProgress(
const composeContent = readFileSync(composePath, 'utf-8');
// Determine the compose directory (for copying all files)
const composeDir = dirname(composePath);
// Determine the source directory and compose filename
let composeDir: string;
let progressComposeFileName: string;
if (gitStack.contextDir) {
const contextDirAbsolute = resolve(repoPath, gitStack.contextDir);
if (!contextDirAbsolute.startsWith(repoPath)) {
throw new Error('Context directory must be within the repository');
}
const relCompose = relative(contextDirAbsolute, composePath);
if (relCompose.startsWith('..')) {
throw new Error('Compose file must be within the context directory');
}
composeDir = contextDirAbsolute;
progressComposeFileName = relCompose;
} else {
composeDir = dirname(composePath);
progressComposeFileName = basename(gitStack.composePath);
}
// Read env file if configured (optional - don't fail if missing)
let envFileVars: Record<string, string> | undefined;
@@ -1225,16 +1320,17 @@ export async function deployGitStackWithProgress(
compose: composeContent,
envId: gitStack.environmentId,
sourceDir: composeDir, // Copy entire directory from git repo
composeFileName: basename(gitStack.composePath), // Use original compose filename from repo
composeFileName: progressComposeFileName, // Compose filename relative to source dir
envFileName, // Env file relative to compose dir (for --env-file flag, optional)
build: gitStack.buildOnDeploy,
noBuildCache: gitStack.noBuildCache,
pullPolicy: gitStack.repullImages ? 'always' : undefined
});
if (result.success) {
// Record the stack source with resolved compose path for consistency
const stackDir = await getStackDir(gitStack.stackName, gitStack.environmentId);
const resolvedComposePath = join(stackDir, basename(gitStack.composePath));
const resolvedComposePath = join(stackDir, progressComposeFileName);
await upsertStackSource({
stackName: gitStack.stackName,
@@ -1365,7 +1461,7 @@ export function parseEnvFileContent(content: string, stackName?: string): Record
console.log(`${logPrefix} Parsed env vars count:`, Object.keys(result).length);
console.log(`${logPrefix} Parsed env var keys:`, Object.keys(result).join(', '));
console.log(`${logPrefix} Parsed env vars (masked):`, JSON.stringify(maskSecrets(result), null, 2));
console.log(`${logPrefix} Parsed env vars (masked):`, JSON.stringify(redactEnvVarsForLog(result), null, 2));
if (skippedLines.length > 0) {
console.log(`${logPrefix} Skipped lines (${skippedLines.length}):`, skippedLines.slice(0, 10).join('; '));
}
+20 -17
View File
@@ -9,6 +9,7 @@ import { db, hawserTokens, environments, eq, and } from './db/drizzle.js';
import { logContainerEvent, type ContainerEventAction } from './db.js';
import { containerEventEmitter } from './event-collector.js';
import { sendEnvironmentNotification } from './notifications.js';
import { isNotifyDisabledByLabel } from './container-labels.js';
import { pushMetric } from './metrics-store.js';
import { secureGetRandomValues, secureRandomUUID } from './crypto-fallback.js';
import { hashPassword, verifyPassword } from './auth.js';
@@ -191,24 +192,26 @@ export async function handleEdgeContainerEvent(
// Broadcast to SSE clients
containerEventEmitter.emit('event', savedEvent);
// Prepare notification
const actionLabel = event.action.charAt(0).toUpperCase() + event.action.slice(1);
const containerLabel = event.containerName || event.containerId.substring(0, 12);
const notificationType =
event.action === 'die' || event.action === 'kill' || event.action === 'oom'
? 'error'
: event.action === 'stop'
? 'warning'
: event.action === 'start'
? 'success'
: 'info';
// Check dockhand.notify label before sending notification
// Docker includes container labels in actorAttributes
if (!isNotifyDisabledByLabel(event.actorAttributes)) {
const actionLabel = event.action.charAt(0).toUpperCase() + event.action.slice(1);
const containerLabel = event.containerName || event.containerId.substring(0, 12);
const notificationType =
event.action === 'die' || event.action === 'kill' || event.action === 'oom'
? 'error'
: event.action === 'stop'
? 'warning'
: event.action === 'start'
? 'success'
: 'info';
// Send notification
await sendEnvironmentNotification(environmentId, event.action as ContainerEventAction, {
title: `Container ${actionLabel}`,
message: `Container "${containerLabel}" ${event.action}${event.image ? ` (${event.image})` : ''}`,
type: notificationType as 'success' | 'error' | 'warning' | 'info'
}, event.image);
await sendEnvironmentNotification(environmentId, event.action as ContainerEventAction, {
title: `Container ${actionLabel}`,
message: `Container "${containerLabel}" ${event.action}${event.image ? ` (${event.image})` : ''}`,
type: notificationType as 'success' | 'error' | 'warning' | 'info'
}, event.image);
}
} catch (error) {
const errorMsg = error instanceof Error ? error.message : String(error);
console.error('[Hawser] Error handling container event:', errorMsg);
+27 -2
View File
@@ -34,6 +34,7 @@ let cachedMounts: Array<{ source: string; destination: string }> | null = null;
// Used by scanner to replicate how Dockhand connects to Docker
let cachedOwnDockerHost: string | null = null;
let cachedOwnNetworkMode: string | null = null;
let cachedOwnExtraHosts: string[] | null = null;
/**
* Get our own container ID
@@ -85,12 +86,11 @@ export async function detectHostDataDir(): Promise<string | null> {
if (process.env.HOST_DATA_DIR) {
cachedHostDataDir = process.env.HOST_DATA_DIR;
console.log(`[HostPath] Using HOST_DATA_DIR from environment: ${cachedHostDataDir}`);
return cachedHostDataDir;
}
const containerId = getOwnContainerId();
if (!containerId) {
console.warn('[HostPath] Running in Docker but could not detect container ID');
console.warn('[HostPath] Running in Docker but could not detect container ID; ExtraHosts will not be mirrored to sidecars');
return null;
}
@@ -140,6 +140,9 @@ export async function detectHostDataDir(): Promise<string | null> {
Config?: {
Env?: string[];
};
HostConfig?: {
ExtraHosts?: string[];
};
NetworkSettings?: {
Networks?: Record<string, unknown>;
};
@@ -176,6 +179,19 @@ export async function detectHostDataDir(): Promise<string | null> {
}
}
cachedOwnExtraHosts = containerInfo.HostConfig?.ExtraHosts?.length
? [...containerInfo.HostConfig.ExtraHosts]
: null;
if (cachedOwnExtraHosts) {
console.log(`[HostPath] Detected own ExtraHosts: ${cachedOwnExtraHosts.join(', ')}`);
}
// Explicit override wins for DATA_DIR path, but we still inspect to populate
// mounts/network/DOCKER_HOST/ExtraHosts caches for sibling sidecars.
if (cachedHostDataDir) {
return cachedHostDataDir;
}
// Find the mount for our DATA_DIR
const dataMount = containerInfo.Mounts?.find(m => m.Destination === dataDir);
@@ -229,6 +245,15 @@ export function getOwnNetworkMode(): string | null {
return cachedOwnNetworkMode;
}
/**
* Get the ExtraHosts entries configured on Dockhand itself.
* Used to mirror host aliases into sibling sidecar containers.
* Populated by detectHostDataDir() at startup.
*/
export function getOwnExtraHosts(): string[] | null {
return cachedOwnExtraHosts ? [...cachedOwnExtraHosts] : null;
}
/**
* Translate a container path to host path
*
+36
View File
@@ -0,0 +1,36 @@
type HostConfigLike = {
Binds?: string[] | null;
Mounts?: Array<{ Target?: string | null }> | null;
};
type InspectMountLike = {
Type?: string | null;
Name?: string | null;
Destination?: string | null;
};
/** Build extra bind strings for volume mounts missing from HostConfig. */
export function getAdditionalVolumeBinds(
hostConfig: HostConfigLike,
mounts: InspectMountLike[]
): string[] {
const existingMountTargets = new Set((hostConfig.Binds || []).map((bind: string) => {
const parts = bind.split(':');
return parts.length >= 2 ? parts[1] : parts[0];
}));
for (const mount of hostConfig.Mounts || []) {
if (mount?.Target) existingMountTargets.add(mount.Target);
}
const additionalBinds: string[] = [];
for (const mount of mounts || []) {
if (mount.Type === 'volume' && mount.Name && mount.Destination) {
if (!existingMountTargets.has(mount.Destination)) {
additionalBinds.push(`${mount.Name}:${mount.Destination}`);
}
}
}
return additionalBinds;
}
+103 -42
View File
@@ -9,17 +9,7 @@ import {
type NotificationEventType
} from './db';
// Escape special characters for Telegram Markdown
function escapeTelegramMarkdown(text: string): string {
// Escape characters that have special meaning in Telegram Markdown
return text
.replace(/\\/g, '\\\\') // Escape backslashes first
.replace(/_/g, '\\_') // Underscore (italic)
.replace(/\*/g, '\\*') // Asterisk (bold)
.replace(/\[/g, '\\[') // Opening bracket (link)
.replace(/\]/g, '\\]') // Closing bracket (link)
.replace(/`/g, '\\`'); // Backtick (code)
}
import { escapeTelegramMarkdown, parseTelegramUrl, buildGotifyUrl, parseWorkflowsUrl, buildWorkflowsHttpUrl } from '$lib/utils/notification-parsers';
/** Drain a response body to release the underlying socket/TLS connection. */
async function drainResponse(response: Response): Promise<void> {
@@ -144,6 +134,8 @@ async function sendToAppriseUrl(url: string, payload: NotificationPayload): Prom
case 'json':
case 'jsons':
return await sendGenericWebhook(url, payload);
case 'workflows':
return await sendWorkflows(url, payload);
default:
return { success: false, error: `Unsupported Apprise protocol: ${protocol}` };
}
@@ -277,21 +269,18 @@ async function sendMattermost(appriseUrl: string, payload: NotificationPayload):
// Telegram
async function sendTelegram(appriseUrl: string, payload: NotificationPayload): Promise<NotificationResult> {
// tgram://bot_token/chat_id:topic_id?
const match = appriseUrl.match(/^tgram:\/\/([^/]+)\/([^:\/]+)(?::(\d+))?$/);
if (!match) {
const parsed = parseTelegramUrl(appriseUrl);
if (!parsed) {
return { success: false, error: 'Invalid Telegram URL format. Expected: tgram://bot_token/chat_id or tgram://bot_token/chat_id:topic_id' };
}
const [, botToken, chatId, topicIdStr] = match;
const { botToken, chatId, topicId } = parsed;
const url = `https://api.telegram.org/bot${botToken}/sendMessage`;
// Escape markdown special characters in title and message
const escapedTitle = escapeTelegramMarkdown(payload.title);
const escapedMessage = escapeTelegramMarkdown(payload.message);
const envTag = payload.environmentName ? ` \\[${escapeTelegramMarkdown(payload.environmentName)}\\]` : '';
const topicId = topicIdStr ? parseInt(topicIdStr, 10) : undefined;
const envTag = payload.environmentName ? ` [${escapeTelegramMarkdown(payload.environmentName)}]` : '';
try {
const response = await fetch(url, {
@@ -301,7 +290,10 @@ async function sendTelegram(appriseUrl: string, payload: NotificationPayload): P
chat_id: chatId,
text: `*${escapedTitle}*${envTag}\n${escapedMessage}`,
...(topicId ? { message_thread_id: topicId } : {}),
parse_mode: 'Markdown'
parse_mode: 'Markdown',
link_preview_options: {
is_disabled: true
}
})
});
@@ -319,21 +311,11 @@ async function sendTelegram(appriseUrl: string, payload: NotificationPayload): P
// Gotify
async function sendGotify(appriseUrl: string, payload: NotificationPayload): Promise<NotificationResult> {
// gotify://hostname/token or gotifys://hostname/token
// gotify://hostname/subpath/token (subpath support)
const match = appriseUrl.match(/^gotifys?:\/\/([^/]+)\/(.+)/);
if (!match) {
const url = buildGotifyUrl(appriseUrl);
if (!url) {
return { success: false, error: 'Invalid Gotify URL format. Expected: gotify://hostname/token' };
}
const [, hostname, pathPart] = match;
const protocol = appriseUrl.startsWith('gotifys') ? 'https' : 'http';
// Token is always the last path segment; anything before it is a subpath
const lastSlash = pathPart.lastIndexOf('/');
const subpath = lastSlash >= 0 ? pathPart.substring(0, lastSlash) : '';
const token = lastSlash >= 0 ? pathPart.substring(lastSlash + 1) : pathPart;
const url = `${protocol}://${hostname}${subpath ? '/' + subpath : ''}/message?token=${token}`;
const titleWithEnv = payload.environmentName ? `${payload.title} [${payload.environmentName}]` : payload.title;
try {
@@ -363,7 +345,10 @@ async function sendNtfy(appriseUrl: string, payload: NotificationPayload): Promi
// Supported formats:
// ntfy://topic (public ntfy.sh)
// ntfy://host/topic (custom server, no auth)
// ntfy://user:pass@host/topic (custom server with auth)
// ntfy://user:pass@host/topic (custom server with basic auth)
// ntfy://token@host/topic (custom server with bearer token)
// ntfy://host/topic?auth=BASE64 (custom server with base64-encoded bearer token)
// Query params: ?tags=ship,whale &title=Custom &priority=5
// ntfys:// variants for HTTPS
const isSecure = appriseUrl.startsWith('ntfys');
const path = appriseUrl.replace(/^ntfys?:\/\//, '');
@@ -371,37 +356,60 @@ async function sendNtfy(appriseUrl: string, payload: NotificationPayload): Promi
let url: string;
let authHeader: string | null = null;
// Extract query parameters (?auth=, ?tags=, ?title=, ?priority=)
let queryAuth: string | null = null;
let queryTags: string | null = null;
let queryTitle: string | null = null;
let queryPriority: string | null = null;
let cleanPath = path;
const qIndex = path.indexOf('?');
if (qIndex !== -1) {
const params = new URLSearchParams(path.substring(qIndex + 1));
queryAuth = params.get('auth');
queryTags = params.get('tags');
queryTitle = params.get('title');
queryPriority = params.get('priority');
cleanPath = path.substring(0, qIndex);
}
// Check for user:pass@host/topic format (Basic auth)
const basicMatch = path.match(/^([^:]+):([^@]+)@(.+)$/);
const basicMatch = cleanPath.match(/^([^:]+):([^@]+)@(.+)$/);
if (basicMatch) {
const [, user, pass, hostAndTopic] = basicMatch;
const basic = Buffer.from(`${user}:${pass}`).toString('base64');
authHeader = `Basic ${basic}`;
url = `${isSecure ? 'https' : 'http'}://${hostAndTopic}`;
} else if (path.includes('@') && path.includes('/')) {
} else if (cleanPath.includes('@') && cleanPath.includes('/')) {
// token@host/topic -> Bearer token auth
const tokenMatch = path.match(/^([^@]+)@(.+)$/);
const tokenMatch = cleanPath.match(/^([^@]+)@(.+)$/);
if (tokenMatch) {
const [, token, hostAndTopic] = tokenMatch;
authHeader = `Bearer ${token}`;
url = `${isSecure ? 'https' : 'http'}://${hostAndTopic}`;
} else {
// Fallback to custom server without auth
url = `${isSecure ? 'https' : 'http'}://${path}`;
url = `${isSecure ? 'https' : 'http'}://${cleanPath}`;
}
} else if (path.includes('/')) {
} else if (cleanPath.includes('/')) {
// Custom server without auth
url = `${isSecure ? 'https' : 'http'}://${path}`;
url = `${isSecure ? 'https' : 'http'}://${cleanPath}`;
} else {
// Default ntfy.sh
url = `https://ntfy.sh/${path}`;
url = `https://ntfy.sh/${cleanPath}`;
}
// Apply ?auth= as fallback if no explicit auth was set
if (!authHeader && queryAuth) {
const decoded = Buffer.from(queryAuth, 'base64').toString();
authHeader = decoded.startsWith('Bearer ') ? decoded : `Bearer ${decoded}`;
}
const titleWithEnv = payload.environmentName ? `${payload.title} [${payload.environmentName}]` : payload.title;
const defaultTags = payload.type || 'info';
const headers: Record<string, string> = {
'Title': titleWithEnv,
'Priority': payload.type === 'error' ? '5' : payload.type === 'warning' ? '4' : '3',
'Tags': payload.type || 'info'
'Title': queryTitle || titleWithEnv,
'Priority': queryPriority || (payload.type === 'error' ? '5' : payload.type === 'warning' ? '4' : '3'),
'Tags': queryTags ? `${queryTags},${defaultTags}` : defaultTags
};
if (authHeader) {
@@ -490,6 +498,59 @@ async function sendGenericWebhook(appriseUrl: string, payload: NotificationPaylo
return { success: false, error: `Webhook connection failed: ${error instanceof Error ? error.message : String(error)}` };
}
}
// Microsoft Power Automate Workflows, for e.g. Microsoft Teams
async function sendWorkflows(appriseUrl: string, payload: NotificationPayload): Promise<NotificationResult> {
const parsed = parseWorkflowsUrl(appriseUrl);
if (!parsed) {
return { success: false, error: 'Invalid Workflows URL format. Expected: workflows://hostname/workflow/signature' };
}
const url = buildWorkflowsHttpUrl(parsed.hostname, parsed.workflow, parsed.signature);
const titleWithEnv = payload.environmentName ? `${payload.title} [${payload.environmentName}]` : payload.title;
try {
const response = await fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
type: 'message',
attachments: [
{
contentType: 'application/vnd.microsoft.card.adaptive',
content: {
$schema: 'https://adaptivecards.io/schemas/adaptive-card.json',
type: 'AdaptiveCard',
version: '1.2',
body: [
{
type: 'TextBlock',
style: 'heading',
wrap: true,
text: titleWithEnv
},
{
type: 'TextBlock',
style: 'default',
wrap: true,
text: payload.message
}
]
}
}
]
})
});
if (!response.ok) {
const text = await response.text().catch(() => '');
return { success: false, error: `Workflows error ${response.status}: ${text || response.statusText}` };
}
await drainResponse(response);
return { success: true };
} catch (error) {
return { success: false, error: `Workflows connection failed: ${error instanceof Error ? error.message : String(error)}` };
}
}
// Send notification to all enabled channels
export async function sendNotification(payload: NotificationPayload): Promise<{ success: boolean; results: { name: string; success: boolean }[] }> {
+20 -2
View File
@@ -16,7 +16,14 @@ import {
} from './docker';
import { getEnvironment, getEnvSetting, getSetting } from './db';
import { sendEventNotification } from './notifications';
import { getHostDockerSocket, getHostDataDir, extractUidFromSocketPath, getOwnDockerHost, getOwnNetworkMode } from './host-path';
import {
getHostDockerSocket,
getHostDataDir,
extractUidFromSocketPath,
getOwnDockerHost,
getOwnExtraHosts,
getOwnNetworkMode
} from './host-path';
import { resolve } from 'node:path';
import { mkdir, chown, rm } from 'node:fs/promises';
@@ -625,6 +632,7 @@ async function runScannerContainerCore(
let rootlessUid: string | undefined;
let scannerNetworkMode: string | undefined;
let scannerDockerHost: string | undefined;
const scannerExtraHosts = !isHawser ? getOwnExtraHosts() ?? undefined : undefined;
// Check if Dockhand itself uses TCP to reach Docker (e.g., socket proxy).
// Detected at startup from Dockhand's own container inspect data.
@@ -636,7 +644,12 @@ async function runScannerContainerCore(
// TCP mode: scanner uses the same DOCKER_HOST + network as Dockhand
scannerDockerHost = ownDockerHost;
scannerNetworkMode = getOwnNetworkMode() ?? undefined;
console.log(`[Scanner] TCP mode (from container inspect) - DOCKER_HOST=${scannerDockerHost}, network=${scannerNetworkMode ?? 'default'}`);
console.log(
`[Scanner] TCP mode (from container inspect) - DOCKER_HOST=${scannerDockerHost}, network=${scannerNetworkMode ?? 'default'}`
);
if (scannerExtraHosts?.length) {
console.log(`[Scanner] Reusing ExtraHosts from Dockhand: ${scannerExtraHosts.join(', ')}`);
}
} else if (isHawser) {
// Hawser: scanner runs on remote host, uses remote host's standard Docker socket
hostSocketPath = '/var/run/docker.sock';
@@ -653,6 +666,10 @@ async function runScannerContainerCore(
console.log(`[Scanner] Rootless Docker detected (UID ${rootlessUid})`);
console.log(`[Scanner] Scanner will run as root inside container (maps to UID ${rootlessUid} on host via user namespace)`);
}
if (scannerExtraHosts?.length) {
console.log(`[Scanner] Reusing ExtraHosts from Dockhand: ${scannerExtraHosts.join(', ')}`);
}
}
// Determine cache storage strategy based on environment
@@ -722,6 +739,7 @@ async function runScannerContainerCore(
cmd,
binds,
env: envVars,
extraHosts: scannerExtraHosts,
name: `dockhand-${scannerType}-${Date.now()}`,
envId,
networkMode: scannerNetworkMode,
+50 -41
View File
@@ -17,10 +17,12 @@ import {
getGitStack,
getScheduleCleanupCron,
getEventCleanupCron,
getScannerCleanupCron,
getScheduleRetentionDays,
getEventRetentionDays,
getScheduleCleanupEnabled,
getEventCleanupEnabled,
getScannerCleanupEnabled,
getEnvironments,
getEnvUpdateCheckSettings,
getAllEnvUpdateCheckSettings,
@@ -64,6 +66,31 @@ let scannerCacheCleanupJob: Cron | null = null;
// Scheduler state
let isRunning = false;
/**
* Scanner cache cleanup function that cleans local and all remote environments.
* Shared between cron job, timezone refresh, and manual trigger.
*/
async function scannerCleanupAllEnvs(): Promise<{ volumes: string[]; dirs: string[] }> {
const { cleanupScannerCache } = await import('../scanner');
const envs = await getEnvironments();
// Clean local cache (volumes + bind mount dirs)
const localResult = await cleanupScannerCache();
// Clean remote environment volumes
for (const env of envs) {
try {
const envResult = await cleanupScannerCache(env.id);
localResult.volumes.push(...envResult.volumes);
} catch (error) {
const msg = error instanceof Error ? error.message : String(error);
console.log(`[Scanner] Skipping cache cleanup for env "${env.name}" (id=${env.id}): ${msg}`);
}
}
return localResult;
}
/**
* Clean up stale 'syncing' states from git stacks.
* Called on startup to recover from crashes during sync operations.
@@ -107,6 +134,7 @@ export async function startScheduler(): Promise<void> {
// Get cron expressions and default timezone from database
const scheduleCleanupCron = await getScheduleCleanupCron();
const eventCleanupCron = await getEventCleanupCron();
const scannerCleanupCron = await getScannerCleanupCron();
const defaultTimezone = await getDefaultTimezone();
// Start system cleanup jobs (static schedules with default timezone)
@@ -134,35 +162,18 @@ export async function startScheduler(): Promise<void> {
await runVolumeHelperCleanupJob('cron', volumeCleanupFns);
});
// Scanner cache cleanup runs weekly (Sunday 3am) to prevent DB volume bloat
const scannerCleanupFn = async () => {
const { cleanupScannerCache } = await import('../scanner');
const envs = await getEnvironments();
// Clean local cache (volumes + bind mount dirs)
const localResult = await cleanupScannerCache();
// Clean remote environment volumes
for (const env of envs) {
try {
const envResult = await cleanupScannerCache(env.id);
localResult.volumes.push(...envResult.volumes);
} catch (error) {
const msg = error instanceof Error ? error.message : String(error);
console.log(`[Scanner] Skipping cache cleanup for env "${env.name}" (id=${env.id}): ${msg}`);
}
}
return localResult;
};
scannerCacheCleanupJob = new Cron('0 3 * * 0', { timezone: defaultTimezone, legacyMode: false }, async () => {
await runScannerCacheCleanupJob('cron', scannerCleanupFn);
});
// Scanner cache cleanup to prevent DB volume bloat (configurable schedule)
const scannerCleanupEnabled = await getScannerCleanupEnabled();
if (scannerCleanupEnabled) {
scannerCacheCleanupJob = new Cron(scannerCleanupCron, { timezone: defaultTimezone, legacyMode: false }, async () => {
await runScannerCacheCleanupJob('cron', scannerCleanupAllEnvs);
});
}
console.log(`[Scheduler] System schedule cleanup: ${scheduleCleanupCron} [${defaultTimezone}]`);
console.log(`[Scheduler] System event cleanup: ${eventCleanupCron} [${defaultTimezone}]`);
console.log(`[Scheduler] Volume helper cleanup: every 30 minutes [${defaultTimezone}]`);
console.log(`[Scheduler] Scanner cache cleanup: weekly (Sunday 3am) [${defaultTimezone}]`);
console.log(`[Scheduler] Scanner cache cleanup: ${scannerCleanupEnabled ? scannerCleanupCron : 'disabled'} [${defaultTimezone}]`);
// Register all dynamic schedules from database
await refreshAllSchedules();
@@ -497,6 +508,8 @@ export async function refreshSystemJobs(): Promise<void> {
// Get current settings
const scheduleCleanupCron = await getScheduleCleanupCron();
const eventCleanupCron = await getEventCleanupCron();
const scannerCleanupCron = await getScannerCleanupCron();
const scannerCleanupEnabled = await getScannerCleanupEnabled();
const defaultTimezone = await getDefaultTimezone();
// Cleanup functions to pass to the job
@@ -536,18 +549,16 @@ export async function refreshSystemJobs(): Promise<void> {
await runVolumeHelperCleanupJob('cron', volumeCleanupFns);
});
const scannerCleanupFn = async () => {
const { cleanupScannerCache } = await import('../scanner');
return cleanupScannerCache();
};
scannerCacheCleanupJob = new Cron('0 3 * * 0', { timezone: defaultTimezone, legacyMode: false }, async () => {
await runScannerCacheCleanupJob('cron', scannerCleanupFn);
});
if (scannerCleanupEnabled) {
scannerCacheCleanupJob = new Cron(scannerCleanupCron, { timezone: defaultTimezone, legacyMode: false }, async () => {
await runScannerCacheCleanupJob('cron', scannerCleanupAllEnvs);
});
}
console.log(`[Scheduler] System schedule cleanup: ${scheduleCleanupCron} [${defaultTimezone}]`);
console.log(`[Scheduler] System event cleanup: ${eventCleanupCron} [${defaultTimezone}]`);
console.log(`[Scheduler] Volume helper cleanup: every 30 minutes [${defaultTimezone}]`);
console.log(`[Scheduler] Scanner cache cleanup: weekly (Sunday 3am) [${defaultTimezone}]`);
console.log(`[Scheduler] Scanner cache cleanup: ${scannerCleanupEnabled ? scannerCleanupCron : 'disabled'} [${defaultTimezone}]`);
}
// =============================================================================
@@ -682,11 +693,7 @@ export async function triggerSystemJob(jobId: string): Promise<{ success: boolea
});
return { success: true };
} else if (jobId === String(SYSTEM_SCANNER_CLEANUP_ID) || jobId === 'scanner-cache-cleanup') {
const scannerCleanupFn = async () => {
const { cleanupScannerCache } = await import('../scanner');
return cleanupScannerCache();
};
runScannerCacheCleanupJob('manual', scannerCleanupFn);
runScannerCacheCleanupJob('manual', scannerCleanupAllEnvs);
return { success: true };
} else {
return { success: false, error: 'Unknown system job ID' };
@@ -712,8 +719,10 @@ export async function getSystemSchedules(): Promise<SystemScheduleInfo[]> {
const eventRetention = await getEventRetentionDays();
const scheduleCleanupCron = await getScheduleCleanupCron();
const eventCleanupCron = await getEventCleanupCron();
const scannerCleanupCron = await getScannerCleanupCron();
const scheduleCleanupEnabled = await getScheduleCleanupEnabled();
const eventCleanupEnabled = await getEventCleanupEnabled();
const scannerCleanupEnabled = await getScannerCleanupEnabled();
return [
{
@@ -751,10 +760,10 @@ export async function getSystemSchedules(): Promise<SystemScheduleInfo[]> {
type: 'system_cleanup' as const,
name: 'Scanner cache cleanup',
description: 'Removes scanner vulnerability database cache to reclaim disk space',
cronExpression: '0 3 * * 0',
nextRun: getNextRun('0 3 * * 0')?.toISOString() ?? null,
cronExpression: scannerCleanupCron,
nextRun: scannerCleanupEnabled ? getNextRun(scannerCleanupCron)?.toISOString() ?? null : null,
isSystem: true,
enabled: true
enabled: scannerCleanupEnabled
}
];
}
@@ -38,6 +38,7 @@ import {
import { getScannerSettings, scanImage, type ScanResult, type VulnerabilitySeverity } from '../../scanner';
import { sendEventNotification } from '../../notifications';
import { parseImageNameAndTag, shouldBlockUpdate, combineScanSummaries, isSystemContainer } from './update-utils';
import { isUpdateDisabledByLabel } from '../../container-labels';
// =============================================================================
// TYPES
@@ -369,6 +370,18 @@ export async function runContainerUpdate(
return;
}
// Check dockhand.update label (label wins over DB settings)
if (isUpdateDisabledByLabel(inspectData.Config?.Labels)) {
log(`Skipping - dockhand.update=false label set on container`);
await updateScheduleExecution(execution.id, {
status: 'skipped',
completedAt: new Date().toISOString(),
duration: Date.now() - startTime,
details: { reason: 'Skipped by dockhand.update=false label' }
});
return;
}
// Skip digest-pinned images - they are explicitly locked to a specific version
if (isDigestBasedImage(imageNameFromConfig)) {
log(`Skipping ${containerName} - image pinned to specific digest`);
@@ -31,6 +31,7 @@ import {
import { sendEventNotification } from '../../notifications';
import { getScannerSettings, scanImage, type VulnerabilitySeverity } from '../../scanner';
import { parseImageNameAndTag, shouldBlockUpdate, combineScanSummaries, isSystemContainer } from './update-utils';
import { isUpdateDisabledByLabel } from '../../container-labels';
import { recreateContainer } from './container-update';
interface UpdateInfo {
@@ -129,6 +130,12 @@ export async function runEnvUpdateCheckJob(
continue;
}
// Check dockhand.update label (label wins over DB settings)
if (isUpdateDisabledByLabel(inspectData.Config?.Labels)) {
await log(` [${container.name}] Skipping - dockhand.update=false label`);
continue;
}
checkedCount++;
await log(` Checking: ${container.name} (${imageName})`);
@@ -11,6 +11,7 @@ import {
getEventRetentionDays,
getScheduleCleanupEnabled,
getEventCleanupEnabled,
getScannerCleanupEnabled,
createScheduleExecution,
updateScheduleExecution,
appendScheduleExecutionLog
@@ -210,6 +211,14 @@ export async function runScannerCacheCleanupJob(
triggeredBy: ScheduleTrigger = 'cron',
cleanupFn?: () => Promise<{ volumes: string[]; dirs: string[] }>
): Promise<void> {
// Check if cleanup is enabled (skip check if manually triggered)
if (triggeredBy === 'cron') {
const enabled = await getScannerCleanupEnabled();
if (!enabled) {
return; // Skip execution if disabled
}
}
const startTime = Date.now();
const execution = await createScheduleExecution({
+28 -18
View File
@@ -60,19 +60,20 @@ async function isComposeFile(filePath: string): Promise<boolean> {
}
/**
* Count the number of services defined in a compose file
* Parses YAML to reliably count top-level keys under 'services:' section
* Parse compose file metadata: top-level `name` property and service count.
* The `name` property (if present) should be used as the stack name instead of the directory name,
* matching Docker Compose's behavior with `com.docker.compose.project`.
*/
async function countServices(filePath: string): Promise<number> {
function parseComposeMetadata(filePath: string): { name: string | null; serviceCount: number } {
try {
const content = readFileSync(filePath, 'utf-8');
const doc = yaml.load(content) as Record<string, unknown> | null;
if (doc?.services && typeof doc.services === 'object') {
return Object.keys(doc.services).length;
}
return 0;
const name = typeof doc?.name === 'string' ? doc.name.trim() : null;
const serviceCount = doc?.services && typeof doc.services === 'object'
? Object.keys(doc.services).length : 0;
return { name, serviceCount };
} catch {
return 0;
return { name: null, serviceCount: 0 };
}
}
@@ -122,13 +123,12 @@ async function scanPath(basePath: string): Promise<{ stacks: DiscoveredStack[];
for (const pattern of COMPOSE_PATTERNS) {
const composePath = join(currentPath, pattern);
if (existsSync(composePath)) {
// Found a stack! Stack name = directory name
const stackName = normalizeStackName(basename(currentPath));
// Found a stack! Use compose name property if defined, otherwise directory name
const { name: composeName, serviceCount } = parseComposeMetadata(composePath);
const stackName = normalizeStackName(composeName || basename(currentPath));
if (stackName) {
// Check for .env file
const envPath = join(currentPath, '.env');
// Count services in compose file
const serviceCount = await countServices(composePath);
discovered.push({
name: stackName,
composePath,
@@ -166,14 +166,13 @@ async function scanPath(basePath: string): Promise<{ stacks: DiscoveredStack[];
if (lowerName.endsWith('.yml') || lowerName.endsWith('.yaml')) {
// Validate it's actually a compose file
if (await isComposeFile(entryPath)) {
const { name: composeName, serviceCount } = parseComposeMetadata(entryPath);
const stackName = normalizeStackName(
entry.name.replace(/\.(yml|yaml)$/i, '')
composeName || entry.name.replace(/\.(yml|yaml)$/i, '')
);
if (stackName) {
// Check for .env file in same directory
const envPath = join(currentPath, '.env');
// Count services in compose file
const serviceCount = await countServices(entryPath);
discovered.push({
name: stackName,
composePath: entryPath,
@@ -214,8 +213,18 @@ export async function adoptStack(
return { success: false, error: 'Already adopted' };
}
// If the compose file has a top-level `name:` property, prefer it over the passed name.
// This ensures Docker's project name (from the label) matches Dockhand's stack name.
let stackNameSource = stack.name;
if (stack.composePath && existsSync(stack.composePath)) {
const { name: composeName } = parseComposeMetadata(stack.composePath);
if (composeName) {
stackNameSource = composeName;
}
}
// Check for name conflict within the same environment
let finalName = normalizeStackName(stack.name);
let finalName = normalizeStackName(stackNameSource);
const existingNames = new Set(
existingSources
.filter((s) => s.environmentId === environmentId)
@@ -224,11 +233,12 @@ export async function adoptStack(
if (existingNames.has(finalName)) {
// Append suffix to make unique
const baseName = finalName;
let suffix = 1;
while (existingNames.has(`${stack.name}-${suffix}`)) {
while (existingNames.has(`${baseName}-${suffix}`)) {
suffix++;
}
finalName = `${stack.name}-${suffix}`;
finalName = `${baseName}-${suffix}`;
}
// Create stack source record - use 'internal' since we know the file paths
+26 -20
View File
@@ -102,6 +102,7 @@ export interface DeployStackOptions {
sourceDir?: string; // Directory to copy all files from (for git stacks)
forceRecreate?: boolean;
build?: boolean; // Build images before starting (--build)
noBuildCache?: boolean; // Disable build cache (--no-cache, requires --build)
pullPolicy?: string; // Pull policy: 'always' | 'missing' | 'never'
composePath?: string; // Custom compose file path (for adopted/imported stacks)
envPath?: string; // Custom env file path (for adopted/imported stacks)
@@ -259,23 +260,14 @@ async function readDirFilesAsMap(dirPath: string): Promise<Record<string, string
// =============================================================================
/**
* Mask sensitive values in environment variables for safe logging.
* Masks values for keys containing common secret patterns and truncates long values.
* Redact all env var values for safe logging. Only key names are preserved.
*/
function maskSecrets(vars: Record<string, string>): Record<string, string> {
const masked: Record<string, string> = {};
const secretPatterns = /password|secret|token|key|api_key|apikey|auth|credential|private/i;
for (const [key, value] of Object.entries(vars)) {
if (secretPatterns.test(key)) {
masked[key] = '***';
} else if (value.length > 50) {
// Truncate long values that might be secrets
masked[key] = value.substring(0, 10) + '...(truncated)';
} else {
masked[key] = value;
}
function redactEnvVarsForLog(vars: Record<string, string>): Record<string, string> {
const redacted: Record<string, string> = {};
for (const key of Object.keys(vars)) {
redacted[key] = '***';
}
return masked;
return redacted;
}
// =============================================================================
@@ -794,6 +786,7 @@ interface ComposeCommandOptions {
envId?: number | null;
forceRecreate?: boolean;
build?: boolean; // Build images before starting (--build)
noBuildCache?: boolean; // Disable build cache (--no-cache, requires --build)
pullPolicy?: string; // Pull policy: 'always' | 'missing' | 'never'
removeVolumes?: boolean;
stackFiles?: Record<string, string>; // All files to send to Hawser
@@ -857,6 +850,7 @@ async function executeLocalCompose(
useOverrideFile?: boolean,
serviceName?: string,
build?: boolean,
noBuildCache?: boolean,
pullPolicy?: string
): Promise<StackOperationResult> {
const logPrefix = `[Stack:${stackName}]`;
@@ -1050,6 +1044,7 @@ async function executeLocalCompose(
args.push('up', '-d', '--remove-orphans');
if (forceRecreate) args.push('--force-recreate');
if (build) args.push('--build');
if (build && noBuildCache) args.push('--no-cache');
if (pullPolicy) args.push('--pull', pullPolicy);
// If targeting a specific service, only update that service
if (serviceName) {
@@ -1094,7 +1089,7 @@ async function executeLocalCompose(
console.log(`${logPrefix} Service name:`, serviceName ?? '(all services)');
console.log(`${logPrefix} Env vars count:`, envVars ? Object.keys(envVars).length : 0);
if (envVars && Object.keys(envVars).length > 0) {
console.log(`${logPrefix} Env vars being injected (masked):`, JSON.stringify(maskSecrets(envVars), null, 2));
console.log(`${logPrefix} Env vars being injected (masked):`, JSON.stringify(redactEnvVarsForLog(envVars), null, 2));
}
// Login to registries before pulling images
@@ -1226,6 +1221,7 @@ async function executeComposeViaHawser(
serviceName?: string,
composeFileName?: string,
build?: boolean,
noBuildCache?: boolean,
pullPolicy?: string
): Promise<StackOperationResult> {
const logPrefix = `[Stack:${stackName}]`;
@@ -1249,7 +1245,7 @@ async function executeComposeViaHawser(
console.log(`${logPrefix} Non-secret env vars count:`, envVars ? Object.keys(envVars).length : 0);
console.log(`${logPrefix} Secret env vars count:`, secretCount);
if (allEnvVars && Object.keys(allEnvVars).length > 0) {
console.log(`${logPrefix} All env vars being sent (masked):`, JSON.stringify(maskSecrets(allEnvVars), null, 2));
console.log(`${logPrefix} All env vars being sent (masked):`, JSON.stringify(redactEnvVarsForLog(allEnvVars), null, 2));
}
console.log(`${logPrefix} Compose content length:`, composeContent.length, 'chars');
console.log(`${logPrefix} Stack files count:`, stackFiles ? Object.keys(stackFiles).length : 0);
@@ -1300,6 +1296,7 @@ async function executeComposeViaHawser(
forceRecreate: forceRecreate || false,
removeVolumes: removeVolumes || false,
build: build || false,
noBuildCache: (build && noBuildCache) || false,
pullPolicy: pullPolicy || '',
registries, // Registry credentials for docker login
serviceName // Target specific service only (with --no-deps)
@@ -1368,7 +1365,7 @@ async function executeComposeCommand(
envVars?: Record<string, string>,
secretVars?: Record<string, string>
): Promise<StackOperationResult> {
const { stackName, envId, forceRecreate, build, pullPolicy, removeVolumes, stackFiles, workingDir, composePath, envPath, useOverrideFile, serviceName, composeFileName } = options;
const { stackName, envId, forceRecreate, build, noBuildCache, pullPolicy, removeVolumes, stackFiles, workingDir, composePath, envPath, useOverrideFile, serviceName, composeFileName } = options;
// Get environment configuration
const env = envId ? await getEnvironment(envId) : null;
@@ -1392,6 +1389,7 @@ async function executeComposeCommand(
useOverrideFile,
serviceName,
build,
noBuildCache,
pullPolicy
);
}
@@ -1456,6 +1454,7 @@ async function executeComposeCommand(
serviceName,
composeFileName,
build,
noBuildCache,
pullPolicy
);
}
@@ -1489,6 +1488,7 @@ async function executeComposeCommand(
useOverrideFile,
serviceName,
build,
noBuildCache,
pullPolicy
);
}
@@ -1512,6 +1512,7 @@ async function executeComposeCommand(
useOverrideFile,
serviceName,
build,
noBuildCache,
pullPolicy
);
}
@@ -2175,7 +2176,7 @@ export async function removeStack(
* Uses stack locking to prevent concurrent deployments.
*/
export async function deployStack(options: DeployStackOptions): Promise<StackOperationResult> {
const { name, compose, envId, sourceDir, forceRecreate, build, pullPolicy, composePath, envPath, composeFileName, envFileName } = options;
const { name, compose, envId, sourceDir, forceRecreate, build, noBuildCache, pullPolicy, composePath, envPath, composeFileName, envFileName } = options;
const logPrefix = `[Stack:${name}]`;
console.log(`${logPrefix} ========================================`);
@@ -2253,7 +2254,11 @@ export async function deployStack(options: DeployStackOptions): Promise<StackOpe
// and would be destroyed, causing data loss (#831).
console.log(`${logPrefix} Copying source directory to stack directory...`);
mkdirSync(workingDir, { recursive: true });
cpSync(sourceDir, workingDir, { recursive: true, force: true });
cpSync(sourceDir, workingDir, {
recursive: true,
force: true,
filter: (src) => !src.includes('/.git/') && !src.endsWith('/.git')
});
console.log(`${logPrefix} Copied ${sourceDir} -> ${workingDir}`);
} else {
// Internal stack: check if a custom path exists in DB (adopted/imported stacks)
@@ -2318,6 +2323,7 @@ export async function deployStack(options: DeployStackOptions): Promise<StackOpe
envId,
forceRecreate,
build,
noBuildCache,
pullPolicy,
stackFiles,
workingDir,
+22 -17
View File
@@ -24,6 +24,7 @@ import {
type ContainerEventAction
} from './db';
import { sendEnvironmentNotification, sendEventNotification } from './notifications';
import { isNotifyDisabledByLabel } from './container-labels';
import { rssBeforeOp, rssAfterOp } from './rss-tracker';
import { pushMetric } from './metrics-store';
@@ -285,24 +286,28 @@ async function handleContainerEvent(msg: GoMessage): Promise<void> {
// Sub-category: notification
const notifBefore = rssBeforeOp();
const actionLabel = action.startsWith('health_status')
? action.includes('unhealthy') ? 'Unhealthy' : 'Healthy'
: action.charAt(0).toUpperCase() + action.slice(1);
const containerLabel = containerName || containerId.substring(0, 12);
const notificationType =
action === 'die' || action === 'kill' || action === 'oom' || action.includes('unhealthy')
? 'error'
: action === 'stop'
? 'warning'
: action === 'start' || (action.includes('healthy') && !action.includes('unhealthy'))
? 'success'
: 'info';
sendEnvironmentNotification(msg.envId, action, {
title: `Container ${actionLabel}`,
message: `Container "${containerLabel}" ${action}${image ? ` (${image})` : ''}`,
type: notificationType
}, image).catch(() => {});
// Check dockhand.notify label — Docker includes container labels in event Actor.Attributes
if (!isNotifyDisabledByLabel(event.Actor?.Attributes)) {
const actionLabel = action.startsWith('health_status')
? action.includes('unhealthy') ? 'Unhealthy' : 'Healthy'
: action.charAt(0).toUpperCase() + action.slice(1);
const containerLabel = containerName || containerId.substring(0, 12);
const notificationType =
action === 'die' || action === 'kill' || action === 'oom' || action.includes('unhealthy')
? 'error'
: action === 'stop'
? 'warning'
: action === 'start' || (action.includes('healthy') && !action.includes('unhealthy'))
? 'success'
: 'info';
sendEnvironmentNotification(msg.envId, action, {
title: `Container ${actionLabel}`,
message: `Container "${containerLabel}" ${action}${image ? ` (${image})` : ''}`,
type: notificationType
}, image).catch(() => {});
}
rssAfterOp('events_notif', notifBefore);
rssAfterOp('events', before);
}
+11 -3
View File
@@ -2,6 +2,7 @@ import { writable, get } from 'svelte/store';
import { browser } from '$app/environment';
import type { ContainerInfo, ContainerStats } from '$lib/types';
import { appendEnvParam, clearStaleEnvironment, environments } from '$lib/stores/environment';
import { appSettings } from '$lib/stores/settings';
import { toast } from 'svelte-sonner';
export interface AutoUpdateSetting {
@@ -70,9 +71,16 @@ function createContainerStore() {
const [min, hr, , , dow] = parts;
const hourNum = parseInt(hr);
const minNum = parseInt(min);
const ampm = hourNum >= 12 ? 'PM' : 'AM';
const hour12 = hourNum === 0 ? 12 : hourNum > 12 ? hourNum - 12 : hourNum;
const timeStr = `${hour12}:${minNum.toString().padStart(2, '0')} ${ampm}`;
const is12Hour = get(appSettings).timeFormat === '12h';
let timeStr: string;
if (is12Hour) {
const ampm = hourNum >= 12 ? 'PM' : 'AM';
const hour12 = hourNum === 0 ? 12 : hourNum > 12 ? hourNum - 12 : hourNum;
timeStr = `${hour12}:${minNum.toString().padStart(2, '0')} ${ampm}`;
} else {
timeStr = `${hourNum.toString().padStart(2, '0')}:${minNum.toString().padStart(2, '0')}`;
}
if (scheduleType === 'daily' || dow === '*') {
return { label: 'daily', tooltip: `Daily at ${timeStr}` };
+65 -3
View File
@@ -5,6 +5,7 @@ export type TimeFormat = '12h' | '24h';
export type DateFormat = 'MM/DD/YYYY' | 'DD/MM/YYYY' | 'YYYY-MM-DD' | 'DD.MM.YYYY';
export type DownloadFormat = 'tar' | 'tar.gz';
export type EventCollectionMode = 'stream' | 'poll';
export type LabelFilterMode = 'any' | 'all';
export interface AppSettings {
confirmDestructive: boolean;
@@ -21,6 +22,8 @@ export interface AppSettings {
eventCleanupCron: string;
scheduleCleanupEnabled: boolean;
eventCleanupEnabled: boolean;
scannerCleanupCron: string;
scannerCleanupEnabled: boolean;
logBufferSizeKb: number;
defaultTimezone: string;
eventCollectionMode: EventCollectionMode;
@@ -32,6 +35,8 @@ export interface AppSettings {
primaryStackLocation: string | null;
defaultGrypeImage: string;
defaultTrivyImage: string;
defaultComposeTemplate: string;
labelFilterMode: LabelFilterMode;
}
const DEFAULT_SETTINGS: AppSettings = {
@@ -49,6 +54,8 @@ const DEFAULT_SETTINGS: AppSettings = {
eventCleanupCron: '30 3 * * *',
scheduleCleanupEnabled: true,
eventCleanupEnabled: true,
scannerCleanupCron: '0 3 * * 0',
scannerCleanupEnabled: true,
logBufferSizeKb: 500,
defaultTimezone: 'UTC',
eventCollectionMode: 'stream',
@@ -59,7 +66,26 @@ const DEFAULT_SETTINGS: AppSettings = {
externalStackPaths: [],
primaryStackLocation: null,
defaultGrypeImage: 'anchore/grype:v0.110.0',
defaultTrivyImage: 'aquasec/trivy:0.69.3'
defaultTrivyImage: 'aquasec/trivy:0.69.3',
labelFilterMode: 'any',
defaultComposeTemplate: `version: "3.8"
services:
app:
image: nginx:alpine
ports:
- "8080:80"
environment:
- APP_ENV=\${APP_ENV:-production}
volumes:
- ./html:/usr/share/nginx/html:ro
restart: unless-stopped
# Add more services as needed
# networks:
# default:
# driver: bridge
`
};
// Create a writable store for app settings
@@ -91,6 +117,8 @@ function createSettingsStore() {
eventCleanupCron: settings.eventCleanupCron ?? DEFAULT_SETTINGS.eventCleanupCron,
scheduleCleanupEnabled: settings.scheduleCleanupEnabled ?? DEFAULT_SETTINGS.scheduleCleanupEnabled,
eventCleanupEnabled: settings.eventCleanupEnabled ?? DEFAULT_SETTINGS.eventCleanupEnabled,
scannerCleanupCron: settings.scannerCleanupCron ?? DEFAULT_SETTINGS.scannerCleanupCron,
scannerCleanupEnabled: settings.scannerCleanupEnabled ?? DEFAULT_SETTINGS.scannerCleanupEnabled,
logBufferSizeKb: settings.logBufferSizeKb ?? DEFAULT_SETTINGS.logBufferSizeKb,
defaultTimezone: settings.defaultTimezone ?? DEFAULT_SETTINGS.defaultTimezone,
eventCollectionMode: settings.eventCollectionMode ?? DEFAULT_SETTINGS.eventCollectionMode,
@@ -101,7 +129,9 @@ function createSettingsStore() {
externalStackPaths: settings.externalStackPaths ?? DEFAULT_SETTINGS.externalStackPaths,
primaryStackLocation: settings.primaryStackLocation ?? DEFAULT_SETTINGS.primaryStackLocation,
defaultGrypeImage: settings.defaultGrypeImage ?? DEFAULT_SETTINGS.defaultGrypeImage,
defaultTrivyImage: settings.defaultTrivyImage ?? DEFAULT_SETTINGS.defaultTrivyImage
defaultTrivyImage: settings.defaultTrivyImage ?? DEFAULT_SETTINGS.defaultTrivyImage,
defaultComposeTemplate: settings.defaultComposeTemplate ?? DEFAULT_SETTINGS.defaultComposeTemplate,
labelFilterMode: settings.labelFilterMode ?? DEFAULT_SETTINGS.labelFilterMode
});
}
} catch {
@@ -136,6 +166,8 @@ function createSettingsStore() {
eventCleanupCron: updatedSettings.eventCleanupCron ?? DEFAULT_SETTINGS.eventCleanupCron,
scheduleCleanupEnabled: updatedSettings.scheduleCleanupEnabled ?? DEFAULT_SETTINGS.scheduleCleanupEnabled,
eventCleanupEnabled: updatedSettings.eventCleanupEnabled ?? DEFAULT_SETTINGS.eventCleanupEnabled,
scannerCleanupCron: updatedSettings.scannerCleanupCron ?? DEFAULT_SETTINGS.scannerCleanupCron,
scannerCleanupEnabled: updatedSettings.scannerCleanupEnabled ?? DEFAULT_SETTINGS.scannerCleanupEnabled,
logBufferSizeKb: updatedSettings.logBufferSizeKb ?? DEFAULT_SETTINGS.logBufferSizeKb,
defaultTimezone: updatedSettings.defaultTimezone ?? DEFAULT_SETTINGS.defaultTimezone,
eventCollectionMode: updatedSettings.eventCollectionMode ?? DEFAULT_SETTINGS.eventCollectionMode,
@@ -146,7 +178,9 @@ function createSettingsStore() {
externalStackPaths: updatedSettings.externalStackPaths ?? DEFAULT_SETTINGS.externalStackPaths,
primaryStackLocation: updatedSettings.primaryStackLocation ?? DEFAULT_SETTINGS.primaryStackLocation,
defaultGrypeImage: updatedSettings.defaultGrypeImage ?? DEFAULT_SETTINGS.defaultGrypeImage,
defaultTrivyImage: updatedSettings.defaultTrivyImage ?? DEFAULT_SETTINGS.defaultTrivyImage
defaultTrivyImage: updatedSettings.defaultTrivyImage ?? DEFAULT_SETTINGS.defaultTrivyImage,
defaultComposeTemplate: updatedSettings.defaultComposeTemplate ?? DEFAULT_SETTINGS.defaultComposeTemplate,
labelFilterMode: updatedSettings.labelFilterMode ?? DEFAULT_SETTINGS.labelFilterMode
});
}
} catch (error) {
@@ -271,6 +305,20 @@ function createSettingsStore() {
return newSettings;
});
},
setScannerCleanupCron: (value: string) => {
update((current) => {
const newSettings = { ...current, scannerCleanupCron: value };
saveSettings({ scannerCleanupCron: value });
return newSettings;
});
},
setScannerCleanupEnabled: (value: boolean) => {
update((current) => {
const newSettings = { ...current, scannerCleanupEnabled: value };
saveSettings({ scannerCleanupEnabled: value });
return newSettings;
});
},
setLogBufferSizeKb: (value: number) => {
update((current) => {
const newSettings = { ...current, logBufferSizeKb: value };
@@ -348,6 +396,20 @@ function createSettingsStore() {
return newSettings;
});
},
setDefaultComposeTemplate: (value: string) => {
update((current) => {
const newSettings = { ...current, defaultComposeTemplate: value };
saveSettings({ defaultComposeTemplate: value });
return newSettings;
});
},
setLabelFilterMode: (value: LabelFilterMode) => {
update((current) => {
const newSettings = { ...current, labelFilterMode: value };
saveSettings({ labelFilterMode: value });
return newSettings;
});
},
// Manual refresh from database
refresh: () => {
initialized = false;
+47
View File
@@ -0,0 +1,47 @@
// Pure parsing/building functions for notification providers.
// Extracted from notifications.ts so unit tests can import without pulling in DB deps.
// --- Telegram ---
// Escape special characters for Telegram legacy Markdown (parse_mode: 'Markdown')
// Only _ * ` [ need escaping — ] and other chars are not special in legacy mode
export function escapeTelegramMarkdown(text: string): string {
return text
.replace(/_/g, '\\_') // Underscore (italic)
.replace(/\*/g, '\\*') // Asterisk (bold)
.replace(/`/g, '\\`') // Backtick (code)
.replace(/\[/g, '\\['); // Opening bracket (link)
}
export function parseTelegramUrl(url: string): { botToken: string; chatId: string; topicId?: number } | null {
const match = url.match(/^tgram:\/\/([^/]+)\/([^:\/]+)(?::(\d+))?$/);
if (!match) return null;
const [, botToken, chatId, topicIdStr] = match;
return { botToken, chatId, topicId: topicIdStr ? parseInt(topicIdStr, 10) : undefined };
}
// --- Gotify ---
export function buildGotifyUrl(appriseUrl: string): string | null {
const match = appriseUrl.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}`;
}
// --- Workflows (Microsoft Power Automate) ---
export function parseWorkflowsUrl(appriseUrl: string): { hostname: string; workflow: string; signature: string } | null {
const match = appriseUrl.match(/^workflows?:\/\/([^/]+)\/(.+)\/(.+)/);
if (!match) return null;
const [, hostname, workflow, signature] = match;
return { hostname, workflow, signature };
}
export function buildWorkflowsHttpUrl(hostname: string, workflow: string, signature: string): string {
return `https://${hostname}/powerautomate/automations/direct/workflows/${workflow}/triggers/manual/paths/invoke?api-version=1&sp=%2Ftriggers%2Fmanual%2Frun&sv=1.0&sig=${signature}`;
}
+33 -16
View File
@@ -13,9 +13,10 @@ interface PortInfo {
}
/**
* Format Docker port mappings, collapsing consecutive ranges.
* Format Docker port mappings, collapsing consecutive ranges of 3+ ports.
* Accepts both Docker API format (PublicPort/PrivatePort) and camelCase (publicPort/privatePort).
* e.g. 8080:8080, 8081:8081, 8082:8082 8080-8082:8080-8082
* But 80:80, 81:81 stay as individual ports (only 2 consecutive).
*/
export function formatPorts(ports: PortInfo[] | undefined | null): PortMapping[] {
if (!ports || ports.length === 0) return [];
@@ -35,30 +36,46 @@ export function formatPorts(ports: PortInfo[] | undefined | null): PortMapping[]
})
.sort((a, b) => a.publicPort - b.publicPort);
// Collapse consecutive port ranges
// Collapse consecutive port ranges (3+ ports only)
if (individual.length <= 1) return individual;
const result: PortMapping[] = [];
let rangeStart = individual[0];
let rangeEnd = individual[0];
let rangeStart = 0;
let rangeEnd = 0;
for (let i = 1; i < individual.length; i++) {
const curr = individual[i];
const offset = curr.publicPort - rangeStart.publicPort;
const expectedPrivate = rangeStart.privatePort + offset;
if (curr.publicPort === rangeEnd.publicPort + 1 && curr.privatePort === expectedPrivate) {
rangeEnd = curr;
const start = individual[rangeStart];
const prev = individual[rangeEnd];
const offset = curr.publicPort - start.publicPort;
const expectedPrivate = start.privatePort + offset;
if (curr.publicPort === prev.publicPort + 1 && curr.privatePort === expectedPrivate) {
rangeEnd = i;
} else {
result.push(rangeStart.publicPort === rangeEnd.publicPort
? rangeStart
: { publicPort: rangeStart.publicPort, privatePort: rangeStart.privatePort, display: `${rangeStart.publicPort}-${rangeEnd.publicPort}:${rangeStart.privatePort}-${rangeEnd.privatePort}`, isRange: true });
rangeStart = curr;
rangeEnd = curr;
flushRange(individual, rangeStart, rangeEnd, result);
rangeStart = i;
rangeEnd = i;
}
}
result.push(rangeStart.publicPort === rangeEnd.publicPort
? rangeStart
: { publicPort: rangeStart.publicPort, privatePort: rangeStart.privatePort, display: `${rangeStart.publicPort}-${rangeEnd.publicPort}:${rangeStart.privatePort}-${rangeEnd.privatePort}`, isRange: true });
flushRange(individual, rangeStart, rangeEnd, result);
return result;
}
function flushRange(items: PortMapping[], start: number, end: number, result: PortMapping[]) {
const rangeLen = end - start + 1;
if (rangeLen >= 3) {
// Collapse into range
result.push({
publicPort: items[start].publicPort,
privatePort: items[start].privatePort,
display: `${items[start].publicPort}-${items[end].publicPort}:${items[start].privatePort}-${items[end].privatePort}`,
isRange: true
});
} else {
// Keep as individual ports
for (let i = start; i <= end; i++) {
result.push(items[i]);
}
}
}
+9 -7
View File
@@ -22,6 +22,7 @@
import { getLabelColor, getLabelBgColor } from '$lib/utils/label-colors';
import { Input } from '$lib/components/ui/input';
import MultiSelectFilter from '$lib/components/MultiSelectFilter.svelte';
import { appSettings } from '$lib/stores/settings';
const LABEL_FILTER_STORAGE_KEY = 'dockhand-dashboard-label-filter';
@@ -210,10 +211,10 @@
if (filterLabels.length === 0) {
return tiles;
}
return tiles.filter(t => {
const tileLabels = t.stats?.labels || [];
return tileLabels.some(label => filterLabels.includes(label));
});
const matchFn = $appSettings.labelFilterMode === 'all'
? (tileLabels: string[]) => filterLabels.every(label => tileLabels.includes(label))
: (tileLabels: string[]) => filterLabels.some(label => tileLabels.includes(label));
return tiles.filter(t => matchFn(t.stats?.labels || []));
});
// Filter grid items based on selected labels
@@ -221,11 +222,12 @@
if (filterLabels.length === 0) {
return gridItems;
}
// Filter to only show tiles whose environments have at least one matching label
const matchFn = $appSettings.labelFilterMode === 'all'
? (tileLabels: string[]) => filterLabels.every(label => tileLabels.includes(label))
: (tileLabels: string[]) => filterLabels.some(label => tileLabels.includes(label));
return gridItems.filter(item => {
const tile = tiles.find(t => t.id === item.id);
const tileLabels = tile?.stats?.labels || [];
return tileLabels.some(label => filterLabels.includes(label));
return matchFn(tile?.stats?.labels || []);
});
});
const orderedGridItems = $derived.by(() => {
@@ -66,7 +66,7 @@ export const POST: RequestHandler = async ({ params, url, request, cookies }) =>
let scheduleType: 'daily' | 'weekly' | 'custom' = 'custom';
if (cronExpression) {
const parts = cronExpression.split(' ');
if (parts.length >= 5) {
if (parts.length === 5) {
const [, , day, month, dow] = parts;
if (dow !== '*' && day === '*' && month === '*') {
scheduleType = 'weekly';
+4 -1
View File
@@ -3,6 +3,7 @@ import { listContainers, createContainer, pullImage, EnvironmentNotFoundError, D
import { authorize } from '$lib/server/authorize';
import { auditContainer } from '$lib/server/audit';
import { hasEnvironments } from '$lib/server/db';
import { isHiddenByLabel } from '$lib/server/container-labels';
import type { RequestHandler } from './$types';
export const GET: RequestHandler = async ({ url, cookies }) => {
@@ -34,7 +35,9 @@ export const GET: RequestHandler = async ({ url, cookies }) => {
try {
const containers = await listContainers(all, envIdNum);
return json(containers);
// Filter out containers with dockhand.hidden=true label
const visible = containers.filter(c => !isHiddenByLabel(c.labels));
return json(visible);
} catch (error: any) {
// Return 404 for missing environment so frontend can clear stale localStorage
if (error instanceof EnvironmentNotFoundError) {
+6 -3
View File
@@ -4,7 +4,8 @@ import {
removeContainer,
getContainerLogs
} from '$lib/server/docker';
import { deleteAutoUpdateSchedule, getAutoUpdateSetting, getSecretKeyNames, removePendingContainerUpdate } from '$lib/server/db';
import { deleteAutoUpdateSchedule, getAutoUpdateSetting, getSecretKeysToMask, removePendingContainerUpdate } from '$lib/server/db';
import { getStackComposeFile } from '$lib/server/stacks';
import { authorize } from '$lib/server/authorize';
import { auditContainer } from '$lib/server/audit';
import { unregisterSchedule } from '$lib/server/scheduler';
@@ -34,10 +35,12 @@ export const GET: RequestHandler = async ({ params, url, cookies }) => {
const details = await inspectContainer(params.id, envIdNum);
// Mask secret env vars for containers belonging to a Compose stack
// Mask secret env vars for containers belonging to a Compose stack.
// Uses compose file parsing to detect interpolation (e.g., MYSQL_PASSWORD=${db_secret}).
const stackName = details.Config?.Labels?.['com.docker.compose.project'];
if (stackName && Array.isArray(details.Config?.Env)) {
const secretKeys = await getSecretKeyNames(stackName, envIdNum);
const composeResult = await getStackComposeFile(stackName, envIdNum).catch(() => null);
const secretKeys = await getSecretKeysToMask(stackName, envIdNum, composeResult?.content);
if (secretKeys.size > 0) {
details.Config.Env = details.Config.Env.map((entry: string) => {
const eqIdx = entry.indexOf('=');
@@ -1,5 +1,5 @@
import { json } from '@sveltejs/kit';
import { putContainerArchive } from '$lib/server/docker';
import { putContainerArchive, inspectContainer, execInContainer } from '$lib/server/docker';
import { authorize } from '$lib/server/authorize';
import { validateDockerIdParam } from '$lib/server/docker-validation';
import type { RequestHandler } from './$types';
@@ -111,6 +111,15 @@ export const POST: RequestHandler = async ({ params, url, request, cookies }) =>
return json({ error: 'No files provided' }, { status: 400 });
}
// We'll inspect the container once to determine its default user
let defaultUser: string | undefined;
try {
const inspectData = await inspectContainer(params.id, envIdNum);
defaultUser = inspectData.Config.User || undefined;
} catch (e) {
console.warn('Failed to inspect container for user info', e);
}
// For simplicity, we'll upload files one at a time
// A more sophisticated implementation could pack multiple files into one tar
const uploaded: string[] = [];
@@ -128,6 +137,22 @@ export const POST: RequestHandler = async ({ params, url, request, cookies }) =>
envId ? parseInt(envId) : undefined
);
// chown the uploaded file
if (defaultUser) {
const targetPath = path.endsWith('/') ? `${path}${file.name}` : `${path}/${file.name}`;
const ownerGroup = defaultUser.includes(':') ? defaultUser : `${defaultUser}:${defaultUser}`;
try {
await execInContainer(
params.id,
['chown', '-R', ownerGroup, targetPath],
envId ? parseInt(envId) : undefined,
'root'
);
} catch (e) {
console.warn('Failed to set ownership on', targetPath, e);
}
}
uploaded.push(file.name);
} catch (err: any) {
errors.push(`${file.name}: ${err.message}`);
@@ -1,7 +1,8 @@
import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { inspectContainer } from '$lib/server/docker';
import { getSecretKeyNames } from '$lib/server/db';
import { getSecretKeysToMask } from '$lib/server/db';
import { getStackComposeFile } from '$lib/server/stacks';
import { authorize } from '$lib/server/authorize';
import { validateDockerIdParam } from '$lib/server/docker-validation';
@@ -22,10 +23,12 @@ export const GET: RequestHandler = async ({ params, url, cookies }) => {
try {
const containerData = await inspectContainer(params.id, envIdNum);
// Mask secret env vars for containers belonging to a Compose stack
// Mask secret env vars for containers belonging to a Compose stack.
// Uses compose file parsing to detect interpolation (e.g., MYSQL_PASSWORD=${db_secret}).
const stackName = containerData.Config?.Labels?.['com.docker.compose.project'];
if (stackName && Array.isArray(containerData.Config?.Env)) {
const secretKeys = await getSecretKeyNames(stackName, envIdNum);
const composeResult = await getStackComposeFile(stackName, envIdNum).catch(() => null);
const secretKeys = await getSecretKeysToMask(stackName, envIdNum, composeResult?.content);
if (secretKeys.size > 0) {
containerData.Config.Env = containerData.Config.Env.map((entry: string) => {
const eqIdx = entry.indexOf('=');
@@ -15,6 +15,7 @@ import { auditContainer } from '$lib/server/audit';
import { getScannerSettings, scanImage } from '$lib/server/scanner';
import { saveVulnerabilityScan, removePendingContainerUpdate, type VulnerabilityCriteria } from '$lib/server/db';
import { parseImageNameAndTag, shouldBlockUpdate, combineScanSummaries, isSystemContainer } from '$lib/server/scheduler/tasks/update-utils';
import { isUpdateDisabledByLabel } from '$lib/server/container-labels';
import { recreateContainer } from '$lib/server/scheduler/tasks/container-update';
import { createJob, appendLine, completeJob, failJob } from '$lib/server/jobs';
@@ -173,6 +174,22 @@ export const POST: RequestHandler = async (event) => {
continue;
}
// Skip containers with dockhand.update=false label
if (isUpdateDisabledByLabel(config.Labels)) {
sendData({
type: 'progress',
containerId,
containerName,
step: 'skipped',
current: i + 1,
total: containerIds.length,
success: true,
message: `Skipping ${containerName} - dockhand.update=false label`
});
skippedCount++;
continue;
}
// Skip digest-pinned images - they are explicitly locked to a specific version
if (isDigestBasedImage(imageName)) {
sendData({
@@ -4,6 +4,7 @@ import { authorize } from '$lib/server/authorize';
import { listContainers, pullImage, inspectContainer } from '$lib/server/docker';
import { auditContainer } from '$lib/server/audit';
import { recreateContainer } from '$lib/server/scheduler/tasks/container-update';
import { isUpdateDisabledByLabel } from '$lib/server/container-labels';
export interface BatchUpdateResult {
containerId: string;
@@ -62,6 +63,17 @@ export const POST: RequestHandler = async (event) => {
const imageName = config.Image;
const containerName = container.name;
// Skip containers with dockhand.update=false label
if (isUpdateDisabledByLabel(config.Labels)) {
results.push({
containerId,
containerName,
success: true,
error: 'Skipped - dockhand.update=false label'
});
continue;
}
// Pull latest image first
try {
await pullImage(imageName, undefined, envIdNum);
@@ -4,6 +4,7 @@ import { authorize } from '$lib/server/authorize';
import { listContainers, inspectContainer, checkImageUpdateAvailable } from '$lib/server/docker';
import { clearPendingContainerUpdates, addPendingContainerUpdate } from '$lib/server/db';
import { isSystemContainer } from '$lib/server/scheduler/tasks/update-utils';
import { isUpdateDisabledByLabel } from '$lib/server/container-labels';
import { createJobResponse } from '$lib/server/sse';
export interface UpdateCheckResult {
@@ -16,6 +17,7 @@ export interface UpdateCheckResult {
error?: string;
isLocalImage?: boolean;
systemContainer?: 'dockhand' | 'hawser' | null;
updateDisabled?: boolean;
}
/**
@@ -64,6 +66,7 @@ export const POST: RequestHandler = async ({ url, cookies, request }) => {
}
const result = await checkImageUpdateAvailable(imageName, currentImageId, envIdNum);
const updateDisabled = isUpdateDisabledByLabel(inspectData.Config?.Labels);
return {
containerId: container.id,
@@ -74,7 +77,8 @@ export const POST: RequestHandler = async ({ url, cookies, request }) => {
newDigest: result.registryDigest,
error: result.error,
isLocalImage: result.isLocalImage,
systemContainer: isSystemContainer(imageName) || null
systemContainer: isSystemContainer(imageName) || null,
updateDisabled
};
} catch (error: any) {
return {
@@ -102,12 +106,12 @@ export const POST: RequestHandler = async ({ url, cookies, request }) => {
}
await Promise.all(Array.from({ length: Math.min(CONCURRENCY, containers.length) }, () => runNext()));
const updatesFound = results.filter(r => r.hasUpdate && !r.systemContainer).length;
const updatesFound = results.filter(r => r.hasUpdate && !r.systemContainer && !r.updateDisabled).length;
// Save containers with updates to the database for persistence
if (envIdNum) {
for (const result of results) {
if (result.hasUpdate && !result.systemContainer) {
if (result.hasUpdate && !result.systemContainer && !result.updateDisabled) {
await addPendingContainerUpdate(
envIdNum,
result.containerId,
@@ -1,7 +1,7 @@
import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { authorize } from '$lib/server/authorize';
import { getPendingContainerUpdates, removePendingContainerUpdate } from '$lib/server/db';
import { getPendingContainerUpdates, removePendingContainerUpdate, clearPendingContainerUpdates } from '$lib/server/db';
/**
* Get pending container updates for an environment.
@@ -48,8 +48,8 @@ export const DELETE: RequestHandler = async ({ url, cookies }) => {
const containerId = url.searchParams.get('containerId');
const envIdNum = envId ? parseInt(envId) : undefined;
if (!envIdNum || !containerId) {
return json({ error: 'Environment ID and container ID required' }, { status: 400 });
if (!envIdNum) {
return json({ error: 'Environment ID required' }, { status: 400 });
}
// Need manage permission to delete
@@ -58,7 +58,11 @@ export const DELETE: RequestHandler = async ({ url, cookies }) => {
}
try {
await removePendingContainerUpdate(envIdNum, containerId);
if (containerId) {
await removePendingContainerUpdate(envIdNum, containerId);
} else {
await clearPendingContainerUpdates(envIdNum);
}
return json({ success: true });
} catch (error: any) {
console.error('Error removing pending update:', error);
@@ -115,6 +115,7 @@ export const DELETE: RequestHandler = async (event) => {
// Delete git stack clone directories before cascade deletes the DB rows
const stacks = await getGitStacksByRepositoryId(id);
console.log(`[GitStack] Repository "${repository.name}" (id=${id}) deletion affects ${stacks.length} stacks: ${stacks.map(s => s.stackName).join(', ')}`);
for (const stack of stacks) {
await deleteGitStackFiles(stack.id, stack.stackName, stack.environmentId);
}
+10 -1
View File
@@ -7,7 +7,8 @@ import {
getGitRepository,
createGitRepository,
upsertStackSource,
setStackEnvVars
setStackEnvVars,
getStackSource
} from '$lib/server/db';
import { deployGitStack } from '$lib/server/git';
import { authorize } from '$lib/server/authorize';
@@ -61,6 +62,12 @@ export const POST: RequestHandler = async (event) => {
return json({ error: 'Stack name must start with a letter or number, and contain only letters, numbers, hyphens, and underscores' }, { status: 400 });
}
// Check for name conflicts with existing stacks (regular/external/git)
const existing = await getStackSource(trimmedStackName, data.environmentId || null);
if (existing) {
return json({ error: 'A stack with this name already exists on this environment' }, { status: 409 });
}
// Either repositoryId or new repo details (url, branch) must be provided
let repositoryId = data.repositoryId;
@@ -120,7 +127,9 @@ export const POST: RequestHandler = async (event) => {
autoUpdateCron: data.autoUpdateCron || '0 3 * * *',
webhookEnabled: data.webhookEnabled || false,
webhookSecret: webhookSecret,
contextDir: data.contextDir || null,
buildOnDeploy: data.buildOnDeploy ?? false,
noBuildCache: data.noBuildCache ?? false,
repullImages: data.repullImages ?? false,
forceRedeploy: data.forceRedeploy ?? false
});
@@ -73,7 +73,9 @@ export const PUT: RequestHandler = async (event) => {
autoUpdateCron: data.autoUpdateCron,
webhookEnabled: data.webhookEnabled,
webhookSecret: data.webhookSecret,
contextDir: data.contextDir,
buildOnDeploy: data.buildOnDeploy,
noBuildCache: data.noBuildCache,
repullImages: data.repullImages,
forceRedeploy: data.forceRedeploy
});
+2 -2
View File
@@ -46,8 +46,8 @@ export const GET: RequestHandler = async ({ url }) => {
});
if (!response.ok) {
if (response.status === 401) {
return json({ error: 'Authentication failed. Please check your credentials.' }, { status: 401 });
if (response.status === 401 || response.status === 403) {
return json({ error: 'Catalog listing not available. This registry may not support the _catalog endpoint (common with GitLab and Harbor). Try searching for images by name instead.' }, { status: response.status });
}
if (response.status === 404) {
return json({ error: 'Registry does not support V2 catalog API' }, { status: 404 });
+37 -15
View File
@@ -1,7 +1,7 @@
import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { getRegistry } from '$lib/server/db';
import { getRegistryAuth } from '$lib/server/docker';
import { getRegistryAuth, parseRegistryUrl } from '$lib/server/docker';
interface SearchResult {
name: string;
@@ -46,28 +46,50 @@ async function searchDockerHub(term: string, limit: number): Promise<SearchResul
async function searchPrivateRegistry(registry: any, term: string, limit: number): Promise<SearchResult[]> {
const results: string[] = [];
const { orgPath } = parseRegistryUrl(registry.url);
const orgPrefix = orgPath ? orgPath.replace(/^\//, '') : '';
// Strategy 1: If term looks like an image name (contains /), try direct lookup first
// This is much faster than iterating through catalog for large registries like ghcr.io
// Strategy 1: Direct image lookup — try the exact term and org-prefixed variants
// This uses per-repository auth scope which works on all V2 registries (GitLab, Harbor, etc.)
const directCandidates: string[] = [];
if (term.includes('/')) {
const directResult = await tryDirectImageLookup(registry, term);
if (directResult) {
results.push(term);
directCandidates.push(term);
}
// If registry URL has an org path (e.g., https://registry.example.com/group),
// try prepending it to the search term
if (orgPrefix && !term.startsWith(orgPrefix + '/')) {
directCandidates.push(`${orgPrefix}/${term}`);
}
for (const candidate of directCandidates) {
if (results.length >= limit) break;
const exists = await tryDirectImageLookup(registry, candidate);
if (exists && !results.includes(candidate)) {
results.push(candidate);
}
}
// Strategy 2: Fall back to catalog search for partial matches or if direct lookup failed
// Strategy 2: Fall back to catalog search for partial/fuzzy matches
// Some registries (GitLab, Harbor) don't support _catalog for deploy tokens,
// so catch errors gracefully and return whatever we have from direct lookup
if (results.length < limit) {
const catalogResults = await searchCatalog(registry, term, limit - results.length);
// Add catalog results, avoiding duplicates
for (const name of catalogResults) {
if (!results.includes(name)) {
results.push(name);
try {
const catalogResults = await searchCatalog(registry, term, limit - results.length);
for (const name of catalogResults) {
if (!results.includes(name)) {
results.push(name);
}
}
} catch (e: any) {
// Catalog not supported but we have direct lookup results — that's fine
if (results.length > 0) {
console.warn(`[Registry] Catalog search failed (using direct lookup results): ${e.message}`);
} else {
throw e;
}
}
}
// Return results in the same format as Docker Hub
return results.map((name: string) => ({
name,
description: '',
@@ -136,8 +158,8 @@ async function searchCatalog(registry: any, term: string, limit: number): Promis
});
if (!response.ok) {
if (response.status === 401) {
throw new Error('Authentication failed');
if (response.status === 401 || response.status === 403) {
throw new Error('Authentication failed. This registry may not support catalog listing (common with GitLab and Harbor deploy tokens).');
}
throw new Error(`Registry returned error: ${response.status}`);
}
+2 -2
View File
@@ -93,8 +93,8 @@ async function fetchRegistryTags(registry: any, imageName: string): Promise<TagI
});
if (!response.ok) {
if (response.status === 401) {
throw new Error('Authentication failed');
if (response.status === 401 || response.status === 403) {
throw new Error('Authentication failed. Please check your credentials and permissions.');
}
if (response.status === 404) {
throw new Error('Image not found in registry');
@@ -2,13 +2,17 @@ import { json, type RequestHandler } from '@sveltejs/kit';
import {
setScheduleCleanupEnabled,
setEventCleanupEnabled,
setScannerCleanupEnabled,
getScheduleCleanupEnabled,
getEventCleanupEnabled
getEventCleanupEnabled,
getScannerCleanupEnabled
} from '$lib/server/db';
import { authorize } from '$lib/server/authorize';
import { refreshSystemJobs } from '$lib/server/scheduler';
const SYSTEM_SCHEDULE_CLEANUP_ID = 1;
const SYSTEM_EVENT_CLEANUP_ID = 2;
const SYSTEM_SCANNER_CLEANUP_ID = 4;
export const POST: RequestHandler = async ({ params, cookies }) => {
const auth = await authorize(cookies);
@@ -32,6 +36,11 @@ export const POST: RequestHandler = async ({ params, cookies }) => {
const currentEnabled = await getEventCleanupEnabled();
await setEventCleanupEnabled(!currentEnabled);
return json({ success: true, enabled: !currentEnabled });
} else if (systemId === SYSTEM_SCANNER_CLEANUP_ID) {
const currentEnabled = await getScannerCleanupEnabled();
await setScannerCleanupEnabled(!currentEnabled);
await refreshSystemJobs();
return json({ success: true, enabled: !currentEnabled });
} else {
return json({ error: 'Unknown system schedule' }, { status: 400 });
}
+15 -15
View File
@@ -1,6 +1,13 @@
import { json } from '@sveltejs/kit';
import { authorize } from '$lib/server/authorize';
import { getOwnContainerId, getHostDockerSocket, getOwnDockerHost, getOwnNetworkMode } from '$lib/server/host-path';
import { getAdditionalVolumeBinds } from '$lib/server/mount-dedupe';
import {
getOwnContainerId,
getHostDockerSocket,
getOwnDockerHost,
getOwnExtraHosts,
getOwnNetworkMode
} from '$lib/server/host-path';
import { buildRegistryAuthHeader, unixSocketRequest, unixSocketStreamRequest } from '$lib/server/docker';
import type { RequestHandler } from './$types';
import { prefersJSON, sseToJSON } from '$lib/server/sse';
@@ -160,20 +167,7 @@ function buildCreateConfig(inspectData: any, newImage: string): any {
// Otherwise the old container's hostname is inherited, breaking self-identification
delete createConfig.Hostname;
// Preserve anonymous volumes from Mounts not in HostConfig.Binds
const existingBinds = new Set((hostConfig.Binds || []).map((b: string) => {
const parts = b.split(':');
return parts.length >= 2 ? parts[1] : parts[0];
}));
const mounts = inspectData.Mounts || [];
const additionalBinds: string[] = [];
for (const mount of mounts) {
if (mount.Type === 'volume' && mount.Name && mount.Destination) {
if (!existingBinds.has(mount.Destination)) {
additionalBinds.push(`${mount.Name}:${mount.Destination}`);
}
}
}
const additionalBinds = getAdditionalVolumeBinds(hostConfig, inspectData.Mounts || []);
if (additionalBinds.length > 0) {
createConfig.HostConfig = {
...createConfig.HostConfig,
@@ -395,6 +389,12 @@ export const POST: RequestHandler = async ({ request, cookies }) => {
// Configure updater's Docker access based on connection type
const tcpHost = getDockerTcpHost();
const updaterHostConfig: Record<string, unknown> = { AutoRemove: true };
const updaterExtraHosts = getOwnExtraHosts() ?? undefined;
if (updaterExtraHosts?.length) {
updaterHostConfig.ExtraHosts = updaterExtraHosts;
console.log(`[SelfUpdate] Reusing ExtraHosts for updater: ${updaterExtraHosts.join(', ')}`);
}
if (tcpHost) {
// TCP: pass DOCKER_HOST so docker CLI in sidecar uses TCP
+76 -9
View File
@@ -14,6 +14,10 @@ import {
setScheduleCleanupEnabled,
getEventCleanupEnabled,
setEventCleanupEnabled,
getScannerCleanupCron,
setScannerCleanupCron,
getScannerCleanupEnabled,
setScannerCleanupEnabled,
getDefaultTimezone,
setDefaultTimezone,
getEventCollectionMode,
@@ -52,6 +56,8 @@ export interface GeneralSettings {
eventCleanupCron: string;
scheduleCleanupEnabled: boolean;
eventCleanupEnabled: boolean;
scannerCleanupCron: string;
scannerCleanupEnabled: boolean;
logBufferSizeKb: number;
defaultTimezone: string;
// Background monitoring settings
@@ -77,9 +83,13 @@ export interface GeneralSettings {
// Scanner images
defaultGrypeImage: string;
defaultTrivyImage: string;
// Compose template
defaultComposeTemplate: string;
// Label filter mode
labelFilterMode: 'any' | 'all';
}
const DEFAULT_SETTINGS: Omit<GeneralSettings, 'scheduleRetentionDays' | 'eventRetentionDays' | 'scheduleCleanupCron' | 'eventCleanupCron' | 'scheduleCleanupEnabled' | 'eventCleanupEnabled'> = {
const DEFAULT_SETTINGS: Omit<GeneralSettings, 'scheduleRetentionDays' | 'eventRetentionDays' | 'scheduleCleanupCron' | 'eventCleanupCron' | 'scheduleCleanupEnabled' | 'eventCleanupEnabled' | 'scannerCleanupCron' | 'scannerCleanupEnabled'> = {
confirmDestructive: true,
showStoppedContainers: true,
highlightUpdates: true,
@@ -105,7 +115,26 @@ const DEFAULT_SETTINGS: Omit<GeneralSettings, 'scheduleRetentionDays' | 'eventRe
externalStackPaths: [],
primaryStackLocation: null,
defaultGrypeImage: DEFAULT_GRYPE_IMAGE,
defaultTrivyImage: DEFAULT_TRIVY_IMAGE
defaultTrivyImage: DEFAULT_TRIVY_IMAGE,
labelFilterMode: 'any' as const,
defaultComposeTemplate: `version: "3.8"
services:
app:
image: nginx:alpine
ports:
- "8080:80"
environment:
- APP_ENV=\${APP_ENV:-production}
volumes:
- ./html:/usr/share/nginx/html:ro
restart: unless-stopped
# Add more services as needed
# networks:
# default:
# driver: bridge
`
};
const VALID_LIGHT_THEMES = ['default', 'catppuccin', 'rose-pine', 'nord', 'solarized', 'gruvbox', 'alucard', 'github', 'material', 'atom-one'];
@@ -142,6 +171,8 @@ export const GET: RequestHandler = async ({ cookies }) => {
eventCleanupCron,
scheduleCleanupEnabled,
eventCleanupEnabled,
scannerCleanupCron,
scannerCleanupEnabled,
logBufferSizeKb,
defaultTimezone,
eventCollectionMode,
@@ -159,7 +190,9 @@ export const GET: RequestHandler = async ({ cookies }) => {
externalStackPaths,
primaryStackLocation,
defaultGrypeImage,
defaultTrivyImage
defaultTrivyImage,
defaultComposeTemplate,
labelFilterMode
] = await Promise.all([
getSetting('confirm_destructive'),
getSetting('show_stopped_containers'),
@@ -175,6 +208,8 @@ export const GET: RequestHandler = async ({ cookies }) => {
getEventCleanupCron(),
getScheduleCleanupEnabled(),
getEventCleanupEnabled(),
getScannerCleanupCron(),
getScannerCleanupEnabled(),
getSetting('log_buffer_size_kb'),
getDefaultTimezone(),
getEventCollectionMode(),
@@ -192,7 +227,9 @@ export const GET: RequestHandler = async ({ cookies }) => {
getExternalStackPaths(),
getPrimaryStackLocation(),
getSetting('default_grype_image'),
getSetting('default_trivy_image')
getSetting('default_trivy_image'),
getSetting('default_compose_template'),
getSetting('label_filter_mode')
]);
const settings: GeneralSettings = {
@@ -210,6 +247,8 @@ export const GET: RequestHandler = async ({ cookies }) => {
eventCleanupCron,
scheduleCleanupEnabled,
eventCleanupEnabled,
scannerCleanupCron,
scannerCleanupEnabled,
logBufferSizeKb: logBufferSizeKb ?? DEFAULT_SETTINGS.logBufferSizeKb,
defaultTimezone: defaultTimezone ?? DEFAULT_SETTINGS.defaultTimezone,
eventCollectionMode: (eventCollectionMode ?? DEFAULT_SETTINGS.eventCollectionMode) as EventCollectionMode,
@@ -227,7 +266,9 @@ export const GET: RequestHandler = async ({ cookies }) => {
externalStackPaths,
primaryStackLocation,
defaultGrypeImage: defaultGrypeImage ?? DEFAULT_GRYPE_IMAGE,
defaultTrivyImage: defaultTrivyImage ?? DEFAULT_TRIVY_IMAGE
defaultTrivyImage: defaultTrivyImage ?? DEFAULT_TRIVY_IMAGE,
defaultComposeTemplate: defaultComposeTemplate ?? DEFAULT_SETTINGS.defaultComposeTemplate,
labelFilterMode: labelFilterMode ?? DEFAULT_SETTINGS.labelFilterMode
};
return json(settings);
@@ -245,7 +286,7 @@ export const POST: RequestHandler = async ({ request, cookies }) => {
try {
const body = await request.json();
const { confirmDestructive, showStoppedContainers, highlightUpdates, timeFormat, dateFormat, downloadFormat, defaultGrypeArgs, defaultTrivyArgs, scheduleRetentionDays, eventRetentionDays, scheduleCleanupCron, eventCleanupCron, scheduleCleanupEnabled, eventCleanupEnabled, logBufferSizeKb, defaultTimezone, eventCollectionMode, eventPollInterval, metricsCollectionInterval, lightTheme, darkTheme, font, fontSize, gridFontSize, terminalFont, editorFont, compactPorts, formatLogTimestamps, externalStackPaths, primaryStackLocation, defaultGrypeImage, defaultTrivyImage } = body;
const { confirmDestructive, showStoppedContainers, highlightUpdates, timeFormat, dateFormat, downloadFormat, defaultGrypeArgs, defaultTrivyArgs, scheduleRetentionDays, eventRetentionDays, scheduleCleanupCron, eventCleanupCron, scheduleCleanupEnabled, eventCleanupEnabled, scannerCleanupCron, scannerCleanupEnabled, logBufferSizeKb, defaultTimezone, eventCollectionMode, eventPollInterval, metricsCollectionInterval, lightTheme, darkTheme, font, fontSize, gridFontSize, terminalFont, editorFont, compactPorts, formatLogTimestamps, externalStackPaths, primaryStackLocation, defaultGrypeImage, defaultTrivyImage, defaultComposeTemplate, labelFilterMode } = body;
if (confirmDestructive !== undefined) {
await setSetting('confirm_destructive', confirmDestructive);
@@ -289,6 +330,14 @@ export const POST: RequestHandler = async ({ request, cookies }) => {
if (eventCleanupEnabled !== undefined && typeof eventCleanupEnabled === 'boolean') {
await setEventCleanupEnabled(eventCleanupEnabled);
}
if (scannerCleanupCron !== undefined && typeof scannerCleanupCron === 'string') {
await setScannerCleanupCron(scannerCleanupCron);
await refreshSystemJobs();
}
if (scannerCleanupEnabled !== undefined && typeof scannerCleanupEnabled === 'boolean') {
await setScannerCleanupEnabled(scannerCleanupEnabled);
await refreshSystemJobs();
}
if (logBufferSizeKb !== undefined && typeof logBufferSizeKb === 'number') {
// Clamp to reasonable range: 100KB - 5000KB (5MB)
await setSetting('log_buffer_size_kb', Math.max(100, Math.min(5000, logBufferSizeKb)));
@@ -364,6 +413,12 @@ export const POST: RequestHandler = async ({ request, cookies }) => {
if (defaultTrivyImage !== undefined && typeof defaultTrivyImage === 'string') {
await setSetting('default_trivy_image', defaultTrivyImage);
}
if (defaultComposeTemplate !== undefined && typeof defaultComposeTemplate === 'string') {
await setSetting('default_compose_template', defaultComposeTemplate);
}
if (labelFilterMode !== undefined && (labelFilterMode === 'any' || labelFilterMode === 'all')) {
await setSetting('label_filter_mode', labelFilterMode);
}
// Fetch all settings in parallel for the response
const [
@@ -381,6 +436,8 @@ export const POST: RequestHandler = async ({ request, cookies }) => {
eventCleanupCronVal,
scheduleCleanupEnabledVal,
eventCleanupEnabledVal,
scannerCleanupCronVal,
scannerCleanupEnabledVal,
logBufferSizeKbVal,
defaultTimezoneVal,
eventCollectionModeVal,
@@ -398,7 +455,9 @@ export const POST: RequestHandler = async ({ request, cookies }) => {
externalStackPathsVal,
primaryStackLocationVal,
defaultGrypeImageVal,
defaultTrivyImageVal
defaultTrivyImageVal,
defaultComposeTemplateVal,
labelFilterModeVal
] = await Promise.all([
getSetting('confirm_destructive'),
getSetting('show_stopped_containers'),
@@ -414,6 +473,8 @@ export const POST: RequestHandler = async ({ request, cookies }) => {
getEventCleanupCron(),
getScheduleCleanupEnabled(),
getEventCleanupEnabled(),
getScannerCleanupCron(),
getScannerCleanupEnabled(),
getSetting('log_buffer_size_kb'),
getDefaultTimezone(),
getEventCollectionMode(),
@@ -431,7 +492,9 @@ export const POST: RequestHandler = async ({ request, cookies }) => {
getExternalStackPaths(),
getPrimaryStackLocation(),
getSetting('default_grype_image'),
getSetting('default_trivy_image')
getSetting('default_trivy_image'),
getSetting('default_compose_template'),
getSetting('label_filter_mode')
]);
const settings: GeneralSettings = {
@@ -449,6 +512,8 @@ export const POST: RequestHandler = async ({ request, cookies }) => {
eventCleanupCron: eventCleanupCronVal,
scheduleCleanupEnabled: scheduleCleanupEnabledVal,
eventCleanupEnabled: eventCleanupEnabledVal,
scannerCleanupCron: scannerCleanupCronVal,
scannerCleanupEnabled: scannerCleanupEnabledVal,
logBufferSizeKb: logBufferSizeKbVal ?? DEFAULT_SETTINGS.logBufferSizeKb,
defaultTimezone: defaultTimezoneVal ?? DEFAULT_SETTINGS.defaultTimezone,
eventCollectionMode: (eventCollectionModeVal ?? DEFAULT_SETTINGS.eventCollectionMode) as EventCollectionMode,
@@ -466,7 +531,9 @@ export const POST: RequestHandler = async ({ request, cookies }) => {
externalStackPaths: externalStackPathsVal,
primaryStackLocation: primaryStackLocationVal,
defaultGrypeImage: defaultGrypeImageVal ?? DEFAULT_GRYPE_IMAGE,
defaultTrivyImage: defaultTrivyImageVal ?? DEFAULT_TRIVY_IMAGE
defaultTrivyImage: defaultTrivyImageVal ?? DEFAULT_TRIVY_IMAGE,
defaultComposeTemplate: defaultComposeTemplateVal ?? DEFAULT_SETTINGS.defaultComposeTemplate,
labelFilterMode: labelFilterModeVal ?? DEFAULT_SETTINGS.labelFilterMode
};
return json(settings);
@@ -1,7 +1,7 @@
import { gzipSync } from 'node:zlib';
import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { getVolumeArchive } from '$lib/server/docker';
import { getVolumeArchive, releaseVolumeHelperContainer } from '$lib/server/docker';
import { authorize } from '$lib/server/authorize';
import { validateDockerIdParam } from '$lib/server/docker-validation';
@@ -36,15 +36,33 @@ export const GET: RequestHandler = async ({ params, url, cookies }) => {
let body: ReadableStream<Uint8Array> | Uint8Array = response.body!;
if (format === 'tar.gz') {
// Compress with gzip
// Compress with gzip — fully consumes the archive stream
const tarData = new Uint8Array(await response.arrayBuffer());
body = gzipSync(tarData);
contentType = 'application/gzip';
extension = '.tar.gz';
}
// Note: Helper container is cached and reused for subsequent requests.
// Cache TTL handles cleanup automatically.
// Data fully read, release helper container immediately
releaseVolumeHelperContainer(params.name, envIdNum).catch(() => {});
} else {
// For streaming tar, wrap the stream to release on completion
const reader = body.getReader();
body = new ReadableStream({
async pull(controller) {
const { done, value } = await reader.read();
if (done) {
controller.close();
releaseVolumeHelperContainer(params.name, envIdNum).catch(() => {});
} else {
controller.enqueue(value);
}
},
cancel() {
reader.cancel();
releaseVolumeHelperContainer(params.name, envIdNum).catch(() => {});
}
});
}
const headers: Record<string, string> = {
'Content-Type': contentType,
@@ -66,6 +84,9 @@ export const GET: RequestHandler = async ({ params, url, cookies }) => {
} catch (error: any) {
console.error('Failed to export volume:', error);
// Best-effort cleanup on error
releaseVolumeHelperContainer(params.name, envIdNum).catch(() => {});
if (error.message?.includes('No such file or directory')) {
return json({ error: 'Path not found' }, { status: 404 });
}
+22 -4
View File
@@ -12,6 +12,7 @@
import * as Select from '$lib/components/ui/select';
import * as Tooltip from '$lib/components/ui/tooltip';
import ConfirmPopover from '$lib/components/ConfirmPopover.svelte';
import { formatPorts, type PortMapping } from '$lib/utils/port-format';
import MultiSelectFilter from '$lib/components/MultiSelectFilter.svelte';
import PageHeader from '$lib/components/PageHeader.svelte';
import { Badge } from '$lib/components/ui/badge';
@@ -472,7 +473,7 @@
// Unlock button width
if (updateCheckBtnEl) updateCheckBtnEl.style.minWidth = '';
const containersWithUpdates = data.results.filter((r: any) => r.hasUpdate && !r.systemContainer);
const containersWithUpdates = data.results.filter((r: any) => r.hasUpdate && !r.systemContainer && !r.updateDisabled && !r.isLocalImage);
const failed = data.results.filter((r: any) => r.error && !r.hasUpdate);
failedUpdateChecks = failed.map((r: any) => ({
containerName: r.containerName,
@@ -541,6 +542,19 @@
showBatchUpdateModal = true;
}
async function dismissPendingUpdates() {
const envId = $currentEnvironment?.id;
if (!envId) return;
try {
const response = await fetch(`/api/containers/pending-updates?env=${envId}`, { method: 'DELETE' });
if (response.ok) {
containerStore.setPendingUpdates([], new Map());
}
} catch {
toast.error('Failed to clear update indicators');
}
}
function updateAllContainers() {
if (batchUpdateContainerIds.length === 0) return;
@@ -1149,8 +1163,6 @@
}
}
import { formatPorts, type PortMapping } from '$lib/utils/port-format';
function extractHostFromUrl(urlString: string): string | null {
if (!urlString) return null;
@@ -1443,6 +1455,12 @@
>
<CircleArrowUp class="w-3.5 h-3.5" />
Update all ({updatableContainersCount})
<button
type="button"
onclick={(e) => { e.stopPropagation(); dismissPendingUpdates(); }}
class="-mr-1 text-[12px] leading-none rounded-full hover:bg-destructive/20 hover:text-destructive transition-colors opacity-40 hover:opacity-100"
title="Dismiss all update indicators"
>×</button>
</Button>
{/if}
{#if $canAccess('containers', 'remove')}
@@ -1861,7 +1879,7 @@
{@const remainingCount = ports.length - 1}
<div class="flex {compactPorts ? 'flex-nowrap' : 'flex-wrap'} gap-1">
{#each displayPorts as port}
{@const url = !port.isRange && currentEnvDetails ? getPortUrl(port.publicPort) : null}
{@const url = currentEnvDetails ? getPortUrl(port.publicPort) : null}
{#if url}
<a
href={url}
+67 -5
View File
@@ -6,6 +6,7 @@
import { CircleArrowUp, Loader2, AlertCircle, CheckCircle2, XCircle, ChevronDown, ChevronRight, ExternalLink } from 'lucide-svelte';
import { appendEnvParam } from '$lib/stores/environment';
import { untrack } from 'svelte';
import type { VulnerabilityCriteria } from '$lib/server/db';
import type { StepType } from '$lib/utils/update-steps';
import { getStepLabel, getStepIcon, getStepColor } from '$lib/utils/update-steps';
@@ -78,11 +79,37 @@
let progress = $state<ContainerProgress[]>([]);
let progressListEl = $state<HTMLDivElement | null>(null);
let scrollTick = $state(0);
let userScrolledUp = $state(false);
let currentIndex = $state(0);
let totalCount = $state(0);
let summary = $state<{ total: number; success: number; failed: number; blocked: number } | null>(null);
let errorMessage = $state('');
let forceUpdating = $state<Set<string>>(new Set()); // Track containers being force-updated
let filterMode = $state<'updated' | 'failed'>('updated');
let showFilterButtons = $derived(
summary && (summary.failed > 0 || summary.blocked > 0) && summary.success > 0
);
let filteredProgress = $derived(
!summary || !showFilterButtons
? progress
: filterMode === 'failed'
? progress.filter(p => p.step === 'failed' || p.step === 'blocked')
: progress.filter(p => p.step === 'done' || p.success)
);
$effect(() => {
// Only track filterMode, not progress — avoid re-running on every SSE update
const mode = filterMode;
if (mode === 'updated') {
untrack(() => {
for (const item of progress) {
item.showLogs = false;
}
});
}
});
function formatPullLog(entry: PullLogEntry): string {
// Clarify potentially confusing Docker messages
@@ -241,6 +268,9 @@
} else if (data.type === 'complete') {
status = 'complete';
summary = data.summary;
for (const item of progress) {
item.showLogs = false;
}
onComplete({ success: successIds, failed: failedIds, blocked: blockedIds });
} else if (data.type === 'error') {
status = 'error';
@@ -266,6 +296,7 @@
currentIndex = 0;
summary = null;
errorMessage = '';
filterMode = 'updated';
}
function handleOpenChange(isOpen: boolean) {
@@ -384,10 +415,18 @@ const severityOrder: Record<string, number> = { critical: 0, high: 1, medium: 2,
}
});
// Auto-scroll progress list to bottom on SSE data (not UI toggles)
// Track whether user has scrolled up to read earlier output
function handleProgressScroll() {
if (!progressListEl) return;
const { scrollTop, scrollHeight, clientHeight } = progressListEl;
// Consider "at bottom" if within 50px of the end
userScrolledUp = scrollHeight - scrollTop - clientHeight > 50;
}
// Auto-scroll progress list to bottom on SSE data, but only if user hasn't scrolled up
$effect(() => {
scrollTick;
if (progressListEl) {
if (progressListEl && !userScrolledUp) {
requestAnimationFrame(() => {
progressListEl?.scrollTo({ top: progressListEl.scrollHeight, behavior: 'smooth' });
});
@@ -438,10 +477,33 @@ const severityOrder: Record<string, number> = { critical: 0, high: 1, medium: 2,
<Progress value={progressPercentage} class="h-2" />
</div>
<!-- Container list with status - scrollable area -->
<!-- Filter toggle + Container list with status - scrollable area -->
{#if progress.length > 0}
<div bind:this={progressListEl} class="border rounded-lg divide-y flex-1 min-h-0 overflow-auto">
{#each progress as item (item.containerId)}
{#if showFilterButtons}
<div class="flex items-center gap-1 shrink-0">
<Button
variant={filterMode === 'updated' ? 'default' : 'outline'}
size="sm"
class="h-7 text-xs"
onclick={() => filterMode = 'updated'}
>
<CheckCircle2 class="w-3 h-3 mr-1" />
Updated ({summary.success})
</Button>
<Button
variant={filterMode === 'failed' ? 'destructive' : 'outline'}
size="sm"
class="h-7 text-xs"
onclick={() => filterMode = 'failed'}
>
<XCircle class="w-3 h-3 mr-1" />
Failed ({summary.failed + summary.blocked})
</Button>
</div>
{/if}
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div bind:this={progressListEl} onscroll={handleProgressScroll} class="border rounded-lg divide-y flex-1 min-h-0 overflow-auto">
{#each filteredProgress as item (item.containerId)}
{@const StepIcon = getStepIcon(item.step)}
{@const isActive = item.step !== 'done' && item.step !== 'failed' && item.step !== 'blocked'}
{@const hasLogs = item.pullLogs.length > 0 || item.scanLogs.length > 0 || (item.vulnerabilities && item.vulnerabilities.length > 0)}
+150 -14
View File
@@ -60,6 +60,12 @@
driver: string;
}
interface NetworkEndpointConfig {
ipv4Address: string;
ipv6Address: string;
aliases: string;
}
interface Props {
mode: 'create' | 'edit';
// Basic settings
@@ -82,6 +88,8 @@
// Networks
availableNetworks: DockerNetwork[];
selectedNetworks: string[];
networkConfigs: Record<string, NetworkEndpointConfig>;
macAddress: string;
// User/Group
containerUser: string;
// Privileged mode
@@ -157,6 +165,8 @@
labels = $bindable(),
availableNetworks,
selectedNetworks = $bindable(),
networkConfigs = $bindable(),
macAddress = $bindable(),
containerUser = $bindable(),
privilegedMode = $bindable(),
healthcheckEnabled = $bindable(),
@@ -195,6 +205,60 @@
imageSummary
}: Props = $props();
// Expanded network config rows
let expandedNetworks = $state<Set<string>>(new Set());
function toggleNetworkExpand(networkName: string) {
const next = new Set(expandedNetworks);
if (next.has(networkName)) {
next.delete(networkName);
} else {
next.add(networkName);
}
expandedNetworks = next;
}
function ensureNetworkConfig(networkName: string) {
if (!networkConfigs[networkName]) {
networkConfigs[networkName] = { ipv4Address: '', ipv6Address: '', aliases: '' };
networkConfigs = { ...networkConfigs };
}
}
function hasNetworkConfig(networkName: string): boolean {
const cfg = networkConfigs[networkName];
return !!cfg && !!(cfg.ipv4Address || cfg.ipv6Address || cfg.aliases);
}
// Validation helpers
const ipv4Regex = /^(\d{1,3}\.){3}\d{1,3}(\/\d{1,2})?$/;
const ipv6Regex = /^[0-9a-fA-F:]+$/;
const macRegex = /^([0-9a-fA-F]{2}:){5}[0-9a-fA-F]{2}$/;
function validateIpv4(value: string): string | null {
if (!value) return null;
return ipv4Regex.test(value) ? null : 'Invalid IPv4 address';
}
function validateIpv6(value: string): string | null {
if (!value) return null;
return ipv6Regex.test(value) ? null : 'Invalid IPv6 address';
}
function validateMac(value: string): string | null {
if (!value) return null;
return macRegex.test(value) ? null : 'Invalid MAC address (e.g., 02:42:ac:11:00:02)';
}
// Auto-expand networks that have config
$effect(() => {
for (const net of selectedNetworks) {
if (hasNetworkConfig(net) && !expandedNetworks.has(net)) {
expandedNetworks = new Set([...expandedNetworks, net]);
}
}
});
// Collapsible sections state
let showResources = $state(false);
let showSecurity = $state(false);
@@ -257,6 +321,11 @@
function removeNetwork(networkId: string) {
selectedNetworks = selectedNetworks.filter((n) => n !== networkId);
const { [networkId]: _, ...rest } = networkConfigs;
networkConfigs = rest;
const next = new Set(expandedNetworks);
next.delete(networkId);
expandedNetworks = next;
}
function addDeviceMapping() {
@@ -681,25 +750,92 @@
</Select.Root>
{#if selectedNetworks.length > 0}
<div class="flex flex-wrap gap-2 pt-1">
<div class="space-y-1 pt-1">
{#each selectedNetworks as networkName}
{@const network = availableNetworks.find(n => n.name === networkName)}
<Badge variant="secondary" class="flex items-center gap-1.5 pr-1">
{networkName}
{#if network}
<span class={getDriverBadgeClasses(network.driver)}>{network.driver}</span>
{@const isExpanded = expandedNetworks.has(networkName)}
<div class="border rounded-md">
<div class="flex items-center justify-between px-2.5 py-1.5">
<button
type="button"
onclick={() => { ensureNetworkConfig(networkName); toggleNetworkExpand(networkName); }}
class="flex items-center gap-1.5 text-sm hover:text-foreground transition-colors"
>
{#if isExpanded}
<ChevronDown class="w-3.5 h-3.5 text-muted-foreground" />
{:else}
<ChevronRight class="w-3.5 h-3.5 text-muted-foreground" />
{/if}
<span>{networkName}</span>
{#if network}
<span class={getDriverBadgeClasses(network.driver)}>{network.driver}</span>
{/if}
{#if hasNetworkConfig(networkName)}
<Badge variant="secondary" class="text-2xs">configured</Badge>
{/if}
</button>
<button
type="button"
onclick={() => removeNetwork(networkName)}
class="p-0.5 hover:bg-destructive/20 rounded text-muted-foreground hover:text-destructive"
>
<X class="w-3.5 h-3.5" />
</button>
</div>
{#if isExpanded && networkConfigs[networkName]}
<div class="px-2.5 pb-2.5 pt-1 border-t space-y-2">
<div class="grid grid-cols-2 gap-2">
<div class="space-y-1">
<Label class="text-2xs font-medium text-muted-foreground">IPv4 address</Label>
<Input
bind:value={networkConfigs[networkName].ipv4Address}
placeholder="e.g., 172.28.0.100"
class="h-8 text-xs"
/>
{#if validateIpv4(networkConfigs[networkName].ipv4Address)}
<p class="text-2xs text-destructive">{validateIpv4(networkConfigs[networkName].ipv4Address)}</p>
{/if}
</div>
<div class="space-y-1">
<Label class="text-2xs font-medium text-muted-foreground">IPv6 address</Label>
<Input
bind:value={networkConfigs[networkName].ipv6Address}
placeholder="e.g., fd00::100"
class="h-8 text-xs"
/>
{#if validateIpv6(networkConfigs[networkName].ipv6Address)}
<p class="text-2xs text-destructive">{validateIpv6(networkConfigs[networkName].ipv6Address)}</p>
{/if}
</div>
</div>
<div class="space-y-1">
<Label class="text-2xs font-medium text-muted-foreground">Aliases (comma-separated)</Label>
<Input
bind:value={networkConfigs[networkName].aliases}
placeholder="e.g., myalias, web"
class="h-8 text-xs"
/>
</div>
</div>
{/if}
<button
type="button"
onclick={() => removeNetwork(networkName)}
class="ml-0.5 hover:bg-destructive/20 rounded p-0.5"
>
<X class="w-3 h-3" />
</button>
</Badge>
</div>
{/each}
</div>
{/if}
<!-- MAC Address -->
<div class="space-y-1 pt-1">
<Label class="text-xs font-medium">MAC address</Label>
<Input
bind:value={macAddress}
placeholder="e.g., 02:42:ac:11:00:02"
class="h-9"
/>
{#if validateMac(macAddress)}
<p class="text-xs text-destructive">{validateMac(macAddress)}</p>
{/if}
</div>
{#if mode === 'edit'}
<p class="text-xs text-muted-foreground">Container will be connected to selected networks in addition to the network mode above</p>
{/if}
@@ -851,7 +987,7 @@
size="icon"
variant="ghost"
onclick={() => removeLabel(index)}
disabled={labels.length === 1}
disabled={labels.length <= 1 && !labels[0]?.key}
class="h-9 w-9 text-muted-foreground hover:text-destructive"
>
<Trash2 class="w-4 h-4" />
@@ -112,6 +112,8 @@
}
let availableNetworks = $state<DockerNetwork[]>([]);
let selectedNetworks = $state<string[]>([]);
let networkConfigs = $state<Record<string, { ipv4Address: string; ipv6Address: string; aliases: string }>>({});
let macAddress = $state('');
// Auto-update settings
let autoUpdateEnabled = $state(false);
@@ -431,6 +433,17 @@
deviceRequests = [dr];
}
// Build per-network configs for the API
const netConfigs: Record<string, { ipv4Address?: string; ipv6Address?: string; aliases?: string[] }> = {};
for (const [netName, cfg] of Object.entries(networkConfigs)) {
if (!selectedNetworks.includes(netName)) continue;
const entry: { ipv4Address?: string; ipv6Address?: string; aliases?: string[] } = {};
if (cfg.ipv4Address) entry.ipv4Address = cfg.ipv4Address;
if (cfg.ipv6Address) entry.ipv6Address = cfg.ipv6Address;
if (cfg.aliases) entry.aliases = cfg.aliases.split(',').map(a => a.trim()).filter(Boolean);
if (Object.keys(entry).length > 0) netConfigs[netName] = entry;
}
const payload = {
name: name.trim(),
image: image.trim(),
@@ -443,6 +456,8 @@
restartMaxRetries: restartPolicy === 'on-failure' && restartMaxRetries !== '' ? Number(restartMaxRetries) : undefined,
networkMode,
networks: selectedNetworks.length > 0 ? selectedNetworks : undefined,
networkConfigs: Object.keys(netConfigs).length > 0 ? netConfigs : undefined,
macAddress: macAddress.trim() || undefined,
startAfterCreate,
user: containerUser.trim() || undefined,
privileged: privilegedMode || undefined,
@@ -530,6 +545,8 @@
envVars = [{ key: '', value: '' }];
labels = [{ key: '', value: '' }];
selectedNetworks = [];
networkConfigs = {};
macAddress = '';
autoUpdateEnabled = false;
autoUpdateCronExpression = '0 3 * * *';
vulnerabilityCriteria = 'never';
@@ -723,6 +740,8 @@
bind:labels
{availableNetworks}
bind:selectedNetworks
bind:networkConfigs
bind:macAddress
bind:containerUser
bind:privilegedMode
bind:healthcheckEnabled
@@ -113,6 +113,8 @@
}
let availableNetworks = $state<DockerNetwork[]>([]);
let selectedNetworks = $state<string[]>([]);
let networkConfigs = $state<Record<string, { ipv4Address: string; ipv6Address: string; aliases: string }>>({});
let macAddress = $state('');
// User/Group
let containerUser = $state('');
@@ -182,6 +184,8 @@
envVars: typeof envVars;
labels: typeof labels;
selectedNetworks: string[];
networkConfigs: Record<string, { ipv4Address: string; ipv6Address: string; aliases: string }>;
macAddress: string;
// Advanced options
containerUser: string;
privilegedMode: boolean;
@@ -428,6 +432,24 @@
const networks = data.NetworkSettings?.Networks || {};
selectedNetworks = Object.keys(networks);
// Parse per-network IP/alias config from NetworkSettings
const parsedNetConfigs: Record<string, { ipv4Address: string; ipv6Address: string; aliases: string }> = {};
for (const [netName, netData] of Object.entries(networks) as [string, any][]) {
const ipam = netData.IPAMConfig || {};
const aliases = netData.Aliases || [];
if (ipam.IPv4Address || ipam.IPv6Address || aliases.length > 0) {
parsedNetConfigs[netName] = {
ipv4Address: ipam.IPv4Address || '',
ipv6Address: ipam.IPv6Address || '',
aliases: aliases.join(', ')
};
}
}
networkConfigs = parsedNetConfigs;
// Parse MAC address
macAddress = data.Config?.MacAddress || '';
// Parse advanced options - User
containerUser = data.Config.User || '';
@@ -561,6 +583,8 @@
envVars: JSON.parse(JSON.stringify(envVars)),
labels: JSON.parse(JSON.stringify(labels)),
selectedNetworks: [...selectedNetworks],
networkConfigs: JSON.parse(JSON.stringify(networkConfigs)),
macAddress,
// Advanced options
containerUser,
privilegedMode,
@@ -631,6 +655,8 @@
if (JSON.stringify(currentLabels) !== JSON.stringify(originalLabels)) return true;
if (JSON.stringify([...selectedNetworks].sort()) !== JSON.stringify([...originalConfig.selectedNetworks].sort())) return true;
if (JSON.stringify(networkConfigs) !== JSON.stringify(originalConfig.networkConfigs)) return true;
if (macAddress !== originalConfig.macAddress) return true;
// Advanced options - User & Security
if (containerUser !== originalConfig.containerUser) return true;
@@ -701,7 +727,9 @@
volumeMappings: volumeMappings.filter(v => v.hostPath && v.containerPath),
envVars: envVars.filter(e => e.key),
labels: labels.filter(l => l.key),
selectedNetworks: [...selectedNetworks].sort()
selectedNetworks: [...selectedNetworks].sort(),
networkConfigs,
macAddress
});
}
@@ -716,7 +744,9 @@
volumeMappings: originalConfig.volumeMappings.filter(v => v.hostPath && v.containerPath),
envVars: originalConfig.envVars.filter(e => e.key),
labels: originalConfig.labels.filter(l => l.key),
selectedNetworks: [...originalConfig.selectedNetworks].sort()
selectedNetworks: [...originalConfig.selectedNetworks].sort(),
networkConfigs: originalConfig.networkConfigs,
macAddress: originalConfig.macAddress
});
}
@@ -901,18 +931,31 @@
deviceRequests = [dr];
}
// Build per-network configs for the API
const netConfigs: Record<string, { ipv4Address?: string; ipv6Address?: string; aliases?: string[] }> = {};
for (const [netName, cfg] of Object.entries(networkConfigs)) {
if (!selectedNetworks.includes(netName)) continue;
const entry: { ipv4Address?: string; ipv6Address?: string; aliases?: string[] } = {};
if (cfg.ipv4Address) entry.ipv4Address = cfg.ipv4Address;
if (cfg.ipv6Address) entry.ipv6Address = cfg.ipv6Address;
if (cfg.aliases) entry.aliases = cfg.aliases.split(',').map(a => a.trim()).filter(Boolean);
if (Object.keys(entry).length > 0) netConfigs[netName] = entry;
}
const payload = {
name: name.trim(),
image: image.trim(),
ports: Object.keys(ports).length > 0 ? ports : null,
volumeBinds: volumeBinds.length > 0 ? volumeBinds : null,
env: env.length > 0 ? env : undefined,
labels: Object.keys(labelsObj).length > 0 ? labelsObj : undefined,
labels: labelsObj,
cmd,
restartPolicy,
restartMaxRetries: restartPolicy === 'on-failure' && restartMaxRetries !== '' ? Number(restartMaxRetries) : undefined,
networkMode,
networks: selectedNetworks.length > 0 ? selectedNetworks : undefined,
networkConfigs: Object.keys(netConfigs).length > 0 ? netConfigs : undefined,
macAddress: macAddress.trim() || undefined,
startAfterUpdate,
repullImage,
user: containerUser.trim() || null,
@@ -1091,6 +1134,8 @@
bind:labels
{availableNetworks}
bind:selectedNetworks
bind:networkConfigs
bind:macAddress
bind:containerUser
bind:privilegedMode
bind:healthcheckEnabled
+26 -9
View File
@@ -162,20 +162,37 @@
// Push colliding items down (returns new array)
function pushCollidingItems(movedItem: GridItemLayout, sourceItems: GridItemLayout[]): GridItemLayout[] {
const newItems = sourceItems.map(item => ({ ...item }));
// Step 1: Push items that directly collide with the moved item
for (const item of newItems) {
if (item.id === movedItem.id) continue;
const overlaps = !(item.x + item.w <= movedItem.x || item.x >= movedItem.x + movedItem.w ||
item.y + item.h <= movedItem.y || item.y >= movedItem.y + movedItem.h);
if (overlaps) {
item.y = movedItem.y + movedItem.h;
}
}
// Step 2: Resolve cascading collisions by sorting top-to-bottom and pushing down
let changed = true;
let iterations = 0;
const maxIterations = 100; // Prevent infinite loops
while (changed && iterations < maxIterations) {
while (changed && iterations < 100) {
changed = false;
iterations++;
for (const item of newItems) {
if (item.id === movedItem.id) continue;
const sorted = newItems
.filter(i => i.id !== movedItem.id)
.sort((a, b) => a.y - b.y || a.x - b.x);
if (hasCollision(item, movedItem)) {
// Push this item down
item.y = movedItem.y + movedItem.h;
changed = true;
for (let i = 0; i < sorted.length; i++) {
for (let j = i + 1; j < sorted.length; j++) {
const upper = sorted[i];
const lower = sorted[j];
const overlaps = !(upper.x + upper.w <= lower.x || upper.x >= lower.x + lower.w ||
upper.y + upper.h <= lower.y || upper.y >= lower.y + lower.h);
if (overlaps) {
lower.y = upper.y + upper.h;
changed = true;
}
}
}
}
+6 -6
View File
@@ -338,7 +338,7 @@
</div>
</Card.Header>
<DashboardLabels labels={stats.labels} />
<Card.Content class="overflow-auto" style="max-height: calc(100% - 60px);">
<Card.Content class="overflow-hidden" style="max-height: calc(100% - 60px);">
{#if showOffline}
<DashboardOfflineState error={stats.error} compact={isMini} />
{:else}
@@ -439,7 +439,7 @@
</div>
</Card.Header>
<DashboardLabels labels={stats.labels} />
<Card.Content class="overflow-auto" style="max-height: calc(100% - 60px);">
<Card.Content class="overflow-hidden" style="max-height: calc(100% - 60px);">
{#if showOffline}
<DashboardOfflineState error={stats.error} compact={isMini} />
{:else}
@@ -543,7 +543,7 @@
</div>
</Card.Header>
<DashboardLabels labels={stats.labels} />
<Card.Content class="overflow-auto" style="max-height: calc(100% - 60px);">
<Card.Content class="overflow-hidden" style="max-height: calc(100% - 60px);">
{#if showOffline}
<DashboardOfflineState error={stats.error} compact={isMini} />
{:else}
@@ -586,7 +586,7 @@
/>
</Card.Header>
<DashboardLabels labels={stats.labels} />
<Card.Content class="overflow-auto" style="max-height: calc(100% - 60px);">
<Card.Content class="overflow-hidden" style="max-height: calc(100% - 60px);">
{#if showOffline}
<DashboardOfflineState error={stats.error} compact={isMini} />
{:else}
@@ -632,7 +632,7 @@
/>
</Card.Header>
<DashboardLabels labels={stats.labels} />
<Card.Content class="overflow-auto" style="max-height: calc(100% - 60px);">
<Card.Content class="overflow-hidden" style="max-height: calc(100% - 60px);">
{#if showOffline}
<DashboardOfflineState error={stats.error} compact={isMini} />
{:else}
@@ -684,7 +684,7 @@
/>
</Card.Header>
<DashboardLabels labels={stats.labels} />
<Card.Content class="overflow-auto" style="max-height: calc(100% - 60px);">
<Card.Content class="overflow-hidden" style="max-height: calc(100% - 60px);">
{#if showOffline}
<DashboardOfflineState error={stats.error} compact={isMini} />
{:else}
+209 -31
View File
@@ -10,13 +10,15 @@
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 } 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 } from 'lucide-svelte';
import { copyToClipboard } from '$lib/utils/clipboard';
import PageHeader from '$lib/components/PageHeader.svelte';
import TerminalPanel from '../terminal/TerminalPanel.svelte';
import { detectShells, getBestShell, getSavedUser } from '$lib/utils/shell-detection';
import type { FavoriteGroup } from '../api/preferences/favorite-groups/+server';
import type { ContainerInfo } from '$lib/types';
import { currentEnvironment, environments, appendEnvParam } from '$lib/stores/environment';
import { appSettings } from '$lib/stores/settings';
import { appSettings, formatLogTimestamps } from '$lib/stores/settings';
import { NoEnvironment } from '$lib/components/ui/empty-state';
import { AnsiUp } from 'ansi_up';
const ansiUp = new AnsiUp();
@@ -306,12 +308,94 @@ import type { FavoriteGroup } from '../api/preferences/favorite-groups/+server';
// Log search state
let logSearchActive = $state(false);
let logSearchQuery = $state('');
let logSearchFilterMode = $state(false);
let currentMatchIndex = $state(0);
let matchCount = $state(0);
let logSearchInputRef: HTMLInputElement | undefined;
const fontSizeOptions = [10, 12, 14, 16];
// Terminal state
let terminalOpen = $state(false);
let terminalContainerId = $state<string | null>(null);
let terminalContainerName = $state('');
let terminalShell = $state('/bin/bash');
let terminalUser = $state('root');
let terminalLayout = $state<'below' | 'right'>('below');
let terminalSplitRatio = $state(0.5); // 0-1, ratio of logs panel
let isResizingTerminal = $state(false);
let terminalSplitRef: HTMLDivElement | undefined;
const TERMINAL_LAYOUT_KEY = 'dockhand-logs-terminal-layout';
const TERMINAL_SPLIT_KEY = 'dockhand-logs-terminal-split';
function loadTerminalSettings() {
if (typeof window === 'undefined') return;
const savedLayout = localStorage.getItem(TERMINAL_LAYOUT_KEY);
if (savedLayout === 'below' || savedLayout === 'right') terminalLayout = savedLayout;
const savedSplit = localStorage.getItem(TERMINAL_SPLIT_KEY);
if (savedSplit) {
const r = parseFloat(savedSplit);
if (!isNaN(r) && r >= 0.2 && r <= 0.8) terminalSplitRatio = r;
}
}
function saveTerminalSettings() {
if (typeof window === 'undefined') return;
localStorage.setItem(TERMINAL_LAYOUT_KEY, terminalLayout);
localStorage.setItem(TERMINAL_SPLIT_KEY, String(terminalSplitRatio));
}
async function openTerminal(containerId: string, containerName: string, layout?: 'below' | 'right') {
if (terminalOpen && terminalContainerId === containerId && (!layout || layout === terminalLayout)) {
closeTerminal();
return;
}
if (layout) {
terminalLayout = layout;
saveTerminalSettings();
}
terminalContainerId = containerId;
terminalContainerName = containerName;
const savedUser = getSavedUser(containerId);
if (savedUser) terminalUser = savedUser;
const result = await detectShells(containerId, envId);
const best = getBestShell(result, terminalShell);
if (best) terminalShell = best;
terminalOpen = true;
}
function closeTerminal() {
terminalOpen = false;
terminalContainerId = null;
}
function startTerminalResize(e: MouseEvent) {
e.preventDefault();
isResizingTerminal = true;
document.addEventListener('mousemove', handleTerminalResize);
document.addEventListener('mouseup', stopTerminalResize);
}
function handleTerminalResize(e: MouseEvent) {
if (!isResizingTerminal || !terminalSplitRef) return;
const rect = terminalSplitRef.getBoundingClientRect();
let ratio: number;
if (terminalLayout === 'below') {
ratio = (e.clientY - rect.top) / rect.height;
} else {
ratio = (e.clientX - rect.left) / rect.width;
}
terminalSplitRatio = Math.max(0.2, Math.min(0.8, ratio));
}
function stopTerminalResize() {
isResizingTerminal = false;
document.removeEventListener('mousemove', handleTerminalResize);
document.removeEventListener('mouseup', stopTerminalResize);
saveTerminalSettings();
}
// Subscribe to environment changes - restore state and fetch data
const unsubscribeEnv = currentEnvironment.subscribe(async (env) => {
envId = env?.id ?? null;
@@ -769,6 +853,10 @@ import type { FavoriteGroup } from '../api/preferences/favorite-groups/+server';
return `[${data.containerName}] ${line}`;
}).join('\n');
}
// Format timestamps if enabled
if ($appSettings.formatLogTimestamps) {
text = formatLogTimestamps(text);
}
// Buffer text and schedule flush
pendingText += text;
if (!flushTimer) {
@@ -953,12 +1041,13 @@ import type { FavoriteGroup } from '../api/preferences/favorite-groups/+server';
if (data.text) {
// Use consistent color based on position in all selected containers
const color = getContainerColor(data.containerId);
const logText = $appSettings.formatLogTimestamps ? formatLogTimestamps(data.text) : data.text;
// Add to pending batch instead of updating state immediately
pendingLogs.push({
containerId: data.containerId,
containerName: data.containerName,
color,
text: data.text,
text: logText,
timestamp: data.timestamp,
stream: data.stream
});
@@ -1134,6 +1223,11 @@ import type { FavoriteGroup } from '../api/preferences/favorite-groups/+server';
// Stop any existing stream
stopStreaming();
// Close terminal when switching containers
if (terminalOpen && terminalContainerId !== container.id) {
closeTerminal();
}
selectedContainer = container;
searchQuery = '';
dropdownOpen = false;
@@ -1314,10 +1408,15 @@ import type { FavoriteGroup } from '../api/preferences/favorite-groups/+server';
function closeLogSearch() {
logSearchActive = false;
logSearchQuery = '';
logSearchFilterMode = false;
currentMatchIndex = 0;
matchCount = 0;
}
function toggleSearchFilterMode() {
logSearchFilterMode = !logSearchFilterMode;
}
function navigateMatch(direction: 'prev' | 'next') {
if (!logsRef || matchCount === 0) return;
@@ -1368,38 +1467,54 @@ import type { FavoriteGroup } from '../api/preferences/favorite-groups/+server';
// Highlighted logs with search matches and ANSI color support (single container mode)
let highlightedLogs = $derived(() => {
// First convert ANSI codes to HTML
const withAnsi = ansiToHtml(logs || '');
if (!logSearchQuery.trim()) return withAnsi;
let text = logs || '';
const query = logSearchQuery.trim();
// For search, we need to highlight matches while preserving HTML tags
// We'll only highlight text outside of HTML tags
const query = logSearchQuery.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
const escapedQuery = escapeHtml(query);
// Filter lines before ANSI conversion (plain text matching)
if (logSearchFilterMode && query) {
const escapedForRegex = query.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
const filterRegex = new RegExp(escapedForRegex, 'i');
text = text.split('\n').filter(line => filterRegex.test(line)).join('\n');
}
const withAnsi = ansiToHtml(text);
if (!query) return withAnsi;
const escapedForRegex = query.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
const escapedQuery = escapeHtml(escapedForRegex);
// Split by HTML tags and only process text parts
const parts = withAnsi.split(/(<[^>]*>)/);
const highlighted = parts.map(part => {
// Skip HTML tags
return parts.map(part => {
if (part.startsWith('<')) return part;
// Highlight matches in text
const regex = new RegExp(`(${escapedQuery})`, 'gi');
return part.replace(regex, '<mark class="search-match">$1</mark>');
}).join('');
return highlighted;
});
// Format merged logs HTML — uses pre-built mergedHtml string, only applies search highlighting when needed
let formattedMergedHtml = $derived(() => {
if (!mergedHtml) return '';
if (!logSearchQuery.trim()) return mergedHtml;
const query = logSearchQuery.trim();
// Apply search highlighting (same approach as single mode's highlightedLogs)
const query = logSearchQuery.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
const escapedQuery = escapeHtml(query);
// Filter mode: remove non-matching lines from HTML
let html = mergedHtml;
if (logSearchFilterMode && query) {
const escapedForRegex = query.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
const filterRegex = new RegExp(escapedForRegex, 'i');
// Split by <br/> or newlines, filter lines (strip HTML for matching, keep original for display)
const lines = html.split(/\n/);
html = lines.filter(line => {
const plainText = line.replace(/<[^>]*>/g, '');
return filterRegex.test(plainText);
}).join('\n');
}
if (!query) return html;
const escapedForRegex = query.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
const escapedQuery = escapeHtml(escapedForRegex);
const searchRegex = new RegExp(`(${escapedQuery})`, 'gi');
const parts = mergedHtml.split(/(<[^>]*>)/);
const parts = html.split(/(<[^>]*>)/);
return parts.map(part => {
if (part.startsWith('<')) return part;
return part.replace(searchRegex, '<mark class="search-match">$1</mark>');
@@ -1430,6 +1545,7 @@ import type { FavoriteGroup } from '../api/preferences/favorite-groups/+server';
onMount(() => {
loadTerminalSettings();
// All initialization is handled in currentEnvironment.subscribe
// This just sets up the refresh interval
containerInterval = setInterval(fetchContainers, 10000);
@@ -1437,6 +1553,8 @@ import type { FavoriteGroup } from '../api/preferences/favorite-groups/+server';
});
onDestroy(() => {
document.removeEventListener('mousemove', handleTerminalResize);
document.removeEventListener('mouseup', stopTerminalResize);
unsubscribeEnv();
if (containerInterval) {
clearInterval(containerInterval);
@@ -1831,8 +1949,9 @@ import type { FavoriteGroup } from '../api/preferences/favorite-groups/+server';
</div>
{/if}
<!-- Logs panel -->
<div class="flex-1 min-h-0 border rounded-lg overflow-hidden flex flex-col transition-colors {darkMode ? 'bg-zinc-950 border-zinc-800' : 'bg-gray-50 border-gray-300'}">
<!-- Logs + Terminal split -->
<div bind:this={terminalSplitRef} class="flex-1 min-h-0 min-w-0 overflow-hidden flex {terminalOpen ? (terminalLayout === 'below' ? 'flex-col' : 'flex-row') : ''} gap-0">
<div class="{terminalOpen ? 'min-h-0 min-w-0' : 'flex-1'} border rounded-lg overflow-hidden flex flex-col transition-colors {darkMode ? 'bg-zinc-950 border-zinc-800' : 'bg-gray-50 border-gray-300'}" style="{terminalOpen ? (terminalLayout === 'below' ? `height: ${terminalSplitRatio * 100}%` : `width: ${terminalSplitRatio * 100}%`) : ''}">
{#if layoutMode === 'grouped'}
{#if selectedContainerIds.size === 0}
<div class="flex items-center justify-center h-full text-muted-foreground">
@@ -1840,8 +1959,8 @@ import type { FavoriteGroup } from '../api/preferences/favorite-groups/+server';
</div>
{:else}
<!-- Header bar for grouped mode -->
<div class="flex items-center justify-between px-3 py-1.5 border-b shrink-0 transition-colors {darkMode ? 'border-zinc-800 bg-zinc-900/50' : 'border-gray-300 bg-gray-100'}">
<div class="flex items-center gap-2 min-w-[100px]">
<div class="flex items-center flex-wrap gap-y-1 px-3 py-1.5 border-b shrink-0 transition-colors {darkMode ? 'border-zinc-800 bg-zinc-900/50' : 'border-gray-300 bg-gray-100'}">
<div class="flex items-center gap-2 shrink-0">
{#if streamingEnabled}
{#if isConnected}
<div class="flex items-center gap-1.5" title="Connected - Live streaming">
@@ -1885,7 +2004,7 @@ import type { FavoriteGroup } from '../api/preferences/favorite-groups/+server';
{/if}
</div>
</div>
<div class="flex items-center gap-3">
<div class="flex items-center gap-2 flex-wrap ml-auto">
<button
onclick={toggleStreaming}
class="flex items-center gap-1.5 px-2 py-1 rounded text-xs transition-colors {streamingEnabled ? (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-200'}"
@@ -1943,6 +2062,13 @@ import type { FavoriteGroup } from '../api/preferences/favorite-groups/+server';
onkeydown={handleLogSearchKeydown}
class="bg-transparent border-none outline-none text-xs w-28 {darkMode ? 'text-zinc-200 placeholder:text-zinc-500' : 'text-gray-800 placeholder:text-gray-400'}"
/>
<button
onclick={toggleSearchFilterMode}
class="p-0.5 rounded transition-colors {logSearchFilterMode ? (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-700' : 'hover:bg-gray-300'}"
title={logSearchFilterMode ? 'Show all lines (filter mode active)' : 'Hide non-matching lines'}
>
<Filter class="w-3 h-3 transition-colors {logSearchFilterMode ? (darkMode ? 'text-amber-400' : 'text-amber-700') : darkMode ? 'text-zinc-400' : 'text-gray-500'}" />
</button>
{#if matchCount > 0}
<span class="text-xs {darkMode ? 'text-zinc-400' : 'text-gray-500'}">{currentMatchIndex + 1}/{matchCount}</span>
{:else if logSearchQuery}
@@ -1993,8 +2119,8 @@ import type { FavoriteGroup } from '../api/preferences/favorite-groups/+server';
</div>
{:else}
<!-- Header bar inside black area -->
<div class="flex items-center justify-between px-3 py-1.5 border-b shrink-0 transition-colors {darkMode ? 'border-zinc-800 bg-zinc-900/50' : 'border-gray-300 bg-gray-100'}">
<div class="flex items-center gap-2 min-w-[100px]">
<div class="flex items-center flex-wrap gap-y-1 px-3 py-1.5 border-b shrink-0 transition-colors {darkMode ? 'border-zinc-800 bg-zinc-900/50' : 'border-gray-300 bg-gray-100'}">
<div class="flex items-center gap-2 shrink-0">
<!-- Connection status indicator -->
{#if streamingEnabled}
{#if isConnected}
@@ -2028,14 +2154,34 @@ import type { FavoriteGroup } from '../api/preferences/favorite-groups/+server';
<span class="text-xs {darkMode ? 'text-zinc-500' : 'text-gray-400'}">Paused</span>
</div>
{/if}
<!-- Container name -->
<!-- Container name + terminal toggles -->
{#if selectedContainer}
<div class="flex items-center gap-1 ml-2">
<div class="flex items-center gap-1.5 ml-2">
<span class="text-xs font-medium {darkMode ? 'text-zinc-300' : 'text-gray-700'}">{selectedContainer.name}</span>
<button
onclick={() => openTerminal(selectedContainer!.id, selectedContainer!.name, 'below')}
class="p-0.5 rounded transition-colors {terminalOpen && terminalLayout === 'below' && terminalContainerId === selectedContainer.id ? (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="Terminal below"
>
<span class="inline-flex items-center gap-px">
<Terminal class="w-3.5 h-3.5 {terminalOpen && terminalLayout === 'below' && terminalContainerId === selectedContainer.id ? (darkMode ? 'text-amber-400' : 'text-amber-700') : darkMode ? 'text-zinc-500 hover:text-zinc-300' : 'text-gray-500 hover:text-gray-700'}" />
<ArrowDown class="w-2.5 h-2.5 {terminalOpen && terminalLayout === 'below' && terminalContainerId === selectedContainer.id ? (darkMode ? 'text-amber-400' : 'text-amber-700') : darkMode ? 'text-zinc-600' : 'text-gray-400'}" strokeWidth={2.5} />
</span>
</button>
<button
onclick={() => openTerminal(selectedContainer!.id, selectedContainer!.name, 'right')}
class="p-0.5 rounded transition-colors {terminalOpen && terminalLayout === 'right' && terminalContainerId === selectedContainer.id ? (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="Terminal on side"
>
<span class="inline-flex items-center gap-px">
<Terminal class="w-3.5 h-3.5 {terminalOpen && terminalLayout === 'right' && terminalContainerId === selectedContainer.id ? (darkMode ? 'text-amber-400' : 'text-amber-700') : darkMode ? 'text-zinc-500 hover:text-zinc-300' : 'text-gray-500 hover:text-gray-700'}" />
<ArrowRight class="w-2.5 h-2.5 {terminalOpen && terminalLayout === 'right' && terminalContainerId === selectedContainer.id ? (darkMode ? 'text-amber-400' : 'text-amber-700') : darkMode ? 'text-zinc-600' : 'text-gray-400'}" strokeWidth={2.5} />
</span>
</button>
</div>
{/if}
</div>
<div class="flex items-center gap-3">
<div class="flex items-center gap-2 flex-wrap ml-auto">
<!-- Streaming toggle -->
<button
onclick={toggleStreaming}
@@ -2099,6 +2245,13 @@ import type { FavoriteGroup } from '../api/preferences/favorite-groups/+server';
onkeydown={handleLogSearchKeydown}
class="bg-transparent border-none outline-none text-xs w-28 {darkMode ? 'text-zinc-200 placeholder:text-zinc-500' : 'text-gray-800 placeholder:text-gray-400'}"
/>
<button
onclick={toggleSearchFilterMode}
class="p-0.5 rounded transition-colors {logSearchFilterMode ? (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-700' : 'hover:bg-gray-300'}"
title={logSearchFilterMode ? 'Show all lines (filter mode active)' : 'Hide non-matching lines'}
>
<Filter class="w-3 h-3 transition-colors {logSearchFilterMode ? (darkMode ? 'text-amber-400' : 'text-amber-700') : darkMode ? 'text-zinc-400' : 'text-gray-500'}" />
</button>
{#if matchCount > 0}
<span class="text-xs {darkMode ? 'text-zinc-400' : 'text-gray-500'}">{currentMatchIndex + 1}/{matchCount}</span>
{:else if logSearchQuery}
@@ -2177,6 +2330,31 @@ import type { FavoriteGroup } from '../api/preferences/favorite-groups/+server';
{/if}
{/if}
</div>
<!-- Terminal panel with resize handle -->
{#if terminalOpen && terminalContainerId}
<!-- Resize handle -->
<div
role="separator"
class="{terminalLayout === 'below' ? 'h-2 cursor-ns-resize w-full' : 'w-2 cursor-ew-resize h-full'} flex items-center justify-center hover:bg-muted/50 transition-colors {isResizingTerminal ? 'bg-muted/50' : ''}"
onmousedown={startTerminalResize}
>
<GripHorizontal class="{terminalLayout === 'below' ? 'w-8 h-4' : 'w-4 h-8 rotate-90'} text-zinc-600" />
</div>
<!-- Terminal -->
<div class="min-h-0 min-w-0 border rounded-lg overflow-hidden" style="{terminalLayout === 'below' ? `height: ${(1 - terminalSplitRatio) * 100}%` : `width: ${(1 - terminalSplitRatio) * 100}%`}">
<TerminalPanel
containerId={terminalContainerId}
containerName={terminalContainerName}
shell={terminalShell}
user={terminalUser}
visible={true}
envId={envId}
fillHeight={true}
onClose={closeTerminal}
/>
</div>
{/if}
</div>
</div>
</div>
{/if}
+30 -5
View File
@@ -1,5 +1,5 @@
<script lang="ts">
import { RefreshCw, Copy, Download, WrapText, ArrowDownToLine, Search, ChevronUp, ChevronDown, X, Type, Eraser } from 'lucide-svelte';
import { RefreshCw, Copy, Download, WrapText, ArrowDownToLine, Search, ChevronUp, ChevronDown, X, Type, Eraser, Filter } from 'lucide-svelte';
import { copyToClipboard } from '$lib/utils/clipboard';
import * as Select from '$lib/components/ui/select';
import { appSettings, formatLogTimestamps } from '$lib/stores/settings';
@@ -45,6 +45,7 @@
// Search state
let logSearchActive = $state(false);
let logSearchQuery = $state('');
let logSearchFilterMode = $state(typeof window !== 'undefined' && localStorage.getItem('dockhand-log-filter-mode') === 'true');
let currentMatchIndex = $state(0);
let matchCount = $state(0);
let logSearchInputRef: HTMLInputElement;
@@ -107,10 +108,16 @@
function closeLogSearch() {
logSearchActive = false;
logSearchQuery = '';
logSearchFilterMode = false;
currentMatchIndex = 0;
matchCount = 0;
}
function toggleSearchFilterMode() {
logSearchFilterMode = !logSearchFilterMode;
localStorage.setItem('dockhand-log-filter-mode', String(logSearchFilterMode));
}
function navigateMatch(direction: 'prev' | 'next') {
if (!logsRef || matchCount === 0) return;
@@ -151,11 +158,22 @@
if ($appSettings.formatLogTimestamps) {
text = formatLogTimestamps(text);
}
const withAnsi = ansiUp.ansi_to_html(text);
if (!logSearchQuery.trim()) return withAnsi;
const query = logSearchQuery.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
const escapedQuery = query.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
const query = logSearchQuery.trim();
// Filter lines before ANSI conversion (plain text matching)
if (logSearchFilterMode && query) {
const escapedForRegex = query.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
const filterRegex = new RegExp(escapedForRegex, 'i');
const lines = text.split('\n');
text = lines.filter(line => filterRegex.test(line)).join('\n');
}
const withAnsi = ansiUp.ansi_to_html(text);
if (!query) return withAnsi;
const escapedForRegex = query.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
const escapedQuery = escapedForRegex.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
// Split by HTML tags and only process text parts
const parts = withAnsi.split(/(<[^>]*>)/);
@@ -246,6 +264,13 @@
onkeydown={handleLogSearchKeydown}
class="bg-transparent border-none outline-none text-xs text-zinc-200 w-20 placeholder:text-zinc-500"
/>
<button
onclick={toggleSearchFilterMode}
class="p-0.5 rounded transition-colors {logSearchFilterMode ? 'bg-amber-500/20 ring-1 ring-amber-500/50' : 'hover:bg-zinc-700'}"
title={logSearchFilterMode ? 'Show all lines (filter mode active)' : 'Hide non-matching lines'}
>
<Filter class="w-3 h-3 transition-colors {logSearchFilterMode ? 'text-amber-400' : 'text-zinc-400'}" />
</button>
{#if matchCount > 0}
<span class="text-xs text-zinc-400">{currentMatchIndex + 1}/{matchCount}</span>
{:else if logSearchQuery}
+33 -6
View File
@@ -1,6 +1,6 @@
<script lang="ts">
import { onMount, onDestroy, tick } from 'svelte';
import { X, GripHorizontal, RefreshCw, Copy, Download, WrapText, ArrowDownToLine, Search, ChevronUp, ChevronDown, Sun, Moon, Wifi, WifiOff, Pause, Play, Eraser } from 'lucide-svelte';
import { X, GripHorizontal, RefreshCw, Copy, Download, WrapText, ArrowDownToLine, Search, ChevronUp, ChevronDown, Sun, Moon, Wifi, WifiOff, Pause, Play, Eraser, Filter } from 'lucide-svelte';
import { copyToClipboard } from '$lib/utils/clipboard';
import * as Select from '$lib/components/ui/select';
import { appSettings, formatLogTimestamps } from '$lib/stores/settings';
@@ -53,6 +53,7 @@
// Search state
let logSearchActive = $state(false);
let logSearchQuery = $state('');
let logSearchFilterMode = $state(false);
let currentMatchIndex = $state(0);
let matchCount = $state(0);
let logSearchInputRef: HTMLInputElement | undefined;
@@ -97,6 +98,7 @@
if (settings.fontSize !== undefined) fontSize = settings.fontSize;
if (settings.autoScroll !== undefined) autoScroll = settings.autoScroll;
if (settings.streamingEnabled !== undefined) streamingEnabled = settings.streamingEnabled;
if (settings.logSearchFilterMode !== undefined) logSearchFilterMode = settings.logSearchFilterMode;
} catch {
// Ignore parse errors
}
@@ -112,7 +114,8 @@
wordWrap,
fontSize,
autoScroll,
streamingEnabled
streamingEnabled,
logSearchFilterMode
}));
}
}
@@ -490,10 +493,16 @@
function closeLogSearch() {
logSearchActive = false;
logSearchQuery = '';
logSearchFilterMode = false;
currentMatchIndex = 0;
matchCount = 0;
}
function toggleSearchFilterMode() {
logSearchFilterMode = !logSearchFilterMode;
saveSettings();
}
function navigateMatch(direction: 'prev' | 'next') {
if (!logsRef || matchCount === 0) return;
@@ -534,11 +543,22 @@
if ($appSettings.formatLogTimestamps) {
text = formatLogTimestamps(text);
}
const withAnsi = ansiUp.ansi_to_html(text);
if (!logSearchQuery.trim()) return withAnsi;
const query = logSearchQuery.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
const escapedQuery = query.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
const query = logSearchQuery.trim();
// Filter lines before ANSI conversion (plain text matching)
if (logSearchFilterMode && query) {
const escapedForRegex = query.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
const filterRegex = new RegExp(escapedForRegex, 'i');
const lines = text.split('\n');
text = lines.filter(line => filterRegex.test(line)).join('\n');
}
const withAnsi = ansiUp.ansi_to_html(text);
if (!query) return withAnsi;
const escapedForRegex = query.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
const escapedQuery = escapedForRegex.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
// Split by HTML tags and only process text parts
const parts = withAnsi.split(/(<[^>]*>)/);
@@ -734,6 +754,13 @@
onkeydown={handleLogSearchKeydown}
class="bg-transparent border-none outline-none text-xs w-20 {darkMode ? 'text-zinc-200 placeholder:text-zinc-500' : 'text-gray-800 placeholder:text-gray-400'}"
/>
<button
onclick={toggleSearchFilterMode}
class="p-0.5 rounded transition-colors {logSearchFilterMode ? (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-700' : 'hover:bg-gray-300'}"
title={logSearchFilterMode ? 'Show all lines (filter mode active)' : 'Hide non-matching lines'}
>
<Filter class="w-3 h-3 transition-colors {logSearchFilterMode ? (darkMode ? 'text-amber-400' : 'text-amber-700') : darkMode ? 'text-zinc-400' : 'text-gray-500'}" />
</button>
{#if matchCount > 0}
<span class="text-xs {darkMode ? 'text-zinc-400' : 'text-gray-500'}">{currentMatchIndex + 1}/{matchCount}</span>
{:else if logSearchQuery}
+21 -2
View File
@@ -9,7 +9,7 @@
import { Button } from '$lib/components/ui/button';
import { Input } from '$lib/components/ui/input';
import MultiSelectFilter from '$lib/components/MultiSelectFilter.svelte';
import { Trash2, Search, Plus, Eye, Check, XCircle, RefreshCw, Icon, AlertTriangle, X, Network, Link, Copy, CopyPlus, Share2, Server, Globe, MonitorSmartphone, Cpu, CircleOff } from 'lucide-svelte';
import { Trash2, Search, Plus, Eye, Check, XCircle, RefreshCw, Icon, AlertTriangle, X, Network, Link, Copy, CopyPlus, Share2, Server, Globe, MonitorSmartphone, Cpu, CircleOff, GitGraph } from 'lucide-svelte';
import { broom } from '@lucide/lab';
import { copyToClipboard } from '$lib/utils/clipboard';
import ConfirmPopover from '$lib/components/ConfirmPopover.svelte';
@@ -25,6 +25,7 @@
import PageHeader from '$lib/components/PageHeader.svelte';
import { DataGrid } from '$lib/components/data-grid';
import { ipToNumber } from '$lib/utils/ip';
import NetworkGraphModal from './NetworkGraphModal.svelte';
type SortField = 'name' | 'driver' | 'containers' | 'subnet' | 'gateway';
type SortDirection = 'asc' | 'desc';
@@ -83,6 +84,7 @@
let showCreateModal = $state(false);
let showInspectModal = $state(false);
let showConnectModal = $state(false);
let showGraphModal = $state(false);
let inspectNetworkId = $state('');
let inspectNetworkName = $state('');
let connectNetwork = $state<NetworkInfo | null>(null);
@@ -351,6 +353,10 @@
showConnectModal = true;
}
function openGraphModal() {
showGraphModal = true;
}
async function disconnectContainer(networkId: string, networkName: string, containerId: string, containerName: string) {
disconnectingContainerId = containerId;
try {
@@ -554,8 +560,12 @@
<RefreshCw class="w-3.5 h-3.5" />
Refresh
</Button>
<Button size="sm" variant="outline" onclick={openGraphModal}>
<GitGraph class="w-3.5 h-3.5" />
View Graph
</Button>
{#if $canAccess('networks', 'create')}
<Button size="sm" variant="secondary" onclick={() => showCreateModal = true}>
<Button size="sm" variant="outline" onclick={() => showCreateModal = true}>
<Plus class="w-3.5 h-3.5" />
Create
</Button>
@@ -742,3 +752,12 @@
onClose={() => showBatchOpModal = false}
onComplete={handleBatchComplete}
/>
<!-- Edit Stack Modal -->
<NetworkGraphModal
bind:open={showGraphModal}
networks={networks}
onClose={() => {
showGraphModal = false;
}}
/>
@@ -0,0 +1,146 @@
<script lang="ts">
import { onMount } from "svelte";
import * as Dialog from "$lib/components/ui/dialog";
import CodeEditor from "$lib/components/CodeEditor.svelte";
import { Layers, X } from "lucide-svelte";
import { focusFirstInput } from "$lib/utils";
import { ErrorDialog } from "$lib/components/ui/error-dialog";
import NetworkGraphViewer from "./NetworkGraphViewer.svelte";
import { useSidebar } from "$lib/components/ui/sidebar/context.svelte";
import type { NetworkInfo } from "$lib/types";
// Get sidebar state to adjust modal positioning
const sidebar = useSidebar();
interface Props {
open: boolean;
networks: NetworkInfo[]; // Required for edit mode, optional for create
onClose: () => void;
}
let { open = $bindable(), networks: propNetworks, onClose }: Props = $props();
let networks = $state<NetworkInfo[]>([]);
// Form state
let saving = $state(false);
let editorTheme = $state<"light" | "dark">("dark");
// Error dialog state
let operationError = $state<{
title: string;
message: string;
details?: string;
} | null>(null);
// CodeEditor reference for explicit marker updates
let codeEditorRef: CodeEditor | null = $state(null);
// NetworkGraphViewer reference for resize on panel toggle
let graphViewerRef: NetworkGraphViewer | null = $state(null);
// Display title
const displayName = "DEMO";
onMount(() => {
// Load saved editor theme, or fall back to app theme / system preference
const savedEditorTheme = localStorage.getItem("dockhand-editor-theme");
if (savedEditorTheme === "dark" || savedEditorTheme === "light") {
editorTheme = savedEditorTheme;
} else {
const appTheme = localStorage.getItem("theme");
if (appTheme === "dark" || appTheme === "light") {
editorTheme = appTheme;
} else {
// Fallback to system preference
editorTheme = window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light";
}
}
});
function tryClose() {
handleClose();
}
let containerRef: HTMLDivElement | null = $state(null);
function handleClose() {
// Reset mode back to prop values
networks = propNetworks;
codeEditorRef = null;
operationError = null;
onClose();
}
// Initialize when dialog opens - ONLY ONCE per open
let hasInitialized = $state(false);
$effect(() => {
if (open && !hasInitialized) {
hasInitialized = true;
// Reset mode to prop values on each open
networks = propNetworks;
} else if (!open) {
hasInitialized = false; // Reset when modal closes
}
});
</script>
<Dialog.Root
bind:open
onOpenChange={(isOpen) => {
if (isOpen) {
focusFirstInput();
} else {
// No unsaved changes - reset state
handleClose();
}
}}
>
<Dialog.Content
class="max-w-none h-[95vh] flex flex-col p-0 gap-0 shadow-xl border-zinc-200 dark:border-zinc-700 {sidebar.state === 'collapsed'
? 'w-[calc(100vw-6rem)] ml-[1.5rem]'
: 'w-[calc(100vw-12rem)] ml-[4.5rem]'}"
showCloseButton={false}
>
<Dialog.Header class="px-5 py-3 border-b border-zinc-200 dark:border-zinc-700 flex-shrink-0">
<div class="flex items-center justify-between">
<div class="flex items-center gap-3">
<div class="flex items-center gap-2">
<div class="p-1.5 rounded-md bg-zinc-200 dark:bg-zinc-700">
<Layers class="w-4 h-4 text-zinc-600 dark:text-zinc-300" />
</div>
<div>
<Dialog.Title class="text-sm font-semibold text-zinc-800 dark:text-zinc-100">View network graph</Dialog.Title>
<Dialog.Description class="text-xs text-zinc-500 dark:text-zinc-400">View network connections between containers</Dialog.Description>
</div>
</div>
</div>
<div class="flex items-center gap-2">
<!-- Close button -->
<button
onclick={tryClose}
class="p-1.5 rounded-md text-zinc-400 dark:text-zinc-500 hover:text-zinc-600 dark:hover:text-zinc-300 hover:bg-zinc-200 dark:hover:bg-zinc-700 transition-colors"
>
<X class="w-4 h-4" />
</button>
</div>
</div>
</Dialog.Header>
<!-- Content area -->
<div bind:this={containerRef} class="flex-1 min-h-0 flex flex-col">
<!-- Graph tab: Full width -->
<NetworkGraphViewer bind:this={graphViewerRef} {networks} class="h-full flex-1" />
</div>
<!-- Footer -->
<div class="px-5 py-2.5 border-t border-zinc-200 dark:border-zinc-700 flex items-center justify-between flex-shrink-0" />
</Dialog.Content>
</Dialog.Root>
<!-- Error dialog for failed operations -->
{#if operationError}
{@const errorDialogOpen = true}
<ErrorDialog open={errorDialogOpen} title={operationError.title} message={operationError.message} details={operationError.details} onClose={() => (operationError = null)} />
{/if}
@@ -0,0 +1,832 @@
<script lang="ts">
import { onMount, onDestroy } from "svelte";
import cytoscape from "cytoscape";
import {
Box,
Database,
Network,
ZoomIn,
ZoomOut,
Maximize2,
RotateCcw,
X,
ChevronDown,
Sun,
Moon,
LayoutGrid,
GitBranch,
Circle,
Target,
Sparkles,
Share2,
Server,
Globe,
MonitorSmartphone,
Cpu,
CircleOff,
} from "lucide-svelte";
import { Button } from "$lib/components/ui/button";
import { Input } from "$lib/components/ui/input";
import type { NetworkInfo } from "$lib/types";
interface Props {
networks: NetworkInfo[];
class?: string;
}
let { networks, class: className = "" }: Props = $props();
let containerEl: HTMLDivElement | null = $state(null);
let cy: cytoscape.Core | null = null;
let graphInitialized = $state(false);
let selectedNode = $state<any>(null);
let selectedEdge = $state<any>(null);
// Theme state
let graphTheme = $state<"light" | "dark">("light");
// Layout state
type LayoutType = "breadthfirst" | "grid" | "circle" | "concentric" | "cose";
let currentLayout = $state<LayoutType>("breadthfirst");
let showLayoutMenu = $state(false);
const layoutOptions: { value: LayoutType; label: string; icon: string }[] = [
{ value: "breadthfirst", label: "Tree", icon: "tree" },
{ value: "grid", label: "Grid", icon: "grid" },
{ value: "circle", label: "Circle", icon: "circle" },
{ value: "concentric", label: "Radial", icon: "radial" },
{ value: "cose", label: "Force", icon: "force" },
];
function buildGraphElements(nets: NetworkInfo[]) {
interface ContainerResult {
containerId: string;
containerName: string;
networks: {
ipv4: string;
netName: string;
}[];
}
const elements: cytoscape.ElementDefinition[] = [];
const networks = nets;
// Derive services from networks
const serviceMap = networks.reduce<Record<string, ContainerResult>>((svcs, network) => {
Object.entries(network.containers).forEach(([id, config]) => {
if (!svcs[id]) {
svcs[id] = {
containerId: id,
containerName: config.name,
networks: [],
};
}
svcs[id].networks.push({
ipv4: config.ipv4Address,
netName: network.name,
});
});
return svcs;
}, {});
const services = Object.values(serviceMap);
// Add service nodes
services.forEach((service) => {
elements.push({
data: {
id: `service-${service.containerName}`,
label: service.containerName,
caption: '',
type: "service",
config: service,
},
});
});
// Add network nodes
networks.forEach((network) => {
const driver = network.driver;
elements.push({
data: {
id: `network-${network.name}`,
label: network.name,
caption: driver,
type: "network",
driver: driver,
external: !network.internal,
config: network,
},
});
});
// Connect services to networks
services.forEach((service) => {
const serviceNetworks = service.networks;
if (serviceNetworks) {
serviceNetworks.forEach((network) => {
const netName = network.netName;
const foundName = networks.find((network) => network.name === netName);
if (foundName || netName === "default") {
const targetId = foundName ? `network-${netName}` : "network-default";
const defaultNet = networks.find((network) => network.name === "default");
if (netName === "default" && !defaultNet) {
const defaultExists = elements.find((e) => e.data.id === "network-default");
if (!defaultExists) {
elements.push({
data: {
id: "network-default",
label: "default",
type: "network",
driver: "bridge",
external: false,
},
});
}
}
elements.push({
data: {
id: `net-${service.containerName}-${netName}`,
source: `service-${service.containerName}`,
target: targetId,
type: "network-connection",
},
});
}
});
}
});
return elements;
}
// SVG icons as data URLs for nodes
function getSvgIcon(type: string, color: string): string {
const icons: Record<string, string> = {
service: `<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="${color}" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16Z"/><path d="m3.3 7 8.7 5 8.7-5"/><path d="M12 22V12"/></svg>`,
network: `<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="${color}" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="16" y="16" width="6" height="6" rx="1"/><rect x="2" y="16" width="6" height="6" rx="1"/><rect x="9" y="2" width="6" height="6" rx="1"/><path d="M5 16v-3a1 1 0 0 1 1-1h12a1 1 0 0 1 1 1v3"/><path d="M12 12V8"/></svg>`,
};
const svg = icons[type] || icons.service;
return `data:image/svg+xml,${encodeURIComponent(svg)}`;
}
function createGraph(useExistingData = false, skipLayout = false) {
if (!containerEl) return;
// Even if parsing failed, we get at least an empty structure to render
if (!networks) {
networks = [];
}
const elements = buildGraphElements(networks);
// If skipping layout, store current positions before destroying
let savedPositions: Map<string, { x: number; y: number }> | null = null;
if (skipLayout && cy) {
savedPositions = new Map();
cy.nodes().forEach((node) => {
const pos = node.position();
savedPositions!.set(node.id(), { x: pos.x, y: pos.y });
});
}
if (cy) {
cy.destroy();
}
// Theme-based colors
const isDark = graphTheme === "dark";
const colors = {
service: {
bg: isDark ? "#3b82f6" : "#dbeafe",
border: isDark ? "#2563eb" : "#93c5fd",
text: isDark ? "#ffffff" : "#1e3a5f",
icon: isDark ? "#ffffff" : "#2563eb",
},
network: {
bg: isDark ? "#8b5cf6" : "#ede9fe",
border: isDark ? "#7c3aed" : "#c4b5fd",
text: isDark ? "#ffffff" : "#3b1e5f",
icon: isDark ? "#ffffff" : "#7c3aed",
},
edge: isDark ? "#64748b" : "#94a3b8",
selected: isDark ? "#fbbf24" : "#18181b",
caption: isDark ? "rgba(255,255,255,0.7)" : "rgba(0,0,0,0.5)",
};
cy = cytoscape({
container: containerEl,
elements,
style: [
// Service nodes
{
selector: 'node[type="service"]',
style: {
"background-color": colors.service.bg,
"border-color": colors.service.border,
"border-width": 2,
label: (ele: any) => `${ele.data("label")}\n${ele.data("caption") || ""}`,
color: colors.service.text,
"text-valign": "center",
"text-halign": "center",
"font-size": "10px",
"font-weight": 600,
width: 150,
height: 55,
shape: "roundrectangle",
"text-wrap": "wrap",
"text-max-width": "115px",
"text-overflow-wrap": "anywhere",
"line-height": 1.2,
"background-image": getSvgIcon("service", colors.service.icon),
"background-width": "16px",
"background-height": "16px",
"background-position-x": "8px",
"background-position-y": "50%",
"background-clip": "none",
"text-margin-x": 10,
},
},
// Network nodes
{
selector: 'node[type="network"]',
style: {
"background-color": colors.network.bg,
"border-color": colors.network.border,
"border-width": 2,
label: (ele: any) => `${ele.data("label")}\nnetwork: ${ele.data("caption") || "bridge"}`,
color: colors.network.text,
"text-valign": "center",
"text-halign": "center",
"font-size": "9px",
"font-weight": 600,
width: 120,
height: 46,
shape: "roundrectangle",
"text-wrap": "wrap",
"text-max-width": "90px",
"text-overflow-wrap": "anywhere",
"line-height": 1.2,
"background-image": getSvgIcon("network", colors.network.icon),
"background-width": "14px",
"background-height": "14px",
"background-position-x": "6px",
"background-position-y": "50%",
"background-clip": "none",
"text-margin-x": 8,
},
},
// Link edges
{
selector: 'edge[type="link"]',
style: {
width: 2,
"line-color": "#64748b",
"target-arrow-color": "#64748b",
"target-arrow-shape": "triangle",
"curve-style": "bezier",
"line-style": "dashed",
},
},
// Network connection edges
{
selector: 'edge[type="network-connection"]',
style: {
width: 1.5,
"line-color": "#a78bfa",
"curve-style": "bezier",
"line-style": "dotted",
},
},
// Selected node
{
selector: "node:selected",
style: {
"border-width": 3,
"border-color": "#18181b",
"overlay-color": "#18181b",
"overlay-padding": 3,
"overlay-opacity": 0.15,
},
},
// Selected edge
{
selector: "edge:selected",
style: {
width: 3,
"line-color": "#f59e0b",
"target-arrow-color": "#f59e0b",
},
},
// Connection mode - highlight services
{
selector: "node.connection-source",
style: {
"border-width": 4,
"border-color": "#22c55e",
"overlay-color": "#22c55e",
"overlay-padding": 5,
"overlay-opacity": 0.3,
},
},
{
selector: "node.connection-target",
style: {
"border-color": "#3b82f6",
"border-width": 3,
"overlay-color": "#3b82f6",
"overlay-padding": 3,
"overlay-opacity": 0.2,
},
},
],
layout:
skipLayout && savedPositions
? { name: "preset" }
: {
name: "breadthfirst",
directed: true,
padding: 50,
spacingFactor: 1.5,
avoidOverlap: true,
nodeDimensionsIncludeLabels: true,
},
wheelSensitivity: 0.3,
minZoom: 0.3,
maxZoom: 3,
});
// Restore saved positions if skipping layout
if (skipLayout && savedPositions) {
cy.nodes().forEach((node) => {
const savedPos = savedPositions!.get(node.id());
if (savedPos) {
node.position(savedPos);
}
});
}
// Handle node selection
cy.on("tap", "node", (evt) => {
const nodeData = evt.target.data();
console.log("Node tapped:", nodeData);
selectedNode = nodeData;
selectedEdge = null;
console.log("selectedNode set to:", selectedNode);
});
// Handle edge selection
cy.on("tap", "edge", (evt) => {
selectedEdge = evt.target.data();
selectedNode = null;
});
cy.on("tap", (evt) => {
if (evt.target === cy) {
selectedNode = null;
selectedEdge = null;
}
});
graphInitialized = true;
// Ensure the graph renders correctly after container is sized
setTimeout(() => {
if (cy) {
cy.resize();
cy.fit(undefined, 50);
}
}, 100);
}
function zoomIn() {
if (cy) cy.zoom(cy.zoom() * 1.2);
}
function zoomOut() {
if (cy) cy.zoom(cy.zoom() / 1.2);
}
function fitToScreen() {
if (cy) cy.fit(undefined, 50);
}
// Exported function to handle container resize
export function resize() {
if (cy && containerEl) {
// Cytoscape caches container dimensions aggressively
// We need to unmount and remount to the container
cy!.unmount();
// Wait for DOM to update
requestAnimationFrame(() => {
if (cy && containerEl) {
cy!.mount(containerEl);
cy!.resize();
cy!.fit(undefined, 50);
}
});
}
}
function getLayoutConfig(layoutName: LayoutType): cytoscape.LayoutOptions {
const baseConfig = {
padding: 50,
avoidOverlap: true,
nodeDimensionsIncludeLabels: true,
};
switch (layoutName) {
case "breadthfirst":
return {
...baseConfig,
name: "breadthfirst",
directed: true,
spacingFactor: 1.5,
};
case "grid":
return {
...baseConfig,
name: "grid",
rows: undefined,
cols: undefined,
};
case "circle":
return {
...baseConfig,
name: "circle",
spacingFactor: 1.2,
};
case "concentric":
return {
...baseConfig,
name: "concentric",
minNodeSpacing: 50,
concentric: (node: any) => {
// Services at center, resources around
return node.data("type") === "service" ? 2 : 1;
},
levelWidth: () => 1,
};
case "cose":
return {
...baseConfig,
name: "cose",
idealEdgeLength: () => 100,
nodeOverlap: 20,
animate: true,
animationDuration: 500,
};
default:
return { ...baseConfig, name: layoutName };
}
}
function applyLayout(layoutName: LayoutType) {
if (!cy) return;
currentLayout = layoutName;
showLayoutMenu = false;
cy.layout(getLayoutConfig(layoutName)).run();
cy.fit(undefined, 50);
}
function resetLayout() {
if (cy) {
cy.layout(getLayoutConfig(currentLayout)).run();
cy.fit(undefined, 50);
}
}
onMount(() => {
// Follow app theme from localStorage
const appTheme = localStorage.getItem("theme");
if (appTheme === "dark" || appTheme === "light") {
graphTheme = appTheme;
} else {
// Fallback to system preference
graphTheme = window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light";
}
});
// Create graph when container element becomes available
$effect(() => {
if (containerEl && networks && !graphInitialized) {
createGraph();
}
});
onDestroy(() => {
if (cy) {
cy.destroy();
cy = null;
}
});
function toggleGraphTheme() {
graphTheme = graphTheme === "light" ? "dark" : "light";
createGraph(true); // Recreate graph with new theme, preserve local edits
}
function getNodeIcon(type: string) {
switch (type) {
case "service":
return Box;
case "network":
return Network;
default:
return Database;
}
}
function getNodeColor(type: string) {
switch (type) {
case "service":
return "bg-blue-500";
case "network":
return "bg-violet-500";
default:
return "bg-slate-500";
}
}
</script>
<div class="flex flex-col h-full {className}">
<!-- Toolbar -->
<div class="flex items-center justify-between px-2 py-1.5 border-b border-zinc-200 dark:border-zinc-700 bg-zinc-50 dark:bg-zinc-800 min-h-[40px]">
<div class="flex items-center gap-2 flex-wrap"></div>
<!-- Controls -->
<div class="flex items-center gap-0.5">
<!-- Layout selector -->
<div class="relative">
<button
onclick={() => (showLayoutMenu = !showLayoutMenu)}
class="h-6 px-2 flex items-center gap-1 rounded text-xs text-zinc-600 dark:text-zinc-300 hover:bg-zinc-200 dark:hover:bg-zinc-700 transition-colors"
title="Change layout"
>
{#if currentLayout === "breadthfirst"}
<GitBranch class="w-3 h-3" />
{:else if currentLayout === "grid"}
<LayoutGrid class="w-3 h-3" />
{:else if currentLayout === "circle"}
<Circle class="w-3 h-3" />
{:else if currentLayout === "concentric"}
<Target class="w-3 h-3" />
{:else}
<Sparkles class="w-3 h-3" />
{/if}
<ChevronDown class="w-3 h-3" />
</button>
{#if showLayoutMenu}
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
class="absolute right-0 top-full mt-1 bg-white dark:bg-zinc-800 rounded-lg shadow-lg border border-zinc-200 dark:border-zinc-700 py-1 z-20 min-w-[120px]"
onmouseleave={() => (showLayoutMenu = false)}
>
<button
class="w-full px-3 py-1.5 text-left text-xs flex items-center gap-2 hover:bg-zinc-100 dark:hover:bg-zinc-700 {currentLayout === 'breadthfirst'
? 'text-blue-600 dark:text-blue-400 font-medium'
: 'text-zinc-700 dark:text-zinc-200'}"
onclick={() => applyLayout("breadthfirst")}
>
<GitBranch class="w-3.5 h-3.5" />
Tree
</button>
<button
class="w-full px-3 py-1.5 text-left text-xs flex items-center gap-2 hover:bg-zinc-100 dark:hover:bg-zinc-700 {currentLayout === 'grid'
? 'text-blue-600 dark:text-blue-400 font-medium'
: 'text-zinc-700 dark:text-zinc-200'}"
onclick={() => applyLayout("grid")}
>
<LayoutGrid class="w-3.5 h-3.5" />
Grid
</button>
<button
class="w-full px-3 py-1.5 text-left text-xs flex items-center gap-2 hover:bg-zinc-100 dark:hover:bg-zinc-700 {currentLayout === 'circle'
? 'text-blue-600 dark:text-blue-400 font-medium'
: 'text-zinc-700 dark:text-zinc-200'}"
onclick={() => applyLayout("circle")}
>
<Circle class="w-3.5 h-3.5" />
Circle
</button>
<button
class="w-full px-3 py-1.5 text-left text-xs flex items-center gap-2 hover:bg-zinc-100 dark:hover:bg-zinc-700 {currentLayout === 'concentric'
? 'text-blue-600 dark:text-blue-400 font-medium'
: 'text-zinc-700 dark:text-zinc-200'}"
onclick={() => applyLayout("concentric")}
>
<Target class="w-3.5 h-3.5" />
Radial
</button>
<button
class="w-full px-3 py-1.5 text-left text-xs flex items-center gap-2 hover:bg-zinc-100 dark:hover:bg-zinc-700 {currentLayout === 'cose'
? 'text-blue-600 dark:text-blue-400 font-medium'
: 'text-zinc-700 dark:text-zinc-200'}"
onclick={() => applyLayout("cose")}
>
<Sparkles class="w-3.5 h-3.5" />
Force
</button>
</div>
{/if}
</div>
<div class="w-px h-4 bg-zinc-300 dark:bg-zinc-600 mx-1"></div>
<!-- Theme toggle -->
<button
onclick={toggleGraphTheme}
class="h-6 w-6 flex items-center justify-center rounded text-zinc-500 dark:text-zinc-400 hover:text-zinc-700 dark:hover:text-zinc-200 hover:bg-zinc-200 dark:hover:bg-zinc-700 transition-colors"
title={graphTheme === "light" ? "Switch to dark theme" : "Switch to light theme"}
>
{#if graphTheme === "light"}
<Moon class="w-3.5 h-3.5" />
{:else}
<Sun class="w-3.5 h-3.5" />
{/if}
</button>
<div class="w-px h-4 bg-zinc-300 dark:bg-zinc-600 mx-1"></div>
<Button variant="ghost" size="sm" onclick={zoomOut} class="h-6 w-6 p-0 text-zinc-500 dark:text-zinc-400 hover:text-zinc-700 dark:hover:text-zinc-200">
<ZoomOut class="w-3.5 h-3.5" />
</Button>
<Button variant="ghost" size="sm" onclick={zoomIn} class="h-6 w-6 p-0 text-zinc-500 dark:text-zinc-400 hover:text-zinc-700 dark:hover:text-zinc-200">
<ZoomIn class="w-3.5 h-3.5" />
</Button>
<Button variant="ghost" size="sm" onclick={fitToScreen} class="h-6 w-6 p-0 text-zinc-500 dark:text-zinc-400 hover:text-zinc-700 dark:hover:text-zinc-200">
<Maximize2 class="w-3.5 h-3.5" />
</Button>
<Button variant="ghost" size="sm" onclick={resetLayout} class="h-6 w-6 p-0 text-zinc-500 dark:text-zinc-400 hover:text-zinc-700 dark:hover:text-zinc-200">
<RotateCcw class="w-3.5 h-3.5" />
</Button>
</div>
</div>
<div class="flex-1 flex min-h-0 h-full">
<!-- Graph container -->
<div class="flex-1 relative h-full min-w-0 {graphTheme === 'dark' ? 'bg-zinc-900' : 'bg-zinc-100'}">
<div bind:this={containerEl} class="w-full h-full"></div>
<!-- Footer: Legend -->
<div class="absolute bottom-2 left-2 pointer-events-none z-10">
<div
class="flex items-center gap-2 text-xs bg-white/80 dark:bg-zinc-800/80 backdrop-blur-sm rounded px-2 py-1 shadow-sm border border-zinc-200/50 dark:border-zinc-700/50 whitespace-nowrap"
>
<div class="flex items-center gap-1 flex-shrink-0">
<div class="w-2 h-2 rounded-sm bg-blue-500 flex-shrink-0"></div>
<span class="text-zinc-600 dark:text-zinc-300">Service</span>
</div>
<div class="flex items-center gap-1 flex-shrink-0">
<div class="w-2 h-2 rounded-sm bg-violet-500 flex-shrink-0"></div>
<span class="text-zinc-600 dark:text-zinc-300">Network</span>
</div>
</div>
</div>
<!-- Details panel (overlay) -->
{#if selectedNode || selectedEdge}
<div class="absolute top-0 right-0 bottom-0 w-[420px] border-l border-zinc-200 dark:border-zinc-700 bg-zinc-50 dark:bg-zinc-800/95 shadow-lg z-20 flex flex-col">
<!-- Sticky header -->
{#if selectedNode}
{@const NodeIcon = getNodeIcon(selectedNode.type)}
<div class="sticky top-0 z-10 p-3 border-b border-zinc-200 dark:border-zinc-700 bg-zinc-50 dark:bg-zinc-800/95">
<div class="flex items-center justify-between">
<div class="flex items-center gap-2">
<div class="p-1.5 rounded {getNodeColor(selectedNode.type)}">
<NodeIcon class="w-3.5 h-3.5 text-white" />
</div>
<div>
<h3 class="font-semibold text-sm text-zinc-800 dark:text-zinc-100">
{selectedNode.label}
</h3>
<p class="text-xs text-zinc-500 dark:text-zinc-400 capitalize">
{selectedNode.type}
</p>
</div>
</div>
<div class="flex items-center gap-1">
<Button
variant="ghost"
size="sm"
class="h-6 w-6 p-0 text-zinc-500 hover:text-zinc-600 hover:bg-zinc-100 dark:hover:bg-zinc-700"
onclick={() => {
selectedNode = null;
selectedEdge = null;
}}
title="Close"
>
<X class="w-3.5 h-3.5" />
</Button>
</div>
</div>
</div>
{:else if selectedEdge}
<!-- Sticky header for edge -->
<div class="sticky top-0 z-10 p-3 border-b border-zinc-200 dark:border-zinc-700 bg-zinc-50 dark:bg-zinc-800/95">
<div class="flex items-center justify-between">
<div>
<h3 class="font-semibold text-sm text-zinc-800 dark:text-zinc-100 capitalize">
{selectedEdge.type.replace("-", " ")}
</h3>
<p class="text-xs text-zinc-500 dark:text-zinc-400">
{selectedEdge.source.replace(/^(service|network)-/, "")}
{selectedEdge.target.replace(/^(service|network)-/, "")}
</p>
</div>
<div class="flex items-center gap-1">
<Button
variant="ghost"
size="sm"
class="h-6 w-6 p-0 text-zinc-500 hover:text-zinc-600 hover:bg-zinc-100 dark:hover:bg-zinc-700"
onclick={() => {
selectedNode = null;
selectedEdge = null;
}}
title="Close"
>
<X class="w-3.5 h-3.5" />
</Button>
</div>
</div>
</div>
{/if}
<!-- Scrollable content -->
<div class="flex-1 overflow-y-auto p-3">
{#if selectedNode}
{#if selectedNode.type === "service"}
<div class="space-y-3 text-sm">
<!-- Container Id -->
<div class="space-y-1.5">
<div class="flex items-center justify-between">
<span class="text-xs font-medium text-zinc-600 dark:text-zinc-300">Container Id</span>
</div>
<Input value={selectedNode.config.containerId} placeholder="containerId" class="h-8 text-xs" readonly />
</div>
</div>
{:else if selectedNode.type === "network"}
<div class="space-y-3 text-sm">
<!-- Driver -->
<div class="space-y-1.5">
<span class="text-xs font-medium text-zinc-600 dark:text-zinc-300">Driver</span>
<!-- Simulate the select element -->
<div class="flex items-center justify-between w-fit h-8 px-3 py-2 text-xs border rounded-md border-input bg-background shadow-sm dark:bg-input/30">
<span class="flex items-center gap-1.5">
{#if selectedNode.config.driver === "bridge"}
<Share2 class="w-3.5 h-3.5 text-emerald-500" />
{:else if selectedNode.config.driver === "host"}
<Server class="w-3.5 h-3.5 text-sky-500" />
{:else if selectedNode.config.driver === "overlay"}
<Globe class="w-3.5 h-3.5 text-violet-500" />
{:else if selectedNode.config.driver === "macvlan"}
<MonitorSmartphone class="w-3.5 h-3.5 text-amber-500" />
{:else if selectedNode.config.driver === "ipvlan"}
<Cpu class="w-3.5 h-3.5 text-orange-500" />
{:else}
<CircleOff class="w-3.5 h-3.5 text-muted-foreground" />
{/if}
<span class="capitalize">{selectedNode.config.driver}</span>
</span>
</div>
</div>
<!-- IPAM Config -->
<div class="space-y-1.5">
<span class="text-xs font-medium text-zinc-600 dark:text-zinc-300">IPAM configuration</span>
<div class="space-y-4 pt-2">
<div class="relative">
<span class="absolute -top-2 left-2 text-[9px] text-zinc-400 bg-white dark:bg-zinc-800 px-1 z-10">Subnet</span>
<Input value={selectedNode.config.ipam?.config?.[0].subnet} placeholder="172.20.0.0/16" class="h-9 pt-3 text-xs" readonly />
</div>
<div class="relative">
<span class="absolute -top-2 left-2 text-[9px] text-zinc-400 bg-white dark:bg-zinc-800 px-1 z-10">Gateway</span>
<Input value={selectedNode.config.ipam?.config?.[0].gateway} placeholder="172.20.0.1" class="h-9 pt-3 text-xs" readonly />
</div>
</div>
</div>
<!-- Boolean flags -->
<div class="space-y-2 pointer-events-none select-none">
<label class="flex items-center gap-2 cursor-pointer">
<input type="checkbox" bind:checked={selectedNode.config.external} class="rounded border-zinc-300" />
<span class="text-xs text-zinc-600">External network</span>
</label>
<label class="flex items-center gap-2 cursor-pointer">
<input type="checkbox" bind:checked={selectedNode.config.internal} class="rounded border-zinc-300" />
<span class="text-xs text-zinc-600">Internal network</span>
</label>
<label class="flex items-center gap-2 cursor-pointer">
<input type="checkbox" bind:checked={selectedNode.config.attachable} class="rounded border-zinc-300" />
<span class="text-xs text-zinc-600">Attachable</span>
</label>
</div>
</div>
{/if}
{:else if selectedEdge}
{#if selectedEdge.type === "network-connection"}
<p class="text-xs text-zinc-500 dark:text-zinc-400">Service connected to this network.</p>
{/if}
{/if}
</div>
</div>
{/if}
</div>
</div>
</div>
+11 -2
View File
@@ -90,18 +90,27 @@
}
function formatChangelogDate(dateStr: string): string {
if (!dateStr) return 'Unreleased';
try {
const date = new Date(dateStr);
if (isNaN(date.getTime())) return 'Unreleased';
return date.toLocaleDateString('en-US', {
year: 'numeric',
month: 'long',
day: 'numeric'
});
} catch {
return dateStr;
return 'Unreleased';
}
}
function sortedChanges(changes: ChangelogChange[]): ChangelogChange[] {
return [...changes].sort((a, b) => {
if (a.type === b.type) return 0;
return a.type === 'feature' ? -1 : 1;
});
}
// Build info - injected at build time
declare const __BUILD_BRANCH__: string | null;
const BUILD_DATE = __BUILD_DATE__ ?? null;
@@ -867,7 +876,7 @@
{#if isExpanded}
<div class="px-4 pb-4">
<ul class="space-y-1.5 text-sm text-muted-foreground mb-3">
{#each release.changes as change}
{#each sortedChanges(release.changes) as change}
<li class="flex items-start gap-2">
{#if change.type === 'feature'}
<span class="inline-flex items-center gap-1 px-1.5 py-0.5 rounded text-2xs font-medium bg-emerald-500/15 text-emerald-600 dark:text-emerald-400 shrink-0">
@@ -751,7 +751,7 @@
labels: formLabels,
connectionType: formConnectionType,
hawserToken: formHawserToken || undefined,
publicIp: formConnectionType !== 'hawser-edge' ? (stripHostProtocol(formPublicIp.trim()) || undefined) : undefined
publicIp: stripHostProtocol(formPublicIp.trim()) || undefined
})
});
@@ -869,7 +869,7 @@
labels: formLabels,
connectionType: formConnectionType,
hawserToken: formHawserToken || undefined,
publicIp: formConnectionType !== 'hawser-edge' ? (stripHostProtocol(formPublicIp.trim()) || null) : null
publicIp: stripHostProtocol(formPublicIp.trim()) || null
})
});
@@ -2160,31 +2160,29 @@
</div>
{/if}
<!-- Public IP field (for all types except hawser-edge) -->
{#if formConnectionType !== 'hawser-edge'}
<div class="space-y-2 pt-4 border-t">
<div class="flex items-center gap-2">
<Label for="edit-env-public-ip">Public IP</Label>
<Tooltip.Root>
<Tooltip.Trigger>
<HelpCircle class="w-3.5 h-3.5 text-muted-foreground" />
</Tooltip.Trigger>
<Tooltip.Content side="bottom" class="w-72">
<p>IP address or hostname where container ports are accessible from your browser. For local Docker, use the server's LAN IP.</p>
</Tooltip.Content>
</Tooltip.Root>
</div>
<Input
id="edit-env-public-ip"
bind:value={formPublicIp}
placeholder="e.g., 192.168.1.4"
class="w-full"
/>
<p class="text-xs text-muted-foreground">
Used for clickable port links on the containers page
</p>
<!-- Public IP field -->
<div class="space-y-2 pt-4 border-t">
<div class="flex items-center gap-2">
<Label for="edit-env-public-ip">Public IP</Label>
<Tooltip.Root>
<Tooltip.Trigger>
<HelpCircle class="w-3.5 h-3.5 text-muted-foreground" />
</Tooltip.Trigger>
<Tooltip.Content side="bottom" class="w-72">
<p>IP address or hostname where container ports are accessible from your browser. For local Docker, use the server's LAN IP.</p>
</Tooltip.Content>
</Tooltip.Root>
</div>
{/if}
<Input
id="edit-env-public-ip"
bind:value={formPublicIp}
placeholder="e.g., 192.168.1.4"
class="w-full"
/>
<p class="text-xs text-muted-foreground">
Used for clickable port links on the containers page
</p>
</div>
</Tabs.Content>
<!-- Updates Tab -->
+146 -2
View File
@@ -8,8 +8,9 @@
import { TogglePill, ToggleSwitch } from '$lib/components/ui/toggle-pill';
import CronEditor from '$lib/components/cron-editor.svelte';
import TimezoneSelector from '$lib/components/TimezoneSelector.svelte';
import { Eye, Bell, Database, Calendar, ShieldCheck, FileText, AlertTriangle, HelpCircle, Globe, Activity, Clock, Info } from 'lucide-svelte';
import { appSettings, type DateFormat, type DownloadFormat, type EventCollectionMode } from '$lib/stores/settings';
import { Eye, Bell, Database, Calendar, ShieldCheck, FileText, AlertTriangle, HelpCircle, Globe, Activity, Clock, Info, Save, RotateCcw, LayoutDashboard, Tags } from 'lucide-svelte';
import CodeEditor from '$lib/components/CodeEditor.svelte';
import { appSettings, type DateFormat, type DownloadFormat, type EventCollectionMode, type LabelFilterMode } from '$lib/stores/settings';
import { canAccess, authStore } from '$lib/stores/auth';
import { toast } from 'svelte-sonner';
import ThemeSelector from '$lib/components/ThemeSelector.svelte';
@@ -27,12 +28,54 @@
let defaultTrivyArgs = $derived($appSettings.defaultTrivyArgs);
let defaultGrypeImage = $derived($appSettings.defaultGrypeImage);
let defaultTrivyImage = $derived($appSettings.defaultTrivyImage);
let defaultComposeTemplate = $derived($appSettings.defaultComposeTemplate);
let labelFilterMode = $derived($appSettings.labelFilterMode);
let composeTemplateWIP = $state('');
let composeTemplateInitialized = false;
$effect(() => {
if (!composeTemplateInitialized && defaultComposeTemplate !== undefined) {
composeTemplateWIP = defaultComposeTemplate;
composeTemplateInitialized = true;
}
});
const builtinComposeTemplate = `version: "3.8"
services:
app:
image: nginx:alpine
ports:
- "8080:80"
environment:
- APP_ENV=\${APP_ENV:-production}
volumes:
- ./html:/usr/share/nginx/html:ro
restart: unless-stopped
# Add more services as needed
# networks:
# default:
# driver: bridge
`;
function saveComposeTemplate() {
appSettings.setDefaultComposeTemplate(composeTemplateWIP);
toast.success('Compose template updated');
}
function revertComposeTemplate() {
composeTemplateWIP = builtinComposeTemplate;
toast.info('Template reverted to default');
}
let scheduleRetentionDays = $derived($appSettings.scheduleRetentionDays);
let eventRetentionDays = $derived($appSettings.eventRetentionDays);
let scheduleCleanupCron = $derived($appSettings.scheduleCleanupCron);
let eventCleanupCron = $derived($appSettings.eventCleanupCron);
let scheduleCleanupEnabled = $derived($appSettings.scheduleCleanupEnabled);
let eventCleanupEnabled = $derived($appSettings.eventCleanupEnabled);
let scannerCleanupCron = $derived($appSettings.scannerCleanupCron);
let scannerCleanupEnabled = $derived($appSettings.scannerCleanupEnabled);
let logBufferSizeKb = $derived($appSettings.logBufferSizeKb);
let formatLogTimestamps = $derived($appSettings.formatLogTimestamps);
let defaultTimezone = $derived($appSettings.defaultTimezone);
@@ -105,6 +148,17 @@
toast.success(newState ? 'Event cleanup enabled' : 'Event cleanup disabled');
}
function handleScannerCleanupCronChange(cron: string) {
appSettings.setScannerCleanupCron(cron);
toast.success('Scanner cleanup cron updated');
}
function handleScannerCleanupEnabledChange() {
const newState = !scannerCleanupEnabled;
appSettings.setScannerCleanupEnabled(newState);
toast.success(newState ? 'Scanner cleanup enabled' : 'Scanner cleanup disabled');
}
function handleGrypeImageBlur(e: Event) {
const value = (e.target as HTMLInputElement).value.trim();
if (value && value !== defaultGrypeImage) {
@@ -425,6 +479,39 @@
</Card.Content>
</Card.Root>
<Card.Root>
<Card.Header>
<Card.Title class="text-sm font-medium flex items-center gap-2">
<FileText class="w-4 h-4" />
Compose template
</Card.Title>
<p class="text-xs text-muted-foreground">Default YAML content when creating a new stack.</p>
</Card.Header>
<Card.Content class="space-y-3">
<div class="h-64">
<CodeEditor
value={composeTemplateWIP}
onchange={(v) => { composeTemplateWIP = v; }}
language="yaml"
readonly={!$canAccess('settings', 'edit')}
class="h-full rounded-md overflow-hidden border border-zinc-200 dark:border-zinc-700"
/>
</div>
{#if $canAccess('settings', 'edit')}
<div class="flex gap-2">
<Button size="sm" variant="outline" onclick={saveComposeTemplate}>
<Save class="w-3.5 h-3.5" />
Save template
</Button>
<Button size="sm" variant="ghost" onclick={revertComposeTemplate}>
<RotateCcw class="w-3.5 h-3.5" />
Revert to default
</Button>
</div>
{/if}
</Card.Content>
</Card.Root>
</div>
<!-- Right column -->
@@ -684,6 +771,63 @@
Runs every 30 minutes and on startup.
</p>
</div>
<div class="space-y-1 pt-2 border-t">
<div class="flex items-center gap-3">
<Label>Scanner cache cleanup</Label>
<TogglePill
checked={scannerCleanupEnabled}
onchange={handleScannerCleanupEnabledChange}
disabled={!$canAccess('settings', 'edit')}
/>
</div>
<p class="text-xs text-muted-foreground">Remove cached vulnerability databases to reclaim disk space</p>
<div class="flex items-center gap-2 mt-2">
<div class="ml-auto">
<CronEditor
value={scannerCleanupCron}
onchange={handleScannerCleanupCronChange}
disabled={!$canAccess('settings', 'edit') || !scannerCleanupEnabled}
/>
</div>
</div>
</div>
</Card.Content>
</Card.Root>
<Card.Root>
<Card.Header>
<Card.Title class="text-sm font-medium flex items-center gap-2">
<LayoutDashboard class="w-4 h-4" />
Dashboard
</Card.Title>
</Card.Header>
<Card.Content class="space-y-4">
<div class="space-y-3">
<div class="space-y-1">
<div class="flex items-center gap-3">
<Label>Label filter matching</Label>
<Tooltip.Root>
<Tooltip.Trigger>
<HelpCircle class="w-3.5 h-3.5 text-muted-foreground" />
</Tooltip.Trigger>
<Tooltip.Content class="w-80">
<p class="text-xs">
Controls how multiple selected labels filter environments on the dashboard.
<strong>"Any"</strong>: shows environments that have at least one of the selected labels.
<strong>"All"</strong>: shows only environments that have every selected label.
</p>
</Tooltip.Content>
</Tooltip.Root>
<ToggleSwitch
value={labelFilterMode}
leftValue="any"
rightValue="all"
onchange={(mode) => appSettings.setLabelFilterMode(mode as LabelFilterMode)}
disabled={!$canAccess('settings', 'edit')}
/>
</div>
</div>
</div>
</Card.Content>
</Card.Root>
</div>
@@ -422,12 +422,15 @@ mmost://hostname/webhook-token
tgram://bot_token/chat_id
tgram://bot_token/chat_id:topic_id
ntfy://my-topic
ntfy://host/topic?auth=base64token
ntfys://host/topic?auth=base64token
pushover://user_key/api_token
workflows://hostname/workflow/signature
jsons://hostname/webhook/path"
class="flex min-h-[220px] w-full rounded-md border border-input bg-transparent px-3 py-2 text-sm shadow-sm placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
></textarea>
<p class="text-xs text-muted-foreground">
Supports Gotify (gotify:// or gotifys:// for HTTPS), Discord, Slack, Mattermost (mmost:// or mmosts://), Telegram, ntfy, Pushover, and generic JSON webhooks.
Supports Gotify (gotify:// or gotifys:// for HTTPS), Discord, Slack, Mattermost (mmost:// or mmosts://), Telegram, ntfy, Pushover, Workflows (for e.g. Microsoft Teams), and generic JSON webhooks.
</p>
</div>
</div>
+1 -1
View File
@@ -2019,7 +2019,7 @@
{#if container.ports.length > 0}
{@const mappedPorts = formatPorts(container.ports)}
{#each mappedPorts as port}
{@const url = !port.isRange ? getPortUrl(port.publicPort) : null}
{@const url = getPortUrl(port.publicPort)}
{#if url}
<a
href={url}
+89 -1
View File
@@ -7,7 +7,7 @@
import { Badge } from '$lib/components/ui/badge';
import { Input } from '$lib/components/ui/input';
import { TogglePill } from '$lib/components/ui/toggle-pill';
import { Loader2, GitBranch, RefreshCw, Webhook, Rocket, RefreshCcw, Copy, Check, XCircle, FolderGit2, Github, Key, KeyRound, Lock, FileText, HelpCircle, GripVertical, X, Download, Hammer, ArrowDownToLine, Zap } from 'lucide-svelte';
import { Loader2, GitBranch, RefreshCw, Webhook, Rocket, RefreshCcw, Copy, Check, XCircle, FolderGit2, Github, Key, KeyRound, Lock, FileText, HelpCircle, GripVertical, X, Download, Hammer, ArrowDownToLine, Zap, FolderOpen, Ban, TriangleAlert } from 'lucide-svelte';
import * as Tooltip from '$lib/components/ui/tooltip';
import { copyToClipboard } from '$lib/utils/clipboard';
import CronEditor from '$lib/components/cron-editor.svelte';
@@ -58,7 +58,9 @@
autoUpdateCron: string;
webhookEnabled: boolean;
webhookSecret: string | null;
contextDir: string | null;
buildOnDeploy: boolean;
noBuildCache: boolean;
repullImages: boolean;
forceRedeploy: boolean;
}
@@ -91,12 +93,15 @@
let formAutoUpdateCron = $state('0 3 * * *');
let formWebhookEnabled = $state(false);
let formWebhookSecret = $state('');
let formContextDir = $state<string | null>(null);
let formBuildOnDeploy = $state(false);
let formNoBuildCache = $state(false);
let formRepullImages = $state(false);
let formForceRedeploy = $state(false);
let formDeployNow = $state(false);
let formError = $state('');
let formSaving = $state(false);
let showExistsWarning = $state(false);
let errors = $state<{ stackName?: string; repository?: string; repoName?: string; repoUrl?: string }>({});
// Stack name validation: must start with alphanumeric, can contain alphanumeric, hyphens, underscores
@@ -364,7 +369,9 @@
formAutoUpdateCron = gitStack.autoUpdateCron || '0 3 * * *';
formWebhookEnabled = gitStack.webhookEnabled;
formWebhookSecret = gitStack.webhookSecret || '';
formContextDir = gitStack.contextDir ?? null;
formBuildOnDeploy = gitStack.buildOnDeploy ?? false;
formNoBuildCache = gitStack.noBuildCache ?? false;
formRepullImages = gitStack.repullImages ?? false;
formForceRedeploy = gitStack.forceRedeploy ?? false;
formDeployNow = false;
@@ -391,7 +398,9 @@
formAutoUpdateCron = '0 3 * * *';
formWebhookEnabled = false;
formWebhookSecret = '';
formContextDir = null;
formBuildOnDeploy = false;
formNoBuildCache = false;
formRepullImages = false;
formForceRedeploy = false;
formDeployNow = false;
@@ -428,6 +437,25 @@
if (hasErrors) return;
// Check if stack already exists (only for new stacks)
if (!gitStack) {
try {
const stacksResponse = await fetch(`/api/stacks?env=${environmentId}`);
if (stacksResponse.ok) {
const stacks = await stacksResponse.json();
const existingStack = stacks.find((s: { name: string }) =>
s.name.toLowerCase() === formStackName.trim().toLowerCase()
);
if (existingStack) {
showExistsWarning = true;
return;
}
}
} catch (e) {
console.warn('Failed to check for existing stacks:', e);
}
}
formSaving = true;
formError = '';
@@ -450,7 +478,9 @@
autoUpdateCron: formAutoUpdateCron,
webhookEnabled: formWebhookEnabled,
webhookSecret: formWebhookEnabled ? formWebhookSecret : null,
contextDir: formContextDir || null,
buildOnDeploy: formBuildOnDeploy,
noBuildCache: formNoBuildCache,
repullImages: formRepullImages,
forceRedeploy: formForceRedeploy,
deployNow: deployAfterSave,
@@ -793,6 +823,32 @@
<p class="text-xs text-muted-foreground">Additional env file to pass to Docker Compose</p>
</div>
<!-- Context directory -->
<div class="space-y-2">
<div class="flex items-center gap-1.5">
<Label for="context-dir">Context directory (optional)</Label>
<Tooltip.Root>
<Tooltip.Trigger>
<HelpCircle class="w-3.5 h-3.5 text-muted-foreground cursor-help" />
</Tooltip.Trigger>
<Tooltip.Content>
<div class="w-80">
<p class="text-xs">Working directory for Docker Compose, relative to the repository root. All files in this directory will be available for volume mounts and build contexts.</p>
<p class="text-xs mt-2">Use <code class="bg-muted px-1 rounded">.</code> for the repository root when your compose file references files in sibling directories.</p>
<p class="text-xs mt-2">Defaults to the compose file's parent directory.</p>
</div>
</Tooltip.Content>
</Tooltip.Root>
</div>
<Input
id="context-dir"
value={formContextDir ?? ''}
oninput={(e) => { const v = (e.target as HTMLInputElement).value; formContextDir = v.trim() || null; }}
placeholder="Defaults to compose file's directory"
/>
<p class="text-xs text-muted-foreground">Relative to repository root, e.g. <code class="text-xs bg-muted px-1 rounded">.</code> for root</p>
</div>
<!-- Auto-update section -->
<div class="space-y-3 p-3 bg-muted/50 rounded-md">
<div class="flex items-center gap-3">
@@ -922,6 +978,18 @@
<p class="text-xs text-muted-foreground">
Run <code class="text-xs bg-muted px-1 rounded">--build</code> to build images from Dockerfiles before starting containers.
</p>
{#if formBuildOnDeploy}
<div class="flex items-center gap-3 ml-6">
<div class="flex items-center gap-2 flex-1">
<Ban class="w-4 h-4 text-muted-foreground" />
<Label class="text-sm font-normal">Disable build cache</Label>
</div>
<TogglePill bind:checked={formNoBuildCache} />
</div>
<p class="text-xs text-muted-foreground ml-6">
Pass <code class="text-xs bg-muted px-1 rounded">--no-cache</code> to force a clean build without using cached layers.
</p>
{/if}
<div class="flex items-center gap-3">
<div class="flex items-center gap-2 flex-1">
<ArrowDownToLine class="w-4 h-4 text-muted-foreground" />
@@ -1057,3 +1125,23 @@
</Dialog.Footer>
</Dialog.Content>
</Dialog.Root>
<!-- Stack already exists warning dialog -->
<Dialog.Root bind:open={showExistsWarning}>
<Dialog.Content class="max-w-sm">
<Dialog.Header>
<Dialog.Title class="flex items-center gap-2">
<TriangleAlert class="w-5 h-5 text-amber-500" />
Stack already exists
</Dialog.Title>
<Dialog.Description>
A stack named "{formStackName}" already exists. Please choose a different name.
</Dialog.Description>
</Dialog.Header>
<div class="flex justify-end mt-4">
<Button size="sm" onclick={() => showExistsWarning = false}>
OK
</Button>
</div>
</Dialog.Content>
</Dialog.Root>
+10 -3
View File
@@ -48,6 +48,7 @@
let previewFile = $state<FileEntry | null>(null);
let previewContent = $state<string | null>(null);
let previewServiceCount = $state<number>(0);
let previewComposeName = $state<string | null>(null);
let loadingPreview = $state(false);
// Use current environment from store
@@ -72,6 +73,7 @@
previewFile = null;
previewContent = null;
previewServiceCount = 0;
previewComposeName = null;
}
});
@@ -81,15 +83,19 @@
loadingPreview = true;
previewContent = null;
previewServiceCount = 0;
previewComposeName = null;
try {
const res = await fetch(`/api/system/files/content?path=${encodeURIComponent(entry.path)}`);
if (res.ok) {
const data = await res.json();
previewContent = data.content || '';
// Count services in the compose file
// Parse compose metadata (name + service count)
try {
const doc = yaml.load(previewContent) as Record<string, unknown> | null;
if (typeof doc?.name === 'string' && doc.name.trim()) {
previewComposeName = doc.name.trim();
}
if (doc?.services && typeof doc.services === 'object') {
previewServiceCount = Object.keys(doc.services).length;
}
@@ -191,7 +197,8 @@
try {
const parentDir = entry.path.replace(/\/[^/]+$/, '');
const rawName = parentDir.split('/').pop() || 'adopted-stack';
// Use compose `name:` property if available, otherwise fall back to directory name
const rawName = previewComposeName || parentDir.split('/').pop() || 'adopted-stack';
const stackName = rawName.toLowerCase().replace(/[^a-z0-9_-]/g, '-').replace(/-+/g, '-').replace(/^-|-$/g, '') || 'adopted-stack';
const envFilePath = `${parentDir}/.env`;
@@ -462,7 +469,7 @@
<div class="px-5 py-3 border-b bg-muted/30 flex items-center gap-4 shrink-0">
<div class="flex items-center gap-2">
<span class="text-sm text-muted-foreground">Stack:</span>
<span class="font-medium">{previewFile.path.replace(/\/[^/]+$/, '').split('/').pop() || 'unknown'}</span>
<span class="font-medium">{previewComposeName || previewFile.path.replace(/\/[^/]+$/, '').split('/').pop() || 'unknown'}</span>
{#if previewServiceCount > 0}
<Badge variant="outline" class="text-xs">
{previewServiceCount} service{previewServiceCount !== 1 ? 's' : ''}
+5 -20
View File
@@ -564,24 +564,7 @@
// Debounce timer for validation
let validateTimer: ReturnType<typeof setTimeout> | null = null;
const defaultCompose = `version: "3.8"
services:
app:
image: nginx:alpine
ports:
- "8080:80"
environment:
- APP_ENV=\${APP_ENV:-production}
volumes:
- ./html:/usr/share/nginx/html:ro
restart: unless-stopped
# Add more services as needed
# networks:
# default:
# driver: bridge
`;
const defaultCompose = $appSettings.defaultComposeTemplate;
// Count of defined environment variables (with non-empty keys)
const envVarCount = $derived(envVars.filter(v => v.key.trim()).length);
@@ -904,7 +887,8 @@ services:
error = null;
// Prepare env vars for creating - syncs variables and rawContent
const prepared = envVarsPanelRef?.prepareForSave() || { rawContent: '', variables: [] };
// If env panel is unmounted (e.g. graph tab active), use bound state directly
const prepared = envVarsPanelRef?.prepareForSave() || { rawContent: rawEnvContent, variables: envVars };
let response: Response | undefined;
try {
@@ -1028,7 +1012,8 @@ services:
error = null;
// Prepare env vars for saving - syncs variables and rawContent
const prepared = envVarsPanelRef?.prepareForSave() || { rawContent: '', variables: [] };
// If env panel is unmounted (e.g. graph tab active), use bound state directly
const prepared = envVarsPanelRef?.prepareForSave() || { rawContent: rawEnvContent, variables: envVars };
// Resolve env path (use working or suggested)
const envPathToSave = workingEnvPath.trim() || suggestedEnvPath || '';