mirror of
https://github.com/Finsys/dockhand.git
synced 2026-06-18 11:29:56 +03:00
Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| d9054ff347 | |||
| 002d969a5d | |||
| 91ef3e3c9b |
+1
-1
@@ -37,7 +37,7 @@ RUN APKO_ARCH=$([ "$TARGETARCH" = "arm64" ] && echo "aarch64" || echo "x86_64")
|
||||
" - busybox" \
|
||||
" - tzdata" \
|
||||
" - docker-cli" \
|
||||
" - docker-compose=5.1.3-r0" \
|
||||
" - docker-compose=5.1.3-r2" \
|
||||
" - docker-cli-buildx" \
|
||||
" - sqlite" \
|
||||
" - postgresql-client" \
|
||||
|
||||
+1
-1
@@ -421,7 +421,7 @@ func (m *manager) collectMetrics(env *environment) {
|
||||
sCtx, sCancel := context.WithTimeout(env.ctx, 10*time.Second)
|
||||
defer sCancel()
|
||||
|
||||
sResp, sErr := env.doRequest(sCtx, "GET", fmt.Sprintf("/containers/%s/stats?stream=false&one-shot=true", id))
|
||||
sResp, sErr := env.doRequest(sCtx, "GET", fmt.Sprintf("/containers/%s/stats?stream=false", id))
|
||||
if sErr != nil {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
ALTER TABLE "git_stacks" ADD COLUMN "context_dir" text;--> statement-breakpoint
|
||||
ALTER TABLE "git_stacks" ADD COLUMN "no_build_cache" boolean DEFAULT false;
|
||||
File diff suppressed because it is too large
Load Diff
@@ -43,6 +43,13 @@
|
||||
"when": 1775312212996,
|
||||
"tag": "0005_add_api_tokens",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 6,
|
||||
"version": "7",
|
||||
"when": 1777220350655,
|
||||
"tag": "0006_add_git_stack_context_dir",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
ALTER TABLE `git_stacks` ADD `context_dir` text;--> statement-breakpoint
|
||||
ALTER TABLE `git_stacks` ADD `no_build_cache` integer DEFAULT false;
|
||||
File diff suppressed because it is too large
Load Diff
@@ -43,6 +43,13 @@
|
||||
"when": 1775311743346,
|
||||
"tag": "0005_add_api_tokens",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 6,
|
||||
"version": "6",
|
||||
"when": 1777220350655,
|
||||
"tag": "0006_add_git_stack_context_dir",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
+2
-2
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "dockhand",
|
||||
"private": true,
|
||||
"version": "1.0.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",
|
||||
|
||||
@@ -191,7 +191,7 @@ async function handleTerminalConnection(ws, url, connId) {
|
||||
};
|
||||
if (target.tls.ca) tlsOpts.ca = [target.tls.ca, ...rootCertificates];
|
||||
if (target.tls.cert) tlsOpts.cert = [target.tls.cert];
|
||||
if (target.tls.key) tlsOpts.key = target.tls.key;
|
||||
if (target.tls.key) tlsOpts.key = [target.tls.key];
|
||||
dockerStream = tlsConnect(tlsOpts);
|
||||
} else {
|
||||
// Plain HTTP (direct TCP or hawser-standard)
|
||||
@@ -430,7 +430,12 @@ function handleHawserConnection(ws, connId, remoteIp) {
|
||||
|
||||
// Use the global hawser message handler injected by the SvelteKit app
|
||||
if (typeof globalThis.__hawserHandleMessage === 'function') {
|
||||
await globalThis.__hawserHandleMessage(ws, msg, connId, remoteIp);
|
||||
try {
|
||||
await globalThis.__hawserHandleMessage(ws, msg, connId, remoteIp);
|
||||
} catch (handlerError) {
|
||||
console.error('[Hawser WS] Handler error:', handlerError);
|
||||
// Don't close connection - let it recover
|
||||
}
|
||||
} else {
|
||||
console.warn('[Hawser WS] No global handler registered');
|
||||
ws.send(JSON.stringify({ type: 'error', message: 'Server not ready' }));
|
||||
|
||||
@@ -200,8 +200,8 @@
|
||||
* Sync rawContent TO variables.
|
||||
* Parses raw content for non-secrets, preserves existing secrets.
|
||||
*/
|
||||
function syncRawToVariables() {
|
||||
const { vars, warnings } = parseRawContent(rawContent);
|
||||
function syncRawToVariables(content?: string) {
|
||||
const { vars, warnings } = parseRawContent(content ?? rawContent);
|
||||
parseWarnings = warnings;
|
||||
|
||||
// Preserve existing secrets (they're not in rawContent)
|
||||
@@ -240,8 +240,9 @@
|
||||
// Form → Text: sync variables to raw (preserves comments)
|
||||
syncVariablesToRaw();
|
||||
} else if (newMode === 'form' && viewMode === 'text') {
|
||||
// Text → Form: sync raw to variables (preserves secrets)
|
||||
syncRawToVariables();
|
||||
// Text → Form: use textEditorContent which falls back to generatedRawContent
|
||||
// when rawContent is empty (fixes vars lost on view switch for git stacks)
|
||||
syncRawToVariables(textEditorContent);
|
||||
}
|
||||
|
||||
viewMode = newMode;
|
||||
|
||||
@@ -62,7 +62,7 @@
|
||||
<span class="text-muted-foreground font-normal">({release.date})</span>
|
||||
</h3>
|
||||
<div class="space-y-1.5 ml-1">
|
||||
{#each release.changes as change}
|
||||
{#each [...release.changes].sort((a, b) => a.type === b.type ? 0 : a.type === 'feature' ? -1 : 1) as change}
|
||||
{@const { icon: Icon, class: iconClass } = getChangeIcon(change.type)}
|
||||
<div class="flex items-start gap-2">
|
||||
<Icon class="w-4 h-4 mt-0.5 shrink-0 {iconClass}" />
|
||||
|
||||
@@ -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
@@ -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
|
||||
// =============================================================================
|
||||
|
||||
@@ -315,7 +315,9 @@ export const gitStacks = sqliteTable('git_stacks', {
|
||||
autoUpdateCron: text('auto_update_cron').default('0 3 * * *'),
|
||||
webhookEnabled: integer('webhook_enabled', { mode: 'boolean' }).default(false),
|
||||
webhookSecret: text('webhook_secret'),
|
||||
contextDir: text('context_dir'), // Working directory relative to repo root (null = compose file's directory)
|
||||
buildOnDeploy: integer('build_on_deploy', { mode: 'boolean' }).default(false),
|
||||
noBuildCache: integer('no_build_cache', { mode: 'boolean' }).default(false),
|
||||
repullImages: integer('repull_images', { mode: 'boolean' }).default(false),
|
||||
forceRedeploy: integer('force_redeploy', { mode: 'boolean' }).default(false),
|
||||
lastSync: text('last_sync'),
|
||||
|
||||
@@ -318,7 +318,9 @@ export const gitStacks = pgTable('git_stacks', {
|
||||
autoUpdateCron: text('auto_update_cron').default('0 3 * * *'),
|
||||
webhookEnabled: boolean('webhook_enabled').default(false),
|
||||
webhookSecret: text('webhook_secret'),
|
||||
contextDir: text('context_dir'), // Working directory relative to repo root (null = compose file's directory)
|
||||
buildOnDeploy: boolean('build_on_deploy').default(false),
|
||||
noBuildCache: boolean('no_build_cache').default(false),
|
||||
repullImages: boolean('repull_images').default(false),
|
||||
forceRedeploy: boolean('force_redeploy').default(false),
|
||||
lastSync: timestamp('last_sync', { mode: 'string' }),
|
||||
|
||||
@@ -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
@@ -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('; '));
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -60,19 +60,20 @@ async function isComposeFile(filePath: string): Promise<boolean> {
|
||||
}
|
||||
|
||||
/**
|
||||
* Count the number of services defined in a compose file
|
||||
* Parses YAML to reliably count top-level keys under 'services:' section
|
||||
* Parse compose file metadata: top-level `name` property and service count.
|
||||
* The `name` property (if present) should be used as the stack name instead of the directory name,
|
||||
* matching Docker Compose's behavior with `com.docker.compose.project`.
|
||||
*/
|
||||
async function countServices(filePath: string): Promise<number> {
|
||||
function parseComposeMetadata(filePath: string): { name: string | null; serviceCount: number } {
|
||||
try {
|
||||
const content = readFileSync(filePath, 'utf-8');
|
||||
const doc = yaml.load(content) as Record<string, unknown> | null;
|
||||
if (doc?.services && typeof doc.services === 'object') {
|
||||
return Object.keys(doc.services).length;
|
||||
}
|
||||
return 0;
|
||||
const name = typeof doc?.name === 'string' ? doc.name.trim() : null;
|
||||
const serviceCount = doc?.services && typeof doc.services === 'object'
|
||||
? Object.keys(doc.services).length : 0;
|
||||
return { name, serviceCount };
|
||||
} catch {
|
||||
return 0;
|
||||
return { name: null, serviceCount: 0 };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -122,13 +123,12 @@ async function scanPath(basePath: string): Promise<{ stacks: DiscoveredStack[];
|
||||
for (const pattern of COMPOSE_PATTERNS) {
|
||||
const composePath = join(currentPath, pattern);
|
||||
if (existsSync(composePath)) {
|
||||
// Found a stack! Stack name = directory name
|
||||
const stackName = normalizeStackName(basename(currentPath));
|
||||
// Found a stack! Use compose name property if defined, otherwise directory name
|
||||
const { name: composeName, serviceCount } = parseComposeMetadata(composePath);
|
||||
const stackName = normalizeStackName(composeName || basename(currentPath));
|
||||
if (stackName) {
|
||||
// Check for .env file
|
||||
const envPath = join(currentPath, '.env');
|
||||
// Count services in compose file
|
||||
const serviceCount = await countServices(composePath);
|
||||
discovered.push({
|
||||
name: stackName,
|
||||
composePath,
|
||||
@@ -166,14 +166,13 @@ async function scanPath(basePath: string): Promise<{ stacks: DiscoveredStack[];
|
||||
if (lowerName.endsWith('.yml') || lowerName.endsWith('.yaml')) {
|
||||
// Validate it's actually a compose file
|
||||
if (await isComposeFile(entryPath)) {
|
||||
const { name: composeName, serviceCount } = parseComposeMetadata(entryPath);
|
||||
const stackName = normalizeStackName(
|
||||
entry.name.replace(/\.(yml|yaml)$/i, '')
|
||||
composeName || entry.name.replace(/\.(yml|yaml)$/i, '')
|
||||
);
|
||||
if (stackName) {
|
||||
// Check for .env file in same directory
|
||||
const envPath = join(currentPath, '.env');
|
||||
// Count services in compose file
|
||||
const serviceCount = await countServices(entryPath);
|
||||
discovered.push({
|
||||
name: stackName,
|
||||
composePath: entryPath,
|
||||
@@ -214,8 +213,18 @@ export async function adoptStack(
|
||||
return { success: false, error: 'Already adopted' };
|
||||
}
|
||||
|
||||
// If the compose file has a top-level `name:` property, prefer it over the passed name.
|
||||
// This ensures Docker's project name (from the label) matches Dockhand's stack name.
|
||||
let stackNameSource = stack.name;
|
||||
if (stack.composePath && existsSync(stack.composePath)) {
|
||||
const { name: composeName } = parseComposeMetadata(stack.composePath);
|
||||
if (composeName) {
|
||||
stackNameSource = composeName;
|
||||
}
|
||||
}
|
||||
|
||||
// Check for name conflict within the same environment
|
||||
let finalName = normalizeStackName(stack.name);
|
||||
let finalName = normalizeStackName(stackNameSource);
|
||||
const existingNames = new Set(
|
||||
existingSources
|
||||
.filter((s) => s.environmentId === environmentId)
|
||||
@@ -224,11 +233,12 @@ export async function adoptStack(
|
||||
|
||||
if (existingNames.has(finalName)) {
|
||||
// Append suffix to make unique
|
||||
const baseName = finalName;
|
||||
let suffix = 1;
|
||||
while (existingNames.has(`${stack.name}-${suffix}`)) {
|
||||
while (existingNames.has(`${baseName}-${suffix}`)) {
|
||||
suffix++;
|
||||
}
|
||||
finalName = `${stack.name}-${suffix}`;
|
||||
finalName = `${baseName}-${suffix}`;
|
||||
}
|
||||
|
||||
// Create stack source record - use 'internal' since we know the file paths
|
||||
|
||||
+26
-20
@@ -102,6 +102,7 @@ export interface DeployStackOptions {
|
||||
sourceDir?: string; // Directory to copy all files from (for git stacks)
|
||||
forceRecreate?: boolean;
|
||||
build?: boolean; // Build images before starting (--build)
|
||||
noBuildCache?: boolean; // Disable build cache (--no-cache, requires --build)
|
||||
pullPolicy?: string; // Pull policy: 'always' | 'missing' | 'never'
|
||||
composePath?: string; // Custom compose file path (for adopted/imported stacks)
|
||||
envPath?: string; // Custom env file path (for adopted/imported stacks)
|
||||
@@ -259,23 +260,14 @@ async function readDirFilesAsMap(dirPath: string): Promise<Record<string, string
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* Mask sensitive values in environment variables for safe logging.
|
||||
* Masks values for keys containing common secret patterns and truncates long values.
|
||||
* Redact all env var values for safe logging. Only key names are preserved.
|
||||
*/
|
||||
function maskSecrets(vars: Record<string, string>): Record<string, string> {
|
||||
const masked: Record<string, string> = {};
|
||||
const secretPatterns = /password|secret|token|key|api_key|apikey|auth|credential|private/i;
|
||||
for (const [key, value] of Object.entries(vars)) {
|
||||
if (secretPatterns.test(key)) {
|
||||
masked[key] = '***';
|
||||
} else if (value.length > 50) {
|
||||
// Truncate long values that might be secrets
|
||||
masked[key] = value.substring(0, 10) + '...(truncated)';
|
||||
} else {
|
||||
masked[key] = value;
|
||||
}
|
||||
function redactEnvVarsForLog(vars: Record<string, string>): Record<string, string> {
|
||||
const redacted: Record<string, string> = {};
|
||||
for (const key of Object.keys(vars)) {
|
||||
redacted[key] = '***';
|
||||
}
|
||||
return masked;
|
||||
return redacted;
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
@@ -794,6 +786,7 @@ interface ComposeCommandOptions {
|
||||
envId?: number | null;
|
||||
forceRecreate?: boolean;
|
||||
build?: boolean; // Build images before starting (--build)
|
||||
noBuildCache?: boolean; // Disable build cache (--no-cache, requires --build)
|
||||
pullPolicy?: string; // Pull policy: 'always' | 'missing' | 'never'
|
||||
removeVolumes?: boolean;
|
||||
stackFiles?: Record<string, string>; // All files to send to Hawser
|
||||
@@ -857,6 +850,7 @@ async function executeLocalCompose(
|
||||
useOverrideFile?: boolean,
|
||||
serviceName?: string,
|
||||
build?: boolean,
|
||||
noBuildCache?: boolean,
|
||||
pullPolicy?: string
|
||||
): Promise<StackOperationResult> {
|
||||
const logPrefix = `[Stack:${stackName}]`;
|
||||
@@ -1050,6 +1044,7 @@ async function executeLocalCompose(
|
||||
args.push('up', '-d', '--remove-orphans');
|
||||
if (forceRecreate) args.push('--force-recreate');
|
||||
if (build) args.push('--build');
|
||||
if (build && noBuildCache) args.push('--no-cache');
|
||||
if (pullPolicy) args.push('--pull', pullPolicy);
|
||||
// If targeting a specific service, only update that service
|
||||
if (serviceName) {
|
||||
@@ -1094,7 +1089,7 @@ async function executeLocalCompose(
|
||||
console.log(`${logPrefix} Service name:`, serviceName ?? '(all services)');
|
||||
console.log(`${logPrefix} Env vars count:`, envVars ? Object.keys(envVars).length : 0);
|
||||
if (envVars && Object.keys(envVars).length > 0) {
|
||||
console.log(`${logPrefix} Env vars being injected (masked):`, JSON.stringify(maskSecrets(envVars), null, 2));
|
||||
console.log(`${logPrefix} Env vars being injected (masked):`, JSON.stringify(redactEnvVarsForLog(envVars), null, 2));
|
||||
}
|
||||
|
||||
// Login to registries before pulling images
|
||||
@@ -1226,6 +1221,7 @@ async function executeComposeViaHawser(
|
||||
serviceName?: string,
|
||||
composeFileName?: string,
|
||||
build?: boolean,
|
||||
noBuildCache?: boolean,
|
||||
pullPolicy?: string
|
||||
): Promise<StackOperationResult> {
|
||||
const logPrefix = `[Stack:${stackName}]`;
|
||||
@@ -1249,7 +1245,7 @@ async function executeComposeViaHawser(
|
||||
console.log(`${logPrefix} Non-secret env vars count:`, envVars ? Object.keys(envVars).length : 0);
|
||||
console.log(`${logPrefix} Secret env vars count:`, secretCount);
|
||||
if (allEnvVars && Object.keys(allEnvVars).length > 0) {
|
||||
console.log(`${logPrefix} All env vars being sent (masked):`, JSON.stringify(maskSecrets(allEnvVars), null, 2));
|
||||
console.log(`${logPrefix} All env vars being sent (masked):`, JSON.stringify(redactEnvVarsForLog(allEnvVars), null, 2));
|
||||
}
|
||||
console.log(`${logPrefix} Compose content length:`, composeContent.length, 'chars');
|
||||
console.log(`${logPrefix} Stack files count:`, stackFiles ? Object.keys(stackFiles).length : 0);
|
||||
@@ -1300,6 +1296,7 @@ async function executeComposeViaHawser(
|
||||
forceRecreate: forceRecreate || false,
|
||||
removeVolumes: removeVolumes || false,
|
||||
build: build || false,
|
||||
noBuildCache: (build && noBuildCache) || false,
|
||||
pullPolicy: pullPolicy || '',
|
||||
registries, // Registry credentials for docker login
|
||||
serviceName // Target specific service only (with --no-deps)
|
||||
@@ -1368,7 +1365,7 @@ async function executeComposeCommand(
|
||||
envVars?: Record<string, string>,
|
||||
secretVars?: Record<string, string>
|
||||
): Promise<StackOperationResult> {
|
||||
const { stackName, envId, forceRecreate, build, pullPolicy, removeVolumes, stackFiles, workingDir, composePath, envPath, useOverrideFile, serviceName, composeFileName } = options;
|
||||
const { stackName, envId, forceRecreate, build, noBuildCache, pullPolicy, removeVolumes, stackFiles, workingDir, composePath, envPath, useOverrideFile, serviceName, composeFileName } = options;
|
||||
|
||||
// Get environment configuration
|
||||
const env = envId ? await getEnvironment(envId) : null;
|
||||
@@ -1392,6 +1389,7 @@ async function executeComposeCommand(
|
||||
useOverrideFile,
|
||||
serviceName,
|
||||
build,
|
||||
noBuildCache,
|
||||
pullPolicy
|
||||
);
|
||||
}
|
||||
@@ -1456,6 +1454,7 @@ async function executeComposeCommand(
|
||||
serviceName,
|
||||
composeFileName,
|
||||
build,
|
||||
noBuildCache,
|
||||
pullPolicy
|
||||
);
|
||||
}
|
||||
@@ -1489,6 +1488,7 @@ async function executeComposeCommand(
|
||||
useOverrideFile,
|
||||
serviceName,
|
||||
build,
|
||||
noBuildCache,
|
||||
pullPolicy
|
||||
);
|
||||
}
|
||||
@@ -1512,6 +1512,7 @@ async function executeComposeCommand(
|
||||
useOverrideFile,
|
||||
serviceName,
|
||||
build,
|
||||
noBuildCache,
|
||||
pullPolicy
|
||||
);
|
||||
}
|
||||
@@ -2175,7 +2176,7 @@ export async function removeStack(
|
||||
* Uses stack locking to prevent concurrent deployments.
|
||||
*/
|
||||
export async function deployStack(options: DeployStackOptions): Promise<StackOperationResult> {
|
||||
const { name, compose, envId, sourceDir, forceRecreate, build, pullPolicy, composePath, envPath, composeFileName, envFileName } = options;
|
||||
const { name, compose, envId, sourceDir, forceRecreate, build, noBuildCache, pullPolicy, composePath, envPath, composeFileName, envFileName } = options;
|
||||
const logPrefix = `[Stack:${name}]`;
|
||||
|
||||
console.log(`${logPrefix} ========================================`);
|
||||
@@ -2253,7 +2254,11 @@ export async function deployStack(options: DeployStackOptions): Promise<StackOpe
|
||||
// and would be destroyed, causing data loss (#831).
|
||||
console.log(`${logPrefix} Copying source directory to stack directory...`);
|
||||
mkdirSync(workingDir, { recursive: true });
|
||||
cpSync(sourceDir, workingDir, { recursive: true, force: true });
|
||||
cpSync(sourceDir, workingDir, {
|
||||
recursive: true,
|
||||
force: true,
|
||||
filter: (src) => !src.includes('/.git/') && !src.endsWith('/.git')
|
||||
});
|
||||
console.log(`${logPrefix} Copied ${sourceDir} -> ${workingDir}`);
|
||||
} else {
|
||||
// Internal stack: check if a custom path exists in DB (adopted/imported stacks)
|
||||
@@ -2318,6 +2323,7 @@ export async function deployStack(options: DeployStackOptions): Promise<StackOpe
|
||||
envId,
|
||||
forceRecreate,
|
||||
build,
|
||||
noBuildCache,
|
||||
pullPolicy,
|
||||
stackFiles,
|
||||
workingDir,
|
||||
|
||||
@@ -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,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);
|
||||
|
||||
@@ -7,7 +7,8 @@ import {
|
||||
getGitRepository,
|
||||
createGitRepository,
|
||||
upsertStackSource,
|
||||
setStackEnvVars
|
||||
setStackEnvVars,
|
||||
getStackSource
|
||||
} from '$lib/server/db';
|
||||
import { deployGitStack } from '$lib/server/git';
|
||||
import { authorize } from '$lib/server/authorize';
|
||||
@@ -61,6 +62,12 @@ export const POST: RequestHandler = async (event) => {
|
||||
return json({ error: 'Stack name must start with a letter or number, and contain only letters, numbers, hyphens, and underscores' }, { status: 400 });
|
||||
}
|
||||
|
||||
// Check for name conflicts with existing stacks (regular/external/git)
|
||||
const existing = await getStackSource(trimmedStackName, data.environmentId || null);
|
||||
if (existing) {
|
||||
return json({ error: 'A stack with this name already exists on this environment' }, { status: 409 });
|
||||
}
|
||||
|
||||
// Either repositoryId or new repo details (url, branch) must be provided
|
||||
let repositoryId = data.repositoryId;
|
||||
|
||||
@@ -120,7 +127,9 @@ export const POST: RequestHandler = async (event) => {
|
||||
autoUpdateCron: data.autoUpdateCron || '0 3 * * *',
|
||||
webhookEnabled: data.webhookEnabled || false,
|
||||
webhookSecret: webhookSecret,
|
||||
contextDir: data.contextDir || null,
|
||||
buildOnDeploy: data.buildOnDeploy ?? false,
|
||||
noBuildCache: data.noBuildCache ?? false,
|
||||
repullImages: data.repullImages ?? false,
|
||||
forceRedeploy: data.forceRedeploy ?? false
|
||||
});
|
||||
|
||||
@@ -73,7 +73,9 @@ export const PUT: RequestHandler = async (event) => {
|
||||
autoUpdateCron: data.autoUpdateCron,
|
||||
webhookEnabled: data.webhookEnabled,
|
||||
webhookSecret: data.webhookSecret,
|
||||
contextDir: data.contextDir,
|
||||
buildOnDeploy: data.buildOnDeploy,
|
||||
noBuildCache: data.noBuildCache,
|
||||
repullImages: data.repullImages,
|
||||
forceRedeploy: data.forceRedeploy
|
||||
});
|
||||
|
||||
@@ -46,8 +46,8 @@ export const GET: RequestHandler = async ({ url }) => {
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
if (response.status === 401) {
|
||||
return json({ error: 'Authentication failed. Please check your credentials.' }, { status: 401 });
|
||||
if (response.status === 401 || response.status === 403) {
|
||||
return json({ error: 'Catalog listing not available. This registry may not support the _catalog endpoint (common with GitLab and Harbor). Try searching for images by name instead.' }, { status: response.status });
|
||||
}
|
||||
if (response.status === 404) {
|
||||
return json({ error: 'Registry does not support V2 catalog API' }, { status: 404 });
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { json } from '@sveltejs/kit';
|
||||
import type { RequestHandler } from './$types';
|
||||
import { getRegistry } from '$lib/server/db';
|
||||
import { getRegistryAuth } from '$lib/server/docker';
|
||||
import { getRegistryAuth, parseRegistryUrl } from '$lib/server/docker';
|
||||
|
||||
interface SearchResult {
|
||||
name: string;
|
||||
@@ -46,28 +46,50 @@ async function searchDockerHub(term: string, limit: number): Promise<SearchResul
|
||||
|
||||
async function searchPrivateRegistry(registry: any, term: string, limit: number): Promise<SearchResult[]> {
|
||||
const results: string[] = [];
|
||||
const { orgPath } = parseRegistryUrl(registry.url);
|
||||
const orgPrefix = orgPath ? orgPath.replace(/^\//, '') : '';
|
||||
|
||||
// Strategy 1: If term looks like an image name (contains /), try direct lookup first
|
||||
// This is much faster than iterating through catalog for large registries like ghcr.io
|
||||
// Strategy 1: Direct image lookup — try the exact term and org-prefixed variants
|
||||
// This uses per-repository auth scope which works on all V2 registries (GitLab, Harbor, etc.)
|
||||
const directCandidates: string[] = [];
|
||||
if (term.includes('/')) {
|
||||
const directResult = await tryDirectImageLookup(registry, term);
|
||||
if (directResult) {
|
||||
results.push(term);
|
||||
directCandidates.push(term);
|
||||
}
|
||||
// If registry URL has an org path (e.g., https://registry.example.com/group),
|
||||
// try prepending it to the search term
|
||||
if (orgPrefix && !term.startsWith(orgPrefix + '/')) {
|
||||
directCandidates.push(`${orgPrefix}/${term}`);
|
||||
}
|
||||
|
||||
for (const candidate of directCandidates) {
|
||||
if (results.length >= limit) break;
|
||||
const exists = await tryDirectImageLookup(registry, candidate);
|
||||
if (exists && !results.includes(candidate)) {
|
||||
results.push(candidate);
|
||||
}
|
||||
}
|
||||
|
||||
// Strategy 2: Fall back to catalog search for partial matches or if direct lookup failed
|
||||
// Strategy 2: Fall back to catalog search for partial/fuzzy matches
|
||||
// Some registries (GitLab, Harbor) don't support _catalog for deploy tokens,
|
||||
// so catch errors gracefully and return whatever we have from direct lookup
|
||||
if (results.length < limit) {
|
||||
const catalogResults = await searchCatalog(registry, term, limit - results.length);
|
||||
// Add catalog results, avoiding duplicates
|
||||
for (const name of catalogResults) {
|
||||
if (!results.includes(name)) {
|
||||
results.push(name);
|
||||
try {
|
||||
const catalogResults = await searchCatalog(registry, term, limit - results.length);
|
||||
for (const name of catalogResults) {
|
||||
if (!results.includes(name)) {
|
||||
results.push(name);
|
||||
}
|
||||
}
|
||||
} catch (e: any) {
|
||||
// Catalog not supported but we have direct lookup results — that's fine
|
||||
if (results.length > 0) {
|
||||
console.warn(`[Registry] Catalog search failed (using direct lookup results): ${e.message}`);
|
||||
} else {
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Return results in the same format as Docker Hub
|
||||
return results.map((name: string) => ({
|
||||
name,
|
||||
description: '',
|
||||
@@ -136,8 +158,8 @@ async function searchCatalog(registry: any, term: string, limit: number): Promis
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
if (response.status === 401) {
|
||||
throw new Error('Authentication failed');
|
||||
if (response.status === 401 || response.status === 403) {
|
||||
throw new Error('Authentication failed. This registry may not support catalog listing (common with GitLab and Harbor deploy tokens).');
|
||||
}
|
||||
throw new Error(`Registry returned error: ${response.status}`);
|
||||
}
|
||||
|
||||
@@ -93,8 +93,8 @@ async function fetchRegistryTags(registry: any, imageName: string): Promise<TagI
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
if (response.status === 401) {
|
||||
throw new Error('Authentication failed');
|
||||
if (response.status === 401 || response.status === 403) {
|
||||
throw new Error('Authentication failed. Please check your credentials and permissions.');
|
||||
}
|
||||
if (response.status === 404) {
|
||||
throw new Error('Image not found in registry');
|
||||
|
||||
@@ -2,13 +2,17 @@ import { json, type RequestHandler } from '@sveltejs/kit';
|
||||
import {
|
||||
setScheduleCleanupEnabled,
|
||||
setEventCleanupEnabled,
|
||||
setScannerCleanupEnabled,
|
||||
getScheduleCleanupEnabled,
|
||||
getEventCleanupEnabled
|
||||
getEventCleanupEnabled,
|
||||
getScannerCleanupEnabled
|
||||
} from '$lib/server/db';
|
||||
import { authorize } from '$lib/server/authorize';
|
||||
import { refreshSystemJobs } from '$lib/server/scheduler';
|
||||
|
||||
const SYSTEM_SCHEDULE_CLEANUP_ID = 1;
|
||||
const SYSTEM_EVENT_CLEANUP_ID = 2;
|
||||
const SYSTEM_SCANNER_CLEANUP_ID = 4;
|
||||
|
||||
export const POST: RequestHandler = async ({ params, cookies }) => {
|
||||
const auth = await authorize(cookies);
|
||||
@@ -32,6 +36,11 @@ export const POST: RequestHandler = async ({ params, cookies }) => {
|
||||
const currentEnabled = await getEventCleanupEnabled();
|
||||
await setEventCleanupEnabled(!currentEnabled);
|
||||
return json({ success: true, enabled: !currentEnabled });
|
||||
} else if (systemId === SYSTEM_SCANNER_CLEANUP_ID) {
|
||||
const currentEnabled = await getScannerCleanupEnabled();
|
||||
await setScannerCleanupEnabled(!currentEnabled);
|
||||
await refreshSystemJobs();
|
||||
return json({ success: true, enabled: !currentEnabled });
|
||||
} else {
|
||||
return json({ error: 'Unknown system schedule' }, { status: 400 });
|
||||
}
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
@@ -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'}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
import { Badge } from '$lib/components/ui/badge';
|
||||
import { Input } from '$lib/components/ui/input';
|
||||
import { TogglePill } from '$lib/components/ui/toggle-pill';
|
||||
import { Loader2, GitBranch, RefreshCw, Webhook, Rocket, RefreshCcw, Copy, Check, XCircle, FolderGit2, Github, Key, KeyRound, Lock, FileText, HelpCircle, GripVertical, X, Download, Hammer, ArrowDownToLine, Zap } from 'lucide-svelte';
|
||||
import { Loader2, GitBranch, RefreshCw, Webhook, Rocket, RefreshCcw, Copy, Check, XCircle, FolderGit2, Github, Key, KeyRound, Lock, FileText, HelpCircle, GripVertical, X, Download, Hammer, ArrowDownToLine, Zap, FolderOpen, Ban, TriangleAlert } from 'lucide-svelte';
|
||||
import * as Tooltip from '$lib/components/ui/tooltip';
|
||||
import { copyToClipboard } from '$lib/utils/clipboard';
|
||||
import CronEditor from '$lib/components/cron-editor.svelte';
|
||||
@@ -58,7 +58,9 @@
|
||||
autoUpdateCron: string;
|
||||
webhookEnabled: boolean;
|
||||
webhookSecret: string | null;
|
||||
contextDir: string | null;
|
||||
buildOnDeploy: boolean;
|
||||
noBuildCache: boolean;
|
||||
repullImages: boolean;
|
||||
forceRedeploy: boolean;
|
||||
}
|
||||
@@ -91,12 +93,15 @@
|
||||
let formAutoUpdateCron = $state('0 3 * * *');
|
||||
let formWebhookEnabled = $state(false);
|
||||
let formWebhookSecret = $state('');
|
||||
let formContextDir = $state<string | null>(null);
|
||||
let formBuildOnDeploy = $state(false);
|
||||
let formNoBuildCache = $state(false);
|
||||
let formRepullImages = $state(false);
|
||||
let formForceRedeploy = $state(false);
|
||||
let formDeployNow = $state(false);
|
||||
let formError = $state('');
|
||||
let formSaving = $state(false);
|
||||
let showExistsWarning = $state(false);
|
||||
let errors = $state<{ stackName?: string; repository?: string; repoName?: string; repoUrl?: string }>({});
|
||||
|
||||
// Stack name validation: must start with alphanumeric, can contain alphanumeric, hyphens, underscores
|
||||
@@ -364,7 +369,9 @@
|
||||
formAutoUpdateCron = gitStack.autoUpdateCron || '0 3 * * *';
|
||||
formWebhookEnabled = gitStack.webhookEnabled;
|
||||
formWebhookSecret = gitStack.webhookSecret || '';
|
||||
formContextDir = gitStack.contextDir ?? null;
|
||||
formBuildOnDeploy = gitStack.buildOnDeploy ?? false;
|
||||
formNoBuildCache = gitStack.noBuildCache ?? false;
|
||||
formRepullImages = gitStack.repullImages ?? false;
|
||||
formForceRedeploy = gitStack.forceRedeploy ?? false;
|
||||
formDeployNow = false;
|
||||
@@ -391,7 +398,9 @@
|
||||
formAutoUpdateCron = '0 3 * * *';
|
||||
formWebhookEnabled = false;
|
||||
formWebhookSecret = '';
|
||||
formContextDir = null;
|
||||
formBuildOnDeploy = false;
|
||||
formNoBuildCache = false;
|
||||
formRepullImages = false;
|
||||
formForceRedeploy = false;
|
||||
formDeployNow = false;
|
||||
@@ -428,6 +437,25 @@
|
||||
|
||||
if (hasErrors) return;
|
||||
|
||||
// Check if stack already exists (only for new stacks)
|
||||
if (!gitStack) {
|
||||
try {
|
||||
const stacksResponse = await fetch(`/api/stacks?env=${environmentId}`);
|
||||
if (stacksResponse.ok) {
|
||||
const stacks = await stacksResponse.json();
|
||||
const existingStack = stacks.find((s: { name: string }) =>
|
||||
s.name.toLowerCase() === formStackName.trim().toLowerCase()
|
||||
);
|
||||
if (existingStack) {
|
||||
showExistsWarning = true;
|
||||
return;
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('Failed to check for existing stacks:', e);
|
||||
}
|
||||
}
|
||||
|
||||
formSaving = true;
|
||||
formError = '';
|
||||
|
||||
@@ -450,7 +478,9 @@
|
||||
autoUpdateCron: formAutoUpdateCron,
|
||||
webhookEnabled: formWebhookEnabled,
|
||||
webhookSecret: formWebhookEnabled ? formWebhookSecret : null,
|
||||
contextDir: formContextDir || null,
|
||||
buildOnDeploy: formBuildOnDeploy,
|
||||
noBuildCache: formNoBuildCache,
|
||||
repullImages: formRepullImages,
|
||||
forceRedeploy: formForceRedeploy,
|
||||
deployNow: deployAfterSave,
|
||||
@@ -793,6 +823,32 @@
|
||||
<p class="text-xs text-muted-foreground">Additional env file to pass to Docker Compose</p>
|
||||
</div>
|
||||
|
||||
<!-- Context directory -->
|
||||
<div class="space-y-2">
|
||||
<div class="flex items-center gap-1.5">
|
||||
<Label for="context-dir">Context directory (optional)</Label>
|
||||
<Tooltip.Root>
|
||||
<Tooltip.Trigger>
|
||||
<HelpCircle class="w-3.5 h-3.5 text-muted-foreground cursor-help" />
|
||||
</Tooltip.Trigger>
|
||||
<Tooltip.Content>
|
||||
<div class="w-80">
|
||||
<p class="text-xs">Working directory for Docker Compose, relative to the repository root. All files in this directory will be available for volume mounts and build contexts.</p>
|
||||
<p class="text-xs mt-2">Use <code class="bg-muted px-1 rounded">.</code> for the repository root when your compose file references files in sibling directories.</p>
|
||||
<p class="text-xs mt-2">Defaults to the compose file's parent directory.</p>
|
||||
</div>
|
||||
</Tooltip.Content>
|
||||
</Tooltip.Root>
|
||||
</div>
|
||||
<Input
|
||||
id="context-dir"
|
||||
value={formContextDir ?? ''}
|
||||
oninput={(e) => { const v = (e.target as HTMLInputElement).value; formContextDir = v.trim() || null; }}
|
||||
placeholder="Defaults to compose file's directory"
|
||||
/>
|
||||
<p class="text-xs text-muted-foreground">Relative to repository root, e.g. <code class="text-xs bg-muted px-1 rounded">.</code> for root</p>
|
||||
</div>
|
||||
|
||||
<!-- Auto-update section -->
|
||||
<div class="space-y-3 p-3 bg-muted/50 rounded-md">
|
||||
<div class="flex items-center gap-3">
|
||||
@@ -922,6 +978,18 @@
|
||||
<p class="text-xs text-muted-foreground">
|
||||
Run <code class="text-xs bg-muted px-1 rounded">--build</code> to build images from Dockerfiles before starting containers.
|
||||
</p>
|
||||
{#if formBuildOnDeploy}
|
||||
<div class="flex items-center gap-3 ml-6">
|
||||
<div class="flex items-center gap-2 flex-1">
|
||||
<Ban class="w-4 h-4 text-muted-foreground" />
|
||||
<Label class="text-sm font-normal">Disable build cache</Label>
|
||||
</div>
|
||||
<TogglePill bind:checked={formNoBuildCache} />
|
||||
</div>
|
||||
<p class="text-xs text-muted-foreground ml-6">
|
||||
Pass <code class="text-xs bg-muted px-1 rounded">--no-cache</code> to force a clean build without using cached layers.
|
||||
</p>
|
||||
{/if}
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="flex items-center gap-2 flex-1">
|
||||
<ArrowDownToLine class="w-4 h-4 text-muted-foreground" />
|
||||
@@ -1057,3 +1125,23 @@
|
||||
</Dialog.Footer>
|
||||
</Dialog.Content>
|
||||
</Dialog.Root>
|
||||
|
||||
<!-- Stack already exists warning dialog -->
|
||||
<Dialog.Root bind:open={showExistsWarning}>
|
||||
<Dialog.Content class="max-w-sm">
|
||||
<Dialog.Header>
|
||||
<Dialog.Title class="flex items-center gap-2">
|
||||
<TriangleAlert class="w-5 h-5 text-amber-500" />
|
||||
Stack already exists
|
||||
</Dialog.Title>
|
||||
<Dialog.Description>
|
||||
A stack named "{formStackName}" already exists. Please choose a different name.
|
||||
</Dialog.Description>
|
||||
</Dialog.Header>
|
||||
<div class="flex justify-end mt-4">
|
||||
<Button size="sm" onclick={() => showExistsWarning = false}>
|
||||
OK
|
||||
</Button>
|
||||
</div>
|
||||
</Dialog.Content>
|
||||
</Dialog.Root>
|
||||
|
||||
@@ -48,6 +48,7 @@
|
||||
let previewFile = $state<FileEntry | null>(null);
|
||||
let previewContent = $state<string | null>(null);
|
||||
let previewServiceCount = $state<number>(0);
|
||||
let previewComposeName = $state<string | null>(null);
|
||||
let loadingPreview = $state(false);
|
||||
|
||||
// Use current environment from store
|
||||
@@ -72,6 +73,7 @@
|
||||
previewFile = null;
|
||||
previewContent = null;
|
||||
previewServiceCount = 0;
|
||||
previewComposeName = null;
|
||||
}
|
||||
});
|
||||
|
||||
@@ -81,15 +83,19 @@
|
||||
loadingPreview = true;
|
||||
previewContent = null;
|
||||
previewServiceCount = 0;
|
||||
previewComposeName = null;
|
||||
|
||||
try {
|
||||
const res = await fetch(`/api/system/files/content?path=${encodeURIComponent(entry.path)}`);
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
previewContent = data.content || '';
|
||||
// Count services in the compose file
|
||||
// Parse compose metadata (name + service count)
|
||||
try {
|
||||
const doc = yaml.load(previewContent) as Record<string, unknown> | null;
|
||||
if (typeof doc?.name === 'string' && doc.name.trim()) {
|
||||
previewComposeName = doc.name.trim();
|
||||
}
|
||||
if (doc?.services && typeof doc.services === 'object') {
|
||||
previewServiceCount = Object.keys(doc.services).length;
|
||||
}
|
||||
@@ -191,7 +197,8 @@
|
||||
|
||||
try {
|
||||
const parentDir = entry.path.replace(/\/[^/]+$/, '');
|
||||
const rawName = parentDir.split('/').pop() || 'adopted-stack';
|
||||
// Use compose `name:` property if available, otherwise fall back to directory name
|
||||
const rawName = previewComposeName || parentDir.split('/').pop() || 'adopted-stack';
|
||||
const stackName = rawName.toLowerCase().replace(/[^a-z0-9_-]/g, '-').replace(/-+/g, '-').replace(/^-|-$/g, '') || 'adopted-stack';
|
||||
const envFilePath = `${parentDir}/.env`;
|
||||
|
||||
@@ -462,7 +469,7 @@
|
||||
<div class="px-5 py-3 border-b bg-muted/30 flex items-center gap-4 shrink-0">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-sm text-muted-foreground">Stack:</span>
|
||||
<span class="font-medium">{previewFile.path.replace(/\/[^/]+$/, '').split('/').pop() || 'unknown'}</span>
|
||||
<span class="font-medium">{previewComposeName || previewFile.path.replace(/\/[^/]+$/, '').split('/').pop() || 'unknown'}</span>
|
||||
{#if previewServiceCount > 0}
|
||||
<Badge variant="outline" class="text-xs">
|
||||
{previewServiceCount} service{previewServiceCount !== 1 ? 's' : ''}
|
||||
|
||||
@@ -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 || '';
|
||||
|
||||
Reference in New Issue
Block a user