Compare commits

...

3 Commits

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

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

The inner try-catch ensures handler errors are logged but don't close the
connection, allowing the agent to recover and continue processing messages.
2026-05-05 13:12:50 +02:00
48 changed files with 7210 additions and 236 deletions
+1 -1
View File
@@ -37,7 +37,7 @@ RUN APKO_ARCH=$([ "$TARGETARCH" = "arm64" ] && echo "aarch64" || echo "x86_64")
" - busybox" \
" - tzdata" \
" - docker-cli" \
" - docker-compose=5.1.3-r0" \
" - docker-compose=5.1.3-r2" \
" - docker-cli-buildx" \
" - sqlite" \
" - postgresql-client" \
+1 -1
View File
@@ -1 +1 @@
v1.0.27
v1.0.28
+1 -1
View File
@@ -421,7 +421,7 @@ func (m *manager) collectMetrics(env *environment) {
sCtx, sCancel := context.WithTimeout(env.ctx, 10*time.Second)
defer sCancel()
sResp, sErr := env.doRequest(sCtx, "GET", fmt.Sprintf("/containers/%s/stats?stream=false&one-shot=true", id))
sResp, sErr := env.doRequest(sCtx, "GET", fmt.Sprintf("/containers/%s/stats?stream=false", id))
if sErr != nil {
return
}
@@ -0,0 +1,2 @@
ALTER TABLE "git_stacks" ADD COLUMN "context_dir" text;--> statement-breakpoint
ALTER TABLE "git_stacks" ADD COLUMN "no_build_cache" boolean DEFAULT false;
File diff suppressed because it is too large Load Diff
+7
View File
@@ -43,6 +43,13 @@
"when": 1775312212996,
"tag": "0005_add_api_tokens",
"breakpoints": true
},
{
"idx": 6,
"version": "7",
"when": 1777220350655,
"tag": "0006_add_git_stack_context_dir",
"breakpoints": true
}
]
}
@@ -0,0 +1,2 @@
ALTER TABLE `git_stacks` ADD `context_dir` text;--> statement-breakpoint
ALTER TABLE `git_stacks` ADD `no_build_cache` integer DEFAULT false;
File diff suppressed because it is too large Load Diff
+7
View File
@@ -43,6 +43,13 @@
"when": 1775311743346,
"tag": "0005_add_api_tokens",
"breakpoints": true
},
{
"idx": 6,
"version": "6",
"when": 1777220350655,
"tag": "0006_add_git_stack_context_dir",
"breakpoints": true
}
]
}
+2 -2
View File
@@ -1,7 +1,7 @@
{
"name": "dockhand",
"private": true,
"version": "1.0.27",
"version": "1.0.28",
"type": "module",
"scripts": {
"dev": "npx vite dev",
@@ -78,7 +78,7 @@
"cronstrue": "3.9.0",
"devalue": "5.6.4",
"drizzle-orm": "0.45.2",
"fast-xml-parser": "5.5.8",
"fast-xml-parser": "5.7.3",
"js-yaml": "4.1.1",
"ldapts": "8.1.3",
"nodemailer": "8.0.5",
+7 -2
View File
@@ -191,7 +191,7 @@ async function handleTerminalConnection(ws, url, connId) {
};
if (target.tls.ca) tlsOpts.ca = [target.tls.ca, ...rootCertificates];
if (target.tls.cert) tlsOpts.cert = [target.tls.cert];
if (target.tls.key) tlsOpts.key = target.tls.key;
if (target.tls.key) tlsOpts.key = [target.tls.key];
dockerStream = tlsConnect(tlsOpts);
} else {
// Plain HTTP (direct TCP or hawser-standard)
@@ -430,7 +430,12 @@ function handleHawserConnection(ws, connId, remoteIp) {
// Use the global hawser message handler injected by the SvelteKit app
if (typeof globalThis.__hawserHandleMessage === 'function') {
await globalThis.__hawserHandleMessage(ws, msg, connId, remoteIp);
try {
await globalThis.__hawserHandleMessage(ws, msg, connId, remoteIp);
} catch (handlerError) {
console.error('[Hawser WS] Handler error:', handlerError);
// Don't close connection - let it recover
}
} else {
console.warn('[Hawser WS] No global handler registered');
ws.send(JSON.stringify({ type: 'error', message: 'Server not ready' }));
+5 -4
View File
@@ -200,8 +200,8 @@
* Sync rawContent TO variables.
* Parses raw content for non-secrets, preserves existing secrets.
*/
function syncRawToVariables() {
const { vars, warnings } = parseRawContent(rawContent);
function syncRawToVariables(content?: string) {
const { vars, warnings } = parseRawContent(content ?? rawContent);
parseWarnings = warnings;
// Preserve existing secrets (they're not in rawContent)
@@ -240,8 +240,9 @@
// Form → Text: sync variables to raw (preserves comments)
syncVariablesToRaw();
} else if (newMode === 'form' && viewMode === 'text') {
// Text → Form: sync raw to variables (preserves secrets)
syncRawToVariables();
// Text → Form: use textEditorContent which falls back to generatedRawContent
// when rawContent is empty (fixes vars lost on view switch for git stacks)
syncRawToVariables(textEditorContent);
}
viewMode = newMode;
+1 -1
View File
@@ -62,7 +62,7 @@
<span class="text-muted-foreground font-normal">({release.date})</span>
</h3>
<div class="space-y-1.5 ml-1">
{#each release.changes as change}
{#each [...release.changes].sort((a, b) => a.type === b.type ? 0 : a.type === 'feature' ? -1 : 1) as change}
{@const { icon: Icon, class: iconClass } = getChangeIcon(change.type)}
<div class="flex items-start gap-2">
<Icon class="w-4 h-4 mt-0.5 shrink-0 {iconClass}" />
+25
View File
@@ -1,4 +1,29 @@
[
{
"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,
+86 -1
View File
@@ -113,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),
@@ -2088,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;
@@ -2124,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,
@@ -2155,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,
@@ -2186,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,
@@ -2219,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,
@@ -2250,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,
@@ -2282,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,
@@ -2314,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,
@@ -2346,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,
@@ -2383,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,
@@ -2415,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,
@@ -2447,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,
@@ -2477,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> {
@@ -2487,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();
@@ -2511,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;
@@ -2549,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,
@@ -2579,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,
@@ -2610,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,
@@ -2639,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,
@@ -4057,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));
@@ -4192,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
// =============================================================================
+2
View File
@@ -315,7 +315,9 @@ export const gitStacks = sqliteTable('git_stacks', {
autoUpdateCron: text('auto_update_cron').default('0 3 * * *'),
webhookEnabled: integer('webhook_enabled', { mode: 'boolean' }).default(false),
webhookSecret: text('webhook_secret'),
contextDir: text('context_dir'), // Working directory relative to repo root (null = compose file's directory)
buildOnDeploy: integer('build_on_deploy', { mode: 'boolean' }).default(false),
noBuildCache: integer('no_build_cache', { mode: 'boolean' }).default(false),
repullImages: integer('repull_images', { mode: 'boolean' }).default(false),
forceRedeploy: integer('force_redeploy', { mode: 'boolean' }).default(false),
lastSync: text('last_sync'),
+2
View File
@@ -318,7 +318,9 @@ export const gitStacks = pgTable('git_stacks', {
autoUpdateCron: text('auto_update_cron').default('0 3 * * *'),
webhookEnabled: boolean('webhook_enabled').default(false),
webhookSecret: text('webhook_secret'),
contextDir: text('context_dir'), // Working directory relative to repo root (null = compose file's directory)
buildOnDeploy: boolean('build_on_deploy').default(false),
noBuildCache: boolean('no_build_cache').default(false),
repullImages: boolean('repull_images').default(false),
forceRedeploy: boolean('force_redeploy').default(false),
lastSync: timestamp('last_sync', { mode: 'string' }),
+46 -8
View File
@@ -1248,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;
@@ -1431,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;
}
@@ -1617,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)
@@ -2325,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
+60 -30
View File
@@ -110,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 {
@@ -843,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
);
@@ -893,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)
@@ -998,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
@@ -1038,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
});
@@ -1214,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;
@@ -1243,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;
@@ -1291,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,
@@ -1431,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('; '));
}
+37 -11
View File
@@ -345,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?:\/\//, '');
@@ -353,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) {
+50 -41
View File
@@ -17,10 +17,12 @@ import {
getGitStack,
getScheduleCleanupCron,
getEventCleanupCron,
getScannerCleanupCron,
getScheduleRetentionDays,
getEventRetentionDays,
getScheduleCleanupEnabled,
getEventCleanupEnabled,
getScannerCleanupEnabled,
getEnvironments,
getEnvUpdateCheckSettings,
getAllEnvUpdateCheckSettings,
@@ -64,6 +66,31 @@ let scannerCacheCleanupJob: Cron | null = null;
// Scheduler state
let isRunning = false;
/**
* Scanner cache cleanup function that cleans local and all remote environments.
* Shared between cron job, timezone refresh, and manual trigger.
*/
async function scannerCleanupAllEnvs(): Promise<{ volumes: string[]; dirs: string[] }> {
const { cleanupScannerCache } = await import('../scanner');
const envs = await getEnvironments();
// Clean local cache (volumes + bind mount dirs)
const localResult = await cleanupScannerCache();
// Clean remote environment volumes
for (const env of envs) {
try {
const envResult = await cleanupScannerCache(env.id);
localResult.volumes.push(...envResult.volumes);
} catch (error) {
const msg = error instanceof Error ? error.message : String(error);
console.log(`[Scanner] Skipping cache cleanup for env "${env.name}" (id=${env.id}): ${msg}`);
}
}
return localResult;
}
/**
* Clean up stale 'syncing' states from git stacks.
* Called on startup to recover from crashes during sync operations.
@@ -107,6 +134,7 @@ export async function startScheduler(): Promise<void> {
// Get cron expressions and default timezone from database
const scheduleCleanupCron = await getScheduleCleanupCron();
const eventCleanupCron = await getEventCleanupCron();
const scannerCleanupCron = await getScannerCleanupCron();
const defaultTimezone = await getDefaultTimezone();
// Start system cleanup jobs (static schedules with default timezone)
@@ -134,35 +162,18 @@ export async function startScheduler(): Promise<void> {
await runVolumeHelperCleanupJob('cron', volumeCleanupFns);
});
// Scanner cache cleanup runs weekly (Sunday 3am) to prevent DB volume bloat
const scannerCleanupFn = async () => {
const { cleanupScannerCache } = await import('../scanner');
const envs = await getEnvironments();
// Clean local cache (volumes + bind mount dirs)
const localResult = await cleanupScannerCache();
// Clean remote environment volumes
for (const env of envs) {
try {
const envResult = await cleanupScannerCache(env.id);
localResult.volumes.push(...envResult.volumes);
} catch (error) {
const msg = error instanceof Error ? error.message : String(error);
console.log(`[Scanner] Skipping cache cleanup for env "${env.name}" (id=${env.id}): ${msg}`);
}
}
return localResult;
};
scannerCacheCleanupJob = new Cron('0 3 * * 0', { timezone: defaultTimezone, legacyMode: false }, async () => {
await runScannerCacheCleanupJob('cron', scannerCleanupFn);
});
// Scanner cache cleanup to prevent DB volume bloat (configurable schedule)
const scannerCleanupEnabled = await getScannerCleanupEnabled();
if (scannerCleanupEnabled) {
scannerCacheCleanupJob = new Cron(scannerCleanupCron, { timezone: defaultTimezone, legacyMode: false }, async () => {
await runScannerCacheCleanupJob('cron', scannerCleanupAllEnvs);
});
}
console.log(`[Scheduler] System schedule cleanup: ${scheduleCleanupCron} [${defaultTimezone}]`);
console.log(`[Scheduler] System event cleanup: ${eventCleanupCron} [${defaultTimezone}]`);
console.log(`[Scheduler] Volume helper cleanup: every 30 minutes [${defaultTimezone}]`);
console.log(`[Scheduler] Scanner cache cleanup: weekly (Sunday 3am) [${defaultTimezone}]`);
console.log(`[Scheduler] Scanner cache cleanup: ${scannerCleanupEnabled ? scannerCleanupCron : 'disabled'} [${defaultTimezone}]`);
// Register all dynamic schedules from database
await refreshAllSchedules();
@@ -497,6 +508,8 @@ export async function refreshSystemJobs(): Promise<void> {
// Get current settings
const scheduleCleanupCron = await getScheduleCleanupCron();
const eventCleanupCron = await getEventCleanupCron();
const scannerCleanupCron = await getScannerCleanupCron();
const scannerCleanupEnabled = await getScannerCleanupEnabled();
const defaultTimezone = await getDefaultTimezone();
// Cleanup functions to pass to the job
@@ -536,18 +549,16 @@ export async function refreshSystemJobs(): Promise<void> {
await runVolumeHelperCleanupJob('cron', volumeCleanupFns);
});
const scannerCleanupFn = async () => {
const { cleanupScannerCache } = await import('../scanner');
return cleanupScannerCache();
};
scannerCacheCleanupJob = new Cron('0 3 * * 0', { timezone: defaultTimezone, legacyMode: false }, async () => {
await runScannerCacheCleanupJob('cron', scannerCleanupFn);
});
if (scannerCleanupEnabled) {
scannerCacheCleanupJob = new Cron(scannerCleanupCron, { timezone: defaultTimezone, legacyMode: false }, async () => {
await runScannerCacheCleanupJob('cron', scannerCleanupAllEnvs);
});
}
console.log(`[Scheduler] System schedule cleanup: ${scheduleCleanupCron} [${defaultTimezone}]`);
console.log(`[Scheduler] System event cleanup: ${eventCleanupCron} [${defaultTimezone}]`);
console.log(`[Scheduler] Volume helper cleanup: every 30 minutes [${defaultTimezone}]`);
console.log(`[Scheduler] Scanner cache cleanup: weekly (Sunday 3am) [${defaultTimezone}]`);
console.log(`[Scheduler] Scanner cache cleanup: ${scannerCleanupEnabled ? scannerCleanupCron : 'disabled'} [${defaultTimezone}]`);
}
// =============================================================================
@@ -682,11 +693,7 @@ export async function triggerSystemJob(jobId: string): Promise<{ success: boolea
});
return { success: true };
} else if (jobId === String(SYSTEM_SCANNER_CLEANUP_ID) || jobId === 'scanner-cache-cleanup') {
const scannerCleanupFn = async () => {
const { cleanupScannerCache } = await import('../scanner');
return cleanupScannerCache();
};
runScannerCacheCleanupJob('manual', scannerCleanupFn);
runScannerCacheCleanupJob('manual', scannerCleanupAllEnvs);
return { success: true };
} else {
return { success: false, error: 'Unknown system job ID' };
@@ -712,8 +719,10 @@ export async function getSystemSchedules(): Promise<SystemScheduleInfo[]> {
const eventRetention = await getEventRetentionDays();
const scheduleCleanupCron = await getScheduleCleanupCron();
const eventCleanupCron = await getEventCleanupCron();
const scannerCleanupCron = await getScannerCleanupCron();
const scheduleCleanupEnabled = await getScheduleCleanupEnabled();
const eventCleanupEnabled = await getEventCleanupEnabled();
const scannerCleanupEnabled = await getScannerCleanupEnabled();
return [
{
@@ -751,10 +760,10 @@ export async function getSystemSchedules(): Promise<SystemScheduleInfo[]> {
type: 'system_cleanup' as const,
name: 'Scanner cache cleanup',
description: 'Removes scanner vulnerability database cache to reclaim disk space',
cronExpression: '0 3 * * 0',
nextRun: getNextRun('0 3 * * 0')?.toISOString() ?? null,
cronExpression: scannerCleanupCron,
nextRun: scannerCleanupEnabled ? getNextRun(scannerCleanupCron)?.toISOString() ?? null : null,
isSystem: true,
enabled: true
enabled: scannerCleanupEnabled
}
];
}
@@ -11,6 +11,7 @@ import {
getEventRetentionDays,
getScheduleCleanupEnabled,
getEventCleanupEnabled,
getScannerCleanupEnabled,
createScheduleExecution,
updateScheduleExecution,
appendScheduleExecutionLog
@@ -210,6 +211,14 @@ export async function runScannerCacheCleanupJob(
triggeredBy: ScheduleTrigger = 'cron',
cleanupFn?: () => Promise<{ volumes: string[]; dirs: string[] }>
): Promise<void> {
// Check if cleanup is enabled (skip check if manually triggered)
if (triggeredBy === 'cron') {
const enabled = await getScannerCleanupEnabled();
if (!enabled) {
return; // Skip execution if disabled
}
}
const startTime = Date.now();
const execution = await createScheduleExecution({
+28 -18
View File
@@ -60,19 +60,20 @@ async function isComposeFile(filePath: string): Promise<boolean> {
}
/**
* Count the number of services defined in a compose file
* Parses YAML to reliably count top-level keys under 'services:' section
* Parse compose file metadata: top-level `name` property and service count.
* The `name` property (if present) should be used as the stack name instead of the directory name,
* matching Docker Compose's behavior with `com.docker.compose.project`.
*/
async function countServices(filePath: string): Promise<number> {
function parseComposeMetadata(filePath: string): { name: string | null; serviceCount: number } {
try {
const content = readFileSync(filePath, 'utf-8');
const doc = yaml.load(content) as Record<string, unknown> | null;
if (doc?.services && typeof doc.services === 'object') {
return Object.keys(doc.services).length;
}
return 0;
const name = typeof doc?.name === 'string' ? doc.name.trim() : null;
const serviceCount = doc?.services && typeof doc.services === 'object'
? Object.keys(doc.services).length : 0;
return { name, serviceCount };
} catch {
return 0;
return { name: null, serviceCount: 0 };
}
}
@@ -122,13 +123,12 @@ async function scanPath(basePath: string): Promise<{ stacks: DiscoveredStack[];
for (const pattern of COMPOSE_PATTERNS) {
const composePath = join(currentPath, pattern);
if (existsSync(composePath)) {
// Found a stack! Stack name = directory name
const stackName = normalizeStackName(basename(currentPath));
// Found a stack! Use compose name property if defined, otherwise directory name
const { name: composeName, serviceCount } = parseComposeMetadata(composePath);
const stackName = normalizeStackName(composeName || basename(currentPath));
if (stackName) {
// Check for .env file
const envPath = join(currentPath, '.env');
// Count services in compose file
const serviceCount = await countServices(composePath);
discovered.push({
name: stackName,
composePath,
@@ -166,14 +166,13 @@ async function scanPath(basePath: string): Promise<{ stacks: DiscoveredStack[];
if (lowerName.endsWith('.yml') || lowerName.endsWith('.yaml')) {
// Validate it's actually a compose file
if (await isComposeFile(entryPath)) {
const { name: composeName, serviceCount } = parseComposeMetadata(entryPath);
const stackName = normalizeStackName(
entry.name.replace(/\.(yml|yaml)$/i, '')
composeName || entry.name.replace(/\.(yml|yaml)$/i, '')
);
if (stackName) {
// Check for .env file in same directory
const envPath = join(currentPath, '.env');
// Count services in compose file
const serviceCount = await countServices(entryPath);
discovered.push({
name: stackName,
composePath: entryPath,
@@ -214,8 +213,18 @@ export async function adoptStack(
return { success: false, error: 'Already adopted' };
}
// If the compose file has a top-level `name:` property, prefer it over the passed name.
// This ensures Docker's project name (from the label) matches Dockhand's stack name.
let stackNameSource = stack.name;
if (stack.composePath && existsSync(stack.composePath)) {
const { name: composeName } = parseComposeMetadata(stack.composePath);
if (composeName) {
stackNameSource = composeName;
}
}
// Check for name conflict within the same environment
let finalName = normalizeStackName(stack.name);
let finalName = normalizeStackName(stackNameSource);
const existingNames = new Set(
existingSources
.filter((s) => s.environmentId === environmentId)
@@ -224,11 +233,12 @@ export async function adoptStack(
if (existingNames.has(finalName)) {
// Append suffix to make unique
const baseName = finalName;
let suffix = 1;
while (existingNames.has(`${stack.name}-${suffix}`)) {
while (existingNames.has(`${baseName}-${suffix}`)) {
suffix++;
}
finalName = `${stack.name}-${suffix}`;
finalName = `${baseName}-${suffix}`;
}
// Create stack source record - use 'internal' since we know the file paths
+26 -20
View File
@@ -102,6 +102,7 @@ export interface DeployStackOptions {
sourceDir?: string; // Directory to copy all files from (for git stacks)
forceRecreate?: boolean;
build?: boolean; // Build images before starting (--build)
noBuildCache?: boolean; // Disable build cache (--no-cache, requires --build)
pullPolicy?: string; // Pull policy: 'always' | 'missing' | 'never'
composePath?: string; // Custom compose file path (for adopted/imported stacks)
envPath?: string; // Custom env file path (for adopted/imported stacks)
@@ -259,23 +260,14 @@ async function readDirFilesAsMap(dirPath: string): Promise<Record<string, string
// =============================================================================
/**
* Mask sensitive values in environment variables for safe logging.
* Masks values for keys containing common secret patterns and truncates long values.
* Redact all env var values for safe logging. Only key names are preserved.
*/
function maskSecrets(vars: Record<string, string>): Record<string, string> {
const masked: Record<string, string> = {};
const secretPatterns = /password|secret|token|key|api_key|apikey|auth|credential|private/i;
for (const [key, value] of Object.entries(vars)) {
if (secretPatterns.test(key)) {
masked[key] = '***';
} else if (value.length > 50) {
// Truncate long values that might be secrets
masked[key] = value.substring(0, 10) + '...(truncated)';
} else {
masked[key] = value;
}
function redactEnvVarsForLog(vars: Record<string, string>): Record<string, string> {
const redacted: Record<string, string> = {};
for (const key of Object.keys(vars)) {
redacted[key] = '***';
}
return masked;
return redacted;
}
// =============================================================================
@@ -794,6 +786,7 @@ interface ComposeCommandOptions {
envId?: number | null;
forceRecreate?: boolean;
build?: boolean; // Build images before starting (--build)
noBuildCache?: boolean; // Disable build cache (--no-cache, requires --build)
pullPolicy?: string; // Pull policy: 'always' | 'missing' | 'never'
removeVolumes?: boolean;
stackFiles?: Record<string, string>; // All files to send to Hawser
@@ -857,6 +850,7 @@ async function executeLocalCompose(
useOverrideFile?: boolean,
serviceName?: string,
build?: boolean,
noBuildCache?: boolean,
pullPolicy?: string
): Promise<StackOperationResult> {
const logPrefix = `[Stack:${stackName}]`;
@@ -1050,6 +1044,7 @@ async function executeLocalCompose(
args.push('up', '-d', '--remove-orphans');
if (forceRecreate) args.push('--force-recreate');
if (build) args.push('--build');
if (build && noBuildCache) args.push('--no-cache');
if (pullPolicy) args.push('--pull', pullPolicy);
// If targeting a specific service, only update that service
if (serviceName) {
@@ -1094,7 +1089,7 @@ async function executeLocalCompose(
console.log(`${logPrefix} Service name:`, serviceName ?? '(all services)');
console.log(`${logPrefix} Env vars count:`, envVars ? Object.keys(envVars).length : 0);
if (envVars && Object.keys(envVars).length > 0) {
console.log(`${logPrefix} Env vars being injected (masked):`, JSON.stringify(maskSecrets(envVars), null, 2));
console.log(`${logPrefix} Env vars being injected (masked):`, JSON.stringify(redactEnvVarsForLog(envVars), null, 2));
}
// Login to registries before pulling images
@@ -1226,6 +1221,7 @@ async function executeComposeViaHawser(
serviceName?: string,
composeFileName?: string,
build?: boolean,
noBuildCache?: boolean,
pullPolicy?: string
): Promise<StackOperationResult> {
const logPrefix = `[Stack:${stackName}]`;
@@ -1249,7 +1245,7 @@ async function executeComposeViaHawser(
console.log(`${logPrefix} Non-secret env vars count:`, envVars ? Object.keys(envVars).length : 0);
console.log(`${logPrefix} Secret env vars count:`, secretCount);
if (allEnvVars && Object.keys(allEnvVars).length > 0) {
console.log(`${logPrefix} All env vars being sent (masked):`, JSON.stringify(maskSecrets(allEnvVars), null, 2));
console.log(`${logPrefix} All env vars being sent (masked):`, JSON.stringify(redactEnvVarsForLog(allEnvVars), null, 2));
}
console.log(`${logPrefix} Compose content length:`, composeContent.length, 'chars');
console.log(`${logPrefix} Stack files count:`, stackFiles ? Object.keys(stackFiles).length : 0);
@@ -1300,6 +1296,7 @@ async function executeComposeViaHawser(
forceRecreate: forceRecreate || false,
removeVolumes: removeVolumes || false,
build: build || false,
noBuildCache: (build && noBuildCache) || false,
pullPolicy: pullPolicy || '',
registries, // Registry credentials for docker login
serviceName // Target specific service only (with --no-deps)
@@ -1368,7 +1365,7 @@ async function executeComposeCommand(
envVars?: Record<string, string>,
secretVars?: Record<string, string>
): Promise<StackOperationResult> {
const { stackName, envId, forceRecreate, build, pullPolicy, removeVolumes, stackFiles, workingDir, composePath, envPath, useOverrideFile, serviceName, composeFileName } = options;
const { stackName, envId, forceRecreate, build, noBuildCache, pullPolicy, removeVolumes, stackFiles, workingDir, composePath, envPath, useOverrideFile, serviceName, composeFileName } = options;
// Get environment configuration
const env = envId ? await getEnvironment(envId) : null;
@@ -1392,6 +1389,7 @@ async function executeComposeCommand(
useOverrideFile,
serviceName,
build,
noBuildCache,
pullPolicy
);
}
@@ -1456,6 +1454,7 @@ async function executeComposeCommand(
serviceName,
composeFileName,
build,
noBuildCache,
pullPolicy
);
}
@@ -1489,6 +1488,7 @@ async function executeComposeCommand(
useOverrideFile,
serviceName,
build,
noBuildCache,
pullPolicy
);
}
@@ -1512,6 +1512,7 @@ async function executeComposeCommand(
useOverrideFile,
serviceName,
build,
noBuildCache,
pullPolicy
);
}
@@ -2175,7 +2176,7 @@ export async function removeStack(
* Uses stack locking to prevent concurrent deployments.
*/
export async function deployStack(options: DeployStackOptions): Promise<StackOperationResult> {
const { name, compose, envId, sourceDir, forceRecreate, build, pullPolicy, composePath, envPath, composeFileName, envFileName } = options;
const { name, compose, envId, sourceDir, forceRecreate, build, noBuildCache, pullPolicy, composePath, envPath, composeFileName, envFileName } = options;
const logPrefix = `[Stack:${name}]`;
console.log(`${logPrefix} ========================================`);
@@ -2253,7 +2254,11 @@ export async function deployStack(options: DeployStackOptions): Promise<StackOpe
// and would be destroyed, causing data loss (#831).
console.log(`${logPrefix} Copying source directory to stack directory...`);
mkdirSync(workingDir, { recursive: true });
cpSync(sourceDir, workingDir, { recursive: true, force: true });
cpSync(sourceDir, workingDir, {
recursive: true,
force: true,
filter: (src) => !src.includes('/.git/') && !src.endsWith('/.git')
});
console.log(`${logPrefix} Copied ${sourceDir} -> ${workingDir}`);
} else {
// Internal stack: check if a custom path exists in DB (adopted/imported stacks)
@@ -2318,6 +2323,7 @@ export async function deployStack(options: DeployStackOptions): Promise<StackOpe
envId,
forceRecreate,
build,
noBuildCache,
pullPolicy,
stackFiles,
workingDir,
+11 -3
View File
@@ -2,6 +2,7 @@ import { writable, get } from 'svelte/store';
import { browser } from '$app/environment';
import type { ContainerInfo, ContainerStats } from '$lib/types';
import { appendEnvParam, clearStaleEnvironment, environments } from '$lib/stores/environment';
import { appSettings } from '$lib/stores/settings';
import { toast } from 'svelte-sonner';
export interface AutoUpdateSetting {
@@ -70,9 +71,16 @@ function createContainerStore() {
const [min, hr, , , dow] = parts;
const hourNum = parseInt(hr);
const minNum = parseInt(min);
const ampm = hourNum >= 12 ? 'PM' : 'AM';
const hour12 = hourNum === 0 ? 12 : hourNum > 12 ? hourNum - 12 : hourNum;
const timeStr = `${hour12}:${minNum.toString().padStart(2, '0')} ${ampm}`;
const is12Hour = get(appSettings).timeFormat === '12h';
let timeStr: string;
if (is12Hour) {
const ampm = hourNum >= 12 ? 'PM' : 'AM';
const hour12 = hourNum === 0 ? 12 : hourNum > 12 ? hourNum - 12 : hourNum;
timeStr = `${hour12}:${minNum.toString().padStart(2, '0')} ${ampm}`;
} else {
timeStr = `${hourNum.toString().padStart(2, '0')}:${minNum.toString().padStart(2, '0')}`;
}
if (scheduleType === 'daily' || dow === '*') {
return { label: 'daily', tooltip: `Daily at ${timeStr}` };
+22
View File
@@ -22,6 +22,8 @@ export interface AppSettings {
eventCleanupCron: string;
scheduleCleanupEnabled: boolean;
eventCleanupEnabled: boolean;
scannerCleanupCron: string;
scannerCleanupEnabled: boolean;
logBufferSizeKb: number;
defaultTimezone: string;
eventCollectionMode: EventCollectionMode;
@@ -52,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',
@@ -113,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,
@@ -160,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,
@@ -297,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 };
@@ -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);
+10 -1
View File
@@ -7,7 +7,8 @@ import {
getGitRepository,
createGitRepository,
upsertStackSource,
setStackEnvVars
setStackEnvVars,
getStackSource
} from '$lib/server/db';
import { deployGitStack } from '$lib/server/git';
import { authorize } from '$lib/server/authorize';
@@ -61,6 +62,12 @@ export const POST: RequestHandler = async (event) => {
return json({ error: 'Stack name must start with a letter or number, and contain only letters, numbers, hyphens, and underscores' }, { status: 400 });
}
// Check for name conflicts with existing stacks (regular/external/git)
const existing = await getStackSource(trimmedStackName, data.environmentId || null);
if (existing) {
return json({ error: 'A stack with this name already exists on this environment' }, { status: 409 });
}
// Either repositoryId or new repo details (url, branch) must be provided
let repositoryId = data.repositoryId;
@@ -120,7 +127,9 @@ export const POST: RequestHandler = async (event) => {
autoUpdateCron: data.autoUpdateCron || '0 3 * * *',
webhookEnabled: data.webhookEnabled || false,
webhookSecret: webhookSecret,
contextDir: data.contextDir || null,
buildOnDeploy: data.buildOnDeploy ?? false,
noBuildCache: data.noBuildCache ?? false,
repullImages: data.repullImages ?? false,
forceRedeploy: data.forceRedeploy ?? false
});
@@ -73,7 +73,9 @@ export const PUT: RequestHandler = async (event) => {
autoUpdateCron: data.autoUpdateCron,
webhookEnabled: data.webhookEnabled,
webhookSecret: data.webhookSecret,
contextDir: data.contextDir,
buildOnDeploy: data.buildOnDeploy,
noBuildCache: data.noBuildCache,
repullImages: data.repullImages,
forceRedeploy: data.forceRedeploy
});
+2 -2
View File
@@ -46,8 +46,8 @@ export const GET: RequestHandler = async ({ url }) => {
});
if (!response.ok) {
if (response.status === 401) {
return json({ error: 'Authentication failed. Please check your credentials.' }, { status: 401 });
if (response.status === 401 || response.status === 403) {
return json({ error: 'Catalog listing not available. This registry may not support the _catalog endpoint (common with GitLab and Harbor). Try searching for images by name instead.' }, { status: response.status });
}
if (response.status === 404) {
return json({ error: 'Registry does not support V2 catalog API' }, { status: 404 });
+37 -15
View File
@@ -1,7 +1,7 @@
import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { getRegistry } from '$lib/server/db';
import { getRegistryAuth } from '$lib/server/docker';
import { getRegistryAuth, parseRegistryUrl } from '$lib/server/docker';
interface SearchResult {
name: string;
@@ -46,28 +46,50 @@ async function searchDockerHub(term: string, limit: number): Promise<SearchResul
async function searchPrivateRegistry(registry: any, term: string, limit: number): Promise<SearchResult[]> {
const results: string[] = [];
const { orgPath } = parseRegistryUrl(registry.url);
const orgPrefix = orgPath ? orgPath.replace(/^\//, '') : '';
// Strategy 1: If term looks like an image name (contains /), try direct lookup first
// This is much faster than iterating through catalog for large registries like ghcr.io
// Strategy 1: Direct image lookup — try the exact term and org-prefixed variants
// This uses per-repository auth scope which works on all V2 registries (GitLab, Harbor, etc.)
const directCandidates: string[] = [];
if (term.includes('/')) {
const directResult = await tryDirectImageLookup(registry, term);
if (directResult) {
results.push(term);
directCandidates.push(term);
}
// If registry URL has an org path (e.g., https://registry.example.com/group),
// try prepending it to the search term
if (orgPrefix && !term.startsWith(orgPrefix + '/')) {
directCandidates.push(`${orgPrefix}/${term}`);
}
for (const candidate of directCandidates) {
if (results.length >= limit) break;
const exists = await tryDirectImageLookup(registry, candidate);
if (exists && !results.includes(candidate)) {
results.push(candidate);
}
}
// Strategy 2: Fall back to catalog search for partial matches or if direct lookup failed
// Strategy 2: Fall back to catalog search for partial/fuzzy matches
// Some registries (GitLab, Harbor) don't support _catalog for deploy tokens,
// so catch errors gracefully and return whatever we have from direct lookup
if (results.length < limit) {
const catalogResults = await searchCatalog(registry, term, limit - results.length);
// Add catalog results, avoiding duplicates
for (const name of catalogResults) {
if (!results.includes(name)) {
results.push(name);
try {
const catalogResults = await searchCatalog(registry, term, limit - results.length);
for (const name of catalogResults) {
if (!results.includes(name)) {
results.push(name);
}
}
} catch (e: any) {
// Catalog not supported but we have direct lookup results — that's fine
if (results.length > 0) {
console.warn(`[Registry] Catalog search failed (using direct lookup results): ${e.message}`);
} else {
throw e;
}
}
}
// Return results in the same format as Docker Hub
return results.map((name: string) => ({
name,
description: '',
@@ -136,8 +158,8 @@ async function searchCatalog(registry: any, term: string, limit: number): Promis
});
if (!response.ok) {
if (response.status === 401) {
throw new Error('Authentication failed');
if (response.status === 401 || response.status === 403) {
throw new Error('Authentication failed. This registry may not support catalog listing (common with GitLab and Harbor deploy tokens).');
}
throw new Error(`Registry returned error: ${response.status}`);
}
+2 -2
View File
@@ -93,8 +93,8 @@ async function fetchRegistryTags(registry: any, imageName: string): Promise<TagI
});
if (!response.ok) {
if (response.status === 401) {
throw new Error('Authentication failed');
if (response.status === 401 || response.status === 403) {
throw new Error('Authentication failed. Please check your credentials and permissions.');
}
if (response.status === 404) {
throw new Error('Image not found in registry');
@@ -2,13 +2,17 @@ import { json, type RequestHandler } from '@sveltejs/kit';
import {
setScheduleCleanupEnabled,
setEventCleanupEnabled,
setScannerCleanupEnabled,
getScheduleCleanupEnabled,
getEventCleanupEnabled
getEventCleanupEnabled,
getScannerCleanupEnabled
} from '$lib/server/db';
import { authorize } from '$lib/server/authorize';
import { refreshSystemJobs } from '$lib/server/scheduler';
const SYSTEM_SCHEDULE_CLEANUP_ID = 1;
const SYSTEM_EVENT_CLEANUP_ID = 2;
const SYSTEM_SCANNER_CLEANUP_ID = 4;
export const POST: RequestHandler = async ({ params, cookies }) => {
const auth = await authorize(cookies);
@@ -32,6 +36,11 @@ export const POST: RequestHandler = async ({ params, cookies }) => {
const currentEnabled = await getEventCleanupEnabled();
await setEventCleanupEnabled(!currentEnabled);
return json({ success: true, enabled: !currentEnabled });
} else if (systemId === SYSTEM_SCANNER_CLEANUP_ID) {
const currentEnabled = await getScannerCleanupEnabled();
await setScannerCleanupEnabled(!currentEnabled);
await refreshSystemJobs();
return json({ success: true, enabled: !currentEnabled });
} else {
return json({ error: 'Unknown system schedule' }, { status: 400 });
}
+28 -2
View File
@@ -14,6 +14,10 @@ import {
setScheduleCleanupEnabled,
getEventCleanupEnabled,
setEventCleanupEnabled,
getScannerCleanupCron,
setScannerCleanupCron,
getScannerCleanupEnabled,
setScannerCleanupEnabled,
getDefaultTimezone,
setDefaultTimezone,
getEventCollectionMode,
@@ -52,6 +56,8 @@ export interface GeneralSettings {
eventCleanupCron: string;
scheduleCleanupEnabled: boolean;
eventCleanupEnabled: boolean;
scannerCleanupCron: string;
scannerCleanupEnabled: boolean;
logBufferSizeKb: number;
defaultTimezone: string;
// Background monitoring settings
@@ -83,7 +89,7 @@ export interface GeneralSettings {
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,
@@ -165,6 +171,8 @@ export const GET: RequestHandler = async ({ cookies }) => {
eventCleanupCron,
scheduleCleanupEnabled,
eventCleanupEnabled,
scannerCleanupCron,
scannerCleanupEnabled,
logBufferSizeKb,
defaultTimezone,
eventCollectionMode,
@@ -200,6 +208,8 @@ export const GET: RequestHandler = async ({ cookies }) => {
getEventCleanupCron(),
getScheduleCleanupEnabled(),
getEventCleanupEnabled(),
getScannerCleanupCron(),
getScannerCleanupEnabled(),
getSetting('log_buffer_size_kb'),
getDefaultTimezone(),
getEventCollectionMode(),
@@ -237,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,
@@ -274,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, defaultComposeTemplate, labelFilterMode } = 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);
@@ -318,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)));
@@ -416,6 +436,8 @@ export const POST: RequestHandler = async ({ request, cookies }) => {
eventCleanupCronVal,
scheduleCleanupEnabledVal,
eventCleanupEnabledVal,
scannerCleanupCronVal,
scannerCleanupEnabledVal,
logBufferSizeKbVal,
defaultTimezoneVal,
eventCollectionModeVal,
@@ -451,6 +473,8 @@ export const POST: RequestHandler = async ({ request, cookies }) => {
getEventCleanupCron(),
getScheduleCleanupEnabled(),
getEventCleanupEnabled(),
getScannerCleanupCron(),
getScannerCleanupEnabled(),
getSetting('log_buffer_size_kb'),
getDefaultTimezone(),
getEventCollectionMode(),
@@ -488,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,
@@ -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 });
}
+20 -1
View File
@@ -473,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,
@@ -542,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;
@@ -1442,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')}
@@ -87,8 +87,12 @@
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
!summary || !showFilterButtons
? progress
: filterMode === 'failed'
? progress.filter(p => p.step === 'failed' || p.step === 'blocked')
@@ -475,7 +479,7 @@ const severityOrder: Record<string, number> = { critical: 0, high: 1, medium: 2,
<!-- Filter toggle + Container list with status - scrollable area -->
{#if progress.length > 0}
{#if summary && (summary.failed > 0 || summary.blocked > 0) && summary.success > 0}
{#if showFilterButtons}
<div class="flex items-center gap-1 shrink-0">
<Button
variant={filterMode === 'updated' ? 'default' : 'outline'}
+150 -14
View File
@@ -60,6 +60,12 @@
driver: string;
}
interface NetworkEndpointConfig {
ipv4Address: string;
ipv6Address: string;
aliases: string;
}
interface Props {
mode: 'create' | 'edit';
// Basic settings
@@ -82,6 +88,8 @@
// Networks
availableNetworks: DockerNetwork[];
selectedNetworks: string[];
networkConfigs: Record<string, NetworkEndpointConfig>;
macAddress: string;
// User/Group
containerUser: string;
// Privileged mode
@@ -157,6 +165,8 @@
labels = $bindable(),
availableNetworks,
selectedNetworks = $bindable(),
networkConfigs = $bindable(),
macAddress = $bindable(),
containerUser = $bindable(),
privilegedMode = $bindable(),
healthcheckEnabled = $bindable(),
@@ -195,6 +205,60 @@
imageSummary
}: Props = $props();
// Expanded network config rows
let expandedNetworks = $state<Set<string>>(new Set());
function toggleNetworkExpand(networkName: string) {
const next = new Set(expandedNetworks);
if (next.has(networkName)) {
next.delete(networkName);
} else {
next.add(networkName);
}
expandedNetworks = next;
}
function ensureNetworkConfig(networkName: string) {
if (!networkConfigs[networkName]) {
networkConfigs[networkName] = { ipv4Address: '', ipv6Address: '', aliases: '' };
networkConfigs = { ...networkConfigs };
}
}
function hasNetworkConfig(networkName: string): boolean {
const cfg = networkConfigs[networkName];
return !!cfg && !!(cfg.ipv4Address || cfg.ipv6Address || cfg.aliases);
}
// Validation helpers
const ipv4Regex = /^(\d{1,3}\.){3}\d{1,3}(\/\d{1,2})?$/;
const ipv6Regex = /^[0-9a-fA-F:]+$/;
const macRegex = /^([0-9a-fA-F]{2}:){5}[0-9a-fA-F]{2}$/;
function validateIpv4(value: string): string | null {
if (!value) return null;
return ipv4Regex.test(value) ? null : 'Invalid IPv4 address';
}
function validateIpv6(value: string): string | null {
if (!value) return null;
return ipv6Regex.test(value) ? null : 'Invalid IPv6 address';
}
function validateMac(value: string): string | null {
if (!value) return null;
return macRegex.test(value) ? null : 'Invalid MAC address (e.g., 02:42:ac:11:00:02)';
}
// Auto-expand networks that have config
$effect(() => {
for (const net of selectedNetworks) {
if (hasNetworkConfig(net) && !expandedNetworks.has(net)) {
expandedNetworks = new Set([...expandedNetworks, net]);
}
}
});
// Collapsible sections state
let showResources = $state(false);
let showSecurity = $state(false);
@@ -257,6 +321,11 @@
function removeNetwork(networkId: string) {
selectedNetworks = selectedNetworks.filter((n) => n !== networkId);
const { [networkId]: _, ...rest } = networkConfigs;
networkConfigs = rest;
const next = new Set(expandedNetworks);
next.delete(networkId);
expandedNetworks = next;
}
function addDeviceMapping() {
@@ -681,25 +750,92 @@
</Select.Root>
{#if selectedNetworks.length > 0}
<div class="flex flex-wrap gap-2 pt-1">
<div class="space-y-1 pt-1">
{#each selectedNetworks as networkName}
{@const network = availableNetworks.find(n => n.name === networkName)}
<Badge variant="secondary" class="flex items-center gap-1.5 pr-1">
{networkName}
{#if network}
<span class={getDriverBadgeClasses(network.driver)}>{network.driver}</span>
{@const isExpanded = expandedNetworks.has(networkName)}
<div class="border rounded-md">
<div class="flex items-center justify-between px-2.5 py-1.5">
<button
type="button"
onclick={() => { ensureNetworkConfig(networkName); toggleNetworkExpand(networkName); }}
class="flex items-center gap-1.5 text-sm hover:text-foreground transition-colors"
>
{#if isExpanded}
<ChevronDown class="w-3.5 h-3.5 text-muted-foreground" />
{:else}
<ChevronRight class="w-3.5 h-3.5 text-muted-foreground" />
{/if}
<span>{networkName}</span>
{#if network}
<span class={getDriverBadgeClasses(network.driver)}>{network.driver}</span>
{/if}
{#if hasNetworkConfig(networkName)}
<Badge variant="secondary" class="text-2xs">configured</Badge>
{/if}
</button>
<button
type="button"
onclick={() => removeNetwork(networkName)}
class="p-0.5 hover:bg-destructive/20 rounded text-muted-foreground hover:text-destructive"
>
<X class="w-3.5 h-3.5" />
</button>
</div>
{#if isExpanded && networkConfigs[networkName]}
<div class="px-2.5 pb-2.5 pt-1 border-t space-y-2">
<div class="grid grid-cols-2 gap-2">
<div class="space-y-1">
<Label class="text-2xs font-medium text-muted-foreground">IPv4 address</Label>
<Input
bind:value={networkConfigs[networkName].ipv4Address}
placeholder="e.g., 172.28.0.100"
class="h-8 text-xs"
/>
{#if validateIpv4(networkConfigs[networkName].ipv4Address)}
<p class="text-2xs text-destructive">{validateIpv4(networkConfigs[networkName].ipv4Address)}</p>
{/if}
</div>
<div class="space-y-1">
<Label class="text-2xs font-medium text-muted-foreground">IPv6 address</Label>
<Input
bind:value={networkConfigs[networkName].ipv6Address}
placeholder="e.g., fd00::100"
class="h-8 text-xs"
/>
{#if validateIpv6(networkConfigs[networkName].ipv6Address)}
<p class="text-2xs text-destructive">{validateIpv6(networkConfigs[networkName].ipv6Address)}</p>
{/if}
</div>
</div>
<div class="space-y-1">
<Label class="text-2xs font-medium text-muted-foreground">Aliases (comma-separated)</Label>
<Input
bind:value={networkConfigs[networkName].aliases}
placeholder="e.g., myalias, web"
class="h-8 text-xs"
/>
</div>
</div>
{/if}
<button
type="button"
onclick={() => removeNetwork(networkName)}
class="ml-0.5 hover:bg-destructive/20 rounded p-0.5"
>
<X class="w-3 h-3" />
</button>
</Badge>
</div>
{/each}
</div>
{/if}
<!-- MAC Address -->
<div class="space-y-1 pt-1">
<Label class="text-xs font-medium">MAC address</Label>
<Input
bind:value={macAddress}
placeholder="e.g., 02:42:ac:11:00:02"
class="h-9"
/>
{#if validateMac(macAddress)}
<p class="text-xs text-destructive">{validateMac(macAddress)}</p>
{/if}
</div>
{#if mode === 'edit'}
<p class="text-xs text-muted-foreground">Container will be connected to selected networks in addition to the network mode above</p>
{/if}
@@ -851,7 +987,7 @@
size="icon"
variant="ghost"
onclick={() => removeLabel(index)}
disabled={labels.length === 1}
disabled={labels.length <= 1 && !labels[0]?.key}
class="h-9 w-9 text-muted-foreground hover:text-destructive"
>
<Trash2 class="w-4 h-4" />
@@ -112,6 +112,8 @@
}
let availableNetworks = $state<DockerNetwork[]>([]);
let selectedNetworks = $state<string[]>([]);
let networkConfigs = $state<Record<string, { ipv4Address: string; ipv6Address: string; aliases: string }>>({});
let macAddress = $state('');
// Auto-update settings
let autoUpdateEnabled = $state(false);
@@ -431,6 +433,17 @@
deviceRequests = [dr];
}
// Build per-network configs for the API
const netConfigs: Record<string, { ipv4Address?: string; ipv6Address?: string; aliases?: string[] }> = {};
for (const [netName, cfg] of Object.entries(networkConfigs)) {
if (!selectedNetworks.includes(netName)) continue;
const entry: { ipv4Address?: string; ipv6Address?: string; aliases?: string[] } = {};
if (cfg.ipv4Address) entry.ipv4Address = cfg.ipv4Address;
if (cfg.ipv6Address) entry.ipv6Address = cfg.ipv6Address;
if (cfg.aliases) entry.aliases = cfg.aliases.split(',').map(a => a.trim()).filter(Boolean);
if (Object.keys(entry).length > 0) netConfigs[netName] = entry;
}
const payload = {
name: name.trim(),
image: image.trim(),
@@ -443,6 +456,8 @@
restartMaxRetries: restartPolicy === 'on-failure' && restartMaxRetries !== '' ? Number(restartMaxRetries) : undefined,
networkMode,
networks: selectedNetworks.length > 0 ? selectedNetworks : undefined,
networkConfigs: Object.keys(netConfigs).length > 0 ? netConfigs : undefined,
macAddress: macAddress.trim() || undefined,
startAfterCreate,
user: containerUser.trim() || undefined,
privileged: privilegedMode || undefined,
@@ -530,6 +545,8 @@
envVars = [{ key: '', value: '' }];
labels = [{ key: '', value: '' }];
selectedNetworks = [];
networkConfigs = {};
macAddress = '';
autoUpdateEnabled = false;
autoUpdateCronExpression = '0 3 * * *';
vulnerabilityCriteria = 'never';
@@ -723,6 +740,8 @@
bind:labels
{availableNetworks}
bind:selectedNetworks
bind:networkConfigs
bind:macAddress
bind:containerUser
bind:privilegedMode
bind:healthcheckEnabled
@@ -113,6 +113,8 @@
}
let availableNetworks = $state<DockerNetwork[]>([]);
let selectedNetworks = $state<string[]>([]);
let networkConfigs = $state<Record<string, { ipv4Address: string; ipv6Address: string; aliases: string }>>({});
let macAddress = $state('');
// User/Group
let containerUser = $state('');
@@ -182,6 +184,8 @@
envVars: typeof envVars;
labels: typeof labels;
selectedNetworks: string[];
networkConfigs: Record<string, { ipv4Address: string; ipv6Address: string; aliases: string }>;
macAddress: string;
// Advanced options
containerUser: string;
privilegedMode: boolean;
@@ -428,6 +432,24 @@
const networks = data.NetworkSettings?.Networks || {};
selectedNetworks = Object.keys(networks);
// Parse per-network IP/alias config from NetworkSettings
const parsedNetConfigs: Record<string, { ipv4Address: string; ipv6Address: string; aliases: string }> = {};
for (const [netName, netData] of Object.entries(networks) as [string, any][]) {
const ipam = netData.IPAMConfig || {};
const aliases = netData.Aliases || [];
if (ipam.IPv4Address || ipam.IPv6Address || aliases.length > 0) {
parsedNetConfigs[netName] = {
ipv4Address: ipam.IPv4Address || '',
ipv6Address: ipam.IPv6Address || '',
aliases: aliases.join(', ')
};
}
}
networkConfigs = parsedNetConfigs;
// Parse MAC address
macAddress = data.Config?.MacAddress || '';
// Parse advanced options - User
containerUser = data.Config.User || '';
@@ -561,6 +583,8 @@
envVars: JSON.parse(JSON.stringify(envVars)),
labels: JSON.parse(JSON.stringify(labels)),
selectedNetworks: [...selectedNetworks],
networkConfigs: JSON.parse(JSON.stringify(networkConfigs)),
macAddress,
// Advanced options
containerUser,
privilegedMode,
@@ -631,6 +655,8 @@
if (JSON.stringify(currentLabels) !== JSON.stringify(originalLabels)) return true;
if (JSON.stringify([...selectedNetworks].sort()) !== JSON.stringify([...originalConfig.selectedNetworks].sort())) return true;
if (JSON.stringify(networkConfigs) !== JSON.stringify(originalConfig.networkConfigs)) return true;
if (macAddress !== originalConfig.macAddress) return true;
// Advanced options - User & Security
if (containerUser !== originalConfig.containerUser) return true;
@@ -701,7 +727,9 @@
volumeMappings: volumeMappings.filter(v => v.hostPath && v.containerPath),
envVars: envVars.filter(e => e.key),
labels: labels.filter(l => l.key),
selectedNetworks: [...selectedNetworks].sort()
selectedNetworks: [...selectedNetworks].sort(),
networkConfigs,
macAddress
});
}
@@ -716,7 +744,9 @@
volumeMappings: originalConfig.volumeMappings.filter(v => v.hostPath && v.containerPath),
envVars: originalConfig.envVars.filter(e => e.key),
labels: originalConfig.labels.filter(l => l.key),
selectedNetworks: [...originalConfig.selectedNetworks].sort()
selectedNetworks: [...originalConfig.selectedNetworks].sort(),
networkConfigs: originalConfig.networkConfigs,
macAddress: originalConfig.macAddress
});
}
@@ -901,18 +931,31 @@
deviceRequests = [dr];
}
// Build per-network configs for the API
const netConfigs: Record<string, { ipv4Address?: string; ipv6Address?: string; aliases?: string[] }> = {};
for (const [netName, cfg] of Object.entries(networkConfigs)) {
if (!selectedNetworks.includes(netName)) continue;
const entry: { ipv4Address?: string; ipv6Address?: string; aliases?: string[] } = {};
if (cfg.ipv4Address) entry.ipv4Address = cfg.ipv4Address;
if (cfg.ipv6Address) entry.ipv6Address = cfg.ipv6Address;
if (cfg.aliases) entry.aliases = cfg.aliases.split(',').map(a => a.trim()).filter(Boolean);
if (Object.keys(entry).length > 0) netConfigs[netName] = entry;
}
const payload = {
name: name.trim(),
image: image.trim(),
ports: Object.keys(ports).length > 0 ? ports : null,
volumeBinds: volumeBinds.length > 0 ? volumeBinds : null,
env: env.length > 0 ? env : undefined,
labels: Object.keys(labelsObj).length > 0 ? labelsObj : undefined,
labels: labelsObj,
cmd,
restartPolicy,
restartMaxRetries: restartPolicy === 'on-failure' && restartMaxRetries !== '' ? Number(restartMaxRetries) : undefined,
networkMode,
networks: selectedNetworks.length > 0 ? selectedNetworks : undefined,
networkConfigs: Object.keys(netConfigs).length > 0 ? netConfigs : undefined,
macAddress: macAddress.trim() || undefined,
startAfterUpdate,
repullImage,
user: containerUser.trim() || null,
@@ -1091,6 +1134,8 @@
bind:labels
{availableNetworks}
bind:selectedNetworks
bind:networkConfigs
bind:macAddress
bind:containerUser
bind:privilegedMode
bind:healthcheckEnabled
+6 -6
View File
@@ -338,7 +338,7 @@
</div>
</Card.Header>
<DashboardLabels labels={stats.labels} />
<Card.Content class="overflow-auto" style="max-height: calc(100% - 60px);">
<Card.Content class="overflow-hidden" style="max-height: calc(100% - 60px);">
{#if showOffline}
<DashboardOfflineState error={stats.error} compact={isMini} />
{:else}
@@ -439,7 +439,7 @@
</div>
</Card.Header>
<DashboardLabels labels={stats.labels} />
<Card.Content class="overflow-auto" style="max-height: calc(100% - 60px);">
<Card.Content class="overflow-hidden" style="max-height: calc(100% - 60px);">
{#if showOffline}
<DashboardOfflineState error={stats.error} compact={isMini} />
{:else}
@@ -543,7 +543,7 @@
</div>
</Card.Header>
<DashboardLabels labels={stats.labels} />
<Card.Content class="overflow-auto" style="max-height: calc(100% - 60px);">
<Card.Content class="overflow-hidden" style="max-height: calc(100% - 60px);">
{#if showOffline}
<DashboardOfflineState error={stats.error} compact={isMini} />
{:else}
@@ -586,7 +586,7 @@
/>
</Card.Header>
<DashboardLabels labels={stats.labels} />
<Card.Content class="overflow-auto" style="max-height: calc(100% - 60px);">
<Card.Content class="overflow-hidden" style="max-height: calc(100% - 60px);">
{#if showOffline}
<DashboardOfflineState error={stats.error} compact={isMini} />
{:else}
@@ -632,7 +632,7 @@
/>
</Card.Header>
<DashboardLabels labels={stats.labels} />
<Card.Content class="overflow-auto" style="max-height: calc(100% - 60px);">
<Card.Content class="overflow-hidden" style="max-height: calc(100% - 60px);">
{#if showOffline}
<DashboardOfflineState error={stats.error} compact={isMini} />
{:else}
@@ -684,7 +684,7 @@
/>
</Card.Header>
<DashboardLabels labels={stats.labels} />
<Card.Content class="overflow-auto" style="max-height: calc(100% - 60px);">
<Card.Content class="overflow-hidden" style="max-height: calc(100% - 60px);">
{#if showOffline}
<DashboardOfflineState error={stats.error} compact={isMini} />
{:else}
+11 -2
View File
@@ -90,18 +90,27 @@
}
function formatChangelogDate(dateStr: string): string {
if (!dateStr) return 'Unreleased';
try {
const date = new Date(dateStr);
if (isNaN(date.getTime())) return 'Unreleased';
return date.toLocaleDateString('en-US', {
year: 'numeric',
month: 'long',
day: 'numeric'
});
} catch {
return dateStr;
return 'Unreleased';
}
}
function sortedChanges(changes: ChangelogChange[]): ChangelogChange[] {
return [...changes].sort((a, b) => {
if (a.type === b.type) return 0;
return a.type === 'feature' ? -1 : 1;
});
}
// Build info - injected at build time
declare const __BUILD_BRANCH__: string | null;
const BUILD_DATE = __BUILD_DATE__ ?? null;
@@ -867,7 +876,7 @@
{#if isExpanded}
<div class="px-4 pb-4">
<ul class="space-y-1.5 text-sm text-muted-foreground mb-3">
{#each release.changes as change}
{#each sortedChanges(release.changes) as change}
<li class="flex items-start gap-2">
{#if change.type === 'feature'}
<span class="inline-flex items-center gap-1 px-1.5 py-0.5 rounded text-2xs font-medium bg-emerald-500/15 text-emerald-600 dark:text-emerald-400 shrink-0">
@@ -751,7 +751,7 @@
labels: formLabels,
connectionType: formConnectionType,
hawserToken: formHawserToken || undefined,
publicIp: formConnectionType !== 'hawser-edge' ? (stripHostProtocol(formPublicIp.trim()) || undefined) : undefined
publicIp: stripHostProtocol(formPublicIp.trim()) || undefined
})
});
@@ -869,7 +869,7 @@
labels: formLabels,
connectionType: formConnectionType,
hawserToken: formHawserToken || undefined,
publicIp: formConnectionType !== 'hawser-edge' ? (stripHostProtocol(formPublicIp.trim()) || null) : null
publicIp: stripHostProtocol(formPublicIp.trim()) || null
})
});
@@ -2160,31 +2160,29 @@
</div>
{/if}
<!-- Public IP field (for all types except hawser-edge) -->
{#if formConnectionType !== 'hawser-edge'}
<div class="space-y-2 pt-4 border-t">
<div class="flex items-center gap-2">
<Label for="edit-env-public-ip">Public IP</Label>
<Tooltip.Root>
<Tooltip.Trigger>
<HelpCircle class="w-3.5 h-3.5 text-muted-foreground" />
</Tooltip.Trigger>
<Tooltip.Content side="bottom" class="w-72">
<p>IP address or hostname where container ports are accessible from your browser. For local Docker, use the server's LAN IP.</p>
</Tooltip.Content>
</Tooltip.Root>
</div>
<Input
id="edit-env-public-ip"
bind:value={formPublicIp}
placeholder="e.g., 192.168.1.4"
class="w-full"
/>
<p class="text-xs text-muted-foreground">
Used for clickable port links on the containers page
</p>
<!-- Public IP field -->
<div class="space-y-2 pt-4 border-t">
<div class="flex items-center gap-2">
<Label for="edit-env-public-ip">Public IP</Label>
<Tooltip.Root>
<Tooltip.Trigger>
<HelpCircle class="w-3.5 h-3.5 text-muted-foreground" />
</Tooltip.Trigger>
<Tooltip.Content side="bottom" class="w-72">
<p>IP address or hostname where container ports are accessible from your browser. For local Docker, use the server's LAN IP.</p>
</Tooltip.Content>
</Tooltip.Root>
</div>
{/if}
<Input
id="edit-env-public-ip"
bind:value={formPublicIp}
placeholder="e.g., 192.168.1.4"
class="w-full"
/>
<p class="text-xs text-muted-foreground">
Used for clickable port links on the containers page
</p>
</div>
</Tabs.Content>
<!-- Updates Tab -->
@@ -74,6 +74,8 @@ services:
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);
@@ -146,6 +148,17 @@ services:
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) {
@@ -758,6 +771,26 @@ services:
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>
@@ -422,6 +422,8 @@ 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"
+89 -1
View File
@@ -7,7 +7,7 @@
import { Badge } from '$lib/components/ui/badge';
import { Input } from '$lib/components/ui/input';
import { TogglePill } from '$lib/components/ui/toggle-pill';
import { Loader2, GitBranch, RefreshCw, Webhook, Rocket, RefreshCcw, Copy, Check, XCircle, FolderGit2, Github, Key, KeyRound, Lock, FileText, HelpCircle, GripVertical, X, Download, Hammer, ArrowDownToLine, Zap } from 'lucide-svelte';
import { Loader2, GitBranch, RefreshCw, Webhook, Rocket, RefreshCcw, Copy, Check, XCircle, FolderGit2, Github, Key, KeyRound, Lock, FileText, HelpCircle, GripVertical, X, Download, Hammer, ArrowDownToLine, Zap, FolderOpen, Ban, TriangleAlert } from 'lucide-svelte';
import * as Tooltip from '$lib/components/ui/tooltip';
import { copyToClipboard } from '$lib/utils/clipboard';
import CronEditor from '$lib/components/cron-editor.svelte';
@@ -58,7 +58,9 @@
autoUpdateCron: string;
webhookEnabled: boolean;
webhookSecret: string | null;
contextDir: string | null;
buildOnDeploy: boolean;
noBuildCache: boolean;
repullImages: boolean;
forceRedeploy: boolean;
}
@@ -91,12 +93,15 @@
let formAutoUpdateCron = $state('0 3 * * *');
let formWebhookEnabled = $state(false);
let formWebhookSecret = $state('');
let formContextDir = $state<string | null>(null);
let formBuildOnDeploy = $state(false);
let formNoBuildCache = $state(false);
let formRepullImages = $state(false);
let formForceRedeploy = $state(false);
let formDeployNow = $state(false);
let formError = $state('');
let formSaving = $state(false);
let showExistsWarning = $state(false);
let errors = $state<{ stackName?: string; repository?: string; repoName?: string; repoUrl?: string }>({});
// Stack name validation: must start with alphanumeric, can contain alphanumeric, hyphens, underscores
@@ -364,7 +369,9 @@
formAutoUpdateCron = gitStack.autoUpdateCron || '0 3 * * *';
formWebhookEnabled = gitStack.webhookEnabled;
formWebhookSecret = gitStack.webhookSecret || '';
formContextDir = gitStack.contextDir ?? null;
formBuildOnDeploy = gitStack.buildOnDeploy ?? false;
formNoBuildCache = gitStack.noBuildCache ?? false;
formRepullImages = gitStack.repullImages ?? false;
formForceRedeploy = gitStack.forceRedeploy ?? false;
formDeployNow = false;
@@ -391,7 +398,9 @@
formAutoUpdateCron = '0 3 * * *';
formWebhookEnabled = false;
formWebhookSecret = '';
formContextDir = null;
formBuildOnDeploy = false;
formNoBuildCache = false;
formRepullImages = false;
formForceRedeploy = false;
formDeployNow = false;
@@ -428,6 +437,25 @@
if (hasErrors) return;
// Check if stack already exists (only for new stacks)
if (!gitStack) {
try {
const stacksResponse = await fetch(`/api/stacks?env=${environmentId}`);
if (stacksResponse.ok) {
const stacks = await stacksResponse.json();
const existingStack = stacks.find((s: { name: string }) =>
s.name.toLowerCase() === formStackName.trim().toLowerCase()
);
if (existingStack) {
showExistsWarning = true;
return;
}
}
} catch (e) {
console.warn('Failed to check for existing stacks:', e);
}
}
formSaving = true;
formError = '';
@@ -450,7 +478,9 @@
autoUpdateCron: formAutoUpdateCron,
webhookEnabled: formWebhookEnabled,
webhookSecret: formWebhookEnabled ? formWebhookSecret : null,
contextDir: formContextDir || null,
buildOnDeploy: formBuildOnDeploy,
noBuildCache: formNoBuildCache,
repullImages: formRepullImages,
forceRedeploy: formForceRedeploy,
deployNow: deployAfterSave,
@@ -793,6 +823,32 @@
<p class="text-xs text-muted-foreground">Additional env file to pass to Docker Compose</p>
</div>
<!-- Context directory -->
<div class="space-y-2">
<div class="flex items-center gap-1.5">
<Label for="context-dir">Context directory (optional)</Label>
<Tooltip.Root>
<Tooltip.Trigger>
<HelpCircle class="w-3.5 h-3.5 text-muted-foreground cursor-help" />
</Tooltip.Trigger>
<Tooltip.Content>
<div class="w-80">
<p class="text-xs">Working directory for Docker Compose, relative to the repository root. All files in this directory will be available for volume mounts and build contexts.</p>
<p class="text-xs mt-2">Use <code class="bg-muted px-1 rounded">.</code> for the repository root when your compose file references files in sibling directories.</p>
<p class="text-xs mt-2">Defaults to the compose file's parent directory.</p>
</div>
</Tooltip.Content>
</Tooltip.Root>
</div>
<Input
id="context-dir"
value={formContextDir ?? ''}
oninput={(e) => { const v = (e.target as HTMLInputElement).value; formContextDir = v.trim() || null; }}
placeholder="Defaults to compose file's directory"
/>
<p class="text-xs text-muted-foreground">Relative to repository root, e.g. <code class="text-xs bg-muted px-1 rounded">.</code> for root</p>
</div>
<!-- Auto-update section -->
<div class="space-y-3 p-3 bg-muted/50 rounded-md">
<div class="flex items-center gap-3">
@@ -922,6 +978,18 @@
<p class="text-xs text-muted-foreground">
Run <code class="text-xs bg-muted px-1 rounded">--build</code> to build images from Dockerfiles before starting containers.
</p>
{#if formBuildOnDeploy}
<div class="flex items-center gap-3 ml-6">
<div class="flex items-center gap-2 flex-1">
<Ban class="w-4 h-4 text-muted-foreground" />
<Label class="text-sm font-normal">Disable build cache</Label>
</div>
<TogglePill bind:checked={formNoBuildCache} />
</div>
<p class="text-xs text-muted-foreground ml-6">
Pass <code class="text-xs bg-muted px-1 rounded">--no-cache</code> to force a clean build without using cached layers.
</p>
{/if}
<div class="flex items-center gap-3">
<div class="flex items-center gap-2 flex-1">
<ArrowDownToLine class="w-4 h-4 text-muted-foreground" />
@@ -1057,3 +1125,23 @@
</Dialog.Footer>
</Dialog.Content>
</Dialog.Root>
<!-- Stack already exists warning dialog -->
<Dialog.Root bind:open={showExistsWarning}>
<Dialog.Content class="max-w-sm">
<Dialog.Header>
<Dialog.Title class="flex items-center gap-2">
<TriangleAlert class="w-5 h-5 text-amber-500" />
Stack already exists
</Dialog.Title>
<Dialog.Description>
A stack named "{formStackName}" already exists. Please choose a different name.
</Dialog.Description>
</Dialog.Header>
<div class="flex justify-end mt-4">
<Button size="sm" onclick={() => showExistsWarning = false}>
OK
</Button>
</div>
</Dialog.Content>
</Dialog.Root>
+10 -3
View File
@@ -48,6 +48,7 @@
let previewFile = $state<FileEntry | null>(null);
let previewContent = $state<string | null>(null);
let previewServiceCount = $state<number>(0);
let previewComposeName = $state<string | null>(null);
let loadingPreview = $state(false);
// Use current environment from store
@@ -72,6 +73,7 @@
previewFile = null;
previewContent = null;
previewServiceCount = 0;
previewComposeName = null;
}
});
@@ -81,15 +83,19 @@
loadingPreview = true;
previewContent = null;
previewServiceCount = 0;
previewComposeName = null;
try {
const res = await fetch(`/api/system/files/content?path=${encodeURIComponent(entry.path)}`);
if (res.ok) {
const data = await res.json();
previewContent = data.content || '';
// Count services in the compose file
// Parse compose metadata (name + service count)
try {
const doc = yaml.load(previewContent) as Record<string, unknown> | null;
if (typeof doc?.name === 'string' && doc.name.trim()) {
previewComposeName = doc.name.trim();
}
if (doc?.services && typeof doc.services === 'object') {
previewServiceCount = Object.keys(doc.services).length;
}
@@ -191,7 +197,8 @@
try {
const parentDir = entry.path.replace(/\/[^/]+$/, '');
const rawName = parentDir.split('/').pop() || 'adopted-stack';
// Use compose `name:` property if available, otherwise fall back to directory name
const rawName = previewComposeName || parentDir.split('/').pop() || 'adopted-stack';
const stackName = rawName.toLowerCase().replace(/[^a-z0-9_-]/g, '-').replace(/-+/g, '-').replace(/^-|-$/g, '') || 'adopted-stack';
const envFilePath = `${parentDir}/.env`;
@@ -462,7 +469,7 @@
<div class="px-5 py-3 border-b bg-muted/30 flex items-center gap-4 shrink-0">
<div class="flex items-center gap-2">
<span class="text-sm text-muted-foreground">Stack:</span>
<span class="font-medium">{previewFile.path.replace(/\/[^/]+$/, '').split('/').pop() || 'unknown'}</span>
<span class="font-medium">{previewComposeName || previewFile.path.replace(/\/[^/]+$/, '').split('/').pop() || 'unknown'}</span>
{#if previewServiceCount > 0}
<Badge variant="outline" class="text-xs">
{previewServiceCount} service{previewServiceCount !== 1 ? 's' : ''}
+4 -2
View File
@@ -887,7 +887,8 @@
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 {
@@ -1011,7 +1012,8 @@
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 || '';