mirror of
https://github.com/Finsys/dockhand.git
synced 2026-06-18 03:20:43 +03:00
Compare commits
18 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| d9054ff347 | |||
| 002d969a5d | |||
| 91ef3e3c9b | |||
| 7e3797cbfe | |||
| ccfda4c054 | |||
| 28a6211457 | |||
| 7c123833b5 | |||
| a1def17750 | |||
| 94657735fb | |||
| 74741d2a01 | |||
| 94591fef48 | |||
| 44b06e8fc6 | |||
| e35d485ae9 | |||
| f27c0b066f | |||
| 4840ac024d | |||
| d3aacfa94b | |||
| 8671dfaf32 | |||
| d10f6dfd6d |
+1
-1
@@ -37,7 +37,7 @@ RUN APKO_ARCH=$([ "$TARGETARCH" = "arm64" ] && echo "aarch64" || echo "x86_64")
|
||||
" - busybox" \
|
||||
" - tzdata" \
|
||||
" - docker-cli" \
|
||||
" - docker-compose=5.1.3-r0" \
|
||||
" - docker-compose=5.1.3-r2" \
|
||||
" - docker-cli-buildx" \
|
||||
" - sqlite" \
|
||||
" - postgresql-client" \
|
||||
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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",
|
||||
|
||||
@@ -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' }));
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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}" />
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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'),
|
||||
|
||||
@@ -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
@@ -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;
|
||||
|
||||
@@ -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
@@ -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
@@ -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);
|
||||
|
||||
@@ -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
|
||||
*
|
||||
|
||||
@@ -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
@@ -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 }[] }> {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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}` };
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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}`;
|
||||
}
|
||||
@@ -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]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
});
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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}`);
|
||||
}
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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)}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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}
|
||||
|
||||
@@ -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, '&').replace(/</g, '<').replace(/>/g, '>');
|
||||
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, '&').replace(/</g, '<').replace(/>/g, '>');
|
||||
|
||||
// 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}
|
||||
|
||||
@@ -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, '&').replace(/</g, '<').replace(/>/g, '>');
|
||||
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, '&').replace(/</g, '<').replace(/>/g, '>');
|
||||
|
||||
// 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}
|
||||
|
||||
@@ -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>
|
||||
@@ -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 -->
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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' : ''}
|
||||
|
||||
@@ -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 || '';
|
||||
|
||||
Reference in New Issue
Block a user