This commit is contained in:
jarek
2026-05-09 09:55:18 +02:00
parent 91ef3e3c9b
commit 002d969a5d
46 changed files with 7202 additions and 233 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
@@ -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",
+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 || '';