diff --git a/Dockerfile b/Dockerfile index 22fb948..c51b0cd 100644 --- a/Dockerfile +++ b/Dockerfile @@ -51,6 +51,7 @@ RUN APKO_ARCH=$([ "$TARGETARCH" = "arm64" ] && echo "aarch64" || echo "x86_64") " - docker-compose" \ " - docker-cli-buildx" \ " - sqlite" \ + " - postgresql-client" \ " - git" \ " - openssh-client" \ " - curl" \ diff --git a/package.json b/package.json index 70822d1..c7b5667 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "dockhand", "private": true, - "version": "1.0.12", + "version": "1.0.13", "type": "module", "scripts": { "dev": "bunx --bun vite dev", diff --git a/src/hooks.server.ts b/src/hooks.server.ts index 021e261..0c284b0 100644 --- a/src/hooks.server.ts +++ b/src/hooks.server.ts @@ -142,7 +142,8 @@ const PUBLIC_PATHS = [ '/api/license', '/api/changelog', '/api/dependencies', - '/api/health' + '/api/health', + '/api/settings/theme' ]; // Check if path is public diff --git a/src/lib/components/CodeEditor.svelte b/src/lib/components/CodeEditor.svelte index b33d96c..c617312 100644 --- a/src/lib/components/CodeEditor.svelte +++ b/src/lib/components/CodeEditor.svelte @@ -548,14 +548,14 @@ fontSize: '13px' }, '.cm-content': { - fontFamily: 'Menlo, Monaco, "Courier New", monospace', + fontFamily: 'var(--font-editor, ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace)', padding: '8px 0' }, '.cm-gutters': { backgroundColor: '#1a1a1a', color: '#858585', border: 'none', - fontFamily: 'Menlo, Monaco, "Courier New", monospace', + fontFamily: 'var(--font-editor, ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace)', fontSize: '13px' }, '.cm-activeLineGutter': { @@ -590,14 +590,14 @@ fontSize: '13px' }, '.cm-content': { - fontFamily: 'Menlo, Monaco, "Courier New", monospace', + fontFamily: 'var(--font-editor, ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace)', padding: '8px 0' }, '.cm-gutters': { backgroundColor: '#fafafa', color: '#a1a1aa', border: 'none', - fontFamily: 'Menlo, Monaco, "Courier New", monospace', + fontFamily: 'var(--font-editor, ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace)', fontSize: '13px' }, '.cm-activeLineGutter': { diff --git a/src/lib/components/DiffViewer.svelte b/src/lib/components/DiffViewer.svelte new file mode 100644 index 0000000..8fcd7ea --- /dev/null +++ b/src/lib/components/DiffViewer.svelte @@ -0,0 +1,75 @@ + + +{#if diff && diff.changes.length > 0} +
+ {#each diff.changes as change} + {@const oldComplex = isComplex(change.oldValue)} + {@const newComplex = isComplex(change.newValue)} + +
+ + {formatFieldName(change.field)} + + + {#if oldComplex || newComplex} + +
+
{formatDisplayValue(change.oldValue)}
+
{formatDisplayValue(change.newValue)}
+
+ {:else} + +
+ + {formatDisplayValue(change.oldValue)} + + + + {formatDisplayValue(change.newValue)} + +
+ {/if} +
+ {/each} +
+{:else} +

No changes recorded

+{/if} diff --git a/src/lib/components/ImagePullModal.svelte b/src/lib/components/ImagePullModal.svelte new file mode 100644 index 0000000..664acaa --- /dev/null +++ b/src/lib/components/ImagePullModal.svelte @@ -0,0 +1,495 @@ + + + + + + + {#if scanStatus === 'complete' && scanResults.length > 0} + {#if hasCriticalOrHigh} + + {:else if totalVulnerabilities > 0} + + {:else} + + {/if} + {:else if pullStatus === 'complete' && !envHasScanning} + + {:else if pullStatus === 'error' || scanStatus === 'error'} + + {:else} + + {/if} + {title} + {#if effectiveImageName} + {effectiveImageName} + {/if} + + + + +
+ {#if needsConfigureStep} + + + {/if} + + {#if envHasScanning} + + + {/if} +
+ +
+ + {#if needsConfigureStep} +
+
+ + selectedRegistryId = v === 'dockerhub' ? 'dockerhub' : Number(v)} + > + + {#if selectedRegistry} + {#if selectedRegistryId === 'dockerhub'} + + {:else} + + {/if} + {selectedRegistry.name} + {:else} + Select registry + {/if} + + + {#each allRegistries as registry} + + {#if registry.id === 'dockerhub'} + + {:else} + + {/if} + {registry.name} + {#if registry.hasCredentials} + auth + {/if} + + {/each} + + +
+ +
+ + { + if (e.key === 'Enter' && configImageName.trim()) { + startPullFromConfigure(); + } + }} + /> +

+ Format: image:tag or namespace/image:tag +

+
+ + {#if configImageName.trim()} +
+ +
+ {fullImageReference} +
+
+ {/if} +
+ {/if} + + +
+ +
+ + + {#if envHasScanning} +
+ +
+ {/if} +
+ + +
+ {#if activeTab === 'pull' && pullStatus === 'error'} + + {:else if activeTab === 'scan' && scanStatus === 'error'} + + {/if} +
+
+ {#if showDeleteButton && scanStatus === 'complete'} + + + + {:else if showDeleteButton && pullStatus === 'complete' && !envHasScanning} + + + + {:else} + + {#if activeTab === 'configure'} + + {:else if pullStatus === 'complete' || scanStatus === 'complete'} + + {/if} + {/if} +
+
+
+
diff --git a/src/lib/components/StackEnvVarsEditor.svelte b/src/lib/components/StackEnvVarsEditor.svelte index 54a7b10..335e115 100644 --- a/src/lib/components/StackEnvVarsEditor.svelte +++ b/src/lib/components/StackEnvVarsEditor.svelte @@ -2,7 +2,7 @@ import { Button } from '$lib/components/ui/button'; import { Input } from '$lib/components/ui/input'; import * as Tooltip from '$lib/components/ui/tooltip'; - import { Plus, Trash2, Key, AlertCircle, CheckCircle2, FileText, Pencil, CircleDot } from 'lucide-svelte'; + import { Plus, Trash2, Key, AlertCircle, CheckCircle2, FileText, Pencil, CircleDot, Undo2 } from 'lucide-svelte'; export interface EnvVar { key: string; @@ -25,6 +25,7 @@ readonly?: boolean; showSource?: boolean; // For git stacks - show where variable comes from sources?: Record; // Key -> source mapping + fileValues?: Record; // Original file values for revert placeholder?: { key: string; value: string }; existingSecretKeys?: Set; // Keys of secrets loaded from DB (can't toggle visibility) onchange?: () => void; @@ -36,6 +37,7 @@ readonly = false, showSource = false, sources = {}, + fileValues = {}, placeholder = { key: 'VARIABLE_NAME', value: 'value' }, existingSecretKeys = new Set(), onchange @@ -119,14 +121,29 @@ -

From .env file

+

From env file in repository

{:else if source === 'override'} - + {#if fileValues[variable.key] !== undefined} + + {:else} + + {/if} -

Manual override

+

{fileValues[variable.key] !== undefined ? 'Revert to file value' : 'Manual override (not in file)'}

{/if} diff --git a/src/lib/components/StackEnvVarsPanel.svelte b/src/lib/components/StackEnvVarsPanel.svelte index df98404..2f0952e 100644 --- a/src/lib/components/StackEnvVarsPanel.svelte +++ b/src/lib/components/StackEnvVarsPanel.svelte @@ -1,25 +1,27 @@
@@ -244,4 +270,28 @@
+ + +
+
+ + +
+ + + {#each monospaceFonts as font} + {#if font.id === selectedEditorFont} + {font.name} + {/if} + {/each} + + + {#each monospaceFonts as font} + + {font.name} + + {/each} + + +
diff --git a/src/lib/config/grid-columns.ts b/src/lib/config/grid-columns.ts index ef58556..cf13407 100644 --- a/src/lib/config/grid-columns.ts +++ b/src/lib/config/grid-columns.ts @@ -6,7 +6,7 @@ export const containerColumns: ColumnConfig[] = [ { id: 'name', label: 'Name', sortable: true, sortField: 'name', width: 140, minWidth: 80, grow: true }, { id: 'image', label: 'Image', sortable: true, sortField: 'image', width: 180, minWidth: 100, grow: true }, { id: 'state', label: 'State', sortable: true, sortField: 'state', width: 90, minWidth: 70, noTruncate: true }, - { id: 'health', label: 'Health', width: 55, minWidth: 40 }, + { id: 'health', label: 'Health', sortable: true, sortField: 'health', width: 55, minWidth: 40 }, { id: 'uptime', label: 'Uptime', sortable: true, sortField: 'uptime', width: 80, minWidth: 60 }, { id: 'restartCount', label: 'Restarts', width: 70, minWidth: 50 }, { id: 'cpu', label: 'CPU', sortable: true, sortField: 'cpu', width: 50, minWidth: 40, align: 'right' }, @@ -94,6 +94,18 @@ export const activityColumns: ColumnConfig[] = [ { id: 'actions', label: '', fixed: 'end', width: 50, resizable: false } ]; +// Audit log grid columns +export const auditColumns: ColumnConfig[] = [ + { id: 'timestamp', label: 'Timestamp', width: 165, minWidth: 140 }, + { id: 'environment', label: 'Environment', width: 140, minWidth: 100 }, + { id: 'user', label: 'User', width: 120, minWidth: 80 }, + { id: 'action', label: 'Action', width: 55, resizable: false }, + { id: 'entity', label: 'Entity', width: 100, minWidth: 80 }, + { id: 'name', label: 'Name', width: 200, minWidth: 100, grow: true }, + { id: 'ip', label: 'IP address', width: 120, minWidth: 90 }, + { id: 'actions', label: '', fixed: 'end', width: 50, resizable: false } +]; + // Schedule grid columns export const scheduleColumns: ColumnConfig[] = [ { id: 'expand', label: '', fixed: 'start', width: 24, resizable: false }, @@ -115,7 +127,8 @@ export const gridColumnConfigs: Record = { stacks: stackColumns, volumes: volumeColumns, activity: activityColumns, - schedules: scheduleColumns + schedules: scheduleColumns, + audit: auditColumns }; // Get configurable columns (not fixed) diff --git a/src/lib/data/changelog.json b/src/lib/data/changelog.json index 5ceed16..60054fd 100644 --- a/src/lib/data/changelog.json +++ b/src/lib/data/changelog.json @@ -1,4 +1,29 @@ [ + { + "version": "1.0.13", + "date": "2026-01-23", + "changes": [ + { "type": "feature", "text": "Add DISABLE_LOCAL_LOGIN env var to hide local password login when SSO/LDAP is configured" }, + { "type": "feature", "text": "Add ntfy authentication support (user:pass@host/topic format)" }, + { "type": "feature", "text": "Sortable health column in containers grid (unhealthy containers first)" }, + { "type": "feature", "text": "GPU device configuration in container create/edit/inspect" }, + { "type": "feature", "text": "Editor font setting with expanded monospace font options" }, + { "type": "feature", "text": "Dedicated NFS/CIFS form fields in create volume modal" }, + { "type": "feature", "text": "Scheduled image pruning per environment" }, + { "type": "feature", "text": "Git stack env populate button to preview overridable variables before deploy" }, + { "type": "fix", "text": "Fix vulnerability scanning failing with rootless Docker" }, + { "type": "fix", "text": "Honor DATA_DIR env var in hawser SQLite operations" }, + { "type": "fix", "text": "Show detailed error messages when notification test fails" }, + { "type": "fix", "text": "Fix compose file browse in create mode showing default path instead of selected file" }, + { "type": "fix", "text": "Fix custom env file path not preserved in create mode" }, + { "type": "fix", "text": "Fix git stacks creating duplicate compose.yaml alongside repo file" }, + { "type": "fix", "text": "Fix env vars not showing after stack create" }, + { "type": "fix", "text": "Fix stack path defaults accidentally enforced over custom paths" }, + { "type": "fix", "text": "Fix adopted stack save & restart breaking paths and env vars" } + ], + "imageTag": "fnsys/dockhand:v1.0.13", + "comingSoon": true + }, { "version": "1.0.12", "date": "2026-01-22", diff --git a/src/lib/server/audit.ts b/src/lib/server/audit.ts index 34f275d..5da5b1d 100644 --- a/src/lib/server/audit.ts +++ b/src/lib/server/audit.ts @@ -207,6 +207,24 @@ export async function auditUser( }); } +/** + * Helper for role actions + */ +export async function auditRole( + event: RequestEvent, + action: AuditAction, + roleId: number, + roleName: string, + details?: any +): Promise { + await audit(event, action, 'role', { + entityId: String(roleId), + entityName: roleName, + description: `Role ${roleName} ${action}`, + details + }); +} + /** * Helper for settings actions */ @@ -261,6 +279,134 @@ export async function auditRegistry( }); } +/** + * Helper for git repository actions + */ +export async function auditGitRepository( + event: RequestEvent, + action: AuditAction, + repositoryId: number, + repositoryName: string, + details?: any +): Promise { + await audit(event, action, 'git_repository', { + entityId: String(repositoryId), + entityName: repositoryName, + description: `Git repository ${repositoryName} ${action}`, + details + }); +} + +/** + * Helper for git credential actions + */ +export async function auditGitCredential( + event: RequestEvent, + action: AuditAction, + credentialId: number, + credentialName: string, + details?: any +): Promise { + await audit(event, action, 'git_credential', { + entityId: String(credentialId), + entityName: credentialName, + description: `Git credential ${credentialName} ${action}`, + details + }); +} + +/** + * Helper for config set actions + */ +export async function auditConfigSet( + event: RequestEvent, + action: AuditAction, + configSetId: number, + configSetName: string, + details?: any +): Promise { + await audit(event, action, 'config_set', { + entityId: String(configSetId), + entityName: configSetName, + description: `Config set ${configSetName} ${action}`, + details + }); +} + +/** + * Helper for notification channel actions + */ +export async function auditNotification( + event: RequestEvent, + action: AuditAction, + notificationId: number, + notificationName: string, + details?: any +): Promise { + await audit(event, action, 'notification', { + entityId: String(notificationId), + entityName: notificationName, + description: `Notification channel ${notificationName} ${action}`, + details + }); +} + +/** + * Helper for OIDC provider actions + */ +export async function auditOidcProvider( + event: RequestEvent, + action: AuditAction, + providerId: number, + providerName: string, + details?: any +): Promise { + await audit(event, action, 'oidc_provider', { + entityId: String(providerId), + entityName: providerName, + description: `OIDC provider ${providerName} ${action}`, + details + }); +} + +/** + * Helper for LDAP config actions + */ +export async function auditLdapConfig( + event: RequestEvent, + action: AuditAction, + configId: number, + configName: string, + details?: any +): Promise { + await audit(event, action, 'ldap_config', { + entityId: String(configId), + entityName: configName, + description: `LDAP config ${configName} ${action}`, + details + }); +} + +/** + * Helper for git stack actions + */ +export async function auditGitStack( + event: RequestEvent, + action: AuditAction, + stackId: number, + stackName: string, + environmentId?: number | null, + details?: any +): Promise { + await audit(event, action, 'git_stack', { + entityId: String(stackId), + entityName: stackName, + environmentId, + description: `Git stack ${stackName} ${action}`, + details + }); +} + /** * Helper for auth actions (login/logout) */ diff --git a/src/lib/server/db.ts b/src/lib/server/db.ts index 86f3363..1cee923 100644 --- a/src/lib/server/db.ts +++ b/src/lib/server/db.ts @@ -382,14 +382,16 @@ export async function getUserThemePreferences(userId: number): Promise<{ fontSize: string; gridFontSize: string; terminalFont: string; + editorFont: string; }> { - const [lightTheme, darkTheme, font, fontSize, gridFontSize, terminalFont] = await Promise.all([ + const [lightTheme, darkTheme, font, fontSize, gridFontSize, terminalFont, editorFont] = await Promise.all([ getUserSetting(userId, 'light_theme'), getUserSetting(userId, 'dark_theme'), getUserSetting(userId, 'font'), getUserSetting(userId, 'font_size'), getUserSetting(userId, 'grid_font_size'), - getUserSetting(userId, 'terminal_font') + getUserSetting(userId, 'terminal_font'), + getUserSetting(userId, 'editor_font') ]); return { lightTheme: lightTheme || 'default', @@ -397,13 +399,14 @@ export async function getUserThemePreferences(userId: number): Promise<{ font: font || 'system', fontSize: fontSize || 'normal', gridFontSize: gridFontSize || 'normal', - terminalFont: terminalFont || 'system-mono' + terminalFont: terminalFont || 'system-mono', + editorFont: editorFont || 'system-mono' }; } export async function setUserThemePreferences( userId: number, - prefs: { lightTheme?: string; darkTheme?: string; font?: string; fontSize?: string; gridFontSize?: string; terminalFont?: string } + prefs: { lightTheme?: string; darkTheme?: string; font?: string; fontSize?: string; gridFontSize?: string; terminalFont?: string; editorFont?: string } ): Promise { const updates: Promise[] = []; if (prefs.lightTheme !== undefined) { @@ -424,6 +427,9 @@ export async function setUserThemePreferences( if (prefs.terminalFont !== undefined) { updates.push(setUserSetting(userId, 'terminal_font', prefs.terminalFont)); } + if (prefs.editorFont !== undefined) { + updates.push(setUserSetting(userId, 'editor_font', prefs.editorFont)); + } await Promise.all(updates); } @@ -803,6 +809,8 @@ export const NOTIFICATION_EVENT_TYPES = [ { id: 'environment_offline', label: 'Environment offline', description: 'Environment became unreachable', group: 'system', scope: 'environment' }, { id: 'environment_online', label: 'Environment online', description: 'Environment came back online', group: 'system', scope: 'environment' }, { id: 'disk_space_warning', label: 'Disk space warning', description: 'Docker disk usage exceeds threshold', group: 'system', scope: 'environment' }, + { id: 'image_prune_success', label: 'Image prune success', description: 'Scheduled image prune completed successfully', group: 'system', scope: 'environment' }, + { id: 'image_prune_failed', label: 'Image prune failed', description: 'Scheduled image prune failed', group: 'system', scope: 'environment' }, { id: 'license_expiring', label: 'License expiring', description: 'Enterprise license expiring soon (global)', group: 'system', scope: 'system' } ] as const; @@ -2941,7 +2949,8 @@ export type AuditAction = export type AuditEntityType = | 'container' | 'image' | 'stack' | 'volume' | 'network' - | 'user' | 'settings' | 'environment' | 'registry'; + | 'user' | 'role' | 'settings' | 'environment' | 'registry' | 'git_repository' | 'git_credential' + | 'config_set' | 'notification' | 'oidc_provider' | 'ldap_config' | 'git_stack'; export interface AuditLogData { id: number; @@ -4160,6 +4169,68 @@ export async function getAllEnvUpdateCheckSettings(): Promise { + const key = `env_${envId}_image_prune`; + const result = await db.select().from(settings).where(eq(settings.key, key)); + if (!result[0]) return null; + try { + return JSON.parse(result[0].value); + } catch { + return null; + } +} + +export async function setImagePruneSettings(envId: number, config: ImagePruneSettings): Promise { + const key = `env_${envId}_image_prune`; + const value = JSON.stringify(config); + const existing = await db.select().from(settings).where(eq(settings.key, key)); + if (existing.length > 0) { + await db.update(settings) + .set({ value, updatedAt: new Date().toISOString() }) + .where(eq(settings.key, key)); + } else { + await db.insert(settings).values({ key, value }); + } +} + +export async function deleteImagePruneSettings(envId: number): Promise { + const key = `env_${envId}_image_prune`; + await db.delete(settings).where(eq(settings.key, key)); +} + +export async function getAllImagePruneSettings(): Promise> { + const rows = await db.select().from(settings).where(sql`${settings.key} LIKE 'env_%_image_prune'`); + const results: Array<{ envId: number; settings: ImagePruneSettings }> = []; + for (const row of rows) { + try { + const match = row.key.match(/^env_(\d+)_image_prune$/); + if (!match) continue; + const envId = parseInt(match[1]); + const config = JSON.parse(row.value) as ImagePruneSettings; + // Return all settings, not just enabled ones (UI needs to show disabled schedules too) + results.push({ envId, settings: config }); + } catch { + // Skip invalid entries + } + } + return results; +} + // ============================================================================= // ENVIRONMENT TIMEZONE SETTINGS // ============================================================================= diff --git a/src/lib/server/docker.ts b/src/lib/server/docker.ts index e7bd2d4..b62ea89 100644 --- a/src/lib/server/docker.ts +++ b/src/lib/server/docker.ts @@ -1299,17 +1299,320 @@ export async function createContainer(options: CreateContainerOptions, envId?: n return { id: result.Id, start: () => startContainer(result.Id, envId) }; } -export async function updateContainer(id: string, options: CreateContainerOptions, startAfterUpdate = false, envId?: number | null) { +/** + * Extract all container options from Docker inspect data. + * This preserves ALL container settings for recreation. + * Used by both updateContainer and recreateContainer to ensure consistency. + */ +export function extractContainerOptions(inspectData: any): CreateContainerOptions { + const config = inspectData.Config; + const hostConfig = inspectData.HostConfig; + const name = inspectData.Name?.replace(/^\//, '') || ''; + + // Port bindings - preserve all host port mappings including HostIp + const ports: { [key: string]: { HostIp?: string; HostPort: string } } = {}; + if (hostConfig.PortBindings) { + for (const [containerPort, bindings] of Object.entries(hostConfig.PortBindings)) { + if (bindings && (bindings as any[]).length > 0) { + const binding = (bindings as any[])[0]; + ports[containerPort] = { + HostPort: binding.HostPort || '' + }; + // Preserve HostIp if specified (e.g., '192.168.0.250:80:80' in compose) + if (binding.HostIp) { + ports[containerPort].HostIp = binding.HostIp; + } + } + } + } + + // Volume bindings - preserve ALL volumes including anonymous volumes + const volumeBinds: string[] = []; + const mountedPaths = new Set(); + + // First, add all entries from hostConfig.Binds (named volumes and bind mounts) + if (hostConfig.Binds && Array.isArray(hostConfig.Binds)) { + for (const bind of hostConfig.Binds) { + volumeBinds.push(bind); + const parts = bind.split(':'); + if (parts.length >= 2) { + mountedPaths.add(parts[1].split(':')[0]); + } + } + } + + // Then, add anonymous volumes from Mounts that aren't already in Binds + const mounts = inspectData.Mounts || []; + for (const mount of mounts) { + if (mount.Type === 'volume' && mount.Name && mount.Destination) { + if (!mountedPaths.has(mount.Destination)) { + const bindStr = mount.RW === false + ? `${mount.Name}:${mount.Destination}:ro` + : `${mount.Name}:${mount.Destination}`; + volumeBinds.push(bindStr); + } + } + } + + // Healthcheck configuration + let healthcheck: HealthcheckConfig | undefined = undefined; + if (config.Healthcheck && config.Healthcheck.Test && config.Healthcheck.Test.length > 0) { + if (config.Healthcheck.Test[0] !== 'NONE') { + healthcheck = { + test: config.Healthcheck.Test, + interval: config.Healthcheck.Interval, + timeout: config.Healthcheck.Timeout, + retries: config.Healthcheck.Retries, + startPeriod: config.Healthcheck.StartPeriod + }; + } + } + + // Device mappings + const devices = (hostConfig.Devices || []).map((d: any) => ({ + hostPath: d.PathOnHost || '', + containerPath: d.PathInContainer || '', + permissions: d.CgroupPermissions || 'rwm' + })).filter((d: any) => d.hostPath && d.containerPath); + + // Ulimits + const ulimits = (hostConfig.Ulimits || []).map((u: any) => ({ + name: u.Name, + soft: u.Soft, + hard: u.Hard + })); + + // Extract network settings + const networkSettings = inspectData.NetworkSettings?.Networks || {}; + const primaryNetwork = hostConfig.NetworkMode || 'bridge'; + + // Extract primary network aliases, static IP, and gateway priority + let networkAliases: string[] | undefined; + let networkIpv4Address: string | undefined; + let networkIpv6Address: string | undefined; + let macAddress: string | undefined; + let networkGwPriority: number | undefined; + + const containerId = inspectData.Id || ''; + const shortContainerId = containerId.substring(0, 12); + + // Extract compose labels for alias reconstruction + const composeProject = config.Labels?.['com.docker.compose.project']; + const composeService = config.Labels?.['com.docker.compose.service']; + + for (const [netName, netConfig] of Object.entries(networkSettings)) { + const netConf = netConfig as any; + const isPrimary = netName === primaryNetwork || + (primaryNetwork === 'bridge' && (netName === 'bridge' || netName === 'default')); + + if (isPrimary) { + // Filter out auto-generated container IDs + const allAliases = (netConf.Aliases?.length > 0 ? netConf.Aliases : netConf.DNSNames) || []; + networkAliases = allAliases.filter((a: string) => + a !== containerId && a !== shortContainerId + ); + + // For compose containers, ensure service name and project-service aliases + if (composeProject && composeService) { + if (!networkAliases) networkAliases = []; + if (!networkAliases.includes(composeService)) { + networkAliases.push(composeService); + } + const projectService = `${composeProject}-${composeService}`; + if (!networkAliases.includes(projectService)) { + networkAliases.push(projectService); + } + } + + if (!networkAliases || networkAliases.length === 0) { + networkAliases = undefined; + } + + networkIpv4Address = netConf.IPAMConfig?.IPv4Address || undefined; + networkIpv6Address = netConf.IPAMConfig?.IPv6Address || undefined; + macAddress = netConf.MacAddress || undefined; + networkGwPriority = netConf.GwPriority !== undefined && netConf.GwPriority !== 0 + ? netConf.GwPriority : undefined; + break; + } + } + + // Device requests (GPU, etc.) + const deviceRequests = hostConfig.DeviceRequests?.length > 0 + ? hostConfig.DeviceRequests.map((dr: any) => ({ + driver: dr.Driver || undefined, + count: dr.Count, + deviceIDs: dr.DeviceIDs?.length > 0 ? dr.DeviceIDs : undefined, + capabilities: dr.Capabilities?.length > 0 ? dr.Capabilities : undefined, + options: dr.Options && Object.keys(dr.Options).length > 0 ? dr.Options : undefined + })) + : undefined; + + return { + name, + image: config.Image, + + // Command and entrypoint + cmd: config.Cmd || undefined, + entrypoint: config.Entrypoint || undefined, + workingDir: config.WorkingDir || undefined, + + // Environment and labels + env: config.Env || [], + labels: config.Labels || {}, + + // Port mappings + ports: Object.keys(ports).length > 0 ? ports : undefined, + + // Volume bindings + volumeBinds: volumeBinds.length > 0 ? volumeBinds : undefined, + + // Restart policy + restartPolicy: hostConfig.RestartPolicy?.Name || 'no', + restartMaxRetries: hostConfig.RestartPolicy?.MaximumRetryCount, + + // Network settings + networkMode: hostConfig.NetworkMode || undefined, + networkAliases, + networkIpv4Address, + networkIpv6Address, + networkGwPriority, + + // User and hostname + user: config.User || undefined, + hostname: config.Hostname || undefined, + domainname: config.Domainname || undefined, + + // Privileged mode + privileged: hostConfig.Privileged || undefined, + + // Healthcheck + healthcheck, + + // Terminal settings + tty: config.Tty || undefined, + stdinOpen: config.OpenStdin || undefined, + + // Memory limits + memory: hostConfig.Memory || undefined, + memoryReservation: hostConfig.MemoryReservation || undefined, + memorySwap: hostConfig.MemorySwap || undefined, + + // CPU limits + cpuShares: hostConfig.CpuShares || undefined, + cpuQuota: hostConfig.CpuQuota || undefined, + cpuPeriod: hostConfig.CpuPeriod || undefined, + nanoCpus: hostConfig.NanoCpus || undefined, + + // Capabilities + capAdd: hostConfig.CapAdd?.length > 0 ? hostConfig.CapAdd : undefined, + capDrop: hostConfig.CapDrop?.length > 0 ? hostConfig.CapDrop : undefined, + + // Devices + devices: devices.length > 0 ? devices : undefined, + + // DNS settings + dns: hostConfig.Dns?.length > 0 ? hostConfig.Dns : undefined, + dnsSearch: hostConfig.DnsSearch?.length > 0 ? hostConfig.DnsSearch : undefined, + dnsOptions: hostConfig.DnsOptions?.length > 0 ? hostConfig.DnsOptions : undefined, + + // Security options + securityOpt: hostConfig.SecurityOpt?.length > 0 ? hostConfig.SecurityOpt : undefined, + + // Ulimits + ulimits: ulimits.length > 0 ? ulimits : undefined, + + // Process and memory settings + oomKillDisable: hostConfig.OomKillDisable || undefined, + pidsLimit: hostConfig.PidsLimit || undefined, + shmSize: hostConfig.ShmSize || undefined, + + // Tmpfs mounts + tmpfs: hostConfig.Tmpfs && Object.keys(hostConfig.Tmpfs).length > 0 ? hostConfig.Tmpfs : undefined, + + // Sysctls + sysctls: hostConfig.Sysctls && Object.keys(hostConfig.Sysctls).length > 0 ? hostConfig.Sysctls : undefined, + + // Logging configuration + logDriver: hostConfig.LogConfig?.Type || undefined, + logOptions: hostConfig.LogConfig?.Config && Object.keys(hostConfig.LogConfig.Config).length > 0 + ? hostConfig.LogConfig.Config : undefined, + + // Namespace settings + ipcMode: hostConfig.IpcMode || undefined, + pidMode: hostConfig.PidMode || undefined, + utsMode: hostConfig.UTSMode || undefined, + + // Cgroup parent + cgroupParent: hostConfig.CgroupParent || undefined, + + // Stop signal and timeout + stopSignal: config.StopSignal || undefined, + stopTimeout: config.StopTimeout || undefined, + + // Init process + init: hostConfig.Init === true ? true : undefined, + + // MAC address + macAddress, + + // Extra hosts + extraHosts: hostConfig.ExtraHosts?.length > 0 ? hostConfig.ExtraHosts : undefined, + + // Device requests (GPU) + deviceRequests, + + // Container runtime + runtime: hostConfig.Runtime && hostConfig.Runtime !== 'runc' ? hostConfig.Runtime : undefined, + + // Read-only root filesystem + readonlyRootfs: hostConfig.ReadonlyRootfs === true ? true : undefined, + + // CPU pinning + cpusetCpus: hostConfig.CpusetCpus || undefined, + cpusetMems: hostConfig.CpusetMems || undefined, + + // Additional groups + groupAdd: hostConfig.GroupAdd?.length > 0 ? hostConfig.GroupAdd : undefined, + + // Memory swappiness + memorySwappiness: hostConfig.MemorySwappiness !== null ? hostConfig.MemorySwappiness : undefined, + + // User namespace mode + usernsMode: hostConfig.UsernsMode || undefined + }; +} + +/** + * Update a container by recreating it with merged options. + * Preserves ALL existing container settings and merges user-provided options on top. + */ +export async function updateContainer(id: string, options: Partial, startAfterUpdate = false, envId?: number | null) { const oldContainerInfo = await inspectContainer(id, envId); const wasRunning = oldContainerInfo.State.Running; + // Extract ALL existing container options + const existingOptions = extractContainerOptions(oldContainerInfo); + + // Merge user-provided options on top of existing options + // User options take precedence, but we preserve everything not explicitly provided + const mergedOptions: CreateContainerOptions = { + ...existingOptions, + ...options, + // Special handling for labels - merge instead of replace to preserve Docker internal labels + labels: { + ...existingOptions.labels, + ...options.labels + } + }; + if (wasRunning) { await stopContainer(id, envId); } await removeContainer(id, true, envId); - const newContainer = await createContainer(options, envId); + const newContainer = await createContainer(mergedOptions, envId); if (startAfterUpdate || wasRunning) { await newContainer.start(); @@ -2746,6 +3049,7 @@ export async function runContainerWithStreaming(options: { binds?: string[]; env?: string[]; name?: string; + user?: string; envId?: number | null; onStdout?: (data: string) => void; onStderr?: (data: string) => void; @@ -2765,6 +3069,11 @@ export async function runContainerWithStreaming(options: { } }; + // Set user if specified (needed for rootless Docker socket access) + if (options.user) { + containerConfig.User = options.user; + } + const createResult = await dockerJsonRequest<{ Id: string }>( `/containers/create?name=${encodeURIComponent(containerName)}`, { method: 'POST', body: JSON.stringify(containerConfig) }, diff --git a/src/lib/server/git.ts b/src/lib/server/git.ts index d54209d..0a9b586 100644 --- a/src/lib/server/git.ts +++ b/src/lib/server/git.ts @@ -7,6 +7,7 @@ import { getGitStack, updateGitStack, upsertStackSource, + getEnvironment, type GitRepository, type GitCredential, type GitStackWithRepo @@ -14,7 +15,8 @@ import { import { deployStack, getStackDir } from './stacks'; // Directory for storing cloned repositories -const GIT_REPOS_DIR = process.env.GIT_REPOS_DIR || './data/git-repos'; +const dataDir = process.env.DATA_DIR || './data'; +const GIT_REPOS_DIR = resolve(process.env.GIT_REPOS_DIR || join(dataDir, 'git-repos')); // Ensure git repos directory exists if (!existsSync(GIT_REPOS_DIR)) { @@ -544,7 +546,21 @@ export function deleteRepositoryFiles(repoId: number): void { // === Git Stack Functions === -function getStackRepoPath(stackId: number): string { +async function getStackRepoPath(stackId: number, stackName?: string, environmentId?: number | null): Promise { + if (stackName && environmentId) { + // Use old path if it already exists (backward compat), otherwise use name-based path + const oldPath = join(GIT_REPOS_DIR, `stack-${stackId}`); + if (existsSync(oldPath)) { + return oldPath; + } + // Format: envName/stackName (e.g. production/webapp) - consistent with internal stacks + const env = await getEnvironment(environmentId); + const envDir = join(GIT_REPOS_DIR, env ? env.name : String(environmentId)); + if (!existsSync(envDir)) { + mkdirSync(envDir, { recursive: true }); + } + return join(envDir, stackName); + } return join(GIT_REPOS_DIR, `stack-${stackId}`); } @@ -597,7 +613,7 @@ export async function syncGitStack(stackId: number): Promise { console.log(`${logPrefix} Repository branch:`, repo.branch); const credential = repo.credentialId ? await getGitCredential(repo.credentialId) : null; - const repoPath = getStackRepoPath(stackId); + const repoPath = await getStackRepoPath(stackId, gitStack.stackName, gitStack.environmentId); const env = await buildGitEnv(credential); console.log(`${logPrefix} Local repo path:`, repoPath); @@ -818,14 +834,13 @@ export async function deployGitStack(stackId: number, options?: { force?: boolea }; } - const forceRecreate = syncResult.updated && !!gitStack.envFilePath; - console.log(`${logPrefix} Will force recreate:`, forceRecreate, `(updated=${syncResult.updated}, hasEnvFile=${!!gitStack.envFilePath})`); + const forceRecreate = syncResult.updated; + console.log(`${logPrefix} Will force recreate:`, forceRecreate, `(updated=${syncResult.updated})`); // Deploy using unified function - handles both new and existing stacks // Uses `docker compose up -d --remove-orphans` which only recreates changed services - // Force recreate when git detected changes AND stack has .env file configured - // This ensures containers pick up new env var values even if compose file didn't change - // Note: Without this, docker compose only detects compose file changes, not env var changes + // Force recreate whenever git detected changes to ensure containers pick up + // new env var values even if compose file itself didn't change console.log(`${logPrefix} Calling deployStack...`); console.log(`${logPrefix} Source directory (composeDir):`, syncResult.composeDir); console.log(`${logPrefix} Compose filename:`, syncResult.composeFileName); @@ -835,7 +850,6 @@ export async function deployGitStack(stackId: number, options?: { force?: boolea name: gitStack.stackName, compose: syncResult.composeContent!, envId: gitStack.environmentId, - envFileVars: syncResult.envFileVars, sourceDir: syncResult.composeDir, // Copy entire directory from git repo composeFileName: syncResult.composeFileName, // Use original compose filename from repo envFileName: syncResult.envFileName, // Env file relative to compose dir (for --env-file flag, optional) @@ -923,8 +937,8 @@ export async function testGitStack(stackId: number): Promise { } } -export function deleteGitStackFiles(stackId: number): void { - const repoPath = getStackRepoPath(stackId); +export async function deleteGitStackFiles(stackId: number, stackName?: string, environmentId?: number | null): Promise { + const repoPath = await getStackRepoPath(stackId, stackName, environmentId); try { if (existsSync(repoPath)) { rmSync(repoPath, { recursive: true, force: true }); @@ -967,7 +981,7 @@ export async function deployGitStackWithProgress( } const credential = repo.credentialId ? await getGitCredential(repo.credentialId) : null; - const repoPath = getStackRepoPath(stackId); + const repoPath = await getStackRepoPath(stackId, gitStack.stackName, gitStack.environmentId); const env = await buildGitEnv(credential); const totalSteps = 5; @@ -1079,7 +1093,6 @@ export async function deployGitStackWithProgress( name: gitStack.stackName, compose: composeContent, envId: gitStack.environmentId, - envFileVars, sourceDir: composeDir // Copy entire directory from git repo }); @@ -1124,7 +1137,7 @@ export async function listGitStackEnvFiles(stackId: number): Promise<{ files: st return { files: [], error: 'Git stack not found' }; } - const repoPath = getStackRepoPath(stackId); + const repoPath = await getStackRepoPath(stackId, gitStack.stackName, gitStack.environmentId); if (!existsSync(repoPath)) { return { files: [], error: 'Repository not synced - deploy the stack first' }; } @@ -1237,7 +1250,7 @@ export async function readGitStackEnvFile( return { vars: {}, error: 'Git stack not found' }; } - const repoPath = getStackRepoPath(stackId); + const repoPath = await getStackRepoPath(stackId, gitStack.stackName, gitStack.environmentId); if (!existsSync(repoPath)) { return { vars: {}, error: 'Repository not synced - deploy the stack first' }; } @@ -1262,3 +1275,126 @@ export async function readGitStackEnvFile( return { vars: {}, error: error.message }; } } + +interface PreviewEnvOptions { + repoUrl: string; + branch: string; + credential: { + id: number; + authType: string; + sshPrivateKey?: string | null; + username?: string | null; + password?: string | null; + } | null; + composePath: string; + envFilePath: string | null; +} + +interface PreviewEnvResult { + vars: Record; + sources: Record; + error?: string; +} + +/** + * Clone a repository to a temp directory and read env files for preview. + * Used to populate env editor when creating a new git stack. + * Cleans up temp directory after reading. + */ +export async function previewRepoEnvFiles(options: PreviewEnvOptions): Promise { + const { repoUrl, branch, credential, composePath, envFilePath } = options; + const logPrefix = '[Git:Preview]'; + + // Create a unique temp directory + const tempId = `preview-${Date.now()}-${Math.random().toString(36).substring(2, 8)}`; + const tempDir = join(GIT_REPOS_DIR, tempId); + + console.log(`${logPrefix} Starting preview for ${repoUrl}`); + console.log(`${logPrefix} Temp directory: ${tempDir}`); + + try { + // Ensure temp directory exists + mkdirSync(tempDir, { recursive: true }); + + // Build git environment with credentials + // Cast credential to GitCredential type (only uses id, authType, sshPrivateKey) + const env = await buildGitEnv(credential as GitCredential | null); + const authenticatedUrl = buildRepoUrl(repoUrl, credential as GitCredential | null); + + // Clone with depth 1 (shallow clone for speed) + const cloneProc = Bun.spawn( + ['git', 'clone', '--depth', '1', '--branch', branch, '--single-branch', authenticatedUrl, tempDir], + { + stdout: 'pipe', + stderr: 'pipe', + env + } + ); + + const cloneStderr = await new Response(cloneProc.stderr).text(); + const cloneExitCode = await cloneProc.exited; + + if (cloneExitCode !== 0) { + console.error(`${logPrefix} Clone failed:`, cloneStderr); + return { vars: {}, sources: {}, error: `Failed to clone repository: ${cloneStderr.trim()}` }; + } + + console.log(`${logPrefix} Clone successful`); + + // Determine the compose directory (where .env file should be) + const composeDir = dirname(composePath); + const baseEnvPath = join(tempDir, composeDir, '.env'); + + const vars: Record = {}; + const sources: Record = {}; + + // Read base .env file if it exists + if (existsSync(baseEnvPath)) { + console.log(`${logPrefix} Reading .env from: ${baseEnvPath}`); + const content = await Bun.file(baseEnvPath).text(); + const baseVars = parseEnvFileContent(content, 'preview'); + for (const [key, value] of Object.entries(baseVars)) { + vars[key] = value; + sources[key] = '.env'; + } + console.log(`${logPrefix} Found ${Object.keys(baseVars).length} vars in .env`); + } else { + console.log(`${logPrefix} No .env file at ${baseEnvPath}`); + } + + // Read additional env file if specified + if (envFilePath) { + const additionalEnvPath = join(tempDir, envFilePath); + if (existsSync(additionalEnvPath)) { + console.log(`${logPrefix} Reading additional env file: ${additionalEnvPath}`); + const content = await Bun.file(additionalEnvPath).text(); + const additionalVars = parseEnvFileContent(content, 'preview'); + for (const [key, value] of Object.entries(additionalVars)) { + vars[key] = value; + sources[key] = 'envFile'; + } + console.log(`${logPrefix} Found ${Object.keys(additionalVars).length} vars in ${envFilePath}`); + } else { + console.log(`${logPrefix} Additional env file not found: ${additionalEnvPath}`); + } + } + + console.log(`${logPrefix} Total variables: ${Object.keys(vars).length}`); + + return { vars, sources }; + } catch (error: any) { + console.error(`${logPrefix} Error:`, error); + return { vars: {}, sources: {}, error: error.message }; + } finally { + // Always clean up temp directory + cleanupSshKey(credential as GitCredential | null); + try { + if (existsSync(tempDir)) { + rmSync(tempDir, { recursive: true, force: true }); + console.log(`${logPrefix} Cleaned up temp directory`); + } + } catch (cleanupError) { + console.error(`${logPrefix} Failed to cleanup temp directory:`, cleanupError); + } + } +} diff --git a/src/lib/server/host-path.ts b/src/lib/server/host-path.ts index e2dbf89..679ca95 100644 --- a/src/lib/server/host-path.ts +++ b/src/lib/server/host-path.ts @@ -208,6 +208,72 @@ export function translateContainerPathViaMount(containerPath: string): string | return null; } +/** + * Get the host path for the Docker socket mount. + * This is needed for sibling containers (e.g., scanners) that need socket access. + * + * When Dockhand runs in Docker with a non-standard socket mount like: + * -v /var/run/user/1000/docker.sock:/var/run/docker.sock + * + * We need to detect the HOST path (/var/run/user/1000/docker.sock) so that + * scanner containers can bind-mount the correct path. + * + * @returns The host path to Docker socket, or '/var/run/docker.sock' as default + */ +export function getHostDockerSocket(): string { + // Priority 1: Explicit environment variable override + if (process.env.HOST_DOCKER_SOCKET) { + console.log(`[HostPath] Using HOST_DOCKER_SOCKET from env: ${process.env.HOST_DOCKER_SOCKET}`); + return process.env.HOST_DOCKER_SOCKET; + } + + // Priority 2: Look up from cached mounts (populated by detectHostDataDir on startup) + if (cachedMounts && cachedMounts.length > 0) { + console.log(`[HostPath] Searching ${cachedMounts.length} cached mount(s) for Docker socket`); + + // Find mount where destination is docker.sock + const socketMount = cachedMounts.find(m => + m.destination === '/var/run/docker.sock' || + m.destination === '/run/docker.sock' || + m.destination.endsWith('/docker.sock') + ); + + if (socketMount) { + console.log(`[HostPath] Found Docker socket mount: ${socketMount.source} -> ${socketMount.destination}`); + return socketMount.source; + } + + // Log available mounts for debugging + console.log(`[HostPath] No Docker socket mount found. Available mounts:`); + for (const m of cachedMounts) { + console.log(`[HostPath] ${m.source} -> ${m.destination}`); + } + } else { + console.log(`[HostPath] No cached mounts available (not running in Docker or detectHostDataDir not called)`); + } + + // Priority 3: Default fallback (works for standard Docker setups) + console.log(`[HostPath] Using default Docker socket: /var/run/docker.sock`); + return '/var/run/docker.sock'; +} + +/** + * Extract UID from a user-specific Docker socket path. + * User-specific sockets are at /run/user//docker.sock + * + * @param socketPath - The host Docker socket path + * @returns The UID as a string (e.g., "1000"), or null if not a user-specific path + */ +export function extractUidFromSocketPath(socketPath: string): string | null { + // Match patterns like /run/user/1000/docker.sock or /var/run/user/1000/docker.sock + const match = socketPath.match(/\/user\/(\d+)\/docker\.sock$/); + if (match) { + console.log(`[HostPath] Extracted UID ${match[1]} from socket path: ${socketPath}`); + return match[1]; + } + return null; +} + /** * Rewrite relative volume paths in a compose file to use absolute host paths. * This is necessary when Dockhand runs inside Docker with a mounted data volume. diff --git a/src/lib/server/notifications.ts b/src/lib/server/notifications.ts index 734b7d5..79b43ee 100644 --- a/src/lib/server/notifications.ts +++ b/src/lib/server/notifications.ts @@ -29,8 +29,14 @@ export interface NotificationPayload { environmentName?: string; } +// Result type for functions that can return detailed errors +export interface NotificationResult { + success: boolean; + error?: string; +} + // Send notification via SMTP -async function sendSmtpNotification(config: SmtpConfig, payload: NotificationPayload): Promise { +async function sendSmtpNotification(config: SmtpConfig, payload: NotificationPayload): Promise { try { const transporter = nodemailer.createTransport({ host: config.host, @@ -67,41 +73,43 @@ async function sendSmtpNotification(config: SmtpConfig, payload: NotificationPay html }); - return true; + return { success: true }; } catch (error) { const errorMsg = error instanceof Error ? error.message : String(error); - console.error('[Notifications] SMTP send failed:', errorMsg); - return false; + return { success: false, error: `SMTP error: ${errorMsg}` }; } } // Parse Apprise URL and send notification -async function sendAppriseNotification(config: AppriseConfig, payload: NotificationPayload): Promise { - let success = true; +async function sendAppriseNotification(config: AppriseConfig, payload: NotificationPayload): Promise { + const errors: string[] = []; for (const url of config.urls) { try { - const sent = await sendToAppriseUrl(url, payload); - if (!sent) success = false; + const result = await sendToAppriseUrl(url, payload); + if (!result.success && result.error) { + errors.push(result.error); + } } catch (error) { const errorMsg = error instanceof Error ? error.message : String(error); - console.error(`[Notifications] Failed to send to ${url}:`, errorMsg); - success = false; + errors.push(`Failed to send: ${errorMsg}`); } } - return success; + if (errors.length > 0) { + return { success: false, error: errors.join('; ') }; + } + return { success: true }; } // Send to a single Apprise URL -async function sendToAppriseUrl(url: string, payload: NotificationPayload): Promise { +async function sendToAppriseUrl(url: string, payload: NotificationPayload): Promise { try { // Extract protocol from Apprise URL format (protocol://...) // Note: Can't use new URL() because custom schemes like 'tgram://' are not valid URLs const protocolMatch = url.match(/^([a-z]+):\/\//i); if (!protocolMatch) { - console.error('[Notifications] Invalid Apprise URL format - missing protocol:', url); - return false; + return { success: false, error: 'Invalid Apprise URL format - missing protocol' }; } const protocol = protocolMatch[1].toLowerCase(); @@ -127,42 +135,48 @@ async function sendToAppriseUrl(url: string, payload: NotificationPayload): Prom case 'jsons': return await sendGenericWebhook(url, payload); default: - console.warn(`[Notifications] Unsupported Apprise protocol: ${protocol}`); - return false; + return { success: false, error: `Unsupported Apprise protocol: ${protocol}` }; } } catch (error) { const errorMsg = error instanceof Error ? error.message : String(error); - console.error('[Notifications] Failed to parse Apprise URL:', errorMsg); - return false; + return { success: false, error: `Failed to parse Apprise URL: ${errorMsg}` }; } } // Discord webhook -async function sendDiscord(appriseUrl: string, payload: NotificationPayload): Promise { +async function sendDiscord(appriseUrl: string, payload: NotificationPayload): Promise { // discord://webhook_id/webhook_token or discords://... const url = appriseUrl.replace(/^discords?:\/\//, 'https://discord.com/api/webhooks/'); const titleWithEnv = payload.environmentName ? `${payload.title} [${payload.environmentName}]` : payload.title; - const response = await fetch(url, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - embeds: [{ - title: titleWithEnv, - description: payload.message, - color: payload.type === 'error' ? 0xff0000 : payload.type === 'warning' ? 0xffaa00 : payload.type === 'success' ? 0x00ff00 : 0x0099ff, - ...(payload.environmentName && { - footer: { text: `Environment: ${payload.environmentName}` } - }) - }] - }) - }); + try { + const response = await fetch(url, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + embeds: [{ + title: titleWithEnv, + description: payload.message, + color: payload.type === 'error' ? 0xff0000 : payload.type === 'warning' ? 0xffaa00 : payload.type === 'success' ? 0x00ff00 : 0x0099ff, + ...(payload.environmentName && { + footer: { text: `Environment: ${payload.environmentName}` } + }) + }] + }) + }); - return response.ok; + if (!response.ok) { + const text = await response.text().catch(() => ''); + return { success: false, error: `Discord error ${response.status}: ${text || response.statusText}` }; + } + return { success: true }; + } catch (error) { + return { success: false, error: `Discord connection failed: ${error instanceof Error ? error.message : String(error)}` }; + } } // Slack webhook -async function sendSlack(appriseUrl: string, payload: NotificationPayload): Promise { +async function sendSlack(appriseUrl: string, payload: NotificationPayload): Promise { // slack://token_a/token_b/token_c or webhook URL let url: string; if (appriseUrl.includes('hooks.slack.com')) { @@ -173,24 +187,32 @@ async function sendSlack(appriseUrl: string, payload: NotificationPayload): Prom } const envTag = payload.environmentName ? ` \`${payload.environmentName}\`` : ''; - const response = await fetch(url, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - text: `*${payload.title}*${envTag}\n${payload.message}` - }) - }); - return response.ok; + try { + const response = await fetch(url, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + text: `*${payload.title}*${envTag}\n${payload.message}` + }) + }); + + if (!response.ok) { + const text = await response.text().catch(() => ''); + return { success: false, error: `Slack error ${response.status}: ${text || response.statusText}` }; + } + return { success: true }; + } catch (error) { + return { success: false, error: `Slack connection failed: ${error instanceof Error ? error.message : String(error)}` }; + } } // Telegram -async function sendTelegram(appriseUrl: string, payload: NotificationPayload): Promise { +async function sendTelegram(appriseUrl: string, payload: NotificationPayload): Promise { // tgram://bot_token/chat_id const match = appriseUrl.match(/^tgram:\/\/([^/]+)\/(.+)/); if (!match) { - console.error('[Notifications] Invalid Telegram URL format. Expected: tgram://bot_token/chat_id'); - return false; + return { success: false, error: 'Invalid Telegram URL format. Expected: tgram://bot_token/chat_id' }; } const [, botToken, chatId] = match; @@ -213,110 +235,162 @@ async function sendTelegram(appriseUrl: string, payload: NotificationPayload): P }); if (!response.ok) { - const errorData = await response.json().catch(() => ({})); - console.error('[Notifications] Telegram API error:', response.status, errorData); + const errorData = await response.json().catch(() => ({})) as { description?: string }; + const errorMsg = errorData.description || response.statusText; + return { success: false, error: `Telegram error ${response.status}: ${errorMsg}` }; } - - return response.ok; + return { success: true }; } catch (error) { - const errorMsg = error instanceof Error ? error.message : String(error); - console.error('[Notifications] Telegram send failed:', errorMsg); - return false; + return { success: false, error: `Telegram connection failed: ${error instanceof Error ? error.message : String(error)}` }; } } // Gotify -async function sendGotify(appriseUrl: string, payload: NotificationPayload): Promise { +async function sendGotify(appriseUrl: string, payload: NotificationPayload): Promise { // gotify://hostname/token or gotifys://hostname/token const match = appriseUrl.match(/^gotifys?:\/\/([^/]+)\/(.+)/); - if (!match) return false; + if (!match) { + return { success: false, error: 'Invalid Gotify URL format. Expected: gotify://hostname/token' }; + } const [, hostname, token] = match; const protocol = appriseUrl.startsWith('gotifys') ? 'https' : 'http'; const url = `${protocol}://${hostname}/message?token=${token}`; - const response = await fetch(url, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - title: payload.title, - message: payload.message, - priority: payload.type === 'error' ? 8 : payload.type === 'warning' ? 5 : 2 - }) - }); + try { + const response = await fetch(url, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + title: payload.title, + message: payload.message, + priority: payload.type === 'error' ? 8 : payload.type === 'warning' ? 5 : 2 + }) + }); - return response.ok; + if (!response.ok) { + const text = await response.text().catch(() => ''); + return { success: false, error: `Gotify error ${response.status}: ${text || response.statusText}` }; + } + return { success: true }; + } catch (error) { + return { success: false, error: `Gotify connection failed: ${error instanceof Error ? error.message : String(error)}` }; + } } // ntfy -async function sendNtfy(appriseUrl: string, payload: NotificationPayload): Promise { - // ntfy://topic or ntfys://hostname/topic - let url: string; +async function sendNtfy(appriseUrl: string, payload: NotificationPayload): Promise { + // Supported formats: + // ntfy://topic (public ntfy.sh) + // ntfy://host/topic (custom server, no auth) + // ntfy://user:pass@host/topic (custom server with auth) + // ntfys:// variants for HTTPS const isSecure = appriseUrl.startsWith('ntfys'); const path = appriseUrl.replace(/^ntfys?:\/\//, ''); - if (path.includes('/')) { - // Custom server + let url: string; + let auth: string | null = null; + + // Check for user:pass@host/topic format + const authMatch = path.match(/^([^:]+):([^@]+)@(.+)$/); + if (authMatch) { + const [, user, pass, hostAndTopic] = authMatch; + auth = Buffer.from(`${user}:${pass}`).toString('base64'); + url = `${isSecure ? 'https' : 'http'}://${hostAndTopic}`; + } else if (path.includes('/')) { + // Custom server without auth url = `${isSecure ? 'https' : 'http'}://${path}`; } else { // Default ntfy.sh url = `https://ntfy.sh/${path}`; } - const response = await fetch(url, { - method: 'POST', - headers: { - 'Title': payload.title, - 'Priority': payload.type === 'error' ? '5' : payload.type === 'warning' ? '4' : '3', - 'Tags': payload.type || 'info' - }, - body: payload.message - }); + const headers: Record = { + 'Title': payload.title, + 'Priority': payload.type === 'error' ? '5' : payload.type === 'warning' ? '4' : '3', + 'Tags': payload.type || 'info' + }; - return response.ok; + if (auth) { + headers['Authorization'] = `Basic ${auth}`; + } + + try { + const response = await fetch(url, { + method: 'POST', + headers, + body: payload.message + }); + + if (!response.ok) { + const text = await response.text().catch(() => ''); + return { success: false, error: `ntfy error ${response.status}: ${text || response.statusText}` }; + } + return { success: true }; + } catch (error) { + return { success: false, error: `ntfy connection failed: ${error instanceof Error ? error.message : String(error)}` }; + } } // Pushover -async function sendPushover(appriseUrl: string, payload: NotificationPayload): Promise { +async function sendPushover(appriseUrl: string, payload: NotificationPayload): Promise { // pushover://user_key/api_token const match = appriseUrl.match(/^pushover:\/\/([^/]+)\/(.+)/); - if (!match) return false; + if (!match) { + return { success: false, error: 'Invalid Pushover URL format. Expected: pushover://user_key/api_token' }; + } const [, userKey, apiToken] = match; const url = 'https://api.pushover.net/1/messages.json'; - const response = await fetch(url, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - token: apiToken, - user: userKey, - title: payload.title, - message: payload.message, - priority: payload.type === 'error' ? 1 : 0 - }) - }); + try { + const response = await fetch(url, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + token: apiToken, + user: userKey, + title: payload.title, + message: payload.message, + priority: payload.type === 'error' ? 1 : 0 + }) + }); - return response.ok; + if (!response.ok) { + const text = await response.text().catch(() => ''); + return { success: false, error: `Pushover error ${response.status}: ${text || response.statusText}` }; + } + return { success: true }; + } catch (error) { + return { success: false, error: `Pushover connection failed: ${error instanceof Error ? error.message : String(error)}` }; + } } // Generic JSON webhook -async function sendGenericWebhook(appriseUrl: string, payload: NotificationPayload): Promise { +async function sendGenericWebhook(appriseUrl: string, payload: NotificationPayload): Promise { // json://hostname/path or jsons://hostname/path const url = appriseUrl.replace(/^jsons?:\/\//, appriseUrl.startsWith('jsons') ? 'https://' : 'http://'); - const response = await fetch(url, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - title: payload.title, - message: payload.message, - type: payload.type || 'info', - timestamp: new Date().toISOString() - }) - }); + try { + const response = await fetch(url, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + title: payload.title, + message: payload.message, + type: payload.type || 'info', + timestamp: new Date().toISOString() + }) + }); - return response.ok; + if (!response.ok) { + const text = await response.text().catch(() => ''); + return { success: false, error: `Webhook error ${response.status}: ${text || response.statusText}` }; + } + return { success: true }; + } catch (error) { + return { success: false, error: `Webhook connection failed: ${error instanceof Error ? error.message : String(error)}` }; + } } // Send notification to all enabled channels @@ -325,15 +399,15 @@ export async function sendNotification(payload: NotificationPayload): Promise<{ const results: { name: string; success: boolean }[] = []; for (const setting of settings) { - let success = false; + let result: NotificationResult = { success: false }; if (setting.type === 'smtp') { - success = await sendSmtpNotification(setting.config as SmtpConfig, payload); + result = await sendSmtpNotification(setting.config as SmtpConfig, payload); } else if (setting.type === 'apprise') { - success = await sendAppriseNotification(setting.config as AppriseConfig, payload); + result = await sendAppriseNotification(setting.config as AppriseConfig, payload); } - results.push({ name: setting.name, success }); + results.push({ name: setting.name, success: result.success }); } return { @@ -343,7 +417,7 @@ export async function sendNotification(payload: NotificationPayload): Promise<{ } // Test a specific notification setting -export async function testNotification(setting: NotificationSettingData): Promise { +export async function testNotification(setting: NotificationSettingData): Promise { const payload: NotificationPayload = { title: 'Dockhand Test Notification', message: 'This is a test notification from Dockhand. If you receive this, your notification settings are configured correctly.', @@ -356,7 +430,7 @@ export async function testNotification(setting: NotificationSettingData): Promis return await sendAppriseNotification(setting.config as AppriseConfig, payload); } - return false; + return { success: false, error: 'Unknown notification type' }; } // Map Docker action to notification event type @@ -432,13 +506,13 @@ export async function sendEnvironmentNotification( for (const notif of envNotifications) { try { - let success = false; + let result: NotificationResult = { success: false }; if (notif.channelType === 'smtp') { - success = await sendSmtpNotification(notif.config as SmtpConfig, enrichedPayload); + result = await sendSmtpNotification(notif.config as SmtpConfig, enrichedPayload); } else if (notif.channelType === 'apprise') { - success = await sendAppriseNotification(notif.config as AppriseConfig, enrichedPayload); + result = await sendAppriseNotification(notif.config as AppriseConfig, enrichedPayload); } - if (success) sent++; + if (result.success) sent++; else allSuccess = false; } catch (error) { const errorMsg = error instanceof Error ? error.message : String(error); @@ -505,13 +579,13 @@ export async function sendEventNotification( for (const channel of channels) { try { - let success = false; + let result: NotificationResult = { success: false }; if (channel.channel_type === 'smtp') { - success = await sendSmtpNotification(channel.config as SmtpConfig, enrichedPayload); + result = await sendSmtpNotification(channel.config as SmtpConfig, enrichedPayload); } else if (channel.channel_type === 'apprise') { - success = await sendAppriseNotification(channel.config as AppriseConfig, enrichedPayload); + result = await sendAppriseNotification(channel.config as AppriseConfig, enrichedPayload); } - if (success) sent++; + if (result.success) sent++; else allSuccess = false; } catch (error) { const errorMsg = error instanceof Error ? error.message : String(error); diff --git a/src/lib/server/scanner.ts b/src/lib/server/scanner.ts index a8c9e28..308519f 100644 --- a/src/lib/server/scanner.ts +++ b/src/lib/server/scanner.ts @@ -14,6 +14,9 @@ import { } from './docker'; import { getEnvironment, getEnvSetting, getSetting } from './db'; import { sendEventNotification } from './notifications'; +import { getHostDockerSocket, getHostDataDir, extractUidFromSocketPath } from './host-path'; +import { resolve } from 'node:path'; +import { mkdir, chown } from 'node:fs/promises'; export type ScannerType = 'none' | 'grype' | 'trivy' | 'both'; @@ -66,6 +69,10 @@ export async function sendVulnerabilityNotifications( const GRYPE_VOLUME_NAME = 'dockhand-grype-db'; const TRIVY_VOLUME_NAME = 'dockhand-trivy-db'; +// Scanner cache directory for rootless Docker (bind mounts instead of volumes) +const DATA_DIR = process.env.DATA_DIR || '/app/data'; +const SCANNER_CACHE_DIR = 'scanner-cache'; + // Track running scanner instances to detect concurrent scans const runningScanners = new Map(); // key: "grype" or "trivy", value: count @@ -381,6 +388,43 @@ async function ensureVolume(volumeName: string, envId?: number): Promise { } } +/** + * Ensure scanner cache directory exists with correct ownership for rootless Docker. + * Creates the directory in Dockhand's data volume and chowns it to the target UID. + * + * This is needed because Docker volumes are always created with root ownership, + * but rootless Docker scanners run as a non-root user (e.g., UID 1000). + * By using a bind mount from Dockhand's data directory (which Dockhand can chown + * since it runs as root), the scanner can write to its cache. + * + * @param scannerType - 'grype' or 'trivy' + * @param uid - Target UID for ownership (e.g., '1000') + * @returns The HOST path to the cache directory (for bind mounting into scanner) + */ +async function ensureScannerCacheDir( + scannerType: 'grype' | 'trivy', + uid: string +): Promise { + const containerPath = resolve(DATA_DIR, SCANNER_CACHE_DIR, scannerType); + + // Create directory if needed (recursive) + await mkdir(containerPath, { recursive: true }); + + // Chown to the target UID so scanner can write + const uidNum = parseInt(uid, 10); + await chown(containerPath, uidNum, uidNum); + console.log(`[Scanner] Set ownership of ${containerPath} to ${uid}:${uid}`); + + // Return the HOST path for bind mounting + const hostDataDir = getHostDataDir(); + if (hostDataDir) { + return `${hostDataDir}/${SCANNER_CACHE_DIR}/${scannerType}`; + } + + // Fallback: not running in Docker, use container path as-is + return containerPath; +} + // Run scanner in a fresh container with volume-cached database async function runScannerContainer( scannerImage: string, @@ -390,9 +434,7 @@ async function runScannerContainer( envId?: number, onOutput?: (line: string) => void ): Promise { - // Ensure database cache volume exists - const volumeName = scannerType === 'grype' ? GRYPE_VOLUME_NAME : TRIVY_VOLUME_NAME; - await ensureVolume(volumeName, envId); + console.log(`[Scanner] Starting ${scannerType} scan for image: ${imageName}, envId: ${envId ?? 'local'}`); // Check if another scanner of the same type is already running // If so, use a unique cache subdirectory to avoid lock conflicts @@ -407,11 +449,59 @@ async function runScannerContainer( const basePath = scannerType === 'grype' ? '/cache/grype' : '/cache/trivy'; const dbPath = scanId ? `${basePath}${scanId}` : basePath; + // Detect the host Docker socket path based on connection type + // For local socket environments, detect the actual host socket path (handles rootless Docker) + // For remote environments (hawser/direct), scanner runs remotely and uses standard path + const env = envId ? await getEnvironment(envId) : undefined; + const connectionType = env?.connectionType; + + let hostSocketPath: string; + let containerUser: string | undefined; + + if (!connectionType || connectionType === 'socket') { + // Local socket environment - detect host socket path (handles rootless Docker) + hostSocketPath = getHostDockerSocket(); + console.log(`[Scanner] Local socket scan - detected host Docker socket: ${hostSocketPath}`); + + // For user-specific Docker sockets, run scanner as that user + // e.g., /run/user/1000/docker.sock -> run as UID 1000 + const uid = extractUidFromSocketPath(hostSocketPath); + if (uid) { + containerUser = uid; + console.log(`[Scanner] Rootless Docker detected (UID ${containerUser})`); + } + } else { + // Remote environment (direct/hawser-standard/hawser-edge) + // Scanner runs on remote host, uses remote host's standard Docker socket + hostSocketPath = '/var/run/docker.sock'; + console.log(`[Scanner] Remote scan (${connectionType}) - using standard socket path: ${hostSocketPath}`); + } + + // Determine cache storage strategy based on environment + // For rootless Docker: use bind mount from data directory with correct ownership + // For standard Docker: use named volume (root-owned is fine when running as root) + let cacheBind: string; + const volumeName = scannerType === 'grype' ? GRYPE_VOLUME_NAME : TRIVY_VOLUME_NAME; + + if (containerUser) { + // Rootless Docker: use bind mount from data directory with correct ownership + const hostCachePath = await ensureScannerCacheDir(scannerType, containerUser); + cacheBind = `${hostCachePath}:${basePath}`; + console.log(`[Scanner] Rootless mode - using bind mount: ${cacheBind}`); + } else { + // Standard Docker: use named volume (root-owned is fine when running as root) + await ensureVolume(volumeName, envId); + cacheBind = `${volumeName}:${basePath}`; + console.log(`[Scanner] Standard mode - using volume: ${volumeName}`); + } + const binds = [ - '/var/run/docker.sock:/var/run/docker.sock:ro', - `${volumeName}:${basePath}` // Always mount to base path + `${hostSocketPath}:/var/run/docker.sock:ro`, + cacheBind ]; + console.log(`[Scanner] Container bind mounts: ${JSON.stringify(binds)}`); + // Environment variables to ensure scanners use the correct cache path // For concurrent scans, use a unique subdirectory const envVars = scannerType === 'grype' @@ -421,7 +511,11 @@ async function runScannerContainer( if (scanId) { console.log(`[Scanner] Concurrent scan detected - using unique cache dir: ${dbPath}`); } - console.log(`[Scanner] Running ${scannerType} with volume ${volumeName} mounted at ${basePath}`); + console.log(`[Scanner] Running ${scannerType} with cache mounted at ${basePath}`); + console.log(`[Scanner] Container command: ${cmd.join(' ')}`); + if (containerUser) { + console.log(`[Scanner] Running scanner container as UID ${containerUser} to match socket owner`); + } try { // Run the scanner container @@ -431,6 +525,7 @@ async function runScannerContainer( binds, env: envVars, name: `dockhand-${scannerType}-${Date.now()}`, + user: containerUser, envId, onStderr: (data) => { // Stream stderr lines for real-time progress output @@ -443,6 +538,15 @@ async function runScannerContainer( } }); + console.log(`[Scanner] ${scannerType} container completed, output length: ${output.length}`); + if (output.length === 0) { + console.error(`[Scanner] WARNING: Empty output from ${scannerType} container`); + console.error(`[Scanner] This may indicate the scanner couldn't access Docker socket`); + console.error(`[Scanner] Host socket path used: ${hostSocketPath}`); + } else if (output.length < 100) { + console.log(`[Scanner] ${scannerType} output preview: ${output}`); + } + return output; } finally { // Decrement running counter diff --git a/src/lib/server/scheduler/index.ts b/src/lib/server/scheduler/index.ts index 8823205..c6d66a0 100644 --- a/src/lib/server/scheduler/index.ts +++ b/src/lib/server/scheduler/index.ts @@ -24,6 +24,8 @@ import { getEnvironments, getEnvUpdateCheckSettings, getAllEnvUpdateCheckSettings, + getImagePruneSettings, + getAllImagePruneSettings, getEnvironment, getEnvironmentTimezone, getDefaultTimezone @@ -38,6 +40,7 @@ import { import { runContainerUpdate } from './tasks/container-update'; import { runGitStackSync } from './tasks/git-stack-sync'; import { runEnvUpdateCheckJob } from './tasks/env-update-check'; +import { runImagePrune } from './tasks/image-prune'; import { runScheduleCleanupJob, runEventCleanupJob, @@ -247,7 +250,26 @@ export async function refreshAllSchedules(): Promise { console.error('[Scheduler] Error loading env update check schedules:', errorMsg); } - console.log(`[Scheduler] Registered ${containerCount} container schedules, ${gitStackCount} git stack schedules, ${envUpdateCheckCount} env update check schedules`); + // Register image prune schedules + let imagePruneCount = 0; + try { + const pruneConfigs = await getAllImagePruneSettings(); + for (const { envId, settings } of pruneConfigs) { + if (settings.enabled && settings.cronExpression) { + const registered = await registerSchedule( + envId, + 'image_prune', + envId + ); + if (registered) imagePruneCount++; + } + } + } catch (error) { + const errorMsg = error instanceof Error ? error.message : String(error); + console.error('[Scheduler] Error loading image prune schedules:', errorMsg); + } + + console.log(`[Scheduler] Registered ${containerCount} container schedules, ${gitStackCount} git stack schedules, ${envUpdateCheckCount} env update check schedules, ${imagePruneCount} image prune schedules`); } /** @@ -256,7 +278,7 @@ export async function refreshAllSchedules(): Promise { */ export async function registerSchedule( scheduleId: number, - type: 'container_update' | 'git_stack_sync' | 'env_update_check', + type: 'container_update' | 'git_stack_sync' | 'env_update_check' | 'image_prune', environmentId: number | null ): Promise { const key = `${type}-${scheduleId}`; @@ -290,6 +312,14 @@ export async function registerSchedule( cronExpression = config.cron; entityName = `Update: ${env.name}`; enabled = config.enabled; + } else if (type === 'image_prune') { + const config = await getImagePruneSettings(scheduleId); + if (!config) return false; + const env = await getEnvironment(scheduleId); + if (!env) return false; + cronExpression = config.cronExpression; + entityName = `Prune: ${env.name}`; + enabled = config.enabled; } // Don't create job if disabled or no cron expression @@ -315,6 +345,10 @@ export async function registerSchedule( const config = await getEnvUpdateCheckSettings(scheduleId); if (!config || !config.enabled) return; await runEnvUpdateCheckJob(scheduleId, 'cron'); + } else if (type === 'image_prune') { + const config = await getImagePruneSettings(scheduleId); + if (!config || !config.enabled) return; + await runImagePrune(scheduleId, 'cron'); } }); @@ -334,7 +368,7 @@ export async function registerSchedule( */ export function unregisterSchedule( scheduleId: number, - type: 'container_update' | 'git_stack_sync' | 'env_update_check' + type: 'container_update' | 'git_stack_sync' | 'env_update_check' | 'image_prune' ): void { const key = `${type}-${scheduleId}`; const job = activeJobs.get(key); @@ -407,6 +441,22 @@ export async function refreshSchedulesForEnvironment(environmentId: number): Pro console.error('[Scheduler] Error refreshing env update check schedule:', errorMsg); } + // Re-register image prune schedule for this environment + try { + const config = await getImagePruneSettings(environmentId); + if (config && config.enabled && config.cronExpression) { + const registered = await registerSchedule( + environmentId, + 'image_prune', + environmentId + ); + if (registered) refreshedCount++; + } + } catch (error) { + const errorMsg = error instanceof Error ? error.message : String(error); + console.error('[Scheduler] Error refreshing image prune schedule:', errorMsg); + } + console.log(`[Scheduler] Refreshed ${refreshedCount} schedules for environment ${environmentId}`); } @@ -546,6 +596,30 @@ export async function triggerEnvUpdateCheck(environmentId: number): Promise<{ su } } +/** + * Manually trigger an image prune for an environment. + */ +export async function triggerImagePrune(environmentId: number): Promise<{ success: boolean; executionId?: number; error?: string }> { + try { + const config = await getImagePruneSettings(environmentId); + if (!config) { + return { success: false, error: 'Image prune settings not found for this environment' }; + } + + const env = await getEnvironment(environmentId); + if (!env) { + return { success: false, error: 'Environment not found' }; + } + + // Run in background + runImagePrune(environmentId, 'manual'); + + return { success: true }; + } catch (error: any) { + return { success: false, error: error.message }; + } +} + /** * Manually trigger a system job (schedule cleanup, event cleanup, etc.). */ diff --git a/src/lib/server/scheduler/tasks/container-update.ts b/src/lib/server/scheduler/tasks/container-update.ts index b8f02a9..dc8da9f 100644 --- a/src/lib/server/scheduler/tasks/container-update.ts +++ b/src/lib/server/scheduler/tasks/container-update.ts @@ -36,7 +36,8 @@ import { getImageIdByTag, removeTempImage, tagImage, - connectContainerToNetwork + connectContainerToNetwork, + extractContainerOptions } from '../../docker'; import { getScannerSettings, scanImage, type ScanResult, type VulnerabilitySeverity } from '../../scanner'; import { sendEventNotification } from '../../notifications'; @@ -589,8 +590,8 @@ export async function recreateContainer( // Get full container config const inspectData = await inspectContainer(container.id, envId) as any; const wasRunning = inspectData.State.Running; - const config = inspectData.Config; const hostConfig = inspectData.HostConfig; + const config = inspectData.Config; log?.(`Recreating container: ${containerName} (was running: ${wasRunning})`); log?.(`Preserving all container settings...`); @@ -605,96 +606,19 @@ export async function recreateContainer( log?.('Removing old container...'); await removeContainer(container.id, true, envId); - // ============================================================================= - // Extract ALL settings from the original container - // ============================================================================= + // Extract ALL settings using the shared helper function + const containerOptions = extractContainerOptions(inspectData); - // Port bindings - preserve all host port mappings including HostIp - const ports: { [key: string]: { HostIp?: string; HostPort: string } } = {}; - if (hostConfig.PortBindings) { - for (const [containerPort, bindings] of Object.entries(hostConfig.PortBindings)) { - if (bindings && (bindings as any[]).length > 0) { - const binding = (bindings as any[])[0]; - ports[containerPort] = { - HostPort: binding.HostPort || '' - }; - // Preserve HostIp if specified (e.g., '192.168.0.250:80:80' in compose) - if (binding.HostIp) { - ports[containerPort].HostIp = binding.HostIp; - } - } - } - } - - // Volume bindings - preserve ALL volumes including anonymous volumes - // hostConfig.Binds contains named volumes and bind mounts in "source:dest" format - // inspectData.Mounts contains ALL mounts including anonymous volumes with their generated names - const volumeBinds: string[] = []; - const mountedPaths = new Set(); - - // First, add all entries from hostConfig.Binds (named volumes and bind mounts) - if (hostConfig.Binds && Array.isArray(hostConfig.Binds)) { - for (const bind of hostConfig.Binds) { - volumeBinds.push(bind); - // Track the destination path to avoid duplicates - const parts = bind.split(':'); - if (parts.length >= 2) { - mountedPaths.add(parts[1].split(':')[0]); // Handle "src:dest:ro" format - } - } - } - - // Then, add anonymous volumes from Mounts that aren't already in Binds - // These have Type: "volume" and a generated Name (hash), but no entry in Binds - const mounts = inspectData.Mounts || []; - for (const mount of mounts) { - if (mount.Type === 'volume' && mount.Name && mount.Destination) { - // Skip if this destination is already covered by Binds - if (!mountedPaths.has(mount.Destination)) { - // Format: "volumeName:destination" or "volumeName:destination:ro" - const bindStr = mount.RW === false - ? `${mount.Name}:${mount.Destination}:ro` - : `${mount.Name}:${mount.Destination}`; - volumeBinds.push(bindStr); - log?.(`Preserving anonymous volume: ${mount.Name} -> ${mount.Destination}`); - } - } - } - - // Healthcheck configuration - let healthcheck: any = undefined; - if (config.Healthcheck && config.Healthcheck.Test && config.Healthcheck.Test.length > 0) { - // Skip if healthcheck is disabled (NONE) - if (config.Healthcheck.Test[0] !== 'NONE') { - healthcheck = { - test: config.Healthcheck.Test, - interval: config.Healthcheck.Interval, - timeout: config.Healthcheck.Timeout, - retries: config.Healthcheck.Retries, - startPeriod: config.Healthcheck.StartPeriod - }; - } - } - - // Device mappings - const devices = (hostConfig.Devices || []).map((d: any) => ({ - hostPath: d.PathOnHost || '', - containerPath: d.PathInContainer || '', - permissions: d.CgroupPermissions || 'rwm' - })).filter((d: any) => d.hostPath && d.containerPath); - - // Ulimits - const ulimits = (hostConfig.Ulimits || []).map((u: any) => ({ - name: u.Name, - soft: u.Soft, - hard: u.Hard - })); - - // Extract network connections with aliases and static IPs + // Extract additional networks for reconnection (not handled by extractContainerOptions) + // The helper extracts primary network settings, but we need to handle secondary networks separately const networkSettings = inspectData.NetworkSettings?.Networks || {}; const primaryNetwork = hostConfig.NetworkMode || 'bridge'; + const shortContainerId = container.id.substring(0, 12); + + // Extract compose labels for alias reconstruction + const composeProject = config.Labels?.['com.docker.compose.project']; + const composeService = config.Labels?.['com.docker.compose.service']; - // Build network info for reconnection (including aliases, IPs, and gateway priority) interface NetworkInfo { name: string; aliases: string[]; @@ -703,68 +627,46 @@ export async function recreateContainer( gwPriority: number | undefined; } - // Extract primary network aliases, static IP, and gateway priority (for createContainer) - let primaryNetworkAliases: string[] | undefined; - let primaryNetworkIpv4: string | undefined; - let primaryNetworkIpv6: string | undefined; - let primaryNetworkMacAddress: string | undefined; - let primaryNetworkGwPriority: number | undefined; - const additionalNetworks: NetworkInfo[] = []; + for (const [netName, netConfig] of Object.entries(networkSettings)) { const netConf = netConfig as any; - - // Check if this is the primary network const isPrimary = netName === primaryNetwork || (primaryNetwork === 'bridge' && (netName === 'bridge' || netName === 'default')); if (isPrimary) { - // Extract primary network's aliases and static IP - // Filter out auto-generated aliases (container name and ID prefix) - // Note: Docker Compose stores aliases in both Aliases and DNSNames, - // but after container recreation Aliases may be null while DNSNames has the values - const allAliases = (netConf.Aliases?.length > 0 ? netConf.Aliases : netConf.DNSNames) || []; - const shortContainerId = container.id.substring(0, 12); - primaryNetworkAliases = allAliases.filter((a: string) => - a !== containerName && - a !== container.id && - a !== shortContainerId - ); - if (!primaryNetworkAliases || primaryNetworkAliases.length === 0) { - primaryNetworkAliases = undefined; + // Log primary network info + if (containerOptions.networkAliases?.length) { + log?.(`Primary network aliases: ${containerOptions.networkAliases.join(', ')}`); } - - // Extract static IP from IPAMConfig (user-configured) - don't use auto-assigned IPAddress - primaryNetworkIpv4 = netConf.IPAMConfig?.IPv4Address || undefined; - primaryNetworkIpv6 = netConf.IPAMConfig?.IPv6Address || undefined; - - // Extract MAC address (only if explicitly set, not auto-generated) - // Auto-generated MACs start with 02:42, so we preserve all MACs - primaryNetworkMacAddress = netConf.MacAddress || undefined; - - // Extract gateway priority (Docker Engine 28+) - // GwPriority determines which network provides the default gateway - primaryNetworkGwPriority = netConf.GwPriority !== undefined && netConf.GwPriority !== 0 - ? netConf.GwPriority : undefined; - - if (primaryNetworkAliases?.length) { - log?.(`Primary network aliases: ${primaryNetworkAliases.join(', ')}`); + if (containerOptions.networkIpv4Address) { + log?.(`Primary network static IPv4: ${containerOptions.networkIpv4Address}`); } - if (primaryNetworkIpv4) { - log?.(`Primary network static IPv4: ${primaryNetworkIpv4}`); + if (containerOptions.macAddress) { + log?.(`Primary network MAC address: ${containerOptions.macAddress}`); } - if (primaryNetworkMacAddress) { - log?.(`Primary network MAC address: ${primaryNetworkMacAddress}`); - } - if (primaryNetworkGwPriority !== undefined) { - log?.(`Primary network gateway priority: ${primaryNetworkGwPriority}`); + if (containerOptions.networkGwPriority !== undefined) { + log?.(`Primary network gateway priority: ${containerOptions.networkGwPriority}`); } } else { // Secondary network - add to reconnection list - // Use DNSNames as fallback for aliases (see comment above for primary network) + const secondaryAliases = ((netConf.Aliases?.length > 0 ? netConf.Aliases : netConf.DNSNames) || []) + .filter((a: string) => a !== container.id && a !== shortContainerId); + + // For compose containers, ensure service name and project-service aliases on secondary networks + if (composeProject && composeService) { + if (!secondaryAliases.includes(composeService)) { + secondaryAliases.push(composeService); + } + const projectService = `${composeProject}-${composeService}`; + if (!secondaryAliases.includes(projectService)) { + secondaryAliases.push(projectService); + } + } + additionalNetworks.push({ name: netName, - aliases: (netConf.Aliases?.length > 0 ? netConf.Aliases : netConf.DNSNames) || [], + aliases: secondaryAliases, ipv4Address: netConf.IPAMConfig?.IPv4Address || undefined, ipv6Address: netConf.IPAMConfig?.IPv6Address || undefined, gwPriority: netConf.GwPriority !== undefined && netConf.GwPriority !== 0 @@ -778,165 +680,21 @@ export async function recreateContainer( } // Log extra hosts if present - if (hostConfig.ExtraHosts?.length > 0) { - log?.(`Extra hosts: ${hostConfig.ExtraHosts.join(', ')}`); + if (containerOptions.extraHosts?.length) { + log?.(`Extra hosts: ${containerOptions.extraHosts.join(', ')}`); } // Log device requests if present (GPU, etc.) - if (hostConfig.DeviceRequests?.length > 0) { - for (const dr of hostConfig.DeviceRequests) { - const caps = dr.Capabilities?.flat().join(',') || 'none'; - log?.(`Device request: driver=${dr.Driver || 'default'}, count=${dr.Count}, capabilities=[${caps}]`); + if (containerOptions.deviceRequests?.length) { + for (const dr of containerOptions.deviceRequests) { + const caps = dr.capabilities?.flat().join(',') || 'none'; + log?.(`Device request: driver=${dr.driver || 'default'}, count=${dr.count}, capabilities=[${caps}]`); } } // Create new container with ALL preserved settings log?.('Creating new container with preserved settings...'); - const newContainer = await createContainer({ - name: containerName, - image: config.Image, - - // Command and entrypoint - cmd: config.Cmd || undefined, - entrypoint: config.Entrypoint || undefined, - workingDir: config.WorkingDir || undefined, - - // Environment and labels - env: config.Env || [], - labels: config.Labels || {}, - - // Port mappings - ports: Object.keys(ports).length > 0 ? ports : undefined, - - // Volume bindings (includes both named and anonymous volumes) - volumeBinds: volumeBinds.length > 0 ? volumeBinds : undefined, - - // Restart policy - restartPolicy: hostConfig.RestartPolicy?.Name || 'no', - restartMaxRetries: hostConfig.RestartPolicy?.MaximumRetryCount, - - // Network mode and network-specific settings - networkMode: hostConfig.NetworkMode || undefined, - networkAliases: primaryNetworkAliases, - networkIpv4Address: primaryNetworkIpv4, - networkIpv6Address: primaryNetworkIpv6, - networkGwPriority: primaryNetworkGwPriority, - - // User and hostname - user: config.User || undefined, - hostname: config.Hostname || undefined, - - // Privileged mode - privileged: hostConfig.Privileged || undefined, - - // Healthcheck - healthcheck, - - // Terminal settings - tty: config.Tty || undefined, - stdinOpen: config.OpenStdin || undefined, - - // Memory limits - memory: hostConfig.Memory || undefined, - memoryReservation: hostConfig.MemoryReservation || undefined, - memorySwap: hostConfig.MemorySwap || undefined, - - // CPU limits - cpuShares: hostConfig.CpuShares || undefined, - cpuQuota: hostConfig.CpuQuota || undefined, - cpuPeriod: hostConfig.CpuPeriod || undefined, - nanoCpus: hostConfig.NanoCpus || undefined, - - // Capabilities - capAdd: hostConfig.CapAdd?.length > 0 ? hostConfig.CapAdd : undefined, - capDrop: hostConfig.CapDrop?.length > 0 ? hostConfig.CapDrop : undefined, - - // Devices - devices: devices.length > 0 ? devices : undefined, - - // DNS settings - dns: hostConfig.Dns?.length > 0 ? hostConfig.Dns : undefined, - dnsSearch: hostConfig.DnsSearch?.length > 0 ? hostConfig.DnsSearch : undefined, - dnsOptions: hostConfig.DnsOptions?.length > 0 ? hostConfig.DnsOptions : undefined, - - // Security options - securityOpt: hostConfig.SecurityOpt?.length > 0 ? hostConfig.SecurityOpt : undefined, - - // Ulimits - ulimits: ulimits.length > 0 ? ulimits : undefined, - - // Process and memory settings - oomKillDisable: hostConfig.OomKillDisable || undefined, - pidsLimit: hostConfig.PidsLimit || undefined, - shmSize: hostConfig.ShmSize || undefined, - - // Tmpfs mounts - tmpfs: hostConfig.Tmpfs && Object.keys(hostConfig.Tmpfs).length > 0 ? hostConfig.Tmpfs : undefined, - - // Sysctls - sysctls: hostConfig.Sysctls && Object.keys(hostConfig.Sysctls).length > 0 ? hostConfig.Sysctls : undefined, - - // Logging configuration - logDriver: hostConfig.LogConfig?.Type || undefined, - logOptions: hostConfig.LogConfig?.Config && Object.keys(hostConfig.LogConfig.Config).length > 0 - ? hostConfig.LogConfig.Config : undefined, - - // Namespace settings - ipcMode: hostConfig.IpcMode || undefined, - pidMode: hostConfig.PidMode || undefined, - utsMode: hostConfig.UTSMode || undefined, - - // Cgroup parent - cgroupParent: hostConfig.CgroupParent || undefined, - - // Stop signal and timeout - stopSignal: config.StopSignal || undefined, - stopTimeout: config.StopTimeout || undefined, - - // Init process - init: hostConfig.Init === true ? true : undefined, - - // MAC address (from primary network settings) - macAddress: primaryNetworkMacAddress, - - // Extra hosts (/etc/hosts entries) - extraHosts: hostConfig.ExtraHosts?.length > 0 ? hostConfig.ExtraHosts : undefined, - - // Device requests (GPU access, etc.) - deviceRequests: hostConfig.DeviceRequests?.length > 0 - ? hostConfig.DeviceRequests.map((dr: any) => ({ - driver: dr.Driver || undefined, - count: dr.Count, - deviceIDs: dr.DeviceIDs?.length > 0 ? dr.DeviceIDs : undefined, - capabilities: dr.Capabilities?.length > 0 ? dr.Capabilities : undefined, - options: dr.Options && Object.keys(dr.Options).length > 0 ? dr.Options : undefined - })) - : undefined, - - // Container runtime (critical for GPU containers using nvidia runtime) - runtime: hostConfig.Runtime && hostConfig.Runtime !== 'runc' ? hostConfig.Runtime : undefined, - - // Read-only root filesystem (security hardening) - readonlyRootfs: hostConfig.ReadonlyRootfs === true ? true : undefined, - - // CPU pinning - cpusetCpus: hostConfig.CpusetCpus || undefined, - - // NUMA memory nodes - cpusetMems: hostConfig.CpusetMems || undefined, - - // Additional groups - groupAdd: hostConfig.GroupAdd?.length > 0 ? hostConfig.GroupAdd : undefined, - - // Memory swappiness (0-100) - memorySwappiness: hostConfig.MemorySwappiness !== null ? hostConfig.MemorySwappiness : undefined, - - // User namespace mode - usernsMode: hostConfig.UsernsMode || undefined, - - // Domain name - domainname: config.Domainname || undefined - }, envId); + const newContainer = await createContainer(containerOptions, envId); // Reconnect to additional networks with aliases, static IPs, and gateway priority (before starting) if (additionalNetworks.length > 0) { diff --git a/src/lib/server/scheduler/tasks/image-prune.ts b/src/lib/server/scheduler/tasks/image-prune.ts new file mode 100644 index 0000000..a411c71 --- /dev/null +++ b/src/lib/server/scheduler/tasks/image-prune.ts @@ -0,0 +1,138 @@ +/** + * Image Prune Task + * + * Handles scheduled pruning of unused Docker images per environment. + */ + +import type { ScheduleTrigger, ImagePruneSettings } from '../../db'; +import { + getImagePruneSettings, + setImagePruneSettings, + getEnvironment, + createScheduleExecution, + updateScheduleExecution, + appendScheduleExecutionLog +} from '../../db'; +import { pruneImages } from '../../docker'; +import { sendEventNotification } from '../../notifications'; + +/** + * System job ID for image prune (starts at 100 to avoid conflicts with other system jobs) + */ +export const SYSTEM_IMAGE_PRUNE_BASE_ID = 100; + +/** + * Execute image prune for an environment. + */ +export async function runImagePrune( + envId: number, + triggeredBy: ScheduleTrigger +): Promise { + const startTime = Date.now(); + + // Get environment info for logging + const env = await getEnvironment(envId); + if (!env) { + console.error(`[Image Prune] Environment ${envId} not found`); + return; + } + + // Get prune settings + const settings = await getImagePruneSettings(envId); + if (!settings) { + console.error(`[Image Prune] No settings found for environment ${envId}`); + return; + } + + // Create execution record + const execution = await createScheduleExecution({ + scheduleType: 'image_prune', + scheduleId: envId, + environmentId: envId, + entityName: `Image prune: ${env.name}`, + triggeredBy, + status: 'running' + }); + + await updateScheduleExecution(execution.id, { + startedAt: new Date().toISOString() + }); + + const log = async (message: string) => { + console.log(`[Image Prune] [${env.name}] ${message}`); + await appendScheduleExecutionLog(execution.id, `[${new Date().toISOString()}] ${message}`); + }; + + try { + const pruneMode = settings.pruneMode || 'dangling'; + const dangling = pruneMode === 'dangling'; + + await log(`Starting image prune (mode: ${pruneMode})`); + + // Execute prune + const result = await pruneImages(dangling, envId); + + // Extract space reclaimed and images removed from result + const spaceReclaimed = result?.SpaceReclaimed || 0; + const imagesRemoved = result?.ImagesDeleted?.length || 0; + + // Format space for human-readable output + const formatBytes = (bytes: number): string => { + if (bytes === 0) return '0 B'; + const k = 1024; + const sizes = ['B', 'KB', 'MB', 'GB', 'TB']; + const i = Math.floor(Math.log(bytes) / Math.log(k)); + return `${parseFloat((bytes / Math.pow(k, i)).toFixed(2))} ${sizes[i]}`; + }; + + await log(`Prune completed: ${imagesRemoved} images removed, ${formatBytes(spaceReclaimed)} reclaimed`); + + // Update settings with last prune info + const updatedSettings: ImagePruneSettings = { + ...settings, + lastPruned: new Date().toISOString(), + lastResult: { + spaceReclaimed, + imagesRemoved + } + }; + await setImagePruneSettings(envId, updatedSettings); + + // Update execution record + await updateScheduleExecution(execution.id, { + status: 'success', + completedAt: new Date().toISOString(), + duration: Date.now() - startTime, + details: { + pruneMode, + spaceReclaimed, + imagesRemoved, + deletedImages: result?.ImagesDeleted?.map((img: any) => img.Deleted || img.Untagged).filter(Boolean) + } + }); + + // Send success notification + await sendEventNotification('image_prune_success', { + title: 'Image prune completed', + message: `${imagesRemoved} unused images removed, ${formatBytes(spaceReclaimed)} disk space reclaimed`, + type: 'success' + }, envId); + + } catch (error: any) { + await log(`Error: ${error.message}`); + + await updateScheduleExecution(execution.id, { + status: 'failed', + completedAt: new Date().toISOString(), + duration: Date.now() - startTime, + errorMessage: error.message + }); + + // Send failure notification + await sendEventNotification('image_prune_failed', { + title: 'Image prune failed', + message: `Failed to prune images: ${error.message}`, + type: 'error' + }, envId); + } +} diff --git a/src/lib/server/stack-scanner.ts b/src/lib/server/stack-scanner.ts index 818d809..dd02d4d 100644 --- a/src/lib/server/stack-scanner.ts +++ b/src/lib/server/stack-scanner.ts @@ -7,6 +7,7 @@ import { readdirSync, existsSync, statSync } from 'node:fs'; import { join, basename, dirname, resolve } from 'node:path'; +import yaml from 'js-yaml'; import { getExternalStackPaths, getStackSources, upsertStackSource, type StackSourceType } from './db'; // Compose file patterns to detect (in order of priority - prefer new style first) @@ -67,23 +68,17 @@ async function isComposeFile(filePath: string): Promise { /** * Count the number of services defined in a compose file - * Uses simple regex to count top-level keys under 'services:' section + * Parses YAML to reliably count top-level keys under 'services:' section */ async function countServices(filePath: string): Promise { try { const file = Bun.file(filePath); const content = await file.text(); - - // Find the services section and count top-level keys - const servicesMatch = content.match(/^services:\s*\n((?:[ \t]+\S[^\n]*\n?)*)/m) || - content.match(/\nservices:\s*\n((?:[ \t]+\S[^\n]*\n?)*)/m); - - if (!servicesMatch) return 0; - - const servicesBlock = servicesMatch[1]; - // Count lines that start with exactly 2 spaces followed by a non-space (service names) - const serviceLines = servicesBlock.match(/^ [a-zA-Z0-9_-]+:/gm); - return serviceLines?.length || 0; + const doc = yaml.load(content) as Record | null; + if (doc?.services && typeof doc.services === 'object') { + return Object.keys(doc.services).length; + } + return 0; } catch { return 0; } diff --git a/src/lib/server/stacks.ts b/src/lib/server/stacks.ts index cba9ae7..77a1259 100644 --- a/src/lib/server/stacks.ts +++ b/src/lib/server/stacks.ts @@ -6,10 +6,9 @@ */ import { existsSync, mkdirSync, rmSync, readdirSync, cpSync, statSync, unlinkSync, renameSync, readFileSync, writeFileSync } from 'node:fs'; -import { join, resolve, dirname } from 'node:path'; +import { join, resolve, dirname, basename } from 'node:path'; import { getEnvironment, - getStackEnvVarsAsRecord, getSecretEnvVarsAsRecord, getNonSecretEnvVarsAsRecord, getStackEnvVars, @@ -95,7 +94,6 @@ export interface DeployStackOptions { name: string; compose: string; envId?: number | null; - envFileVars?: Record; sourceDir?: string; // Directory to copy all files from (for git stacks) forceRecreate?: boolean; composePath?: string; // Custom compose file path (for adopted/imported stacks) @@ -313,25 +311,6 @@ export async function findStackDir(stackName: string, envId?: number | null): Pr return null; } -/** - * List stacks that have compose files stored locally - */ -export function listManagedStacks(): string[] { - const stacksDir = getStacksDir(); - if (!existsSync(stacksDir)) { - return []; - } - - return readdirSync(stacksDir, { withFileTypes: true }) - .filter((dirent) => dirent.isDirectory()) - .filter((dirent) => { - // Check all valid compose filenames - const composeNames = ['compose.yaml', 'compose.yml', 'docker-compose.yml', 'docker-compose.yaml']; - return composeNames.some(name => existsSync(join(stacksDir, dirent.name, name))); - }) - .map((dirent) => dirent.name); -} - // ============================================================================= // COMPOSE FILE MANAGEMENT // ============================================================================= @@ -761,6 +740,8 @@ interface ComposeCommandOptions { composePath?: string; /** Full path to the env file (for --env-file flag, supports custom names) */ envPath?: string; + /** When true, write non-secret envVars to .env.dockhand override file (git stacks only) */ + useOverrideFile?: boolean; } /** @@ -785,7 +766,8 @@ async function executeLocalCompose( envId?: number | null, workingDir?: string, customComposePath?: string, - customEnvPath?: string + customEnvPath?: string, + useOverrideFile?: boolean ): Promise { const logPrefix = `[Stack:${stackName}]`; @@ -894,12 +876,29 @@ async function executeLocalCompose( const useStdin = finalComposeContent !== composeContent; const args = ['docker', 'compose', '-p', stackName, '-f', useStdin ? '-' : composeFile]; - // Add --env-file flag if env file exists - // This makes Docker Compose load the .env file automatically (like Portainer) - // Uses custom path if provided, otherwise defaults to .env in stack directory - const envFilePath = customEnvPath || join(stackDir, '.env'); - if (existsSync(envFilePath)) { - args.push('--env-file', envFilePath); + // Always auto-detect .env in compose directory + const defaultEnvPath = join(stackDir, '.env'); + if (existsSync(defaultEnvPath)) { + args.push('--env-file', defaultEnvPath); + } + + // Add custom env file if configured and different from auto-detected .env + if (customEnvPath && resolve(customEnvPath) !== resolve(defaultEnvPath) && existsSync(customEnvPath)) { + args.push('--env-file', customEnvPath); + } + + // For git stacks: write non-secret overrides to .env.dockhand and add as second --env-file + // Docker Compose applies env files in order, so later files override earlier ones. + // This lets the repo's .env provide defaults while our overrides take precedence. + // Secrets are still injected via shell env only (never written to disk). + // Only written when useOverrideFile is true (git stacks). Internal/adopted stacks + // already have their non-secrets in the .env file written by the UI. + if (useOverrideFile && envVars && Object.keys(envVars).length > 0) { + const overrideEnvPath = join(stackDir, '.env.dockhand'); + const header = '# Auto-generated by Dockhand. Do not edit - changes will be overwritten on next deploy.\n'; + const lines = Object.entries(envVars).map(([k, v]) => `${k}=${v}`); + await Bun.write(overrideEnvPath, header + lines.join('\n') + '\n'); + args.push('--env-file', overrideEnvPath); } if (useStdin) { @@ -1199,7 +1198,7 @@ async function executeComposeCommand( envVars?: Record, secretVars?: Record ): Promise { - const { stackName, envId, forceRecreate, removeVolumes, stackFiles, workingDir, composePath, envPath } = options; + const { stackName, envId, forceRecreate, removeVolumes, stackFiles, workingDir, composePath, envPath, useOverrideFile } = options; // Get environment configuration const env = envId ? await getEnvironment(envId) : null; @@ -1219,7 +1218,8 @@ async function executeComposeCommand( envId, workingDir, composePath, - envPath + envPath, + useOverrideFile ); } @@ -1263,7 +1263,8 @@ async function executeComposeCommand( envId, workingDir, composePath, - envPath + envPath, + useOverrideFile ); } @@ -1282,7 +1283,8 @@ async function executeComposeCommand( envId, workingDir, composePath, - envPath + envPath, + useOverrideFile ); } } @@ -1487,7 +1489,6 @@ async function withContainerFallback( export interface RequireComposeResult { success: boolean; content?: string; - envVars?: Record; secretVars?: Record; needsFileLocation?: boolean; error?: string; @@ -1495,19 +1496,18 @@ export interface RequireComposeResult { stackDir?: string; /** Full path to the compose file (for imported stacks) */ composePath?: string; + /** Full path to the env file (for --env-file flag) */ + envPath?: string; } /** - * Get compose file and env vars for stack operations. + * Get compose file and secret vars for stack operations. * * Returns: * - content: The compose file content - * - envVars: Non-secret variables (from .env file, with DB fallback) * - secretVars: Secret variables (from DB only, for shell injection) + * - envPath: Path to the .env file (Docker Compose reads non-secrets from it) * - needsFileLocation: true if stack needs user to specify file paths - * - * SECURITY: Secrets are NEVER written to .env files. They are stored in the database - * and injected via shell environment variables at runtime. */ async function requireComposeFile( stackName: string, @@ -1534,10 +1534,7 @@ async function requireComposeFile( // These are NEVER written to disk const secretVars = await getSecretEnvVarsAsRecord(stackName, envId); - // Get non-secret variables from database (for backward compatibility) - const dbNonSecretVars = await getNonSecretEnvVarsAsRecord(stackName, envId); - - // Read non-secret vars from .env file + // Determine env file path for --env-file flag // For stacks with custom composePath (adopted/external), derive envPath from same directory // For internal stacks, use the default data directory let envFilePath: string | null = null; @@ -1560,38 +1557,11 @@ async function requireComposeFile( envFilePath = join(stackDir, '.env'); } - let fileEnvVars: Record = {}; - - if (envFilePath && existsSync(envFilePath)) { - try { - const content = await Bun.file(envFilePath).text(); - for (const line of content.split('\n')) { - const trimmed = line.trim(); - if (!trimmed || trimmed.startsWith('#')) continue; - const eqIndex = trimmed.indexOf('='); - if (eqIndex > 0) { - const key = trimmed.substring(0, eqIndex).trim(); - let value = trimmed.substring(eqIndex + 1); - if ((value.startsWith('"') && value.endsWith('"')) || - (value.startsWith("'") && value.endsWith("'"))) { - value = value.slice(1, -1); - } - fileEnvVars[key] = value; - } - } - } catch { - // Ignore file read errors - } - } - - // Merge non-secret vars: DB as fallback, file values override - // This ensures external edits to .env are respected during deployment - const envVars = { ...dbNonSecretVars, ...fileEnvVars }; - + // Docker Compose reads non-secrets from the .env file via --env-file. + // Only secrets need to be injected via shell environment. return { success: true, content: composeResult.content!, - envVars, secretVars, stackDir: composeResult.stackDir, composePath: composeResult.composePath ?? undefined, @@ -1618,7 +1588,7 @@ export async function startStack( 'up', { stackName, envId, workingDir: result.stackDir, composePath: result.composePath, envPath: result.envPath }, result.content!, - result.envVars, + undefined, result.secretVars ); } @@ -1642,7 +1612,7 @@ export async function stopStack( 'stop', { stackName, envId, workingDir: result.stackDir, composePath: result.composePath, envPath: result.envPath }, result.content!, - result.envVars, + undefined, result.secretVars ); } @@ -1666,7 +1636,7 @@ export async function restartStack( 'restart', { stackName, envId, workingDir: result.stackDir, composePath: result.composePath, envPath: result.envPath }, result.content!, - result.envVars, + undefined, result.secretVars ); } @@ -1691,7 +1661,7 @@ export async function downStack( 'down', { stackName, envId, removeVolumes, workingDir: result.stackDir, composePath: result.composePath, envPath: result.envPath }, result.content!, - result.envVars, + undefined, result.secretVars ); } @@ -1707,7 +1677,7 @@ export async function removeStack( ): Promise { return withStackLock(stackName, async () => { // Get compose file (may not exist for external stacks) - const composeResult = await getStackComposeFile(stackName); + const composeResult = await getStackComposeFile(stackName, envId); // Get stack containers BEFORE removing them (for cleanup later) const stackContainers = await getStackContainers(stackName, envId); @@ -1804,16 +1774,17 @@ export async function removeStack( const resolvedStacksDir = resolve(stacksDir); // Only delete if the directory is within DATA_DIR/stacks/ (files we created) - // AND it looks like a stack directory (contains stackName for safety) + // AND the directory basename matches the stack name exactly (for safety) if (resolvedCustomDir.startsWith(resolvedStacksDir) && - customDir.includes(stackName) && + basename(resolvedCustomDir) === stackName && existsSync(customDir)) { stackDir = customDir; } } - // Fall back to default paths (always within DATA_DIR/stacks/) - if (!stackDir) { + // Fall back to default paths ONLY if no custom path was set in DB + // (Don't delete default-path files when an adopted stack has custom path outside DATA_DIR) + if (!stackDir && !stackSource?.composePath) { const defaultDir = await findStackDir(stackName, envId) || await getStackDir(stackName, envId); if (existsSync(defaultDir)) { stackDir = defaultDir; @@ -1853,14 +1824,14 @@ export async function removeStack( const gitStack = await getGitStackByName(stackName, envId); if (gitStack) { await deleteGitStack(gitStack.id); - deleteGitStackFiles(gitStack.id); + await deleteGitStackFiles(gitStack.id, gitStack.stackName, gitStack.environmentId); } // Also cleanup any orphaned git stacks with NULL environment_id for this stack name if (envId !== undefined && envId !== null) { const orphanedGitStack = await getGitStackByName(stackName, null); if (orphanedGitStack) { await deleteGitStack(orphanedGitStack.id); - deleteGitStackFiles(orphanedGitStack.id); + await deleteGitStackFiles(orphanedGitStack.id, orphanedGitStack.stackName, orphanedGitStack.environmentId); } } } catch (err: any) { @@ -1890,7 +1861,7 @@ export async function removeStack( * Uses stack locking to prevent concurrent deployments. */ export async function deployStack(options: DeployStackOptions): Promise { - const { name, compose, envId, envFileVars, sourceDir, forceRecreate, composePath, envPath, composeFileName, envFileName } = options; + const { name, compose, envId, sourceDir, forceRecreate, composePath, envPath, composeFileName, envFileName } = options; const logPrefix = `[Stack:${name}]`; console.log(`${logPrefix} ========================================`); @@ -1903,11 +1874,6 @@ export async function deployStack(options: DeployStackOptions): Promise 0) { - console.log(`${logPrefix} Env file var keys:`, Object.keys(envFileVars).join(', ')); - console.log(`${logPrefix} Env file vars (masked):`, JSON.stringify(maskSecrets(envFileVars), null, 2)); - } // Validate stack name - Docker Compose requires lowercase alphanumeric, hyphens, underscores // Must also start with a letter or number @@ -1943,7 +1909,16 @@ export async function deployStack(options: DeployStackOptions): Promise ${workingDir}`); } else { - // Internal stack: compose file should already exist (written by saveStackComposeFile) - // Just determine the working directory - workingDir = await getStackDir(name, envId); - console.log(`${logPrefix} Using internal stack directory:`, workingDir); + // Internal stack: check if a custom path exists in DB (adopted/imported stacks) + const source = await getStackSource(name, envId); + if (source?.composePath) { + workingDir = dirname(source.composePath); + actualComposePath = source.composePath; + if (source.envPath) { + actualEnvPath = source.envPath; + } + console.log(`${logPrefix} Using custom path from DB:`, workingDir); + } else { + // Default: compose file should already exist (written by saveStackComposeFile) + workingDir = await getStackDir(name, envId); + console.log(`${logPrefix} Using internal stack directory:`, workingDir); + } } console.log(`${logPrefix} Compose content length:`, compose.length, 'chars'); console.log(`${logPrefix} Compose content (full):`); console.log(compose); - // Fetch stack environment variables from database (these are user overrides) - const dbEnvVars = await getStackEnvVarsAsRecord(name, envId); - console.log(`${logPrefix} DB env vars count:`, Object.keys(dbEnvVars).length); - if (Object.keys(dbEnvVars).length > 0) { - console.log(`${logPrefix} DB env var keys:`, Object.keys(dbEnvVars).join(', ')); - console.log(`${logPrefix} DB env vars (masked):`, JSON.stringify(maskSecrets(dbEnvVars), null, 2)); - } + // Fetch overrides and secrets from DB + const dbNonSecretVars = await getNonSecretEnvVarsAsRecord(name, envId); + const secretVars = await getSecretEnvVarsAsRecord(name, envId); + console.log(`${logPrefix} DB non-secret override vars:`, Object.keys(dbNonSecretVars).length); + console.log(`${logPrefix} DB secret vars:`, Object.keys(secretVars).length); - // Merge: env file vars as base, database overrides take precedence - const envVars = { ...envFileVars, ...dbEnvVars }; - console.log(`${logPrefix} Merged env vars count:`, Object.keys(envVars).length); - if (Object.keys(envVars).length > 0) { - console.log(`${logPrefix} Merged env var keys:`, Object.keys(envVars).join(', ')); - console.log(`${logPrefix} Merged env vars (masked):`, JSON.stringify(maskSecrets(envVars), null, 2)); - } + // For git stacks (sourceDir provided), use the override file (.env.dockhand) + // to layer editor overrides on top of the repo's .env file. + // Only DB overrides go into .env.dockhand - repo values are already in the repo's env file. + // For internal/adopted stacks, the .env file is already the editor's output, + // so no override file is needed - only pass secrets for shell injection. + const isGitStack = !!sourceDir; console.log(`${logPrefix} Calling executeComposeCommand...`); const result = await executeComposeCommand( @@ -2003,10 +1985,12 @@ export async function deployStack(options: DeployStackOptions): Promise { - const envFilePath = customEnvPath - ? customEnvPath - : join(await findStackDir(stackName, envId) || await getStackDir(stackName, envId), '.env'); + let envFilePath: string; + if (customEnvPath) { + envFilePath = customEnvPath; + } else { + // Check if stack has a custom path in DB + const source = await getStackSource(stackName, envId); + if (source?.envPath) { + envFilePath = source.envPath; + } else if (source?.composePath) { + // Derive env path from custom compose path location + envFilePath = join(dirname(source.composePath), '.env'); + } else { + // Fall back to default location + envFilePath = join(await findStackDir(stackName, envId) || await getStackDir(stackName, envId), '.env'); + } + } // Ensure parent directory exists const dir = dirname(envFilePath); @@ -2109,9 +2106,22 @@ export async function writeRawStackEnvFile( envId?: number | null, customEnvPath?: string ): Promise { - const envFilePath = customEnvPath - ? customEnvPath - : join(await findStackDir(stackName, envId) || await getStackDir(stackName, envId), '.env'); + let envFilePath: string; + if (customEnvPath) { + envFilePath = customEnvPath; + } else { + // Check if stack has a custom path in DB + const source = await getStackSource(stackName, envId); + if (source?.envPath) { + envFilePath = source.envPath; + } else if (source?.composePath) { + // Derive env path from custom compose path location + envFilePath = join(dirname(source.composePath), '.env'); + } else { + // Fall back to default location + envFilePath = join(await findStackDir(stackName, envId) || await getStackDir(stackName, envId), '.env'); + } + } // Ensure parent directory exists const dir = dirname(envFilePath); diff --git a/src/lib/stores/theme.ts b/src/lib/stores/theme.ts index 4a00daa..cf36e39 100644 --- a/src/lib/stores/theme.ts +++ b/src/lib/stores/theme.ts @@ -19,6 +19,7 @@ export interface ThemePreferences { fontSize: FontSize; gridFontSize: FontSize; terminalFont: string; + editorFont: string; } const STORAGE_KEY = 'dockhand-theme'; @@ -29,7 +30,8 @@ const defaultPrefs: ThemePreferences = { font: 'system', fontSize: 'normal', gridFontSize: 'normal', - terminalFont: 'system-mono' + terminalFont: 'system-mono', + editorFont: 'system-mono' }; // Font size scale mapping @@ -83,9 +85,10 @@ function createThemeStore() { // Initialize from API (called on mount) async init(userId?: number) { try { + // Use profile preferences for authenticated users, public theme endpoint otherwise const url = userId ? `/api/profile/preferences` - : `/api/settings/general`; + : `/api/settings/theme`; const res = await fetch(url); if (res.ok) { @@ -96,7 +99,8 @@ function createThemeStore() { font: data.font || data.theme_font || 'system', fontSize: data.fontSize || data.font_size || 'normal', gridFontSize: data.gridFontSize || data.grid_font_size || 'normal', - terminalFont: data.terminalFont || data.terminal_font || 'system-mono' + terminalFont: data.terminalFont || data.terminal_font || 'system-mono', + editorFont: data.editorFont || data.editor_font || 'system-mono' }; set(prefs); saveToStorage(prefs); @@ -187,6 +191,9 @@ export function applyTheme(prefs: ThemePreferences) { // Apply terminal font applyTerminalFont(prefs.terminalFont); + + // Apply editor font + applyEditorFont(prefs.editorFont); } // Apply font to document @@ -237,6 +244,22 @@ function applyTerminalFont(fontId: string) { document.documentElement.style.setProperty('--font-mono', fontMeta.family); } +// Apply editor font to document +function applyEditorFont(fontId: string) { + if (typeof document === 'undefined') return; + + const fontMeta = getMonospaceFont(fontId); + if (!fontMeta) return; + + // Load Google Font if needed + if (fontMeta.googleFont) { + loadGoogleFont(fontMeta); + } + + // Set CSS variable + document.documentElement.style.setProperty('--font-editor', fontMeta.family); +} + // Load Google Font dynamically function loadGoogleFont(font: FontMeta) { if (!font.googleFont) return; diff --git a/src/lib/themes.ts b/src/lib/themes.ts index f887e9e..e2d6077 100644 --- a/src/lib/themes.ts +++ b/src/lib/themes.ts @@ -105,7 +105,7 @@ export const fonts: FontMeta[] = [ { id: 'comfortaa', name: 'Comfortaa', family: "'Comfortaa', sans-serif", googleFont: 'Comfortaa:wght@400;500;600;700' } ]; -// Monospace fonts for terminal and logs +// Monospace fonts for terminal, logs, and editors export const monospaceFonts: FontMeta[] = [ // System monospace (no external load) { id: 'system-mono', name: 'System Monospace', family: 'ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace' }, @@ -115,6 +115,15 @@ export const monospaceFonts: FontMeta[] = [ { id: 'fira-code', name: 'Fira Code', family: "'Fira Code', monospace", googleFont: 'Fira+Code:wght@400;500;600;700' }, { id: 'source-code-pro', name: 'Source Code Pro', family: "'Source Code Pro', monospace", googleFont: 'Source+Code+Pro:wght@400;500;600;700' }, { id: 'cascadia-code', name: 'Cascadia Code', family: "'Cascadia Code', monospace", googleFont: 'Cascadia+Code:wght@400;500;600;700' }, + { id: 'ibm-plex-mono', name: 'IBM Plex Mono', family: "'IBM Plex Mono', monospace", googleFont: 'IBM+Plex+Mono:wght@400;500;600;700' }, + { id: 'roboto-mono', name: 'Roboto Mono', family: "'Roboto Mono', monospace", googleFont: 'Roboto+Mono:wght@400;500;600;700' }, + { id: 'ubuntu-mono', name: 'Ubuntu Mono', family: "'Ubuntu Mono', monospace", googleFont: 'Ubuntu+Mono:wght@400;700' }, + { id: 'space-mono', name: 'Space Mono', family: "'Space Mono', monospace", googleFont: 'Space+Mono:wght@400;700' }, + { id: 'inconsolata', name: 'Inconsolata', family: "'Inconsolata', monospace", googleFont: 'Inconsolata:wght@400;500;600;700' }, + { id: 'hack', name: 'Hack', family: "'Hack', monospace", googleFont: 'Hack:wght@400;700' }, + { id: 'anonymous-pro', name: 'Anonymous Pro', family: "'Anonymous Pro', monospace", googleFont: 'Anonymous+Pro:wght@400;700' }, + { id: 'dm-mono', name: 'DM Mono', family: "'DM Mono', monospace", googleFont: 'DM+Mono:wght@400;500' }, + { id: 'red-hat-mono', name: 'Red Hat Mono', family: "'Red Hat Mono', monospace", googleFont: 'Red+Hat+Mono:wght@400;500;600;700' }, // Platform-specific (no external load) { id: 'menlo', name: 'Menlo', family: 'Menlo, Monaco, monospace' }, diff --git a/src/lib/types.ts b/src/lib/types.ts index 0a6674b..ddae809 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -163,7 +163,7 @@ export interface GitRepository { } // Grid column configuration types -export type GridId = 'containers' | 'images' | 'imageTags' | 'networks' | 'stacks' | 'volumes' | 'activity' | 'schedules'; +export type GridId = 'containers' | 'images' | 'imageTags' | 'networks' | 'stacks' | 'volumes' | 'activity' | 'schedules' | 'audit'; export interface ColumnConfig { id: string; diff --git a/src/lib/utils/diff.ts b/src/lib/utils/diff.ts new file mode 100644 index 0000000..db37017 --- /dev/null +++ b/src/lib/utils/diff.ts @@ -0,0 +1,222 @@ +/** + * Utility functions for computing diffs between objects for audit logging + */ + +export interface FieldChange { + field: string; + oldValue: any; + newValue: any; +} + +export interface AuditDiff { + changes: FieldChange[]; +} + +/** + * Fields that should never be included in audit diffs (sensitive data) + */ +const SENSITIVE_FIELDS = new Set([ + 'password', + 'sshPrivateKey', + 'sshPassphrase', + 'tlsKey', + 'tlsCert', + 'tlsCa', + 'hawserToken', + 'token', + 'secret', + 'apiKey' +]); + +/** + * Fields that should be shown as masked if changed + */ +const MASKED_FIELDS = new Set([ + 'password', + 'sshPrivateKey', + 'sshPassphrase', + 'tlsKey', + 'hawserToken', + 'token', + 'secret', + 'apiKey' +]); + +/** + * Fields that should be skipped entirely (internal timestamps, etc.) + */ +const SKIP_FIELDS = new Set([ + 'updatedAt', + 'createdAt', + 'id' +]); + +/** + * Compute the diff between two objects for audit logging + * Returns only the fields that have changed + */ +export function computeAuditDiff( + oldObj: Record | null | undefined, + newObj: Record | null | undefined, + options: { + includeFields?: string[]; + excludeFields?: string[]; + } = {} +): AuditDiff | null { + if (!oldObj || !newObj) { + return null; + } + + const changes: FieldChange[] = []; + const allKeys = new Set([...Object.keys(oldObj), ...Object.keys(newObj)]); + + for (const key of allKeys) { + // Skip internal fields + if (SKIP_FIELDS.has(key)) continue; + + // Skip if excluded + if (options.excludeFields?.includes(key)) continue; + + // Skip if includeFields specified and key not in it + if (options.includeFields && !options.includeFields.includes(key)) continue; + + const oldVal = oldObj[key]; + const newVal = newObj[key]; + + // Skip undefined new values (not provided in update) + if (newVal === undefined) continue; + + // Check if values are different + if (!isEqual(oldVal, newVal)) { + // Handle sensitive fields - show as masked + if (MASKED_FIELDS.has(key)) { + // Only show change if the masked field actually changed + const oldHasValue = oldVal !== null && oldVal !== undefined && oldVal !== ''; + const newHasValue = newVal !== null && newVal !== undefined && newVal !== ''; + + if (oldHasValue !== newHasValue || (oldHasValue && newHasValue)) { + changes.push({ + field: key, + oldValue: oldHasValue ? '••••••••' : null, + newValue: newHasValue ? '••••••••' : null + }); + } + } else if (SENSITIVE_FIELDS.has(key)) { + // Skip entirely for other sensitive fields + continue; + } else { + changes.push({ + field: key, + oldValue: formatValue(oldVal), + newValue: formatValue(newVal) + }); + } + } + } + + if (changes.length === 0) { + return null; + } + + return { changes }; +} + +/** + * Deep equality check for values + */ +function isEqual(a: any, b: any): boolean { + // Handle null/undefined + if (a === b) return true; + if (a === null || b === null) return false; + if (a === undefined || b === undefined) return false; + + // Handle arrays + if (Array.isArray(a) && Array.isArray(b)) { + if (a.length !== b.length) return false; + return a.every((val, idx) => isEqual(val, b[idx])); + } + + // Handle objects + if (typeof a === 'object' && typeof b === 'object') { + const keysA = Object.keys(a); + const keysB = Object.keys(b); + if (keysA.length !== keysB.length) return false; + return keysA.every(key => isEqual(a[key], b[key])); + } + + // Primitive comparison + return a === b; +} + +/** + * Format a value for display in the diff + */ +function formatValue(val: any): any { + if (val === null || val === undefined) { + return null; + } + + // Truncate long strings + if (typeof val === 'string' && val.length > 200) { + return val.substring(0, 200) + '...'; + } + + // Handle arrays - show count if too many items + if (Array.isArray(val)) { + if (val.length > 10) { + return `[${val.length} items]`; + } + return val.map(formatValue); + } + + // Handle objects - show keys if too complex + if (typeof val === 'object') { + const keys = Object.keys(val); + if (keys.length > 10) { + return `{${keys.length} properties}`; + } + const formatted: Record = {}; + for (const key of keys) { + formatted[key] = formatValue(val[key]); + } + return formatted; + } + + return val; +} + +/** + * Format field name for display (camelCase to Title Case) + */ +export function formatFieldName(field: string): string { + // Handle special cases + const specialCases: Record = { + 'tlsCa': 'TLS CA', + 'tlsCert': 'TLS certificate', + 'tlsKey': 'TLS key', + 'tlsSkipVerify': 'Skip TLS verification', + 'sshPrivateKey': 'SSH private key', + 'sshPassphrase': 'SSH passphrase', + 'envVars': 'Environment variables', + 'isDefault': 'Default', + 'ipAddress': 'IP address', + 'authType': 'Auth type', + 'eventTypes': 'Event types', + 'hawserToken': 'Hawser token', + 'connectionType': 'Connection type', + 'socketPath': 'Socket path', + 'collectActivity': 'Collect activity', + 'collectMetrics': 'Collect metrics', + 'highlightChanges': 'Highlight changes' + }; + + if (specialCases[field]) { + return specialCases[field]; + } + + // Convert camelCase to Title Case with spaces + return field + .replace(/([A-Z])/g, ' $1') + .replace(/^./, str => str.toUpperCase()) + .trim(); +} diff --git a/src/routes/api/auth/ldap/+server.ts b/src/routes/api/auth/ldap/+server.ts index 7759329..820fb95 100644 --- a/src/routes/api/auth/ldap/+server.ts +++ b/src/routes/api/auth/ldap/+server.ts @@ -2,6 +2,7 @@ import { json } from '@sveltejs/kit'; import type { RequestHandler } from '@sveltejs/kit'; import { authorize } from '$lib/server/authorize'; import { getLdapConfigs, createLdapConfig } from '$lib/server/db'; +import { auditLdapConfig } from '$lib/server/audit'; // GET /api/auth/ldap - List all LDAP configurations export const GET: RequestHandler = async ({ cookies }) => { @@ -31,7 +32,8 @@ export const GET: RequestHandler = async ({ cookies }) => { }; // POST /api/auth/ldap - Create a new LDAP configuration -export const POST: RequestHandler = async ({ request, cookies }) => { +export const POST: RequestHandler = async (event) => { + const { request, cookies } = event; const auth = await authorize(cookies); // Allow access when auth is disabled (setup mode) or when user is admin @@ -70,6 +72,9 @@ export const POST: RequestHandler = async ({ request, cookies }) => { tlsCa: data.tlsCa || undefined }); + // Audit log + await auditLdapConfig(event, 'create', config.id, config.name); + return json({ ...config, bindPassword: config.bindPassword ? '********' : undefined diff --git a/src/routes/api/auth/ldap/[id]/+server.ts b/src/routes/api/auth/ldap/[id]/+server.ts index f19f1a7..92d6631 100644 --- a/src/routes/api/auth/ldap/[id]/+server.ts +++ b/src/routes/api/auth/ldap/[id]/+server.ts @@ -2,6 +2,8 @@ import { json } from '@sveltejs/kit'; import type { RequestHandler } from '@sveltejs/kit'; import { authorize } from '$lib/server/authorize'; import { getLdapConfig, updateLdapConfig, deleteLdapConfig } from '$lib/server/db'; +import { auditLdapConfig } from '$lib/server/audit'; +import { computeAuditDiff } from '$lib/utils/diff'; // GET /api/auth/ldap/[id] - Get a specific LDAP configuration export const GET: RequestHandler = async ({ params, cookies }) => { @@ -38,7 +40,8 @@ export const GET: RequestHandler = async ({ params, cookies }) => { }; // PUT /api/auth/ldap/[id] - Update a LDAP configuration -export const PUT: RequestHandler = async ({ params, request, cookies }) => { +export const PUT: RequestHandler = async (event) => { + const { params, request, cookies } = event; const auth = await authorize(cookies); // Allow access when auth is disabled (setup mode) or when user is admin @@ -89,6 +92,14 @@ export const PUT: RequestHandler = async ({ params, request, cookies }) => { return json({ error: 'Failed to update configuration' }, { status: 500 }); } + // Compute diff for audit (exclude sensitive fields) + const diff = computeAuditDiff(existing, config, { + excludeFields: ['bindPassword', 'tlsCa', 'createdAt', 'updatedAt'] + }); + + // Audit log + await auditLdapConfig(event, 'update', config.id, config.name, diff); + return json({ ...config, bindPassword: config.bindPassword ? '********' : undefined @@ -100,7 +111,8 @@ export const PUT: RequestHandler = async ({ params, request, cookies }) => { }; // DELETE /api/auth/ldap/[id] - Delete a LDAP configuration -export const DELETE: RequestHandler = async ({ params, cookies }) => { +export const DELETE: RequestHandler = async (event) => { + const { params, cookies } = event; const auth = await authorize(cookies); // Allow access when auth is disabled (setup mode) or when user is admin @@ -118,11 +130,20 @@ export const DELETE: RequestHandler = async ({ params, cookies }) => { } try { - const deleted = await deleteLdapConfig(id); - if (!deleted) { + // Get config before deletion for audit + const config = await getLdapConfig(id); + if (!config) { return json({ error: 'LDAP configuration not found' }, { status: 404 }); } + const deleted = await deleteLdapConfig(id); + if (!deleted) { + return json({ error: 'Failed to delete LDAP configuration' }, { status: 500 }); + } + + // Audit log + await auditLdapConfig(event, 'delete', id, config.name); + return json({ success: true }); } catch (error) { console.error('Failed to delete LDAP config:', error); diff --git a/src/routes/api/auth/login/+server.ts b/src/routes/api/auth/login/+server.ts index 3ecc360..6468283 100644 --- a/src/routes/api/auth/login/+server.ts +++ b/src/routes/api/auth/login/+server.ts @@ -41,6 +41,11 @@ export const POST: RequestHandler = async (event) => { ); } + // Reject local login attempts when DISABLE_LOCAL_LOGIN is set + if (provider === 'local' && process.env.DISABLE_LOCAL_LOGIN === 'true') { + return json({ error: 'Local login is disabled' }, { status: 403 }); + } + // Attempt authentication based on provider let result: any; let authProviderType: 'local' | 'ldap' | 'oidc' = 'local'; diff --git a/src/routes/api/auth/oidc/+server.ts b/src/routes/api/auth/oidc/+server.ts index e14e77a..b3d0201 100644 --- a/src/routes/api/auth/oidc/+server.ts +++ b/src/routes/api/auth/oidc/+server.ts @@ -6,6 +6,7 @@ import { createOidcConfig, type OidcConfig } from '$lib/server/db'; +import { auditOidcProvider } from '$lib/server/audit'; // GET /api/auth/oidc - List all OIDC configurations export const GET: RequestHandler = async ({ cookies }) => { @@ -36,7 +37,8 @@ export const GET: RequestHandler = async ({ cookies }) => { }; // POST /api/auth/oidc - Create new OIDC configuration -export const POST: RequestHandler = async ({ request, cookies }) => { +export const POST: RequestHandler = async (event) => { + const { request, cookies } = event; const auth = await authorize(cookies); // When auth is enabled, require authentication and settings:edit permission @@ -77,6 +79,9 @@ export const POST: RequestHandler = async ({ request, cookies }) => { roleMappings: data.roleMappings || undefined }); + // Audit log + await auditOidcProvider(event, 'create', config.id, config.name); + return json({ ...config, clientSecret: '********' diff --git a/src/routes/api/auth/oidc/[id]/+server.ts b/src/routes/api/auth/oidc/[id]/+server.ts index c344dde..4f7b8d2 100644 --- a/src/routes/api/auth/oidc/[id]/+server.ts +++ b/src/routes/api/auth/oidc/[id]/+server.ts @@ -6,6 +6,8 @@ import { updateOidcConfig, deleteOidcConfig } from '$lib/server/db'; +import { auditOidcProvider } from '$lib/server/audit'; +import { computeAuditDiff } from '$lib/utils/diff'; // GET /api/auth/oidc/[id] - Get specific OIDC configuration export const GET: RequestHandler = async ({ params, cookies }) => { @@ -43,7 +45,8 @@ export const GET: RequestHandler = async ({ params, cookies }) => { }; // PUT /api/auth/oidc/[id] - Update OIDC configuration -export const PUT: RequestHandler = async ({ params, request, cookies }) => { +export const PUT: RequestHandler = async (event) => { + const { params, request, cookies } = event; const auth = await authorize(cookies); // When auth is enabled, require authentication and settings:edit permission @@ -93,6 +96,14 @@ export const PUT: RequestHandler = async ({ params, request, cookies }) => { return json({ error: 'Failed to update OIDC configuration' }, { status: 500 }); } + // Compute diff for audit (exclude sensitive fields) + const diff = computeAuditDiff(existing, config, { + excludeFields: ['clientSecret', 'createdAt', 'updatedAt'] + }); + + // Audit log + await auditOidcProvider(event, 'update', config.id, config.name, diff); + return json({ ...config, clientSecret: config.clientSecret ? '********' : '' @@ -104,7 +115,8 @@ export const PUT: RequestHandler = async ({ params, request, cookies }) => { }; // DELETE /api/auth/oidc/[id] - Delete OIDC configuration -export const DELETE: RequestHandler = async ({ params, cookies }) => { +export const DELETE: RequestHandler = async (event) => { + const { params, cookies } = event; const auth = await authorize(cookies); // When auth is enabled, require authentication and settings:edit permission @@ -123,11 +135,20 @@ export const DELETE: RequestHandler = async ({ params, cookies }) => { } try { - const deleted = await deleteOidcConfig(id); - if (!deleted) { + // Get config before deletion for audit + const config = await getOidcConfig(id); + if (!config) { return json({ error: 'OIDC configuration not found' }, { status: 404 }); } + const deleted = await deleteOidcConfig(id); + if (!deleted) { + return json({ error: 'Failed to delete OIDC configuration' }, { status: 500 }); + } + + // Audit log + await auditOidcProvider(event, 'delete', id, config.name); + return json({ success: true }); } catch (error) { console.error('Failed to delete OIDC config:', error); diff --git a/src/routes/api/auth/providers/+server.ts b/src/routes/api/auth/providers/+server.ts index 966b1cf..8c267bb 100644 --- a/src/routes/api/auth/providers/+server.ts +++ b/src/routes/api/auth/providers/+server.ts @@ -21,8 +21,10 @@ export const GET: RequestHandler = async () => { const providers: { id: string; name: string; type: 'local' | 'ldap' | 'oidc'; initiateUrl?: string }[] = []; - // Local auth is always available when auth is enabled - providers.push({ id: 'local', name: 'Local', type: 'local' }); + // Local auth is available unless DISABLE_LOCAL_LOGIN is set + if (process.env.DISABLE_LOCAL_LOGIN !== 'true') { + providers.push({ id: 'local', name: 'Local', type: 'local' }); + } // Add enabled LDAP providers (enterprise only) for (const config of ldapConfigs) { @@ -49,6 +51,9 @@ export const GET: RequestHandler = async () => { }); } catch (error) { console.error('Failed to get auth providers:', error); - return json({ providers: [{ id: 'local', name: 'Local', type: 'local' }] }); + const fallbackProviders = process.env.DISABLE_LOCAL_LOGIN === 'true' + ? [] + : [{ id: 'local', name: 'Local', type: 'local' }]; + return json({ providers: fallbackProviders }); } }; diff --git a/src/routes/api/config-sets/+server.ts b/src/routes/api/config-sets/+server.ts index 0501718..ce4d34f 100644 --- a/src/routes/api/config-sets/+server.ts +++ b/src/routes/api/config-sets/+server.ts @@ -2,6 +2,7 @@ import { json } from '@sveltejs/kit'; import type { RequestHandler } from './$types'; import { getConfigSets, createConfigSet } from '$lib/server/db'; import { authorize } from '$lib/server/authorize'; +import { auditConfigSet } from '$lib/server/audit'; export const GET: RequestHandler = async ({ cookies }) => { const auth = await authorize(cookies); @@ -18,7 +19,8 @@ export const GET: RequestHandler = async ({ cookies }) => { } }; -export const POST: RequestHandler = async ({ request, cookies }) => { +export const POST: RequestHandler = async (event) => { + const { request, cookies } = event; const auth = await authorize(cookies); if (auth.authEnabled && !await auth.can('configsets', 'create')) { return json({ error: 'Permission denied' }, { status: 403 }); @@ -42,6 +44,9 @@ export const POST: RequestHandler = async ({ request, cookies }) => { restartPolicy: body.restartPolicy || 'no' }); + // Audit log + await auditConfigSet(event, 'create', configSet.id, configSet.name); + return json(configSet, { status: 201 }); } catch (error: any) { console.error('Failed to create config set:', error); diff --git a/src/routes/api/config-sets/[id]/+server.ts b/src/routes/api/config-sets/[id]/+server.ts index 8ad0d68..f4bb2f8 100644 --- a/src/routes/api/config-sets/[id]/+server.ts +++ b/src/routes/api/config-sets/[id]/+server.ts @@ -2,6 +2,8 @@ import { json } from '@sveltejs/kit'; import type { RequestHandler } from './$types'; import { getConfigSet, updateConfigSet, deleteConfigSet } from '$lib/server/db'; import { authorize } from '$lib/server/authorize'; +import { auditConfigSet } from '$lib/server/audit'; +import { computeAuditDiff } from '$lib/utils/diff'; export const GET: RequestHandler = async ({ params, cookies }) => { const auth = await authorize(cookies); @@ -27,7 +29,8 @@ export const GET: RequestHandler = async ({ params, cookies }) => { } }; -export const PUT: RequestHandler = async ({ params, request, cookies }) => { +export const PUT: RequestHandler = async (event) => { + const { params, request, cookies } = event; const auth = await authorize(cookies); if (auth.authEnabled && !await auth.can('configsets', 'edit')) { return json({ error: 'Permission denied' }, { status: 403 }); @@ -39,6 +42,12 @@ export const PUT: RequestHandler = async ({ params, request, cookies }) => { return json({ error: 'Invalid ID' }, { status: 400 }); } + // Get old values before update for diff + const oldConfigSet = await getConfigSet(id); + if (!oldConfigSet) { + return json({ error: 'Config set not found' }, { status: 404 }); + } + const body = await request.json(); const configSet = await updateConfigSet(id, { @@ -56,6 +65,12 @@ export const PUT: RequestHandler = async ({ params, request, cookies }) => { return json({ error: 'Config set not found' }, { status: 404 }); } + // Compute diff for audit + const diff = computeAuditDiff(oldConfigSet, configSet); + + // Audit log + await auditConfigSet(event, 'update', configSet.id, configSet.name, diff); + return json(configSet); } catch (error: any) { console.error('Failed to update config set:', error); @@ -66,7 +81,8 @@ export const PUT: RequestHandler = async ({ params, request, cookies }) => { } }; -export const DELETE: RequestHandler = async ({ params, cookies }) => { +export const DELETE: RequestHandler = async (event) => { + const { params, cookies } = event; const auth = await authorize(cookies); if (auth.authEnabled && !await auth.can('configsets', 'delete')) { return json({ error: 'Permission denied' }, { status: 403 }); @@ -78,11 +94,20 @@ export const DELETE: RequestHandler = async ({ params, cookies }) => { return json({ error: 'Invalid ID' }, { status: 400 }); } - const deleted = await deleteConfigSet(id); - if (!deleted) { + // Get config set name before deletion for audit log + const configSet = await getConfigSet(id); + if (!configSet) { return json({ error: 'Config set not found' }, { status: 404 }); } + const deleted = await deleteConfigSet(id); + if (!deleted) { + return json({ error: 'Failed to delete config set' }, { status: 500 }); + } + + // Audit log + await auditConfigSet(event, 'delete', id, configSet.name); + return json({ success: true }); } catch (error) { console.error('Failed to delete config set:', error); diff --git a/src/routes/api/environments/+server.ts b/src/routes/api/environments/+server.ts index 46b8d71..393a934 100644 --- a/src/routes/api/environments/+server.ts +++ b/src/routes/api/environments/+server.ts @@ -1,7 +1,8 @@ import { json } from '@sveltejs/kit'; import type { RequestHandler } from './$types'; -import { getEnvironments, getEnvironmentByName, createEnvironment, assignUserRole, getRoleByName, getEnvironmentPublicIps, setEnvironmentPublicIp, getEnvUpdateCheckSettings, getEnvironmentTimezone, type Environment } from '$lib/server/db'; +import { getEnvironments, getEnvironmentByName, createEnvironment, assignUserRole, getRoleByName, getEnvironmentPublicIps, setEnvironmentPublicIp, getEnvUpdateCheckSettings, getEnvironmentTimezone, getImagePruneSettings, type Environment } from '$lib/server/db'; import { authorize } from '$lib/server/authorize'; +import { auditEnvironment } from '$lib/server/audit'; import { refreshSubprocessEnvironments } from '$lib/server/subprocess-manager'; import { serializeLabels, parseLabels, MAX_LABELS } from '$lib/utils/label-colors'; import { cleanPem } from '$lib/utils/pem'; @@ -36,16 +37,18 @@ export const GET: RequestHandler = async ({ cookies }) => { } } - // Parse labels from JSON string to array, add public IPs, update check settings, and timezone + // Parse labels from JSON string to array, add public IPs, update check settings, image prune settings, and timezone const envWithParsedLabels = await Promise.all(environments.map(async env => { const updateSettings = updateCheckSettingsMap.get(env.id); const timezone = await getEnvironmentTimezone(env.id); + const imagePruneSettings = await getImagePruneSettings(env.id); return { ...env, labels: parseLabels(env.labels as string | null), publicIp: publicIps[env.id.toString()] || null, updateCheckEnabled: updateSettings?.enabled || false, updateCheckAutoUpdate: updateSettings?.autoUpdate || false, + imagePruneEnabled: imagePruneSettings?.enabled || false, timezone }; })); @@ -57,7 +60,8 @@ export const GET: RequestHandler = async ({ cookies }) => { } }; -export const POST: RequestHandler = async ({ request, cookies }) => { +export const POST: RequestHandler = async (event) => { + const { request, cookies } = event; const auth = await authorize(cookies); if (auth.authEnabled && !await auth.can('environments', 'create')) { return json({ error: 'Permission denied' }, { status: 403 }); @@ -128,6 +132,9 @@ export const POST: RequestHandler = async ({ request, cookies }) => { } } + // Audit log + await auditEnvironment(event, 'create', env.id, env.name); + return json(env); } catch (error) { console.error('Failed to create environment:', error); diff --git a/src/routes/api/environments/[id]/+server.ts b/src/routes/api/environments/[id]/+server.ts index 1f34bd9..97d77d7 100644 --- a/src/routes/api/environments/[id]/+server.ts +++ b/src/routes/api/environments/[id]/+server.ts @@ -1,14 +1,16 @@ import { json } from '@sveltejs/kit'; import type { RequestHandler } from './$types'; -import { getEnvironment, updateEnvironment, deleteEnvironment, getEnvironmentPublicIps, setEnvironmentPublicIp, deleteEnvironmentPublicIp, deleteEnvUpdateCheckSettings, getGitStacksForEnvironmentOnly, deleteGitStack } from '$lib/server/db'; +import { getEnvironment, updateEnvironment, deleteEnvironment, getEnvironmentPublicIps, setEnvironmentPublicIp, deleteEnvironmentPublicIp, deleteEnvUpdateCheckSettings, deleteImagePruneSettings, getGitStacksForEnvironmentOnly, deleteGitStack } from '$lib/server/db'; import { clearDockerClientCache } from '$lib/server/docker'; import { deleteGitStackFiles } from '$lib/server/git'; import { authorize } from '$lib/server/authorize'; +import { auditEnvironment } from '$lib/server/audit'; import { refreshSubprocessEnvironments } from '$lib/server/subprocess-manager'; import { serializeLabels, parseLabels, MAX_LABELS } from '$lib/utils/label-colors'; import { cleanPem } from '$lib/utils/pem'; import { unregisterSchedule } from '$lib/server/scheduler'; import { closeEdgeConnection } from '$lib/server/hawser'; +import { computeAuditDiff } from '$lib/utils/diff'; export const GET: RequestHandler = async ({ params, cookies }) => { const auth = await authorize(cookies); @@ -40,7 +42,8 @@ export const GET: RequestHandler = async ({ params, cookies }) => { } }; -export const PUT: RequestHandler = async ({ params, request, cookies }) => { +export const PUT: RequestHandler = async (event) => { + const { params, request, cookies } = event; const auth = await authorize(cookies); if (auth.authEnabled && !await auth.can('environments', 'edit')) { return json({ error: 'Permission denied' }, { status: 403 }); @@ -48,6 +51,13 @@ export const PUT: RequestHandler = async ({ params, request, cookies }) => { try { const id = parseInt(params.id); + + // Get old values before update for diff + const oldEnv = await getEnvironment(id); + if (!oldEnv) { + return json({ error: 'Environment not found' }, { status: 404 }); + } + const data = await request.json(); // Clear cached Docker client before updating @@ -95,6 +105,14 @@ export const PUT: RequestHandler = async ({ params, request, cookies }) => { const publicIps = await getEnvironmentPublicIps(); const publicIp = publicIps[id.toString()] || null; + // Compute diff for audit (exclude sensitive TLS fields) + const diff = computeAuditDiff(oldEnv, env, { + excludeFields: ['tlsCa', 'tlsCert', 'tlsKey', 'hawserToken', 'labels'] + }); + + // Audit log + await auditEnvironment(event, 'update', env.id, env.name, diff); + // Parse labels from JSON string to array return json({ ...env, @@ -107,7 +125,8 @@ export const PUT: RequestHandler = async ({ params, request, cookies }) => { } }; -export const DELETE: RequestHandler = async ({ params, cookies }) => { +export const DELETE: RequestHandler = async (event) => { + const { params, cookies } = event; const auth = await authorize(cookies); if (auth.authEnabled && !await auth.can('environments', 'delete')) { return json({ error: 'Permission denied' }, { status: 403 }); @@ -116,6 +135,12 @@ export const DELETE: RequestHandler = async ({ params, cookies }) => { try { const id = parseInt(params.id); + // Get environment name before deletion for audit log + const env = await getEnvironment(id); + if (!env) { + return json({ error: 'Environment not found' }, { status: 404 }); + } + // Close Edge connection if this is a Hawser Edge environment // This rejects any pending requests and closes the WebSocket closeEdgeConnection(id); @@ -131,7 +156,7 @@ export const DELETE: RequestHandler = async ({ params, cookies }) => { unregisterSchedule(stack.id, 'git_stack_sync'); } // Delete git stack files from filesystem - deleteGitStackFiles(stack.id); + await deleteGitStackFiles(stack.id, stack.stackName, stack.environmentId); // Delete git stack from database await deleteGitStack(stack.id); } @@ -149,9 +174,16 @@ export const DELETE: RequestHandler = async ({ params, cookies }) => { await deleteEnvUpdateCheckSettings(id); unregisterSchedule(id, 'env_update_check'); + // Clean up image prune settings and unregister schedule + await deleteImagePruneSettings(id); + unregisterSchedule(id, 'image_prune'); + // Notify subprocesses to stop collecting from deleted environment refreshSubprocessEnvironments(); + // Audit log + await auditEnvironment(event, 'delete', id, env.name); + return json({ success: true }); } catch (error) { console.error('Failed to delete environment:', error); diff --git a/src/routes/api/environments/[id]/image-prune/+server.ts b/src/routes/api/environments/[id]/image-prune/+server.ts new file mode 100644 index 0000000..b1a5e37 --- /dev/null +++ b/src/routes/api/environments/[id]/image-prune/+server.ts @@ -0,0 +1,121 @@ +import { json } from '@sveltejs/kit'; +import type { RequestHandler } from './$types'; +import { authorize } from '$lib/server/authorize'; +import { + getImagePruneSettings, + setImagePruneSettings, + getEnvironment +} from '$lib/server/db'; +import { registerSchedule, unregisterSchedule, triggerImagePrune } from '$lib/server/scheduler'; + +/** + * Get image prune settings for an environment. + */ +export const GET: RequestHandler = async ({ params, cookies }) => { + const auth = await authorize(cookies); + if (auth.authEnabled && !await auth.can('environments', 'view')) { + return json({ error: 'Permission denied' }, { status: 403 }); + } + + try { + const id = parseInt(params.id); + + // Verify environment exists + const env = await getEnvironment(id); + if (!env) { + return json({ error: 'Environment not found' }, { status: 404 }); + } + + const settings = await getImagePruneSettings(id); + + return json({ + settings: settings || { + enabled: false, + cronExpression: '0 3 * * 0', // Default: 3 AM Sunday + pruneMode: 'dangling' + } + }); + } catch (error) { + console.error('Failed to get image prune settings:', error); + return json({ error: 'Failed to get image prune settings' }, { status: 500 }); + } +}; + +/** + * Save image prune settings for an environment. + */ +export const POST: RequestHandler = async ({ params, request, cookies }) => { + const auth = await authorize(cookies); + if (auth.authEnabled && !await auth.can('environments', 'edit')) { + return json({ error: 'Permission denied' }, { status: 403 }); + } + + try { + const id = parseInt(params.id); + + // Verify environment exists + const env = await getEnvironment(id); + if (!env) { + return json({ error: 'Environment not found' }, { status: 404 }); + } + + const data = await request.json(); + + // Get existing settings to preserve lastPruned and lastResult + const existingSettings = await getImagePruneSettings(id); + + const settings = { + enabled: data.enabled ?? false, + cronExpression: data.cronExpression || '0 3 * * 0', + pruneMode: data.pruneMode || 'dangling', + lastPruned: existingSettings?.lastPruned, + lastResult: existingSettings?.lastResult + }; + + // Save settings to database + await setImagePruneSettings(id, settings); + + // Register or unregister schedule based on enabled state + if (settings.enabled) { + await registerSchedule(id, 'image_prune', id); + } else { + unregisterSchedule(id, 'image_prune'); + } + + return json({ success: true, settings }); + } catch (error) { + console.error('Failed to save image prune settings:', error); + return json({ error: 'Failed to save image prune settings' }, { status: 500 }); + } +}; + +/** + * Manually trigger image prune for an environment. + */ +export const PUT: RequestHandler = async ({ params, cookies }) => { + const auth = await authorize(cookies); + if (auth.authEnabled && !await auth.can('environments', 'edit')) { + return json({ error: 'Permission denied' }, { status: 403 }); + } + + try { + const id = parseInt(params.id); + + // Verify environment exists + const env = await getEnvironment(id); + if (!env) { + return json({ error: 'Environment not found' }, { status: 404 }); + } + + const result = await triggerImagePrune(id); + + if (!result.success) { + return json({ error: result.error }, { status: 400 }); + } + + return json({ success: true }); + } catch (error) { + console.error('Failed to trigger image prune:', error); + return json({ error: 'Failed to trigger image prune' }, { status: 500 }); + } +}; diff --git a/src/routes/api/git/credentials/+server.ts b/src/routes/api/git/credentials/+server.ts index 77449f1..a427b26 100644 --- a/src/routes/api/git/credentials/+server.ts +++ b/src/routes/api/git/credentials/+server.ts @@ -6,6 +6,7 @@ import { type GitAuthType } from '$lib/server/db'; import { authorize } from '$lib/server/authorize'; +import { auditGitCredential } from '$lib/server/audit'; export const GET: RequestHandler = async ({ cookies }) => { const auth = await authorize(cookies); @@ -33,7 +34,8 @@ export const GET: RequestHandler = async ({ cookies }) => { } }; -export const POST: RequestHandler = async ({ request, cookies }) => { +export const POST: RequestHandler = async (event) => { + const { request, cookies } = event; const auth = await authorize(cookies); if (auth.authEnabled && !await auth.can('git', 'create')) { return json({ error: 'Permission denied' }, { status: 403 }); @@ -68,6 +70,9 @@ export const POST: RequestHandler = async ({ request, cookies }) => { sshPassphrase: data.sshPassphrase }); + // Audit log + await auditGitCredential(event, 'create', credential.id, credential.name); + return json({ id: credential.id, name: credential.name, diff --git a/src/routes/api/git/credentials/[id]/+server.ts b/src/routes/api/git/credentials/[id]/+server.ts index ba774f7..44ba755 100644 --- a/src/routes/api/git/credentials/[id]/+server.ts +++ b/src/routes/api/git/credentials/[id]/+server.ts @@ -7,6 +7,8 @@ import { type GitAuthType } from '$lib/server/db'; import { authorize } from '$lib/server/authorize'; +import { auditGitCredential } from '$lib/server/audit'; +import { computeAuditDiff } from '$lib/utils/diff'; export const GET: RequestHandler = async ({ params, cookies }) => { const auth = await authorize(cookies); @@ -42,7 +44,8 @@ export const GET: RequestHandler = async ({ params, cookies }) => { } }; -export const PUT: RequestHandler = async ({ params, request, cookies }) => { +export const PUT: RequestHandler = async (event) => { + const { params, request, cookies } = event; const auth = await authorize(cookies); if (auth.authEnabled && !await auth.can('git', 'edit')) { return json({ error: 'Permission denied' }, { status: 403 }); @@ -78,6 +81,15 @@ export const PUT: RequestHandler = async ({ params, request, cookies }) => { return json({ error: 'Failed to update credential' }, { status: 500 }); } + // Compute diff for audit (only non-sensitive fields) + const diff = computeAuditDiff( + { name: existing.name, authType: existing.authType, username: existing.username }, + { name: credential.name, authType: credential.authType, username: credential.username } + ); + + // Audit log + await auditGitCredential(event, 'update', credential.id, credential.name, diff); + return json({ id: credential.id, name: credential.name, @@ -97,7 +109,8 @@ export const PUT: RequestHandler = async ({ params, request, cookies }) => { } }; -export const DELETE: RequestHandler = async ({ params, cookies }) => { +export const DELETE: RequestHandler = async (event) => { + const { params, cookies } = event; const auth = await authorize(cookies); if (auth.authEnabled && !await auth.can('git', 'delete')) { return json({ error: 'Permission denied' }, { status: 403 }); @@ -109,11 +122,20 @@ export const DELETE: RequestHandler = async ({ params, cookies }) => { return json({ error: 'Invalid credential ID' }, { status: 400 }); } - const deleted = await deleteGitCredential(id); - if (!deleted) { + // Get credential name before deletion for audit log + const credential = await getGitCredential(id); + if (!credential) { return json({ error: 'Credential not found' }, { status: 404 }); } + const deleted = await deleteGitCredential(id); + if (!deleted) { + return json({ error: 'Failed to delete credential' }, { status: 500 }); + } + + // Audit log + await auditGitCredential(event, 'delete', id, credential.name); + return json({ success: true }); } catch (error) { console.error('Failed to delete git credential:', error); diff --git a/src/routes/api/git/preview-env/+server.ts b/src/routes/api/git/preview-env/+server.ts new file mode 100644 index 0000000..ec776e2 --- /dev/null +++ b/src/routes/api/git/preview-env/+server.ts @@ -0,0 +1,92 @@ +import { json } from '@sveltejs/kit'; +import type { RequestHandler } from './$types'; +import { getGitRepository, getGitCredential } from '$lib/server/db'; +import { previewRepoEnvFiles } from '$lib/server/git'; +import { authorize } from '$lib/server/authorize'; + +/** + * POST /api/git/preview-env + * Clone a git repository to a temp directory and read env files for preview. + * Used when creating a new git stack to populate the env editor. + * + * Body: { + * repositoryId?: number, // Existing repository + * url?: string, // OR new repo URL + * branch?: string, // Branch (default: main) + * credentialId?: number, // Credential for auth + * composePath: string, // Path to compose file + * envFilePath?: string // Optional additional env file + * } + * + * Returns: { + * vars: Record, // Merged env variables + * sources: { // Which file each var came from + * [key: string]: '.env' | 'envFile' + * }, + * error?: string + * } + */ +export const POST: RequestHandler = async ({ request, cookies }) => { + const auth = await authorize(cookies); + + // Basic permission check - must be able to create stacks + if (auth.authEnabled && !auth.isAuthenticated) { + return json({ error: 'Authentication required' }, { status: 401 }); + } + + try { + const data = await request.json(); + + if (!data.composePath || typeof data.composePath !== 'string') { + return json({ error: 'Compose path is required' }, { status: 400 }); + } + + let repoUrl: string; + let branch: string = 'main'; + let credentialId: number | null = null; + + if (data.repositoryId) { + // Use existing repository + const repo = await getGitRepository(data.repositoryId); + if (!repo) { + return json({ error: 'Repository not found' }, { status: 404 }); + } + repoUrl = repo.url; + branch = repo.branch; + credentialId = repo.credentialId; + } else if (data.url) { + // New repository details + repoUrl = data.url; + branch = data.branch || 'main'; + credentialId = data.credentialId || null; + } else { + return json({ error: 'Either repositoryId or url is required' }, { status: 400 }); + } + + // Get credential if specified + let credential = null; + if (credentialId) { + credential = await getGitCredential(credentialId); + } + + const result = await previewRepoEnvFiles({ + repoUrl, + branch, + credential, + composePath: data.composePath, + envFilePath: data.envFilePath || null + }); + + if (result.error) { + return json({ vars: {}, sources: {}, error: result.error }, { status: 400 }); + } + + return json({ + vars: result.vars, + sources: result.sources + }); + } catch (error: any) { + console.error('Failed to preview env files:', error); + return json({ error: error.message || 'Failed to preview env files' }, { status: 500 }); + } +}; diff --git a/src/routes/api/git/repositories/+server.ts b/src/routes/api/git/repositories/+server.ts index a75adaa..3644517 100644 --- a/src/routes/api/git/repositories/+server.ts +++ b/src/routes/api/git/repositories/+server.ts @@ -6,6 +6,7 @@ import { getGitCredentials } from '$lib/server/db'; import { authorize } from '$lib/server/authorize'; +import { auditGitRepository } from '$lib/server/audit'; export const GET: RequestHandler = async ({ url, cookies }) => { const auth = await authorize(cookies); @@ -24,7 +25,8 @@ export const GET: RequestHandler = async ({ url, cookies }) => { } }; -export const POST: RequestHandler = async ({ request, cookies }) => { +export const POST: RequestHandler = async (event) => { + const { request, cookies } = event; const auth = await authorize(cookies); if (auth.authEnabled && !await auth.can('git', 'create')) { return json({ error: 'Permission denied' }, { status: 403 }); @@ -59,6 +61,9 @@ export const POST: RequestHandler = async ({ request, cookies }) => { credentialId: data.credentialId || null }); + // Audit log + await auditGitRepository(event, 'create', repository.id, repository.name); + return json(repository); } catch (error: any) { console.error('Failed to create git repository:', error); diff --git a/src/routes/api/git/repositories/[id]/+server.ts b/src/routes/api/git/repositories/[id]/+server.ts index b643a98..6bb5dec 100644 --- a/src/routes/api/git/repositories/[id]/+server.ts +++ b/src/routes/api/git/repositories/[id]/+server.ts @@ -8,6 +8,8 @@ import { } from '$lib/server/db'; import { deleteRepositoryFiles } from '$lib/server/git'; import { authorize } from '$lib/server/authorize'; +import { auditGitRepository } from '$lib/server/audit'; +import { computeAuditDiff } from '$lib/utils/diff'; export const GET: RequestHandler = async ({ params, cookies }) => { const auth = await authorize(cookies); @@ -33,7 +35,8 @@ export const GET: RequestHandler = async ({ params, cookies }) => { } }; -export const PUT: RequestHandler = async ({ params, request, cookies }) => { +export const PUT: RequestHandler = async (event) => { + const { params, request, cookies } = event; const auth = await authorize(cookies); if (auth.authEnabled && !await auth.can('git', 'edit')) { return json({ error: 'Permission denied' }, { status: 403 }); @@ -74,6 +77,12 @@ export const PUT: RequestHandler = async ({ params, request, cookies }) => { return json({ error: 'Failed to update repository' }, { status: 500 }); } + // Compute diff for audit + const diff = computeAuditDiff(existing, repository); + + // Audit log + await auditGitRepository(event, 'update', repository.id, repository.name, diff); + return json(repository); } catch (error: any) { console.error('Failed to update git repository:', error); @@ -84,7 +93,8 @@ export const PUT: RequestHandler = async ({ params, request, cookies }) => { } }; -export const DELETE: RequestHandler = async ({ params, cookies }) => { +export const DELETE: RequestHandler = async (event) => { + const { params, cookies } = event; const auth = await authorize(cookies); if (auth.authEnabled && !await auth.can('git', 'delete')) { return json({ error: 'Permission denied' }, { status: 403 }); @@ -96,14 +106,23 @@ export const DELETE: RequestHandler = async ({ params, cookies }) => { return json({ error: 'Invalid repository ID' }, { status: 400 }); } + // Get repository name before deletion for audit log + const repository = await getGitRepository(id); + if (!repository) { + return json({ error: 'Repository not found' }, { status: 404 }); + } + // Delete repository files first deleteRepositoryFiles(id); const deleted = await deleteGitRepository(id); if (!deleted) { - return json({ error: 'Repository not found' }, { status: 404 }); + return json({ error: 'Failed to delete repository' }, { status: 500 }); } + // Audit log + await auditGitRepository(event, 'delete', id, repository.name); + return json({ success: true }); } catch (error) { console.error('Failed to delete git repository:', error); diff --git a/src/routes/api/git/stacks/+server.ts b/src/routes/api/git/stacks/+server.ts index 83572e2..2422d5a 100644 --- a/src/routes/api/git/stacks/+server.ts +++ b/src/routes/api/git/stacks/+server.ts @@ -6,12 +6,14 @@ import { getGitCredentials, getGitRepository, createGitRepository, - upsertStackSource + upsertStackSource, + setStackEnvVars } from '$lib/server/db'; import { deployGitStack } from '$lib/server/git'; import { authorize } from '$lib/server/authorize'; import { registerSchedule } from '$lib/server/scheduler'; import { secureRandomBytes } from '$lib/server/crypto-fallback'; +import { auditGitStack } from '$lib/server/audit'; // Stack name validation: must start with alphanumeric, can contain alphanumeric, hyphens, underscores const STACK_NAME_REGEX = /^[a-zA-Z0-9][a-zA-Z0-9_-]*$/; @@ -37,7 +39,8 @@ export const GET: RequestHandler = async ({ url, cookies }) => { } }; -export const POST: RequestHandler = async ({ request, cookies }) => { +export const POST: RequestHandler = async (event) => { + const { request, cookies } = event; const auth = await authorize(cookies); try { @@ -132,9 +135,26 @@ export const POST: RequestHandler = async ({ request, cookies }) => { await registerSchedule(gitStack.id, 'git_stack_sync', gitStack.environmentId); } + // Audit log + await auditGitStack(event, 'create', gitStack.id, gitStack.stackName, gitStack.environmentId); + + // Save environment variable overrides before deploying + if (data.envVars && Array.isArray(data.envVars) && data.envVars.length > 0) { + await setStackEnvVars( + trimmedStackName, + data.environmentId || null, + data.envVars.filter((v: any) => v.key?.trim()).map((v: any) => ({ + key: v.key.trim(), + value: v.value ?? '', + isSecret: v.isSecret ?? false + })) + ); + } + // If deployNow is set, deploy immediately if (data.deployNow) { const deployResult = await deployGitStack(gitStack.id); + await auditGitStack(event, 'deploy', gitStack.id, gitStack.stackName, gitStack.environmentId); return json({ ...gitStack, deployResult: deployResult diff --git a/src/routes/api/git/stacks/[id]/+server.ts b/src/routes/api/git/stacks/[id]/+server.ts index 479483c..5345786 100644 --- a/src/routes/api/git/stacks/[id]/+server.ts +++ b/src/routes/api/git/stacks/[id]/+server.ts @@ -1,9 +1,11 @@ import { json } from '@sveltejs/kit'; import type { RequestHandler } from './$types'; -import { getGitStack, updateGitStack, deleteGitStack, deleteStackSource, updateStackSourceName, updateStackEnvVarsName } from '$lib/server/db'; +import { getGitStack, updateGitStack, deleteGitStack, deleteStackSource, updateStackSourceName, updateStackEnvVarsName, setStackEnvVars } from '$lib/server/db'; import { deleteGitStackFiles, deployGitStack } from '$lib/server/git'; import { authorize } from '$lib/server/authorize'; import { registerSchedule, unregisterSchedule } from '$lib/server/scheduler'; +import { auditGitStack } from '$lib/server/audit'; +import { computeAuditDiff } from '$lib/utils/diff'; // Stack name validation: must start with alphanumeric, can contain alphanumeric, hyphens, underscores const STACK_NAME_REGEX = /^[a-zA-Z0-9][a-zA-Z0-9_-]*$/; @@ -30,7 +32,8 @@ export const GET: RequestHandler = async ({ params, cookies }) => { } }; -export const PUT: RequestHandler = async ({ params, request, cookies }) => { +export const PUT: RequestHandler = async (event) => { + const { params, request, cookies } = event; const auth = await authorize(cookies); try { @@ -84,9 +87,33 @@ export const PUT: RequestHandler = async ({ params, request, cookies }) => { unregisterSchedule(id, 'git_stack_sync'); } + // Compute diff for audit (exclude sensitive fields) + const diff = computeAuditDiff(existing, updated, { + excludeFields: ['webhookSecret', 'createdAt', 'updatedAt', 'lastSync', 'lastCommit', 'syncStatus', 'syncError'] + }); + + // Audit log + await auditGitStack(event, 'update', updated.id, updated.stackName, updated.environmentId, diff); + + // Save environment variable overrides before deploying + if (data.envVars && Array.isArray(data.envVars)) { + const stackName = data.stackName || existing.stackName; + const envId = updated.environmentId ?? null; + await setStackEnvVars( + stackName, + envId, + data.envVars.filter((v: any) => v.key?.trim()).map((v: any) => ({ + key: v.key.trim(), + value: v.value ?? '', + isSecret: v.isSecret ?? false + })) + ); + } + // If deployNow is set, deploy after saving if (data.deployNow) { const deployResult = await deployGitStack(id); + await auditGitStack(event, 'deploy', updated.id, updated.stackName, updated.environmentId); return json({ ...updated, deployResult @@ -103,7 +130,8 @@ export const PUT: RequestHandler = async ({ params, request, cookies }) => { } }; -export const DELETE: RequestHandler = async ({ params, cookies }) => { +export const DELETE: RequestHandler = async (event) => { + const { params, cookies } = event; const auth = await authorize(cookies); try { @@ -122,7 +150,7 @@ export const DELETE: RequestHandler = async ({ params, cookies }) => { unregisterSchedule(id, 'git_stack_sync'); // Delete git files first - deleteGitStackFiles(id); + await deleteGitStackFiles(id, existing.stackName, existing.environmentId); // Delete the stack_sources record to free up the stack name await deleteStackSource(existing.stackName, existing.environmentId); @@ -130,6 +158,9 @@ export const DELETE: RequestHandler = async ({ params, cookies }) => { // Delete from database await deleteGitStack(id); + // Audit log + await auditGitStack(event, 'delete', id, existing.stackName, existing.environmentId); + return json({ success: true }); } catch (error) { console.error('Failed to delete git stack:', error); diff --git a/src/routes/api/git/stacks/[id]/deploy/+server.ts b/src/routes/api/git/stacks/[id]/deploy/+server.ts index 64ef0e5..09ed8fb 100644 --- a/src/routes/api/git/stacks/[id]/deploy/+server.ts +++ b/src/routes/api/git/stacks/[id]/deploy/+server.ts @@ -3,8 +3,10 @@ import type { RequestHandler } from './$types'; import { getGitStack } from '$lib/server/db'; import { deployGitStack } from '$lib/server/git'; import { authorize } from '$lib/server/authorize'; +import { auditGitStack } from '$lib/server/audit'; -export const POST: RequestHandler = async ({ params, cookies }) => { +export const POST: RequestHandler = async (event) => { + const { params, cookies } = event; const auth = await authorize(cookies); try { @@ -20,6 +22,10 @@ export const POST: RequestHandler = async ({ params, cookies }) => { } const result = await deployGitStack(id); + + // Audit log + await auditGitStack(event, 'deploy', id, gitStack.stackName, gitStack.environmentId); + return json(result); } catch (error) { console.error('Failed to deploy git stack:', error); diff --git a/src/routes/api/notifications/+server.ts b/src/routes/api/notifications/+server.ts index a04b51a..f2a8abe 100644 --- a/src/routes/api/notifications/+server.ts +++ b/src/routes/api/notifications/+server.ts @@ -7,6 +7,7 @@ import { type NotificationEventType } from '$lib/server/db'; import { authorize } from '$lib/server/authorize'; +import { auditNotification } from '$lib/server/audit'; import type { RequestHandler } from './$types'; export const GET: RequestHandler = async ({ cookies }) => { @@ -32,7 +33,8 @@ export const GET: RequestHandler = async ({ cookies }) => { } }; -export const POST: RequestHandler = async ({ request, cookies }) => { +export const POST: RequestHandler = async (event) => { + const { request, cookies } = event; const auth = await authorize(cookies); if (auth.authEnabled && !await auth.can('notifications', 'create')) { return json({ error: 'Permission denied' }, { status: 403 }); @@ -73,6 +75,9 @@ export const POST: RequestHandler = async ({ request, cookies }) => { eventTypes: resolvedEventTypes as NotificationEventType[] }); + // Audit log + await auditNotification(event, 'create', setting.id, setting.name); + return json(setting); } catch (error: any) { console.error('Error creating notification setting:', error); diff --git a/src/routes/api/notifications/[id]/+server.ts b/src/routes/api/notifications/[id]/+server.ts index 869e0eb..bad5684 100644 --- a/src/routes/api/notifications/[id]/+server.ts +++ b/src/routes/api/notifications/[id]/+server.ts @@ -8,6 +8,8 @@ import { type NotificationEventType } from '$lib/server/db'; import { authorize } from '$lib/server/authorize'; +import { auditNotification } from '$lib/server/audit'; +import { computeAuditDiff } from '$lib/utils/diff'; import type { RequestHandler } from './$types'; export const GET: RequestHandler = async ({ params, cookies }) => { @@ -43,7 +45,8 @@ export const GET: RequestHandler = async ({ params, cookies }) => { } }; -export const PUT: RequestHandler = async ({ params, request, cookies }) => { +export const PUT: RequestHandler = async (event) => { + const { params, request, cookies } = event; const auth = await authorize(cookies); if (auth.authEnabled && !await auth.can('notifications', 'edit')) { return json({ error: 'Permission denied' }, { status: 403 }); @@ -94,6 +97,15 @@ export const PUT: RequestHandler = async ({ params, request, cookies }) => { return json({ error: 'Failed to update notification setting' }, { status: 500 }); } + // Compute diff for audit (exclude config to avoid logging sensitive data) + const diff = computeAuditDiff( + { name: existing.name, enabled: existing.enabled, eventTypes: existing.eventTypes }, + { name: updated.name, enabled: updated.enabled, eventTypes: updated.eventTypes } + ); + + // Audit log + await auditNotification(event, 'update', updated.id, updated.name, diff); + // Don't expose passwords in response const safeSetting = { ...updated, @@ -110,7 +122,8 @@ export const PUT: RequestHandler = async ({ params, request, cookies }) => { } }; -export const DELETE: RequestHandler = async ({ params, cookies }) => { +export const DELETE: RequestHandler = async (event) => { + const { params, cookies } = event; const auth = await authorize(cookies); if (auth.authEnabled && !await auth.can('notifications', 'delete')) { return json({ error: 'Permission denied' }, { status: 403 }); @@ -122,11 +135,20 @@ export const DELETE: RequestHandler = async ({ params, cookies }) => { return json({ error: 'Invalid ID' }, { status: 400 }); } - const deleted = await deleteNotificationSetting(id); - if (!deleted) { + // Get notification name before deletion for audit log + const setting = await getNotificationSetting(id); + if (!setting) { return json({ error: 'Notification setting not found' }, { status: 404 }); } + const deleted = await deleteNotificationSetting(id); + if (!deleted) { + return json({ error: 'Failed to delete notification setting' }, { status: 500 }); + } + + // Audit log + await auditNotification(event, 'delete', id, setting.name); + return json({ success: true }); } catch (error) { console.error('Error deleting notification setting:', error); diff --git a/src/routes/api/notifications/test/+server.ts b/src/routes/api/notifications/test/+server.ts index f6daa2a..8088f20 100644 --- a/src/routes/api/notifications/test/+server.ts +++ b/src/routes/api/notifications/test/+server.ts @@ -45,11 +45,12 @@ export const POST: RequestHandler = async ({ request, cookies }) => { updatedAt: new Date().toISOString() }; - const success = await testNotification(setting); + const result = await testNotification(setting); return json({ - success, - message: success ? 'Test notification sent successfully' : 'Failed to send test notification' + success: result.success, + message: result.success ? 'Test notification sent successfully' : undefined, + error: result.error || (result.success ? undefined : 'Failed to send test notification') }); } catch (error: any) { console.error('Error testing notification:', error); diff --git a/src/routes/api/profile/preferences/+server.ts b/src/routes/api/profile/preferences/+server.ts index c3af288..0077365 100644 --- a/src/routes/api/profile/preferences/+server.ts +++ b/src/routes/api/profile/preferences/+server.ts @@ -45,7 +45,7 @@ export const PUT: RequestHandler = async ({ request, cookies }) => { const validTerminalFontIds = monospaceFonts.map(f => f.id); const validFontSizes = ['xsmall', 'small', 'normal', 'medium', 'large', 'xlarge']; - const updates: { lightTheme?: string; darkTheme?: string; font?: string; fontSize?: string; gridFontSize?: string; terminalFont?: string } = {}; + const updates: { lightTheme?: string; darkTheme?: string; font?: string; fontSize?: string; gridFontSize?: string; terminalFont?: string; editorFont?: string } = {}; if (data.lightTheme !== undefined) { if (!validLightThemeIds.includes(data.lightTheme)) { @@ -89,6 +89,13 @@ export const PUT: RequestHandler = async ({ request, cookies }) => { updates.terminalFont = data.terminalFont; } + if (data.editorFont !== undefined) { + if (!validTerminalFontIds.includes(data.editorFont)) { + return json({ error: 'Invalid editor font' }, { status: 400 }); + } + updates.editorFont = data.editorFont; + } + await setUserThemePreferences(currentUser.id, updates); // Return updated preferences diff --git a/src/routes/api/prune/all/+server.ts b/src/routes/api/prune/all/+server.ts index d7e91b0..b99b42f 100644 --- a/src/routes/api/prune/all/+server.ts +++ b/src/routes/api/prune/all/+server.ts @@ -1,9 +1,11 @@ import { json } from '@sveltejs/kit'; import { pruneAll } from '$lib/server/docker'; import { authorize } from '$lib/server/authorize'; +import { audit } from '$lib/server/audit'; import type { RequestHandler } from './$types'; -export const POST: RequestHandler = async ({ url, cookies }) => { +export const POST: RequestHandler = async (event) => { + const { url, cookies } = event; const auth = await authorize(cookies); const envId = url.searchParams.get('env'); @@ -16,6 +18,15 @@ export const POST: RequestHandler = async ({ url, cookies }) => { try { const result = await pruneAll(envIdNum); + + // Audit log - single entry for prune all operation + await audit(event, 'prune', 'settings', { + environmentId: envIdNum, + entityName: 'system', + description: 'Pruned all unused Docker resources', + details: { result } + }); + return json({ success: true, result }); } catch (error: any) { console.error('Error pruning all:', error?.message || error, error?.stack); diff --git a/src/routes/api/prune/containers/+server.ts b/src/routes/api/prune/containers/+server.ts index e1c353a..0f79803 100644 --- a/src/routes/api/prune/containers/+server.ts +++ b/src/routes/api/prune/containers/+server.ts @@ -1,9 +1,11 @@ import { json } from '@sveltejs/kit'; import { pruneContainers } from '$lib/server/docker'; import { authorize } from '$lib/server/authorize'; +import { audit } from '$lib/server/audit'; import type { RequestHandler } from './$types'; -export const POST: RequestHandler = async ({ url, cookies }) => { +export const POST: RequestHandler = async (event) => { + const { url, cookies } = event; const auth = await authorize(cookies); const envId = url.searchParams.get('env'); @@ -16,6 +18,14 @@ export const POST: RequestHandler = async ({ url, cookies }) => { try { const result = await pruneContainers(envIdNum); + + // Audit log + await audit(event, 'prune', 'container', { + environmentId: envIdNum, + description: 'Pruned stopped containers', + details: { result } + }); + return json({ success: true, result }); } catch (error) { console.error('Error pruning containers:', error); diff --git a/src/routes/api/prune/images/+server.ts b/src/routes/api/prune/images/+server.ts index c6ab88e..eb6c7ee 100644 --- a/src/routes/api/prune/images/+server.ts +++ b/src/routes/api/prune/images/+server.ts @@ -1,9 +1,11 @@ import { json } from '@sveltejs/kit'; import { pruneImages } from '$lib/server/docker'; import { authorize } from '$lib/server/authorize'; +import { audit } from '$lib/server/audit'; import type { RequestHandler } from './$types'; -export const POST: RequestHandler = async ({ url, cookies }) => { +export const POST: RequestHandler = async (event) => { + const { url, cookies } = event; const auth = await authorize(cookies); const envId = url.searchParams.get('env'); @@ -17,6 +19,14 @@ export const POST: RequestHandler = async ({ url, cookies }) => { try { const result = await pruneImages(danglingOnly, envIdNum); + + // Audit log + await audit(event, 'prune', 'image', { + environmentId: envIdNum, + description: `Pruned ${danglingOnly ? 'dangling' : 'unused'} images`, + details: { danglingOnly, result } + }); + return json({ success: true, result }); } catch (error) { console.error('Error pruning images:', error); diff --git a/src/routes/api/prune/networks/+server.ts b/src/routes/api/prune/networks/+server.ts index 775f4dd..a45ae6b 100644 --- a/src/routes/api/prune/networks/+server.ts +++ b/src/routes/api/prune/networks/+server.ts @@ -1,9 +1,11 @@ import { json } from '@sveltejs/kit'; import { pruneNetworks } from '$lib/server/docker'; import { authorize } from '$lib/server/authorize'; +import { audit } from '$lib/server/audit'; import type { RequestHandler } from './$types'; -export const POST: RequestHandler = async ({ url, cookies }) => { +export const POST: RequestHandler = async (event) => { + const { url, cookies } = event; const auth = await authorize(cookies); const envId = url.searchParams.get('env'); @@ -16,6 +18,14 @@ export const POST: RequestHandler = async ({ url, cookies }) => { try { const result = await pruneNetworks(envIdNum); + + // Audit log + await audit(event, 'prune', 'network', { + environmentId: envIdNum, + description: 'Pruned unused networks', + details: { result } + }); + return json({ success: true, result }); } catch (error) { console.error('Error pruning networks:', error); diff --git a/src/routes/api/prune/volumes/+server.ts b/src/routes/api/prune/volumes/+server.ts index 7a6e63e..7b1c995 100644 --- a/src/routes/api/prune/volumes/+server.ts +++ b/src/routes/api/prune/volumes/+server.ts @@ -1,9 +1,11 @@ import { json } from '@sveltejs/kit'; import { pruneVolumes } from '$lib/server/docker'; import { authorize } from '$lib/server/authorize'; +import { audit } from '$lib/server/audit'; import type { RequestHandler } from './$types'; -export const POST: RequestHandler = async ({ url, cookies }) => { +export const POST: RequestHandler = async (event) => { + const { url, cookies } = event; const auth = await authorize(cookies); const envId = url.searchParams.get('env'); @@ -16,6 +18,14 @@ export const POST: RequestHandler = async ({ url, cookies }) => { try { const result = await pruneVolumes(envIdNum); + + // Audit log + await audit(event, 'prune', 'volume', { + environmentId: envIdNum, + description: 'Pruned unused volumes', + details: { result } + }); + return json({ success: true, result }); } catch (error) { console.error('Error pruning volumes:', error); diff --git a/src/routes/api/registries/+server.ts b/src/routes/api/registries/+server.ts index fc25741..2c5744e 100644 --- a/src/routes/api/registries/+server.ts +++ b/src/routes/api/registries/+server.ts @@ -2,6 +2,7 @@ import { json } from '@sveltejs/kit'; import type { RequestHandler } from './$types'; import { getRegistries, createRegistry, setDefaultRegistry } from '$lib/server/db'; import { authorize } from '$lib/server/authorize'; +import { auditRegistry } from '$lib/server/audit'; export const GET: RequestHandler = async ({ cookies }) => { const auth = await authorize(cookies); @@ -23,7 +24,8 @@ export const GET: RequestHandler = async ({ cookies }) => { } }; -export const POST: RequestHandler = async ({ request, cookies }) => { +export const POST: RequestHandler = async (event) => { + const { request, cookies } = event; const auth = await authorize(cookies); if (auth.authEnabled && !await auth.can('registries', 'create')) { return json({ error: 'Permission denied' }, { status: 403 }); @@ -49,6 +51,9 @@ export const POST: RequestHandler = async ({ request, cookies }) => { await setDefaultRegistry(registry.id); } + // Audit log + await auditRegistry(event, 'create', registry.id, registry.name); + // Don't expose password in response const { password, ...safeRegistry } = registry; return json({ ...safeRegistry, hasCredentials: !!password }, { status: 201 }); diff --git a/src/routes/api/registries/[id]/+server.ts b/src/routes/api/registries/[id]/+server.ts index f640a3c..540b526 100644 --- a/src/routes/api/registries/[id]/+server.ts +++ b/src/routes/api/registries/[id]/+server.ts @@ -2,6 +2,8 @@ import { json } from '@sveltejs/kit'; import type { RequestHandler } from './$types'; import { getRegistry, updateRegistry, deleteRegistry, setDefaultRegistry } from '$lib/server/db'; import { authorize } from '$lib/server/authorize'; +import { auditRegistry } from '$lib/server/audit'; +import { computeAuditDiff } from '$lib/utils/diff'; export const GET: RequestHandler = async ({ params, cookies }) => { const auth = await authorize(cookies); @@ -29,7 +31,8 @@ export const GET: RequestHandler = async ({ params, cookies }) => { } }; -export const PUT: RequestHandler = async ({ params, request, cookies }) => { +export const PUT: RequestHandler = async (event) => { + const { params, request, cookies } = event; const auth = await authorize(cookies); if (auth.authEnabled && !await auth.can('registries', 'edit')) { return json({ error: 'Permission denied' }, { status: 403 }); @@ -41,6 +44,12 @@ export const PUT: RequestHandler = async ({ params, request, cookies }) => { return json({ error: 'Invalid registry ID' }, { status: 400 }); } + // Get old values before update for diff + const oldRegistry = await getRegistry(id); + if (!oldRegistry) { + return json({ error: 'Registry not found' }, { status: 404 }); + } + const data = await request.json(); const registry = await updateRegistry(id, { name: data.name, @@ -59,6 +68,12 @@ export const PUT: RequestHandler = async ({ params, request, cookies }) => { await setDefaultRegistry(id); } + // Compute diff for audit + const diff = computeAuditDiff(oldRegistry, registry); + + // Audit log + await auditRegistry(event, 'update', registry.id, registry.name, diff); + // Don't expose password const { password, ...safeRegistry } = registry; return json({ ...safeRegistry, hasCredentials: !!password }); @@ -71,7 +86,8 @@ export const PUT: RequestHandler = async ({ params, request, cookies }) => { } }; -export const DELETE: RequestHandler = async ({ params, cookies }) => { +export const DELETE: RequestHandler = async (event) => { + const { params, cookies } = event; const auth = await authorize(cookies); if (auth.authEnabled && !await auth.can('registries', 'delete')) { return json({ error: 'Permission denied' }, { status: 403 }); @@ -83,11 +99,20 @@ export const DELETE: RequestHandler = async ({ params, cookies }) => { return json({ error: 'Invalid registry ID' }, { status: 400 }); } + // Get registry name before deletion for audit log + const registry = await getRegistry(id); + if (!registry) { + return json({ error: 'Registry not found' }, { status: 404 }); + } + const deleted = await deleteRegistry(id); if (!deleted) { - return json({ error: 'Registry not found or cannot be deleted' }, { status: 404 }); + return json({ error: 'Registry cannot be deleted' }, { status: 400 }); } + // Audit log + await auditRegistry(event, 'delete', id, registry.name); + return json({ success: true }); } catch (error) { console.error('Error deleting registry:', error); diff --git a/src/routes/api/roles/+server.ts b/src/routes/api/roles/+server.ts index 663c9f7..9f6f2f5 100644 --- a/src/routes/api/roles/+server.ts +++ b/src/routes/api/roles/+server.ts @@ -5,6 +5,7 @@ import { createRole as dbCreateRole } from '$lib/server/db'; import { authorize } from '$lib/server/authorize'; +import { auditRole } from '$lib/server/audit'; // GET /api/roles - List all roles export const GET: RequestHandler = async ({ cookies }) => { @@ -26,7 +27,8 @@ export const GET: RequestHandler = async ({ cookies }) => { }; // POST /api/roles - Create a new role -export const POST: RequestHandler = async ({ request, cookies }) => { +export const POST: RequestHandler = async (event) => { + const { request, cookies } = event; const auth = await authorize(cookies); // Check enterprise license @@ -54,6 +56,9 @@ export const POST: RequestHandler = async ({ request, cookies }) => { environmentIds: environmentIds ?? null }); + // Audit log + await auditRole(event, 'create', role.id, role.name); + return json(role, { status: 201 }); } catch (error: any) { console.error('Failed to create role:', error); diff --git a/src/routes/api/roles/[id]/+server.ts b/src/routes/api/roles/[id]/+server.ts index 1e0343a..f2d081a 100644 --- a/src/routes/api/roles/[id]/+server.ts +++ b/src/routes/api/roles/[id]/+server.ts @@ -6,6 +6,8 @@ import { deleteRole as dbDeleteRole } from '$lib/server/db'; import { authorize } from '$lib/server/authorize'; +import { auditRole } from '$lib/server/audit'; +import { computeAuditDiff } from '$lib/utils/diff'; // GET /api/roles/[id] - Get a specific role export const GET: RequestHandler = async ({ params, cookies }) => { @@ -36,7 +38,8 @@ export const GET: RequestHandler = async ({ params, cookies }) => { }; // PUT /api/roles/[id] - Update a role -export const PUT: RequestHandler = async ({ params, request, cookies }) => { +export const PUT: RequestHandler = async (event) => { + const { params, request, cookies } = event; const auth = await authorize(cookies); // Check enterprise license @@ -72,6 +75,12 @@ export const PUT: RequestHandler = async ({ params, request, cookies }) => { return json({ error: 'Failed to update role' }, { status: 500 }); } + // Compute diff for audit + const diff = computeAuditDiff(existingRole, role); + + // Audit log + await auditRole(event, 'update', role.id, role.name, diff); + return json(role); } catch (error: any) { console.error('Failed to update role:', error); @@ -83,7 +92,8 @@ export const PUT: RequestHandler = async ({ params, request, cookies }) => { }; // DELETE /api/roles/[id] - Delete a role -export const DELETE: RequestHandler = async ({ params, cookies }) => { +export const DELETE: RequestHandler = async (event) => { + const { params, cookies } = event; const auth = await authorize(cookies); // Check enterprise license @@ -118,6 +128,9 @@ export const DELETE: RequestHandler = async ({ params, cookies }) => { return json({ error: 'Failed to delete role' }, { status: 500 }); } + // Audit log + await auditRole(event, 'delete', id, role.name); + return json({ success: true }); } catch (error) { console.error('Failed to delete role:', error); diff --git a/src/routes/api/schedules/+server.ts b/src/routes/api/schedules/+server.ts index 6fe3d04..b732920 100644 --- a/src/routes/api/schedules/+server.ts +++ b/src/routes/api/schedules/+server.ts @@ -12,6 +12,7 @@ import { getAllAutoUpdateSettings, getAllAutoUpdateGitStacks, getAllEnvUpdateCheckSettings, + getAllImagePruneSettings, getLastExecutionForSchedule, getRecentExecutionsForSchedule, getEnvironment, @@ -24,7 +25,7 @@ import { getGlobalScannerDefaults, getScannerSettingsWithDefaults } from '$lib/s export interface ScheduleInfo { id: number; - type: 'container_update' | 'git_stack_sync' | 'system_cleanup' | 'env_update_check'; + type: 'container_update' | 'git_stack_sync' | 'system_cleanup' | 'env_update_check' | 'image_prune'; name: string; entityName: string; description?: string; @@ -164,6 +165,45 @@ export const GET: RequestHandler = async () => { ); schedules.push(...envUpdateCheckSchedules); + // Get image prune schedules + const imagePruneConfigs = await getAllImagePruneSettings(); + const imagePruneSchedules = await Promise.all( + imagePruneConfigs.map(async ({ envId, settings }) => { + const [env, lastExecution, recentExecutions, timezone] = await Promise.all([ + getEnvironment(envId), + getLastExecutionForSchedule('image_prune', envId), + getRecentExecutionsForSchedule('image_prune', envId, 5), + getEnvironmentTimezone(envId) + ]); + const isEnabled = settings.enabled ?? false; + const nextRun = isEnabled && settings.cronExpression ? getNextRun(settings.cronExpression, timezone) : null; + + // Build description based on prune mode + const description = settings.pruneMode === 'all' + ? 'Prune all unused images' + : 'Prune dangling images only'; + + return { + id: envId, + type: 'image_prune' as const, + name: `Prune images: ${env?.name || 'Unknown'}`, + entityName: env?.name || 'Unknown', + description, + environmentId: envId, + environmentName: env?.name ?? null, + enabled: isEnabled, + scheduleType: 'custom', + cronExpression: settings.cronExpression ?? null, + nextRun: nextRun?.toISOString() ?? null, + lastExecution: lastExecution ?? null, + recentExecutions, + isSystem: false, + pruneMode: settings.pruneMode + }; + }) + ); + schedules.push(...imagePruneSchedules); + // Get system schedules const systemSchedules = await getSystemSchedules(); const sysSchedules = await Promise.all( diff --git a/src/routes/api/schedules/[type]/[id]/+server.ts b/src/routes/api/schedules/[type]/[id]/+server.ts index 1142c8b..5f229d8 100644 --- a/src/routes/api/schedules/[type]/[id]/+server.ts +++ b/src/routes/api/schedules/[type]/[id]/+server.ts @@ -9,7 +9,8 @@ import { getAutoUpdateSettingById, deleteAutoUpdateSchedule, updateGitStack, - deleteEnvUpdateCheckSettings + deleteEnvUpdateCheckSettings, + deleteImagePruneSettings } from '$lib/server/db'; import { unregisterSchedule } from '$lib/server/scheduler'; @@ -49,6 +50,12 @@ export const DELETE: RequestHandler = async ({ params }) => { unregisterSchedule(scheduleId, 'env_update_check'); return json({ success: true }); + } else if (type === 'image_prune') { + // Delete image prune settings (scheduleId is environmentId) + await deleteImagePruneSettings(scheduleId); + unregisterSchedule(scheduleId, 'image_prune'); + return json({ success: true }); + } else if (type === 'system_cleanup') { return json({ error: 'System schedules cannot be removed' }, { status: 400 }); diff --git a/src/routes/api/schedules/[type]/[id]/run/+server.ts b/src/routes/api/schedules/[type]/[id]/run/+server.ts index ea8c1bb..8f9feba 100644 --- a/src/routes/api/schedules/[type]/[id]/run/+server.ts +++ b/src/routes/api/schedules/[type]/[id]/run/+server.ts @@ -4,13 +4,13 @@ * POST /api/schedules/[type]/[id]/run - Trigger a manual execution * * Path params: - * - type: 'container_update' | 'git_stack_sync' | 'system_cleanup' + * - type: 'container_update' | 'git_stack_sync' | 'system_cleanup' | 'env_update_check' | 'image_prune' * - id: schedule ID */ import { json } from '@sveltejs/kit'; import type { RequestHandler } from './$types'; -import { triggerContainerUpdate, triggerGitStackSync, triggerSystemJob, triggerEnvUpdateCheck } from '$lib/server/scheduler'; +import { triggerContainerUpdate, triggerGitStackSync, triggerSystemJob, triggerEnvUpdateCheck, triggerImagePrune } from '$lib/server/scheduler'; export const POST: RequestHandler = async ({ params }) => { try { @@ -36,6 +36,9 @@ export const POST: RequestHandler = async ({ params }) => { case 'env_update_check': result = await triggerEnvUpdateCheck(scheduleId); break; + case 'image_prune': + result = await triggerImagePrune(scheduleId); + break; default: return json({ error: 'Invalid schedule type' }, { status: 400 }); } diff --git a/src/routes/api/schedules/[type]/[id]/toggle/+server.ts b/src/routes/api/schedules/[type]/[id]/toggle/+server.ts index b81d3a8..9fb9e66 100644 --- a/src/routes/api/schedules/[type]/[id]/toggle/+server.ts +++ b/src/routes/api/schedules/[type]/[id]/toggle/+server.ts @@ -5,7 +5,7 @@ import { json } from '@sveltejs/kit'; import type { RequestHandler } from './$types'; -import { getAutoUpdateSettingById, updateAutoUpdateSettingById, getGitStack, updateGitStack, getEnvUpdateCheckSettings, setEnvUpdateCheckSettings } from '$lib/server/db'; +import { getAutoUpdateSettingById, updateAutoUpdateSettingById, getGitStack, updateGitStack, getEnvUpdateCheckSettings, setEnvUpdateCheckSettings, getImagePruneSettings, setImagePruneSettings } from '$lib/server/db'; import { registerSchedule, unregisterSchedule } from '$lib/server/scheduler'; export const POST: RequestHandler = async ({ params }) => { @@ -75,6 +75,27 @@ export const POST: RequestHandler = async ({ params }) => { unregisterSchedule(scheduleId, 'env_update_check'); } + return json({ success: true, enabled: newEnabled }); + } else if (type === 'image_prune') { + // scheduleId is environmentId for image prune + const config = await getImagePruneSettings(scheduleId); + if (!config) { + return json({ error: 'Schedule not found' }, { status: 404 }); + } + + const newEnabled = !config.enabled; + await setImagePruneSettings(scheduleId, { + ...config, + enabled: newEnabled + }); + + // Register or unregister schedule with croner + if (newEnabled && config.cronExpression) { + await registerSchedule(scheduleId, 'image_prune', scheduleId); + } else { + unregisterSchedule(scheduleId, 'image_prune'); + } + return json({ success: true, enabled: newEnabled }); } else if (type === 'system_cleanup') { return json({ error: 'System schedules cannot be paused' }, { status: 400 }); diff --git a/src/routes/api/schedules/stream/+server.ts b/src/routes/api/schedules/stream/+server.ts index 5b7045f..8bcfc79 100644 --- a/src/routes/api/schedules/stream/+server.ts +++ b/src/routes/api/schedules/stream/+server.ts @@ -9,6 +9,7 @@ import { getAllAutoUpdateSettings, getAllAutoUpdateGitStacks, getAllEnvUpdateCheckSettings, + getAllImagePruneSettings, getLastExecutionForSchedule, getRecentExecutionsForSchedule, getEnvironment, @@ -140,6 +141,45 @@ async function getSchedulesData(): Promise { ); schedules.push(...envUpdateCheckSchedules); + // Get image prune schedules + const imagePruneConfigs = await getAllImagePruneSettings(); + const imagePruneSchedules = await Promise.all( + imagePruneConfigs.map(async ({ envId, settings }) => { + const [env, lastExecution, recentExecutions, timezone] = await Promise.all([ + getEnvironment(envId), + getLastExecutionForSchedule('image_prune', envId), + getRecentExecutionsForSchedule('image_prune', envId, 5), + getEnvironmentTimezone(envId) + ]); + const isEnabled = settings.enabled ?? false; + const nextRun = isEnabled && settings.cronExpression ? getNextRun(settings.cronExpression, timezone) : null; + + // Build description based on prune mode + const description = settings.pruneMode === 'all' + ? 'Prune all unused images' + : 'Prune dangling images only'; + + return { + id: envId, + type: 'image_prune' as const, + name: `Prune images: ${env?.name || 'Unknown'}`, + entityName: env?.name || 'Unknown', + description, + environmentId: envId, + environmentName: env?.name ?? null, + enabled: isEnabled, + scheduleType: 'custom', + cronExpression: settings.cronExpression ?? null, + nextRun: nextRun?.toISOString() ?? null, + lastExecution: lastExecution ?? null, + recentExecutions, + isSystem: false, + pruneMode: settings.pruneMode + }; + }) + ); + schedules.push(...imagePruneSchedules); + // Get system schedules const systemSchedules = await getSystemSchedules(); const sysSchedules = await Promise.all( diff --git a/src/routes/api/settings/general/+server.ts b/src/routes/api/settings/general/+server.ts index 012429d..b49362c 100644 --- a/src/routes/api/settings/general/+server.ts +++ b/src/routes/api/settings/general/+server.ts @@ -64,6 +64,7 @@ export interface GeneralSettings { fontSize: string; gridFontSize: string; terminalFont: string; + editorFont: string; // External stack paths externalStackPaths: string[]; // Primary stack location @@ -89,14 +90,16 @@ const DEFAULT_SETTINGS: Omit { fontSize, gridFontSize, terminalFont, + editorFont, externalStackPaths, primaryStackLocation ] = await Promise.all([ @@ -164,6 +168,7 @@ export const GET: RequestHandler = async ({ cookies }) => { getSetting('theme_font_size'), getSetting('theme_grid_font_size'), getSetting('theme_terminal_font'), + getSetting('theme_editor_font'), getExternalStackPaths(), getPrimaryStackLocation() ]); @@ -194,6 +199,7 @@ export const GET: RequestHandler = async ({ cookies }) => { fontSize: fontSize ?? DEFAULT_SETTINGS.fontSize, gridFontSize: gridFontSize ?? DEFAULT_SETTINGS.gridFontSize, terminalFont: terminalFont ?? DEFAULT_SETTINGS.terminalFont, + editorFont: editorFont ?? DEFAULT_SETTINGS.editorFont, externalStackPaths, primaryStackLocation }; @@ -213,7 +219,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, externalStackPaths, primaryStackLocation } = body; + 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, externalStackPaths, primaryStackLocation } = body; if (confirmDestructive !== undefined) { await setSetting('confirm_destructive', confirmDestructive); @@ -303,6 +309,9 @@ export const POST: RequestHandler = async ({ request, cookies }) => { if (terminalFont !== undefined && VALID_TERMINAL_FONTS.includes(terminalFont)) { await setSetting('theme_terminal_font', terminalFont); } + if (editorFont !== undefined && VALID_EDITOR_FONTS.includes(editorFont)) { + await setSetting('theme_editor_font', editorFont); + } if (externalStackPaths !== undefined && Array.isArray(externalStackPaths)) { // Filter to valid non-empty strings const validPaths = externalStackPaths.filter((p: unknown) => typeof p === 'string' && p.trim()); @@ -345,6 +354,7 @@ export const POST: RequestHandler = async ({ request, cookies }) => { fontSizeVal, gridFontSizeVal, terminalFontVal, + editorFontVal, externalStackPathsVal, primaryStackLocationVal ] = await Promise.all([ @@ -373,6 +383,7 @@ export const POST: RequestHandler = async ({ request, cookies }) => { getSetting('theme_font_size'), getSetting('theme_grid_font_size'), getSetting('theme_terminal_font'), + getSetting('theme_editor_font'), getExternalStackPaths(), getPrimaryStackLocation() ]); @@ -403,6 +414,7 @@ export const POST: RequestHandler = async ({ request, cookies }) => { fontSize: fontSizeVal ?? DEFAULT_SETTINGS.fontSize, gridFontSize: gridFontSizeVal ?? DEFAULT_SETTINGS.gridFontSize, terminalFont: terminalFontVal ?? DEFAULT_SETTINGS.terminalFont, + editorFont: editorFontVal ?? DEFAULT_SETTINGS.editorFont, externalStackPaths: externalStackPathsVal, primaryStackLocation: primaryStackLocationVal }; diff --git a/src/routes/api/settings/theme/+server.ts b/src/routes/api/settings/theme/+server.ts new file mode 100644 index 0000000..697dd2a --- /dev/null +++ b/src/routes/api/settings/theme/+server.ts @@ -0,0 +1,53 @@ +import { json, type RequestHandler } from '@sveltejs/kit'; +import { getSetting } from '$lib/server/db'; + +/** + * Public endpoint for theme settings - no authentication required. + * Used by the login page to apply the app-level theme before user is authenticated. + */ + +const DEFAULT_THEME_SETTINGS = { + lightTheme: 'default', + darkTheme: 'default', + font: 'system', + fontSize: 'normal', + gridFontSize: 'normal', + terminalFont: 'system-mono', + editorFont: 'system-mono' +}; + +export const GET: RequestHandler = async () => { + try { + const [ + lightTheme, + darkTheme, + font, + fontSize, + gridFontSize, + terminalFont, + editorFont + ] = await Promise.all([ + getSetting('theme_light'), + getSetting('theme_dark'), + getSetting('theme_font'), + getSetting('theme_font_size'), + getSetting('theme_grid_font_size'), + getSetting('theme_terminal_font'), + getSetting('theme_editor_font') + ]); + + return json({ + lightTheme: lightTheme ?? DEFAULT_THEME_SETTINGS.lightTheme, + darkTheme: darkTheme ?? DEFAULT_THEME_SETTINGS.darkTheme, + font: font ?? DEFAULT_THEME_SETTINGS.font, + fontSize: fontSize ?? DEFAULT_THEME_SETTINGS.fontSize, + gridFontSize: gridFontSize ?? DEFAULT_THEME_SETTINGS.gridFontSize, + terminalFont: terminalFont ?? DEFAULT_THEME_SETTINGS.terminalFont, + editorFont: editorFont ?? DEFAULT_THEME_SETTINGS.editorFont + }); + } catch (error) { + console.error('Failed to get theme settings:', error); + // Return defaults on error + return json(DEFAULT_THEME_SETTINGS); + } +}; diff --git a/src/routes/api/stacks/+server.ts b/src/routes/api/stacks/+server.ts index dd79529..e155c9b 100644 --- a/src/routes/api/stacks/+server.ts +++ b/src/routes/api/stacks/+server.ts @@ -1,8 +1,9 @@ import { json } from '@sveltejs/kit'; -import { listComposeStacks, deployStack, saveStackComposeFile, saveStackEnvVars, writeRawStackEnvFile, saveStackEnvVarsToDb } from '$lib/server/stacks'; +import { listComposeStacks, deployStack, saveStackComposeFile, writeStackEnvFile, writeRawStackEnvFile, saveStackEnvVarsToDb } from '$lib/server/stacks'; import { EnvironmentNotFoundError } from '$lib/server/docker'; import { upsertStackSource, getStackSources } from '$lib/server/db'; import { authorize } from '$lib/server/authorize'; +import { auditStack } from '$lib/server/audit'; import type { RequestHandler } from './$types'; export const GET: RequestHandler = async ({ url, cookies }) => { @@ -34,6 +35,14 @@ export const GET: RequestHandler = async ({ url, cookies }) => { const stackSources = await getStackSources(envIdNum); const existingNames = new Set(stacks.map((s) => s.name)); + // Enrich Docker-discovered stacks with source type from DB + for (const stack of stacks) { + const source = stackSources.find(s => s.stackName === stack.name); + if (source) { + (stack as any).sourceType = source.sourceType; + } + } + for (const source of stackSources) { // Add stacks from database that aren't already in the Docker list // This includes internal, git, and external (adopted) stacks that are currently down @@ -42,8 +51,9 @@ export const GET: RequestHandler = async ({ url, cookies }) => { name: source.stackName, containers: [], containerDetails: [], - status: 'created' as any - }); + status: 'created' as any, + sourceType: source.sourceType + } as any); } } @@ -58,7 +68,8 @@ export const GET: RequestHandler = async ({ url, cookies }) => { } }; -export const POST: RequestHandler = async ({ request, url, cookies }) => { +export const POST: RequestHandler = async (event) => { + const { request, url, cookies } = event; const auth = await authorize(cookies); const envId = url.searchParams.get('env'); @@ -97,19 +108,20 @@ export const POST: RequestHandler = async ({ request, url, cookies }) => { } // Save environment variables - // - rawEnvContent: non-secret vars with comments → .env file - // - envVars: ALL vars → DB (secrets stored for shell injection, non-secrets for metadata) + // - rawEnvContent → .env file (non-secrets with comments) + // - secrets only → DB (for shell injection at runtime) if (rawEnvContent) { - // Write raw content to .env file (should NOT contain secrets) await writeRawStackEnvFile(name, rawEnvContent, envIdNum, envPath || undefined); } - // Save ALL vars to DB (secrets for shell injection at runtime) if (envVars && Array.isArray(envVars) && envVars.length > 0) { - await saveStackEnvVarsToDb(name, envVars, envIdNum); - } - // Fallback: if no rawEnvContent, generate .env from non-secret vars - if (!rawEnvContent && envVars && Array.isArray(envVars) && envVars.length > 0) { - await saveStackEnvVars(name, envVars, envIdNum, envPath || undefined); + const secrets = envVars.filter((v: any) => v.isSecret); + if (secrets.length > 0) { + await saveStackEnvVarsToDb(name, secrets, envIdNum); + } + // Fallback: if no rawEnvContent, generate .env from non-secret vars + if (!rawEnvContent) { + await writeStackEnvFile(name, envVars, envIdNum, envPath || undefined); + } } // Record the stack as internally created with custom paths if provided @@ -121,6 +133,9 @@ export const POST: RequestHandler = async ({ request, url, cookies }) => { envPath: envPath || undefined }); + // Audit log + await auditStack(event, 'create', name, envIdNum); + return json({ success: true, started: false }); } @@ -132,19 +147,18 @@ export const POST: RequestHandler = async ({ request, url, cookies }) => { // Save environment variables BEFORE deploying so they're available during start if (rawEnvContent || (envVars && Array.isArray(envVars) && envVars.length > 0)) { - // - rawEnvContent: non-secret vars with comments → .env file - // - envVars: ALL vars → DB (secrets stored for shell injection, non-secrets for metadata) if (rawEnvContent) { - // Write raw content to .env file (should NOT contain secrets) await writeRawStackEnvFile(name, rawEnvContent, envIdNum, envPath || undefined); } - // Save ALL vars to DB (secrets for shell injection at runtime) if (envVars && Array.isArray(envVars) && envVars.length > 0) { - await saveStackEnvVarsToDb(name, envVars, envIdNum); - } - // Fallback: if no rawEnvContent, generate .env from non-secret vars - if (!rawEnvContent && envVars && Array.isArray(envVars) && envVars.length > 0) { - await saveStackEnvVars(name, envVars, envIdNum, envPath || undefined); + const secrets = envVars.filter((v: any) => v.isSecret); + if (secrets.length > 0) { + await saveStackEnvVarsToDb(name, secrets, envIdNum); + } + // Fallback: if no rawEnvContent, generate .env from non-secret vars + if (!rawEnvContent) { + await writeStackEnvFile(name, envVars, envIdNum, envPath || undefined); + } } } @@ -170,6 +184,9 @@ export const POST: RequestHandler = async ({ request, url, cookies }) => { envPath: envPath || undefined }); + // Audit log (create + deploy in one action) + await auditStack(event, 'deploy', name, envIdNum); + return json({ success: true, started: true, output: result.output }); } catch (error: any) { console.error('Error creating compose stack:', error); diff --git a/src/routes/api/stacks/[name]/compose/+server.ts b/src/routes/api/stacks/[name]/compose/+server.ts index 750838f..2bb3093 100644 --- a/src/routes/api/stacks/[name]/compose/+server.ts +++ b/src/routes/api/stacks/[name]/compose/+server.ts @@ -70,7 +70,6 @@ export const PUT: RequestHandler = async ({ params, request, url, cookies }) => if (restart) { // Deploy with docker compose up -d --force-recreate // Force recreate ensures env var changes are applied - // Note: deployStack uses requireComposeFile which will use saved paths // Save paths first if provided if (pathOptions) { const saveResult = await saveStackComposeFile(name, content, false, envIdNum, pathOptions); @@ -78,11 +77,15 @@ export const PUT: RequestHandler = async ({ params, request, url, cookies }) => return json({ error: saveResult.error }, { status: 500 }); } } + // Get authoritative paths from DB/filesystem for deploy + const composeInfo = await getStackComposeFile(name, envIdNum); result = await deployStack({ name, compose: content, envId: envIdNum, - forceRecreate: true + forceRecreate: true, + composePath: composeInfo.composePath || undefined, + envPath: composeInfo.envPath || undefined }); } else { // Just save the file without restarting (update operation, not create) diff --git a/src/routes/api/stacks/[name]/env/+server.ts b/src/routes/api/stacks/[name]/env/+server.ts index 0265db6..a4c1ae5 100644 --- a/src/routes/api/stacks/[name]/env/+server.ts +++ b/src/routes/api/stacks/[name]/env/+server.ts @@ -57,9 +57,8 @@ export const GET: RequestHandler = async ({ params, url, cookies }) => { try { const stackName = decodeURIComponent(params.name); - // Get variables from database (masked - secrets show as '***') - const dbVariables = await getStackEnvVars(stackName, envIdNum, true); - const dbByKey = new Map(dbVariables.map(v => [v.key, v])); + // Get secrets from database (masked - values show as '***') + const dbSecrets = await getStackEnvVars(stackName, envIdNum, true); // Check if this stack has a custom compose path configured const source = await getStackSource(stackName, envIdNum); @@ -67,63 +66,49 @@ export const GET: RequestHandler = async ({ params, url, cookies }) => { // Determine the env file path based on path resolution rules: // - envPath = '' (empty string) → explicitly no env file // - envPath = '/path/.env' → use custom path - // - envPath = null with composePath → suggest .env next to compose (but don't auto-load) + // - envPath = null with composePath → .env next to compose // - envPath = null without composePath → use default location let envFilePath: string | null = null; if (source?.envPath === '') { - // Empty string = explicitly no env file envFilePath = null; } else if (source?.envPath) { - // Custom env path specified envFilePath = source.envPath; } else if (source?.composePath) { - // Custom compose path but no env path - suggest .env next to compose - // For loading, check if it exists (but don't fail if it doesn't) envFilePath = join(dirname(source.composePath), '.env'); } else { - // Default location - .env in stack directory const stackDir = await findStackDir(stackName, envIdNum); if (stackDir) { envFilePath = join(stackDir, '.env'); } } - let fileVars: Record = {}; - - if (envFilePath && existsSync(envFilePath)) { - try { - const content = await Bun.file(envFilePath).text(); - fileVars = parseEnvFile(content); - } catch (e) { - // Ignore file read errors - } - } - - // Merge: DB variables (with secrets masked) + file variables (non-secrets only) - // For non-secrets: file value overrides DB value (user may have edited file) - // For secrets: only DB value exists (masked as '***') - const mergedKeys = new Set([...dbByKey.keys(), ...Object.keys(fileVars)]); const variables: { key: string; value: string; isSecret: boolean }[] = []; - for (const key of mergedKeys) { - const dbVar = dbByKey.get(key); - const fileValue = fileVars[key]; - - if (dbVar) { - if (dbVar.isSecret) { - // Secret: use masked value from DB, ignore any file value - variables.push({ key, value: dbVar.value, isSecret: true }); - } else if (fileValue !== undefined) { - // Non-secret with file value: file overrides (user may have edited) - variables.push({ key, value: fileValue, isSecret: false }); - } else { - // Non-secret only in DB: use DB value - variables.push({ key, value: dbVar.value, isSecret: false }); + if (source?.sourceType === 'git') { + // Git stacks: ALL vars (overrides + secrets) come from DB + for (const dbVar of dbSecrets) { + variables.push({ key: dbVar.key, value: dbVar.value, isSecret: dbVar.isSecret }); + } + } else { + // Internal/adopted stacks: non-secrets from file, secrets from DB + if (envFilePath && existsSync(envFilePath)) { + try { + const content = await Bun.file(envFilePath).text(); + const fileVars = parseEnvFile(content); + for (const [key, value] of Object.entries(fileVars)) { + variables.push({ key, value, isSecret: false }); + } + } catch { + // Ignore file read errors + } + } + + // Secrets come from the database (never written to file) + for (const secret of dbSecrets) { + if (secret.isSecret) { + variables.push({ key: secret.key, value: secret.value, isSecret: true }); } - } else if (fileValue !== undefined) { - // Variable only in file - add it as non-secret - variables.push({ key, value: fileValue, isSecret: false }); } } @@ -136,15 +121,14 @@ export const GET: RequestHandler = async ({ params, url, cookies }) => { /** * PUT /api/stacks/[name]/env?env=X - * Set/replace all environment variables for a stack. - * Body: { variables: [{ key, value, isSecret? }] } + * Save secret environment variables for a stack. + * Body: { variables: [{ key, value, isSecret }] } * - * SECURITY: Secrets are stored ONLY in the database, NEVER written to .env file. - * For secrets, if the value is '***' (the masked placeholder), the original - * secret value from the database is preserved instead of overwriting with '***'. + * Only secrets are stored in the database. Non-secret variables live in the + * .env file (written by PUT /env/raw) and are read directly by Docker Compose. * - * The .env file only contains non-secret variables (can be edited manually). - * Secrets are injected via shell environment variables at runtime. + * If a secret's value is '***' (masked placeholder), the original value + * from the database is preserved. */ export const PUT: RequestHandler = async ({ params, url, cookies, request }) => { const auth = await authorize(cookies); @@ -177,14 +161,12 @@ export const PUT: RequestHandler = async ({ params, url, cookies, request }) => if (typeof v.value !== 'string') { return json({ error: `Invalid variable "${v.key}": value must be a string` }, { status: 400 }); } - // Validate key format (env var naming convention) if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(v.key)) { return json({ error: `Invalid variable name "${v.key}": must start with a letter or underscore and contain only alphanumeric characters and underscores` }, { status: 400 }); } } - // Check if any secrets have the masked placeholder '***' - // If so, we need to preserve their original values from the database + // Preserve masked secret values ('***') from the database const secretsWithMaskedValue = body.variables.filter( (v: { key: string; value: string; isSecret?: boolean }) => v.isSecret && v.value === '***' @@ -193,16 +175,13 @@ export const PUT: RequestHandler = async ({ params, url, cookies, request }) => let variablesToSave = body.variables; if (secretsWithMaskedValue.length > 0) { - // Get existing variables (unmasked) to preserve secret values const existingVars = await getStackEnvVars(stackName, envIdNum, false); const existingByKey = new Map(existingVars.map(v => [v.key, v])); - // Replace masked secrets with their original values variablesToSave = body.variables.map((v: { key: string; value: string; isSecret?: boolean }) => { if (v.isSecret && v.value === '***') { const existing = existingByKey.get(v.key); if (existing && existing.isSecret) { - // Preserve the original secret value return { ...v, value: existing.value }; } } @@ -210,8 +189,7 @@ export const PUT: RequestHandler = async ({ params, url, cookies, request }) => }); } - // Save ALL variables (including secrets) to database - // Note: The .env file is written by PUT /env/raw endpoint, which preserves comments + // Save secrets to database (non-secrets live in the .env file) await setStackEnvVars(stackName, envIdNum, variablesToSave); return json({ success: true, count: variablesToSave.length }); diff --git a/src/routes/api/stacks/default-path/+server.ts b/src/routes/api/stacks/default-path/+server.ts index 88f67f6..a4e77c9 100644 --- a/src/routes/api/stacks/default-path/+server.ts +++ b/src/routes/api/stacks/default-path/+server.ts @@ -49,6 +49,6 @@ export const GET: RequestHandler = async ({ url }) => { stackDir, composePath: `${stackDir}/compose.yaml`, envPath: `${stackDir}/.env`, - source: location ? 'custom' : 'default' + source: 'default' }); }; diff --git a/src/routes/api/system/+server.ts b/src/routes/api/system/+server.ts index 118fb4f..7c27e54 100644 --- a/src/routes/api/system/+server.ts +++ b/src/routes/api/system/+server.ts @@ -8,7 +8,7 @@ import { listNetworks, getDockerConnectionInfo } from '$lib/server/docker'; -import { listManagedStacks } from '$lib/server/stacks'; +import { getStackSources } from '$lib/server/db'; import { isPostgres, isSqlite, getDatabaseSchemaVersion, getPostgresConnectionInfo } from '$lib/server/db/drizzle'; import { hasEnvironments } from '$lib/server/db'; import type { RequestHandler } from './$types'; @@ -149,7 +149,7 @@ export const GET: RequestHandler = async ({ url, cookies }) => { } } - const stacks = listManagedStacks(); + const stacks = await getStackSources(); const runningContainers = containers.filter(c => c.state === 'running').length; const stoppedContainers = containers.length - runningContainers; diff --git a/src/routes/api/users/+server.ts b/src/routes/api/users/+server.ts index 4d3616d..41f1b2b 100644 --- a/src/routes/api/users/+server.ts +++ b/src/routes/api/users/+server.ts @@ -11,6 +11,7 @@ import { } from '$lib/server/db'; import { hashPassword, createUserSession } from '$lib/server/auth'; import { authorize } from '$lib/server/authorize'; +import { auditUser } from '$lib/server/audit'; // GET /api/users - List all users // Free for all - local users are needed for basic auth @@ -63,7 +64,8 @@ export const GET: RequestHandler = async ({ cookies }) => { // POST /api/users - Create a new user // Free for all - local users are needed for basic auth -export const POST: RequestHandler = async ({ request, cookies }) => { +export const POST: RequestHandler = async (event) => { + const { request, cookies } = event; const auth = await authorize(cookies); // When auth is enabled and user is logged in, check they can manage users @@ -116,6 +118,9 @@ export const POST: RequestHandler = async ({ request, cookies }) => { autoLoggedIn = true; } + // Audit log + await auditUser(event, 'create', user.id, user.username); + return json({ id: user.id, username: user.username, diff --git a/src/routes/api/users/[id]/+server.ts b/src/routes/api/users/[id]/+server.ts index 92bcd48..d11aeb5 100644 --- a/src/routes/api/users/[id]/+server.ts +++ b/src/routes/api/users/[id]/+server.ts @@ -14,6 +14,8 @@ import { } from '$lib/server/db'; import { hashPassword } from '$lib/server/auth'; import { authorize } from '$lib/server/authorize'; +import { auditUser } from '$lib/server/audit'; +import { computeAuditDiff } from '$lib/utils/diff'; // GET /api/users/[id] - Get a specific user // Free for all - local users are needed for basic auth @@ -60,7 +62,8 @@ export const GET: RequestHandler = async ({ params, cookies }) => { // PUT /api/users/[id] - Update a user // Free for all - local users are needed for basic auth -export const PUT: RequestHandler = async ({ params, request, cookies }) => { +export const PUT: RequestHandler = async (event) => { + const { params, request, cookies } = event; const auth = await authorize(cookies); if (!params.id) { @@ -203,6 +206,15 @@ export const PUT: RequestHandler = async ({ params, request, cookies }) => { // Compute final isAdmin status const finalIsAdmin = shouldPromote || (existingUserIsAdmin && !shouldDemote); + // Compute diff for audit (exclude sensitive fields) + const diff = computeAuditDiff( + { username: existingUser.username, email: existingUser.email, displayName: existingUser.displayName, isActive: existingUser.isActive, isAdmin: existingUserIsAdmin }, + { username: user.username, email: user.email, displayName: user.displayName, isActive: user.isActive, isAdmin: finalIsAdmin } + ); + + // Audit log + await auditUser(event, 'update', user.id, user.username, diff); + return json({ id: user.id, username: user.username, @@ -226,7 +238,8 @@ export const PUT: RequestHandler = async ({ params, request, cookies }) => { // DELETE /api/users/[id] - Delete a user // Free for all - local users are needed for basic auth -export const DELETE: RequestHandler = async ({ params, url, cookies }) => { +export const DELETE: RequestHandler = async (event) => { + const { params, url, cookies } = event; const auth = await authorize(cookies); // When auth is enabled, check permission (free edition allows all, enterprise checks RBAC) @@ -279,6 +292,9 @@ export const DELETE: RequestHandler = async ({ params, url, cookies }) => { // Disable authentication await updateAuthSettings({ authEnabled: false }); + // Audit log + await auditUser(event, 'delete', id, user.username); + return json({ success: true, authDisabled: true }); } } @@ -291,6 +307,9 @@ export const DELETE: RequestHandler = async ({ params, url, cookies }) => { return json({ error: 'Failed to delete user' }, { status: 500 }); } + // Audit log + await auditUser(event, 'delete', id, user.username); + return json({ success: true }); } catch (error) { console.error('Failed to delete user:', error); diff --git a/src/routes/api/users/[id]/mfa/+server.ts b/src/routes/api/users/[id]/mfa/+server.ts index d767452..f9749eb 100644 --- a/src/routes/api/users/[id]/mfa/+server.ts +++ b/src/routes/api/users/[id]/mfa/+server.ts @@ -6,9 +6,12 @@ import { verifyAndEnableMfa, disableMfa } from '$lib/server/auth'; +import { auditUser } from '$lib/server/audit'; +import { getUser } from '$lib/server/db'; // POST /api/users/[id]/mfa - Setup MFA (generate QR code) -export const POST: RequestHandler = async ({ params, request, cookies }) => { +export const POST: RequestHandler = async (event) => { + const { params, request, cookies } = event; const currentUser = await validateSession(cookies); if (!params.id) { @@ -36,6 +39,15 @@ export const POST: RequestHandler = async ({ params, request, cookies }) => { return json({ error: 'Invalid MFA code' }, { status: 400 }); } + // Audit log - MFA enabled + const targetUser = await getUser(userId); + if (targetUser) { + await auditUser(event, 'update', userId, targetUser.username, { + mfaEnabled: true, + enabledBy: currentUser?.id === userId ? 'self' : currentUser?.username + }); + } + return json({ success: true, message: 'MFA enabled successfully', @@ -60,7 +72,8 @@ export const POST: RequestHandler = async ({ params, request, cookies }) => { }; // DELETE /api/users/[id]/mfa - Disable MFA -export const DELETE: RequestHandler = async ({ params, cookies }) => { +export const DELETE: RequestHandler = async (event) => { + const { params, cookies } = event; const currentUser = await validateSession(cookies); if (!params.id) { @@ -75,11 +88,23 @@ export const DELETE: RequestHandler = async ({ params, cookies }) => { } try { - const success = await disableMfa(userId); - if (!success) { + // Get user info before disabling for audit + const targetUser = await getUser(userId); + if (!targetUser) { return json({ error: 'User not found' }, { status: 404 }); } + const success = await disableMfa(userId); + if (!success) { + return json({ error: 'Failed to disable MFA' }, { status: 500 }); + } + + // Audit log - MFA disabled + await auditUser(event, 'update', userId, targetUser.username, { + mfaDisabled: true, + disabledBy: currentUser?.id === userId ? 'self' : currentUser?.username + }); + return json({ success: true, message: 'MFA disabled successfully' }); } catch (error) { console.error('MFA disable error:', error); diff --git a/src/routes/api/users/[id]/roles/+server.ts b/src/routes/api/users/[id]/roles/+server.ts index 324d276..35bdc6a 100644 --- a/src/routes/api/users/[id]/roles/+server.ts +++ b/src/routes/api/users/[id]/roles/+server.ts @@ -6,8 +6,10 @@ import { getUserRoles, assignUserRole, removeUserRole, - getUser + getUser, + getRole } from '$lib/server/db'; +import { auditUser } from '$lib/server/audit'; // GET /api/users/[id]/roles - Get roles assigned to a user export const GET: RequestHandler = async ({ params, cookies }) => { @@ -37,7 +39,8 @@ export const GET: RequestHandler = async ({ params, cookies }) => { }; // POST /api/users/[id]/roles - Assign a role to a user -export const POST: RequestHandler = async ({ params, request, cookies }) => { +export const POST: RequestHandler = async (event) => { + const { params, request, cookies } = event; // Check enterprise license if (!(await isEnterprise())) { return json({ error: 'Enterprise license required' }, { status: 403 }); @@ -66,6 +69,14 @@ export const POST: RequestHandler = async ({ params, request, cookies }) => { } const userRole = await assignUserRole(userId, roleId, environmentId); + + // Audit log - role assigned + const role = await getRole(roleId); + await auditUser(event, 'update', userId, user.username, { + roleAssigned: role?.name || `Role #${roleId}`, + roleId + }); + return json(userRole, { status: 201 }); } catch (error) { console.error('Failed to assign role:', error); @@ -74,7 +85,8 @@ export const POST: RequestHandler = async ({ params, request, cookies }) => { }; // DELETE /api/users/[id]/roles - Remove a role from a user -export const DELETE: RequestHandler = async ({ params, request, cookies }) => { +export const DELETE: RequestHandler = async (event) => { + const { params, request, cookies } = event; // Check enterprise license if (!(await isEnterprise())) { return json({ error: 'Enterprise license required' }, { status: 403 }); @@ -97,11 +109,23 @@ export const DELETE: RequestHandler = async ({ params, request, cookies }) => { return json({ error: 'Role ID is required' }, { status: 400 }); } + // Get user and role info before deletion for audit + const user = await getUser(userId); + const role = await getRole(roleId); + const deleted = await removeUserRole(userId, roleId, environmentId); if (!deleted) { return json({ error: 'Role assignment not found' }, { status: 404 }); } + // Audit log - role removed + if (user) { + await auditUser(event, 'update', userId, user.username, { + roleRemoved: role?.name || `Role #${roleId}`, + roleId + }); + } + return json({ success: true }); } catch (error) { console.error('Failed to remove role:', error); diff --git a/src/routes/audit/+page.svelte b/src/routes/audit/+page.svelte index 6dbc957..99bb323 100644 --- a/src/routes/audit/+page.svelte +++ b/src/routes/audit/+page.svelte @@ -1,13 +1,11 @@ @@ -663,390 +593,352 @@
-
- - {#if $licenseStore.isEnterprise && total > 0} - - Showing {visibleStart}-{visibleEnd} of {total} - - {/if} - - {#if $licenseStore.isEnterprise} -
- - - - {$auditSseConnected ? 'Live' : 'Connecting'} - - -
- - {#if showExportMenu} -
- - - -
- {/if} -
-
- {/if} +
+
+ 0 ? `${visibleStart}-${visibleEnd}` : undefined} total={total > 0 ? total : undefined} countClass="min-w-32" />
- - {#if $licenseStore.loading} - -
- -

Loading...

-
- {:else if !$licenseStore.isEnterprise} - -
-
- -
-

Enterprise feature

-

- Audit logging is an enterprise feature that tracks all user actions for compliance and security monitoring. -

- -
- {:else} - -
-
-
- - Filters -
- - - - - - {#if filterUsernames.length === 0} - All users - {:else if filterUsernames.length === 1} - {filterUsernames[0]} - {:else} - {filterUsernames.length} users - {/if} - - - - {#if filterUsernames.length > 0} - + {#if $licenseStore.isEnterprise} +
+ + + + + + {#if filterUsernames.length === 0} + User + {:else if filterUsernames.length === 1} + {filterUsernames[0]} + {:else} + {filterUsernames.length} users {/if} - {#each users as user} - - - {user} - - {/each} - - + + + + {#if filterUsernames.length > 0} + + {/if} + {#each users as user} + + + {user} + + {/each} + + - - - - - - {#if filterEntityTypes.length === 0} - All entities - {:else if filterEntityTypes.length === 1} - {entityTypes.find(e => e.value === filterEntityTypes[0])?.label || filterEntityTypes[0]} - {:else} - {filterEntityTypes.length} entities - {/if} - - - - {#if filterEntityTypes.length > 0} - + + + + + + {#if filterEntityTypes.length === 0} + Entity + {:else if filterEntityTypes.length === 1} + {entityTypes.find(e => e.value === filterEntityTypes[0])?.label || filterEntityTypes[0]} + {:else} + {filterEntityTypes.length} entities {/if} - {#each entityTypes as type} - - - {type.label} - - {/each} - - + + + + {#if filterEntityTypes.length > 0} + + {/if} + {#each entityTypes as type} + + + {type.label} + + {/each} + + - - - - - - {#if filterActions.length === 0} - All actions - {:else if filterActions.length === 1} - {actionTypes.find(a => a.value === filterActions[0])?.label || filterActions[0]} - {:else} - {filterActions.length} actions - {/if} - - - - {#if filterActions.length > 0} - + + + + + + {#if filterActions.length === 0} + Action + {:else if filterActions.length === 1} + {actionTypes.find(a => a.value === filterActions[0])?.label || filterActions[0]} + {:else} + {filterActions.length} actions {/if} - {#each actionTypes as action} - - - {action.label} - - {/each} - - + + + + {#if filterActions.length > 0} + + {/if} + {#each actionTypes as action} + + + {action.label} + + {/each} + + - - {#if environments.length > 0} - {@const selectedEnv = environments.find(e => e.id === filterEnvironmentId)} - {@const SelectedEnvIcon = selectedEnv ? getIconComponent(selectedEnv.icon || 'globe') : Server} - filterEnvironmentId = v ? parseInt(v) : null} - > - - - - {#if filterEnvironmentId === null} - All environments - {:else} - {selectedEnv?.name || 'Environment'} - {/if} - - - - - - All environments - - {#each environments as env} - {@const EnvIcon = getIconComponent(env.icon || 'globe')} - - - {env.name} - - {/each} - - - {/if} - - + + {#if environments.length > 0} + {@const selectedEnv = environments.find(e => e.id === filterEnvironmentId)} + {@const SelectedEnvIcon = selectedEnv ? getIconComponent(selectedEnv.icon || 'globe') : Server} { - selectedDatePreset = v || ''; - if (v !== 'custom') { - applyDatePreset(v || ''); - } - }} + value={filterEnvironmentId !== null ? String(filterEnvironmentId) : undefined} + onValueChange={(v) => filterEnvironmentId = v ? parseInt(v) : null} > - - + + - {#if selectedDatePreset === 'custom'} - Custom - {:else if selectedDatePreset} - {datePresets.find(d => d.value === selectedDatePreset)?.label || 'All time'} + {#if filterEnvironmentId === null} + Environment {:else} - All time + {selectedEnv?.name || 'Environment'} {/if} - All time - {#each datePresets as preset} - {preset.label} + + + All environments + + {#each environments as env} + {@const EnvIcon = getIconComponent(env.icon || 'globe')} + + + {env.name} + {/each} - Custom range... + {/if} - - {#if selectedDatePreset === 'custom'} - - - {/if} - - - {#if filterUsernames.length > 0 || filterEntityTypes.length > 0 || filterActions.length > 0 || filterEnvironmentId !== null || selectedDatePreset} - - {/if} -
-
- - -
- -
- -
-
Timestamp
-
Environment
-
User
-
Action
-
Entity
-
Name
-
IP address
-
-
-
- - -
+ { + selectedDatePreset = v || ''; + if (v !== 'custom') { + applyDatePreset(v || ''); + } + }} > - {#if loading || !initialized} -
- - Loading... -
- {:else if logs.length === 0} -
- -

No audit log entries found

-
- {:else} - -
-
- {#each visibleLogs as log (log.id)} -
showDetails(log)} - role="button" - tabindex="0" - onkeydown={(e) => e.key === 'Enter' && showDetails(log)} - > -
- {formatTimestamp(log.createdAt)} -
-
- {#if log.environmentName} - {@const LogEnvIcon = getIconComponent(log.environmentIcon || 'globe')} -
- - {log.environmentName} -
- {:else} - - - {/if} -
-
-
- - {log.username} -
-
-
- - - -
-
-
- - {log.entityType} -
-
-
- - {log.entityName || log.entityId || '-'} - -
-
- {log.ipAddress || '-'} -
-
- -
-
- {/each} -
-
+ + + + {#if selectedDatePreset === 'custom'} + Custom + {:else if selectedDatePreset} + {datePresets.find(d => d.value === selectedDatePreset)?.label || 'All time'} + {:else} + All time + {/if} + + + + All time + {#each datePresets as preset} + {preset.label} + {/each} + Custom range... + +
- - {#if loadingMore} -
- - Loading more... -
- {/if} + + {#if selectedDatePreset === 'custom'} + + + {/if} - - {#if !hasMore && logs.length > 0} -
- End of results ({total.toLocaleString()} entries) -
- {/if} + + + + + + + + + + +
+ + {#if showExportMenu} +
+ + + +
{/if}
{/if} +
+ + {#if $licenseStore.loading} +
+ +

Loading...

+
+ {:else if !$licenseStore.isEnterprise} +
+
+ +
+

Enterprise feature

+

+ Audit logging is an enterprise feature that tracks all user actions for compliance and security monitoring. +

+ +
+ {:else} + showDetails(log)} + class="border-none" + wrapperClass="border rounded-lg" + > + {#snippet cell(column, log, rowState)} + {#if column.id === 'timestamp'} + {formatTimestamp(log.createdAt)} + {:else if column.id === 'environment'} + {#if log.environmentName} + {@const LogEnvIcon = getIconComponent(log.environmentIcon || 'globe')} +
+ + {log.environmentName} +
+ {:else} + - + {/if} + {:else if column.id === 'user'} +
+ + {log.username} +
+ {:else if column.id === 'action'} +
+ + + +
+ {:else if column.id === 'entity'} +
+ + {log.entityType} +
+ {:else if column.id === 'name'} + + {log.entityName || log.entityId || '-'} + + {:else if column.id === 'ip'} + + {log.ipAddress || '-'} + + {:else if column.id === 'actions'} +
+ +
+ {/if} + {/snippet} + + {#snippet emptyState()} +
+ +

No audit log entries found

+
+ {/snippet} + + {#snippet loadingState()} +
+ + Loading... +
+ {/snippet} + + {#snippet footer()} + {#if loadingMore} +
+ + Loading more... +
+ {:else if !hasMore && logs.length > 0} +
+ End of results ({total.toLocaleString()} entries) +
+ {/if} + {/snippet} +
+ {/if}
@@ -1125,7 +1017,12 @@
{/if} - {#if selectedLog.details} + {#if selectedLog.details?.changes} +
+ + +
+ {:else if selectedLog.details}
{JSON.stringify(selectedLog.details, null, 2)}
diff --git a/src/routes/containers/+page.svelte b/src/routes/containers/+page.svelte index 9c8e94b..d6042a4 100644 --- a/src/routes/containers/+page.svelte +++ b/src/routes/containers/+page.svelte @@ -98,7 +98,7 @@ return parseFloat((bytes / Math.pow(k, i)).toFixed(decimals)) + sizes[i]; } - type SortField = 'name' | 'image' | 'state' | 'uptime' | 'stack' | 'ip' | 'cpu' | 'memory'; + type SortField = 'name' | 'image' | 'state' | 'health' | 'uptime' | 'stack' | 'ip' | 'cpu' | 'memory'; type SortDirection = 'asc' | 'desc'; let containers = $state([]); @@ -701,6 +701,12 @@ cmp = (stateOrder[a.state.toLowerCase() as keyof typeof stateOrder] ?? 4) - (stateOrder[b.state.toLowerCase() as keyof typeof stateOrder] ?? 4); break; + case 'health': + const healthOrder: Record = { unhealthy: 0, starting: 1, healthy: 2 }; + const healthA = a.health ? (healthOrder[a.health] ?? 1) : 3; + const healthB = b.health ? (healthOrder[b.health] ?? 1) : 3; + cmp = healthA - healthB; + break; case 'uptime': cmp = parseUptimeToSeconds(a.status) - parseUptimeToSeconds(b.status); break; diff --git a/src/routes/containers/ContainerInspectModal.svelte b/src/routes/containers/ContainerInspectModal.svelte index abaf737..5fb9b0a 100644 --- a/src/routes/containers/ContainerInspectModal.svelte +++ b/src/routes/containers/ContainerInspectModal.svelte @@ -4,7 +4,7 @@ import * as Tabs from '$lib/components/ui/tabs'; import { Button } from '$lib/components/ui/button'; import { Badge } from '$lib/components/ui/badge'; - import { Loader2, Box, Info, Layers, Cpu, MemoryStick, HardDrive, Network, Shield, Settings2, Code, Copy, Check, Activity, Wifi, Pencil, RefreshCw, X, FolderOpen, Moon, Tags, ExternalLink } from 'lucide-svelte'; + import { Loader2, Box, Info, Layers, Cpu, MemoryStick, HardDrive, Network, Shield, Settings2, Code, Copy, Check, Activity, Wifi, Pencil, RefreshCw, X, FolderOpen, Moon, Tags, ExternalLink, Gpu } from 'lucide-svelte'; import { Input } from '$lib/components/ui/input'; import { Label } from '$lib/components/ui/label'; import { currentEnvironment, appendEnvParam, environments } from '$lib/stores/environment'; @@ -1193,6 +1193,57 @@
{/if} + + {#if containerData.HostConfig?.DeviceRequests?.length > 0 || (containerData.HostConfig?.Runtime && containerData.HostConfig.Runtime !== 'runc')} +
+

+ + GPU +

+
+ {#if containerData.HostConfig?.Runtime} +
+

Runtime

+ {containerData.HostConfig.Runtime} +
+ {/if} + {#if containerData.HostConfig?.DeviceRequests?.length > 0} + {@const req = containerData.HostConfig.DeviceRequests[0]} +
+

Count

+ {req.Count === -1 ? 'All' : req.Count} +
+ {#if req.Driver} +
+

Driver

+ {req.Driver} +
+ {/if} + {#if req.DeviceIDs?.length > 0} +
+

Device IDs

+
+ {#each req.DeviceIDs as id} + {id} + {/each} +
+
+ {/if} + {#if req.Capabilities?.length > 0} +
+

Capabilities

+
+ {#each req.Capabilities.flat() as cap} + {cap} + {/each} +
+
+ {/if} + {/if} +
+
+ {/if} +

Cgroup settings

diff --git a/src/routes/containers/ContainerSettingsTab.svelte b/src/routes/containers/ContainerSettingsTab.svelte index a0e3e0f..94c6702 100644 --- a/src/routes/containers/ContainerSettingsTab.svelte +++ b/src/routes/containers/ContainerSettingsTab.svelte @@ -5,7 +5,7 @@ import { Button } from '$lib/components/ui/button'; import { Checkbox } from '$lib/components/ui/checkbox'; import { TogglePill, ToggleGroup } from '$lib/components/ui/toggle-pill'; - import { Plus, Trash2, Settings2, RefreshCw, Network, X, Ban, RotateCw, AlertTriangle, PauseCircle, Share2, Server, CircleOff, ChevronDown, ChevronRight, Cpu, Shield, HeartPulse, Wifi, HardDrive, Lock, Loader2, CheckCircle2, Package } from 'lucide-svelte'; + import { Plus, Trash2, Settings2, RefreshCw, Network, X, Ban, RotateCw, AlertTriangle, PauseCircle, Share2, Server, CircleOff, ChevronDown, ChevronRight, Cpu, Shield, HeartPulse, Wifi, HardDrive, Lock, Loader2, CheckCircle2, Package, Gpu } from 'lucide-svelte'; import { Badge } from '$lib/components/ui/badge'; import AutoUpdateSettings from './AutoUpdateSettings.svelte'; import type { VulnerabilityCriteria } from '$lib/components/VulnerabilityCriteriaSelector.svelte'; @@ -40,6 +40,8 @@ const commonUlimits = ['nofile', 'nproc', 'core', 'memlock', 'stack', 'cpu', 'fsize', 'locks']; + const commonGpuCapabilities = ['gpu', 'compute', 'utility', 'graphics', 'video', 'display']; + interface ConfigSet { id: number; name: string; @@ -104,6 +106,14 @@ securityOptions: string[]; // Devices deviceMappings: { hostPath: string; containerPath: string; permissions: string }[]; + // GPU settings + gpuEnabled: boolean; + gpuMode: 'all' | 'count' | 'specific'; + gpuCount: number; + gpuDeviceIds: string[]; + gpuDriver: string; + gpuCapabilities: string[]; + runtime: string; // DNS settings dnsServers: string[]; dnsSearch: string[]; @@ -166,6 +176,13 @@ capDrop = $bindable(), securityOptions = $bindable(), deviceMappings = $bindable(), + gpuEnabled = $bindable(), + gpuMode = $bindable(), + gpuCount = $bindable(), + gpuDeviceIds = $bindable(), + gpuDriver = $bindable(), + gpuCapabilities = $bindable(), + runtime = $bindable(), dnsServers = $bindable(), dnsSearch = $bindable(), dnsOptions = $bindable(), @@ -187,6 +204,7 @@ let showHealth = $state(false); let showDns = $state(false); let showDevices = $state(false); + let showGpu = $state(false); let showUlimits = $state(false); // DNS input fields @@ -197,6 +215,10 @@ // Security options input let securityOptionInput = $state(''); + // GPU device ID input + let gpuDeviceIdInput = $state(''); + let customRuntimeInput = $state(''); + // Helper functions for form function addPortMapping() { portMappings = [...portMappings, { hostPort: '', containerPort: '', protocol: 'tcp' }]; @@ -256,6 +278,27 @@ ulimits = ulimits.filter((_, i) => i !== index); } + function addGpuDeviceId() { + if (gpuDeviceIdInput.trim() && !gpuDeviceIds.includes(gpuDeviceIdInput.trim())) { + gpuDeviceIds = [...gpuDeviceIds, gpuDeviceIdInput.trim()]; + gpuDeviceIdInput = ''; + } + } + + function removeGpuDeviceId(id: string) { + gpuDeviceIds = gpuDeviceIds.filter(d => d !== id); + } + + function addGpuCapability(cap: string) { + if (cap && !gpuCapabilities.includes(cap)) { + gpuCapabilities = [...gpuCapabilities, cap]; + } + } + + function removeGpuCapability(cap: string) { + gpuCapabilities = gpuCapabilities.filter(c => c !== cap); + } + function addCapability(type: 'add' | 'drop', cap: string) { if (!cap) return; const capUpper = cap.toUpperCase(); @@ -1210,6 +1253,146 @@ {/if}
+ +
+ + {#if showGpu} +
+
+ + +
+ +
+ +
+ { + if (v === '') runtime = ''; + else if (v === 'nvidia') runtime = 'nvidia'; + else if (v === 'custom') runtime = customRuntimeInput || ''; + }}> + + {runtime === '' ? 'Default (runc)' : runtime === 'nvidia' ? 'NVIDIA' : `Custom: ${runtime}`} + + + + + + + + {#if runtime !== '' && runtime !== 'nvidia'} + { runtime = customRuntimeInput; }} + /> + {/if} +
+
+ + {#if gpuEnabled} +
+ + { gpuMode = v as 'all' | 'count' | 'specific'; }} + /> +
+ + {#if gpuMode === 'count'} +
+ + +
+ {/if} + + {#if gpuMode === 'specific'} +
+ +
+ { if (e.key === 'Enter') { e.preventDefault(); addGpuDeviceId(); } }} + /> + +
+ {#if gpuDeviceIds.length > 0} +
+ {#each gpuDeviceIds as id} + + {id} + + + {/each} +
+ {/if} +
+ {/if} + +
+ + +
+ +
+ + { addGpuCapability(v); }}> + + Add capability... + + + {#each commonGpuCapabilities.filter(c => !gpuCapabilities.includes(c)) as cap} + + {/each} + + + {#if gpuCapabilities.length > 0} +
+ {#each gpuCapabilities as cap} + + {cap} + + + {/each} +
+ {/if} +
+ {/if} +
+ {/if} +
+
+ {/if}
@@ -1069,6 +1079,16 @@ {/if}
+ + + {#if pushingImage} { + // Set dark mode class based on saved preference or system preference + // This must happen before applyTheme since applyTheme reads the dark class + const savedTheme = localStorage.getItem('theme'); + const prefersDark = savedTheme === 'dark' || (!savedTheme && window.matchMedia('(prefers-color-scheme: dark)').matches); + if (prefersDark) { + document.documentElement.classList.add('dark'); + } else { + document.documentElement.classList.remove('dark'); + } + + // Apply theme from localStorage immediately (for flash-free loading) + applyTheme(themeStore.get()); + + // Initialize theme from app settings (no user yet, so fetches from /api/settings/theme) + themeStore.init(); + // Set error from URL if present if (urlError) { error = decodeURIComponent(urlError); diff --git a/src/routes/registry/+page.svelte b/src/routes/registry/+page.svelte index 42eafd5..89fd6e8 100644 --- a/src/routes/registry/+page.svelte +++ b/src/routes/registry/+page.svelte @@ -11,7 +11,7 @@ import { Label } from '$lib/components/ui/label'; import { Badge } from '$lib/components/ui/badge'; import CreateContainerModal from '../containers/CreateContainerModal.svelte'; - import ImagePullModal from './ImagePullModal.svelte'; + import ImagePullModal from '$lib/components/ImagePullModal.svelte'; import CopyToRegistryModal from './CopyToRegistryModal.svelte'; import { canAccess } from '$lib/stores/auth'; import { currentEnvironment, appendEnvParam } from '$lib/stores/environment'; @@ -806,4 +806,10 @@ - + diff --git a/src/routes/registry/ImagePullModal.svelte b/src/routes/registry/ImagePullModal.svelte deleted file mode 100644 index 0429b9f..0000000 --- a/src/routes/registry/ImagePullModal.svelte +++ /dev/null @@ -1,230 +0,0 @@ - - - - - - - {#if scanStatus === 'complete' && scanResults.length > 0} - {#if hasCriticalOrHigh} - - {:else if totalVulnerabilities > 0} - - {:else} - - {/if} - {:else if pullStatus === 'complete' && !envHasScanning} - - {:else if pullStatus === 'error' || scanStatus === 'error'} - - {:else} - - {/if} - {title} - {imageName} - - - - - {#if envHasScanning} -
- - - -
- {/if} - -
- -
- -
- - - {#if envHasScanning} -
- -
- {/if} -
- - - - -
-
diff --git a/src/routes/schedules/+page.svelte b/src/routes/schedules/+page.svelte index 2eb0d60..394eebd 100644 --- a/src/routes/schedules/+page.svelte +++ b/src/routes/schedules/+page.svelte @@ -142,7 +142,7 @@ interface ScheduleExecution { id: number; - scheduleType: 'container_update' | 'git_stack_sync' | 'system_cleanup' | 'env_update_check'; + scheduleType: 'container_update' | 'git_stack_sync' | 'system_cleanup' | 'env_update_check' | 'image_prune'; scheduleId: number; environmentId: number | null; entityName: string; @@ -161,7 +161,7 @@ interface Schedule { key: string; // Unique key: type-id id: number; - type: 'container_update' | 'git_stack_sync' | 'system_cleanup' | 'env_update_check'; + type: 'container_update' | 'git_stack_sync' | 'system_cleanup' | 'env_update_check' | 'image_prune'; name: string; entityName: string; description?: string; @@ -921,6 +921,8 @@ Git stack syncs {:else if filterTypes[0] === 'env_update_check'} Env update checks + {:else if filterTypes[0] === 'image_prune'} + Image prune {:else} System jobs {/if} @@ -951,6 +953,10 @@ Env update checks + + + Image prune + {#if !hideSystemJobs} @@ -1131,6 +1137,8 @@ {:else} {/if} + {:else if schedule.type === 'image_prune'} + {:else} {/if} @@ -1166,6 +1174,8 @@ {/if} {schedule.description || 'Env update check'} + {:else if schedule.type === 'image_prune'} + {schedule.description || 'Prune unused images'} {:else} {schedule.description || 'System job'} {/if} diff --git a/src/routes/settings/environments/EnvironmentModal.svelte b/src/routes/settings/environments/EnvironmentModal.svelte index 9e31362..9354ccc 100644 --- a/src/routes/settings/environments/EnvironmentModal.svelte +++ b/src/routes/settings/environments/EnvironmentModal.svelte @@ -72,8 +72,11 @@ import { focusFirstInput } from '$lib/utils'; import { authStore, canAccess } from '$lib/stores/auth'; import { licenseStore } from '$lib/stores/license'; + import { formatDateTime } from '$lib/stores/settings'; import { getLabelColor, getLabelBgColor, parseLabels, MAX_LABELS } from '$lib/utils/label-colors'; import EventTypesEditor from './EventTypesEditor.svelte'; + import UpdatesTab from './tabs/UpdatesTab.svelte'; + import ActivityTab from './tabs/ActivityTab.svelte'; // Scanner options for ToggleGroup const scannerOptions = [ @@ -366,6 +369,14 @@ let updateCheckVulnerabilityCriteria = $state('never'); let updateCheckLoading = $state(false); + // Image prune settings state + let imagePruneEnabled = $state(false); + let imagePruneCron = $state('0 3 * * 0'); // Default: 3 AM Sunday + let imagePruneMode = $state<'dangling' | 'all'>('dangling'); + let imagePruneLastPruned = $state(undefined); + let imagePruneLastResult = $state<{ spaceReclaimed: number; imagesRemoved: number } | undefined>(undefined); + let imagePruneLoading = $state(false); + // === Validation Functions === function isValidHost(host: string): boolean { if (!host) return false; @@ -419,10 +430,11 @@ hawserToken = null; generatedToken = null; pendingToken = null; - // Load scanner settings, notifications, update check settings, and timezone + // Load scanner settings, notifications, update check settings, image prune settings, and timezone loadScannerSettings(environment.id); loadEnvNotifications(environment.id); loadUpdateCheckSettings(environment.id); + loadImagePruneSettings(environment.id); loadTimezone(environment.id); // Load Hawser token if edge mode if (formConnectionType === 'hawser-edge') { @@ -461,6 +473,12 @@ updateCheckEnabled = false; updateCheckCron = '0 4 * * *'; updateCheckAutoUpdate = false; + // Reset image prune settings + imagePruneEnabled = false; + imagePruneCron = '0 3 * * 0'; + imagePruneMode = 'dangling'; + imagePruneLastPruned = undefined; + imagePruneLastResult = undefined; // Load default timezone from global settings loadDefaultTimezone(); } @@ -664,6 +682,10 @@ if (updateCheckEnabled && newEnv?.id) { await saveUpdateCheckSettings(newEnv.id); } + // Save image prune settings if enabled + if (imagePruneEnabled && newEnv?.id) { + await saveImagePruneSettings(newEnv.id); + } // Save timezone if not default if (newEnv?.id) { await saveTimezone(newEnv.id); @@ -735,6 +757,7 @@ if (response.ok) { await saveScannerSettings(environment.id); await saveUpdateCheckSettings(environment.id); + await saveImagePruneSettings(environment.id); await saveTimezone(environment.id); toast.success(`Updated environment: ${formName}`); onSaved(); @@ -897,6 +920,51 @@ } } + // === Image Prune Settings Functions === + async function loadImagePruneSettings(envId: number) { + imagePruneLoading = true; + try { + const response = await fetch(`/api/environments/${envId}/image-prune`); + if (response.ok) { + const data = await response.json(); + if (data.settings) { + imagePruneEnabled = data.settings.enabled ?? false; + imagePruneCron = data.settings.cronExpression || '0 3 * * 0'; + imagePruneMode = data.settings.pruneMode || 'dangling'; + imagePruneLastPruned = data.settings.lastPruned; + imagePruneLastResult = data.settings.lastResult; + } else { + // No settings found - use defaults + imagePruneEnabled = false; + imagePruneCron = '0 3 * * 0'; + imagePruneMode = 'dangling'; + imagePruneLastPruned = undefined; + imagePruneLastResult = undefined; + } + } + } catch (error) { + console.error('Failed to load image prune settings:', error); + } finally { + imagePruneLoading = false; + } + } + + async function saveImagePruneSettings(envId: number) { + try { + await fetch(`/api/environments/${envId}/image-prune`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + enabled: imagePruneEnabled, + cronExpression: imagePruneCron, + pruneMode: imagePruneMode + }) + }); + } catch (error) { + console.error('Failed to save image prune settings:', error); + } + } + async function removeGrype(envId?: number) { removingGrype = true; try { @@ -1929,117 +1997,30 @@ -
-
- Scheduled update check -
-

- Periodically check all containers in this environment for available image updates. -

- - {#if updateCheckLoading} -
- -
- {:else} -
- -
- -

Automatically check for container updates on a schedule

-
- -
- - {#if updateCheckEnabled} -
-
-
- - updateCheckCron = cron} /> -
-
- -
- -
- -

- When enabled, containers will be updated automatically when new images are found. - When disabled, only sends notifications about available updates. -

-
- -
- - {#if updateCheckAutoUpdate && scannerEnabled} -
-
-
- -

- Block auto-updates if the new image has vulnerabilities exceeding this criteria -

-
- -
- {/if} - -
- - {#if updateCheckAutoUpdate} - {#if scannerEnabled && updateCheckVulnerabilityCriteria !== 'never'} - New images are pulled to a temporary tag, scanned, then deployed if they pass the vulnerability check. Blocked images are deleted automatically. - {:else} - Containers will be updated automatically when new images are available. - {/if} - {:else} - You'll receive notifications when updates are available. Containers won't be modified. - {/if} -
- {/if} - {/if} -
- - -
- - -

- Used for scheduling auto-updates and git syncs -

-
+
-
-
- -

Track container events (start, stop, restart, etc.) from this environment in real-time

-
- -
-
-
- -

Collect CPU and memory usage statistics from this environment

-
- -
-
-
- -

Show amber glow when container values change in the containers list

-
- -
+
diff --git a/src/routes/settings/environments/EnvironmentsTab.svelte b/src/routes/settings/environments/EnvironmentsTab.svelte index 40e6167..4574ecb 100644 --- a/src/routes/settings/environments/EnvironmentsTab.svelte +++ b/src/routes/settings/environments/EnvironmentsTab.svelte @@ -63,6 +63,7 @@ updatedAt: string; updateCheckEnabled?: boolean; updateCheckAutoUpdate?: boolean; + imagePruneEnabled?: boolean; timezone?: string; hawserVersion?: string; } @@ -479,7 +480,12 @@ {/if} - {#if !env.updateCheckEnabled && !hasScannerEnabled && !env.collectActivity && !env.collectMetrics} + {#if env.imagePruneEnabled} + + + + {/if} + {#if !env.updateCheckEnabled && !hasScannerEnabled && !env.collectActivity && !env.collectMetrics && !env.imagePruneEnabled} {/if} diff --git a/src/routes/settings/environments/tabs/ActivityTab.svelte b/src/routes/settings/environments/tabs/ActivityTab.svelte new file mode 100644 index 0000000..1f509a1 --- /dev/null +++ b/src/routes/settings/environments/tabs/ActivityTab.svelte @@ -0,0 +1,38 @@ + + +
+
+ +

Track container events (start, stop, restart, etc.) from this environment in real-time

+
+ +
+
+
+ +

Collect CPU and memory usage statistics from this environment

+
+ +
+
+
+ +

Show amber glow when container values change in the containers list

+
+ +
diff --git a/src/routes/settings/environments/tabs/UpdatesTab.svelte b/src/routes/settings/environments/tabs/UpdatesTab.svelte new file mode 100644 index 0000000..832d700 --- /dev/null +++ b/src/routes/settings/environments/tabs/UpdatesTab.svelte @@ -0,0 +1,219 @@ + + + +
+
+ Scheduled update check +
+

+ Periodically check all containers in this environment for available image updates. +

+ + {#if updateCheckLoading} +
+ +
+ {:else} +
+ +
+ +

Automatically check for container updates on a schedule

+
+ +
+ + {#if updateCheckEnabled} +
+
+
+ + updateCheckCron = cron} /> +
+
+ +
+ +
+ +

+ When enabled, containers will be updated automatically when new images are found. + When disabled, only sends notifications about available updates. +

+
+ +
+ + {#if updateCheckAutoUpdate && scannerEnabled} +
+
+
+ +

+ Block auto-updates if the new image has vulnerabilities exceeding this criteria +

+
+ +
+ {/if} + +
+ + {#if updateCheckAutoUpdate} + {#if scannerEnabled && updateCheckVulnerabilityCriteria !== 'never'} + New images are pulled to a temporary tag, scanned, then deployed if they pass the vulnerability check. Blocked images are deleted automatically. + {:else} + Containers will be updated automatically when new images are available. + {/if} + {:else} + You'll receive notifications when updates are available. Containers won't be modified. + {/if} +
+ {/if} + {/if} +
+ + +
+
+ Automatic image pruning +
+

+ Automatically remove unused Docker images on a schedule to free up disk space. +

+ + {#if imagePruneLoading} +
+ +
+ {:else} +
+ +
+ +

Automatically remove unused images on a schedule

+
+ +
+ + {#if imagePruneEnabled} +
+
+
+ + imagePruneCron = cron} /> +
+
+ +
+
+
+ + + + {imagePruneMode === 'dangling' ? 'Dangling images only' : 'All unused images'} + + + Dangling images only + All unused images + + +

+ {#if imagePruneMode === 'dangling'} + Only removes untagged image layers (safest option) + {:else} + Removes all images not used by any container (more aggressive) + {/if} +

+
+
+ + {#if imagePruneLastPruned} +
+
+
+

+ Last pruned: {formatDateTime(imagePruneLastPruned)} + {#if imagePruneLastResult} + - {imagePruneLastResult.imagesRemoved} images removed, {formatBytes(imagePruneLastResult.spaceReclaimed)} reclaimed + {/if} +

+
+
+ {/if} + +
+ + Images in use by running or stopped containers will never be removed. +
+ {/if} + {/if} +
+ + +
+ + +

+ Used for scheduling auto-updates, git syncs, and image pruning +

+
diff --git a/src/routes/settings/general/GeneralTab.svelte b/src/routes/settings/general/GeneralTab.svelte index e1d8758..d397570 100644 --- a/src/routes/settings/general/GeneralTab.svelte +++ b/src/routes/settings/general/GeneralTab.svelte @@ -128,20 +128,22 @@ Appearance - {#if !$authStore.authEnabled} - - - - - - - - Theme and font settings are global when authentication is disabled. When auth is enabled, users can customize their appearance in their profile. - - - - - {/if} + + + + + + + + {#if $authStore.authEnabled} + These settings apply to the login page and as defaults. Personal preferences can be configured in your profile. + {:else} + Theme and font settings are global when authentication is disabled. + {/if} + + + + @@ -225,20 +227,18 @@

How dates are displayed throughout the app

- - {#if !$authStore.authEnabled} -
- -
- {:else} -
- -
-

Appearance settings (theme, fonts) are personal when auth is enabled.

- Configure in your profile + +
+ + {#if $authStore.authEnabled} +
+ +
+

Personal theme preferences can be configured in your profile.

+
-
- {/if} + {/if} +
diff --git a/src/routes/stacks/+page.svelte b/src/routes/stacks/+page.svelte index 93b35a3..91ddfca 100644 --- a/src/routes/stacks/+page.svelte +++ b/src/routes/stacks/+page.svelte @@ -243,6 +243,7 @@ { value: 'running', label: 'Running', icon: Play, color: 'text-emerald-500' }, { value: 'partial', label: 'Partial', icon: CircleDashed, color: 'text-amber-500' }, { value: 'stopped', label: 'Stopped', icon: Square, color: 'text-rose-500' }, + { value: 'created', label: 'Created', icon: CircleDashed, color: 'text-slate-500' }, { value: 'not deployed', label: 'Not deployed', icon: Rocket, color: 'text-violet-500' } ]; @@ -335,13 +336,13 @@ const query = searchQuery.toLowerCase(); result = result.filter(stack => stack.name.toLowerCase().includes(query) || - stack.status.toLowerCase().includes(query) + getDisplayStatus(stack).toLowerCase().includes(query) ); } - // Filter by status + // Filter by status (uses display status so git "created" matches "not deployed") if (statusFilter.length > 0) { - result = result.filter(stack => statusFilter.includes(stack.status.toLowerCase())); + result = result.filter(stack => statusFilter.includes(getDisplayStatus(stack).toLowerCase())); } // Sort @@ -355,7 +356,7 @@ cmp = a.containers.length - b.containers.length; break; case 'status': - cmp = a.status.localeCompare(b.status); + cmp = getDisplayStatus(a).localeCompare(getDisplayStatus(b)); break; case 'cpu': const cpuA = getStackStats(a)?.cpuPercent ?? -1; @@ -391,7 +392,7 @@ // Count by status for selected stacks const selectedRunning = $derived(selectedInFilter.filter(s => s.status === 'running' || s.status === 'partial' || s.status === 'restarting')); - const selectedStopped = $derived(selectedInFilter.filter(s => s.status === 'stopped' || s.status === 'not deployed')); + const selectedStopped = $derived(selectedInFilter.filter(s => s.status === 'stopped' || s.status === 'not deployed' || s.status === 'created')); function toggleSelectAll() { if (allFilteredSelected) { @@ -655,6 +656,13 @@ return stackSources[stackName] || { sourceType: 'external' }; } + function getDisplayStatus(stack: ComposeStackInfo): string { + if (stack.status === 'created' && getStackSource(stack.name).sourceType === 'git') { + return 'not deployed'; + } + return stack.status; + } + async function openGitModal(gitStack?: any) { editingGitStack = gitStack || null; // Fetch repositories and credentials before opening modal @@ -679,8 +687,14 @@ try { const response = await fetch(appendEnvParam(`/api/stacks/${encodeURIComponent(name)}/start`, envId), { method: 'POST' }); if (!response.ok) { - const data = await response.json(); - const errorMsg = data.error || 'Failed to start stack'; + const rawText = await response.text(); + let errorMsg = 'Failed to start stack'; + try { + const data = JSON.parse(rawText); + errorMsg = data.error || errorMsg; + } catch { + errorMsg = rawText || errorMsg; + } showErrorDialog(`Failed to start ${name}`, errorMsg); return; } @@ -701,8 +715,14 @@ try { const response = await fetch(appendEnvParam(`/api/stacks/${encodeURIComponent(name)}/stop`, envId), { method: 'POST' }); if (!response.ok) { - const data = await response.json(); - const errorMsg = data.error || 'Failed to stop stack'; + const rawText = await response.text(); + let errorMsg = 'Failed to stop stack'; + try { + const data = JSON.parse(rawText); + errorMsg = data.error || errorMsg; + } catch { + errorMsg = rawText || errorMsg; + } showErrorDialog(`Failed to stop ${name}`, errorMsg); return; } @@ -723,8 +743,14 @@ try { const response = await fetch(appendEnvParam(`/api/stacks/${encodeURIComponent(name)}/restart`, envId), { method: 'POST' }); if (!response.ok) { - const data = await response.json(); - const errorMsg = data.error || 'Failed to restart stack'; + const rawText = await response.text(); + let errorMsg = 'Failed to restart stack'; + try { + const data = JSON.parse(rawText); + errorMsg = data.error || errorMsg; + } catch { + errorMsg = rawText || errorMsg; + } showErrorDialog(`Failed to restart ${name}`, errorMsg); return; } @@ -817,6 +843,8 @@ return `${base} bg-red-200 dark:bg-red-800 text-red-900 dark:text-red-100`; case 'partial': return `${base} bg-amber-200 dark:bg-amber-800 text-amber-900 dark:text-amber-100`; + case 'created': + return `${base} bg-slate-200 dark:bg-slate-700 text-slate-900 dark:text-slate-100`; case 'not deployed': return `${base} bg-violet-200 dark:bg-violet-800 text-violet-900 dark:text-violet-100`; default: @@ -1342,13 +1370,19 @@ Internal {:else} - - - Untracked - + + + + + Untracked + + + + Compose file location unknown. Click the stack name or edit button to locate it. + + {/if} {:else if column.id === 'location'} {#if source.composePath} @@ -1364,7 +1398,7 @@ {:else} - + Not set {/if} {:else if column.id === 'containers'}
@@ -1465,10 +1499,11 @@ {getStackVolumeCount(stack) || '-'} {:else if column.id === 'status'} - {@const StatusIcon = getStackStatusIcon(stack.status)} - + {@const displayStatus = getDisplayStatus(stack)} + {@const StatusIcon = getStackStatusIcon(displayStatus)} + - {stack.status} + {displayStatus} {:else if column.id === 'actions'}
@@ -1481,7 +1516,7 @@
{/if} - {#if stack.status === 'not deployed' && source.gitStack} + {#if (stack.status === 'not deployed' || stack.status === 'created') && source.gitStack} + + + + + +
+

Clone the repository and load environment variables from the .env file (in compose directory) and additional env file (if specified), so you can see what you can override.

+
+
+
+
+ {/if} + {/snippet} + diff --git a/src/routes/stacks/ImportStackModal.svelte b/src/routes/stacks/ImportStackModal.svelte index 37f14db..d88a98c 100644 --- a/src/routes/stacks/ImportStackModal.svelte +++ b/src/routes/stacks/ImportStackModal.svelte @@ -6,6 +6,7 @@ import { Import, Loader2, Play, Info } from 'lucide-svelte'; import FilesystemBrowser, { type FileEntry } from './FilesystemBrowser.svelte'; import CodeEditor from '$lib/components/CodeEditor.svelte'; + import yaml from 'js-yaml'; import { toast } from 'svelte-sonner'; import { currentEnvironment, environments } from '$lib/stores/environment'; import { getIconComponent } from '$lib/utils/icons'; @@ -83,11 +84,13 @@ const data = await res.json(); previewContent = data.content || ''; // Count services in the compose file - const serviceMatches = previewContent?.match(/^services:\s*\n((?:\s{2,}\w+:.*\n?)+)/m); - if (serviceMatches) { - const servicesBlock = serviceMatches[1]; - const serviceNames = servicesBlock.match(/^\s{2}\w+:/gm); - previewServiceCount = serviceNames?.length || 0; + try { + const doc = yaml.load(previewContent) as Record | null; + if (doc?.services && typeof doc.services === 'object') { + previewServiceCount = Object.keys(doc.services).length; + } + } catch { + previewServiceCount = 0; } } } catch (e) { diff --git a/src/routes/stacks/StackModal.svelte b/src/routes/stacks/StackModal.svelte index a970379..3d12586 100644 --- a/src/routes/stacks/StackModal.svelte +++ b/src/routes/stacks/StackModal.svelte @@ -87,6 +87,7 @@ // Base directory when user browsed to a directory (without stack name yet) let browsedBaseDirectory = $state(null); + // UI state let composePathCopied = $state(false); let envPathCopied = $state(false); @@ -123,9 +124,8 @@ if (!workingComposePath) return undefined; switch (pathSource) { case 'browsed': - return 'Custom location'; case 'custom': - return 'Using saved location'; + return 'Custom location'; case 'default': return 'Using default location'; default: @@ -398,9 +398,8 @@ } // In CREATE mode, we only want the content - don't store external paths - // Files will be saved to internal stack directory + // Files will be saved to the directory containing the selected compose file if (mode === 'create') { - pathSource = 'browsed'; showFileBrowser = false; // Load compose file content when selecting a file (not directory) @@ -409,9 +408,14 @@ const dir = finalPath.replace(/\/[^/]+$/, ''); const potentialEnvPath = `${dir}/.env`; await loadFilesFromLocalFilesystem(finalPath, potentialEnvPath); - // Don't set workingComposePath/workingEnvPath - use internal defaults - workingComposePath = ''; - workingEnvPath = ''; + // Use the selected file's path directly + workingComposePath = finalPath; + workingEnvPath = `${dir}/.env`; + browsedBaseDirectory = null; + // 'custom' prevents the path effect from overriding (it only acts on 'browsed') + pathSource = 'custom'; + } else { + pathSource = 'browsed'; } isDirty = true; return; @@ -480,12 +484,13 @@ } } - // In CREATE mode, don't store external path - content will be saved to internal directory - // In EDIT mode, store the path for the file location - if (mode !== 'create') { + // Store the selected path: + // - Always in EDIT mode + // - In CREATE mode when user selected a custom compose location OR explicitly selected an env file + if (mode !== 'create' || pathSource === 'custom' || pathSource === 'browsed' || !isDirectory) { workingEnvPath = finalPath; } - // If CREATE mode, workingEnvPath stays empty - will use internal default + // Otherwise CREATE mode with internal location uses default via suggestedEnvPath isDirty = true; } @@ -1063,32 +1068,32 @@ services: throw new Error(rawEnvError.error || 'Failed to save environment file'); } - // Save ALL vars to DB (includes secrets with real values) - const definedVars = prepared.variables; - if (definedVars.length > 0 || hadExistingDbVars) { + // Save only secrets to DB (non-secrets are in the .env file written above) + const secretVars = prepared.variables.filter(v => v.isSecret); + if (secretVars.length > 0 || hadExistingDbVars) { const envResponse = await fetch( appendEnvParam(`/api/stacks/${encodeURIComponent(stackName)}/env`, envId), { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ - variables: definedVars.map(v => ({ + variables: secretVars.map(v => ({ key: v.key.trim(), value: v.value, - isSecret: v.isSecret + isSecret: true })) }) } ); if (!envResponse.ok) { - // Log but don't fail - DB stores secret metadata - console.warn('Failed to save environment variable metadata to database'); + // Log but don't fail - DB stores secret values + console.warn('Failed to save secret variables to database'); } - hadExistingDbVars = definedVars.length > 0; + hadExistingDbVars = secretVars.length > 0; existingSecretKeys = new Set( - definedVars.filter(v => v.isSecret && v.key.trim()).map(v => v.key.trim()) + secretVars.filter(v => v.key.trim()).map(v => v.key.trim()) ); } @@ -1220,6 +1225,33 @@ services: return () => clearTimeout(timeout); }); + // Pre-fetched default base directory for create mode (fetched once on open/env change) + let defaultStackDir = $state(null); + + async function fetchDefaultBasePath(envId: number | null, location: string | null) { + const params = new URLSearchParams({ name: '__placeholder__' }); + if (envId) params.set('env', String(envId)); + if (location) params.set('location', location); + try { + const r = await fetch(`/api/stacks/default-path?${params}`); + if (r.ok) { + const data = await r.json(); + // Extract base dir by removing the placeholder name + defaultStackDir = data.stackDir.replace('/__placeholder__', ''); + } + } catch { + // Ignore fetch errors + } + } + + // Fetch default base path when modal opens or environment changes + $effect(() => { + if (!open || mode !== 'create') return; + const envId = $currentEnvironment?.id ?? null; + const location = $appSettings.primaryStackLocation; + fetchDefaultBasePath(envId, location); + }); + // Auto-update default paths when stack name changes in create mode // This unified effect handles both default paths and browsed directory paths $effect(() => { @@ -1227,17 +1259,21 @@ services: const name = newStackName.trim(); - // Case 1: No name entered yet - clear paths + // User selected a specific file - paths are locked, don't touch them + if (pathSource === 'custom') return; + + // No name entered yet - clear paths but preserve browsed state if (!name) { workingComposePath = ''; workingEnvPath = ''; autoComputedComposePath = ''; - pathSource = null; + if (!browsedBaseDirectory) { + pathSource = null; + } return; } - // Case 2: User has browsed and selected a directory - use that as base - // Keep updating as user types (don't clear browsedBaseDirectory!) + // User browsed and selected a directory - build path from that base if (browsedBaseDirectory) { workingComposePath = `${browsedBaseDirectory}/${name}/compose.yaml`; workingEnvPath = `${browsedBaseDirectory}/${name}/.env`; @@ -1245,54 +1281,14 @@ services: return; } - // Case 3: User already has a browsed path set (from previous name entry) - // Update the stack name portion in the existing path - if (pathSource === 'browsed' && workingComposePath) { - // Extract base directory from existing path and rebuild with new name - // Path format: {baseDir}/{stackName}/compose.yaml - const pathParts = workingComposePath.split('/'); - pathParts.pop(); // remove 'compose.yaml' - pathParts.pop(); // remove old stack name - const baseDir = pathParts.join('/'); - if (baseDir) { - workingComposePath = `${baseDir}/${name}/compose.yaml`; - workingEnvPath = `${baseDir}/${name}/.env`; - } - return; + // Use pre-fetched default base directory + if (defaultStackDir) { + const dir = `${defaultStackDir}/${name}`; + autoComputedComposePath = `${dir}/compose.yaml`; + workingComposePath = `${dir}/compose.yaml`; + workingEnvPath = `${dir}/.env`; + pathSource = 'default'; } - - // Case 4: Default path from settings/API - const location = $appSettings.primaryStackLocation; - const envId = $currentEnvironment?.id ?? null; - - const fetchDefaultPath = async () => { - try { - const params = new URLSearchParams({ name }); - if (envId) params.set('env', String(envId)); - if (location) { - params.set('location', location); - } - const response = await fetch(`/api/stacks/default-path?${params}`); - if (response.ok) { - const data = await response.json(); - // Check if user has customized before updating auto-computed - // Compare current working path against OLD auto path (before we update it) - const userHasCustomized = workingComposePath !== '' && - workingComposePath !== autoComputedComposePath; - // Track the auto-computed path - autoComputedComposePath = data.composePath; - // Only update working paths if user hasn't customized - if (!userHasCustomized) { - workingComposePath = data.composePath; - workingEnvPath = data.envPath; - pathSource = data.source || 'default'; - } - } - } catch (e) { - console.error('Failed to fetch default path:', e); - } - }; - fetchDefaultPath(); }); @@ -1433,7 +1429,7 @@ services:

- Untracked stack. Select the compose file location to start managing this stack with Dockhand. + Untracked stack — this stack is running in Docker but Dockhand doesn't know where its compose file is stored on disk. Browse to locate the file to start editing and managing it.

{#if stackContainers.length > 0}
@@ -1621,10 +1617,10 @@ services: {/if} diff --git a/src/routes/volumes/CreateVolumeModal.svelte b/src/routes/volumes/CreateVolumeModal.svelte index 3c5a9b2..9384940 100644 --- a/src/routes/volumes/CreateVolumeModal.svelte +++ b/src/routes/volumes/CreateVolumeModal.svelte @@ -8,13 +8,29 @@ import { Label } from '$lib/components/ui/label'; import { Input } from '$lib/components/ui/input'; import { Button } from '$lib/components/ui/button'; - import { Plus, Trash2, HardDrive, Database, Server } from 'lucide-svelte'; + import { TogglePill } from '$lib/components/ui/toggle-pill'; + import { Plus, Trash2, HardDrive, Database, Server, ChevronDown } from 'lucide-svelte'; const VOLUME_DRIVERS = [ { value: 'local', label: 'Local', description: 'Default local driver', icon: HardDrive }, { value: 'nfs', label: 'NFS', description: 'Network file system', icon: Server }, { value: 'cifs', label: 'CIFS', description: 'Windows/SMB shares', icon: Database } ]; + + const SMB_VERSIONS = [ + { value: '2.0', label: 'SMB 2.0' }, + { value: '2.1', label: 'SMB 2.1' }, + { value: '3.0', label: 'SMB 3.0' }, + { value: '3.1.1', label: 'SMB 3.1.1' } + ]; + + const NFS_VERSIONS = [ + { value: '3', label: 'NFSv3' }, + { value: '4', label: 'NFSv4' }, + { value: '4.1', label: 'NFSv4.1' }, + { value: '4.2', label: 'NFSv4.2' } + ]; + import { currentEnvironment, appendEnvParam } from '$lib/stores/environment'; import { focusFirstInput } from '$lib/utils'; @@ -32,9 +48,28 @@ let driverOpts = $state([]); let labels = $state([]); + // CIFS fields + let cifsServer = $state(''); + let cifsShare = $state(''); + let cifsUsername = $state(''); + let cifsPassword = $state(''); + let cifsVersion = $state('3.0'); + let cifsDomain = $state(''); + + // NFS fields + let nfsServer = $state(''); + let nfsPath = $state(''); + let nfsVersion = $state('4'); + let nfsSoft = $state(true); + let nfsNolock = $state(true); + let nfsReadOnly = $state(false); + + // Additional options visibility + let showAdditionalOpts = $state(false); + let creating = $state(false); let error = $state(''); - let errors = $state<{ name?: string }>({}); + let errors = $state<{ name?: string; server?: string; share?: string; path?: string }>({}); function addDriverOpt() { driverOpts = [...driverOpts, { key: '', value: '' }]; @@ -57,6 +92,19 @@ driver = 'local'; driverOpts = []; labels = []; + cifsServer = ''; + cifsShare = ''; + cifsUsername = ''; + cifsPassword = ''; + cifsVersion = '3.0'; + cifsDomain = ''; + nfsServer = ''; + nfsPath = ''; + nfsVersion = '4'; + nfsSoft = true; + nfsNolock = true; + nfsReadOnly = false; + showAdditionalOpts = false; error = ''; errors = {}; } @@ -66,22 +114,62 @@ if (!name.trim()) { errors.name = 'Volume name is required'; - return; } + // Validate driver-specific required fields + if (driver === 'cifs') { + if (!cifsServer.trim()) errors.server = 'Server is required'; + if (!cifsShare.trim()) errors.share = 'Share path is required'; + } else if (driver === 'nfs') { + if (!nfsServer.trim()) errors.server = 'Server is required'; + if (!nfsPath.trim()) errors.path = 'Export path is required'; + } + + if (Object.keys(errors).length > 0) return; + creating = true; error = ''; try { const envId = $currentEnvironment?.id ?? null; - // Convert key-value arrays to objects + // Build driverOpts based on driver type const driverOptsObj: Record = {}; - driverOpts.forEach(({ key, value }) => { - if (key && value) { - driverOptsObj[key] = value; - } - }); + + if (driver === 'cifs') { + driverOptsObj.type = 'cifs'; + const share = cifsShare.trim().replace(/^\/+/, ''); + driverOptsObj.device = `//${cifsServer.trim()}/${share}`; + const opts = [`addr=${cifsServer.trim()}`, `username=${cifsUsername}`, `password=${cifsPassword}`, `vers=${cifsVersion}`]; + if (cifsDomain.trim()) opts.push(`domain=${cifsDomain.trim()}`); + // Append additional options + driverOpts.forEach(({ key, value }) => { + if (key && value) opts.push(`${key}=${value}`); + else if (key) opts.push(key); + }); + driverOptsObj.o = opts.join(','); + } else if (driver === 'nfs') { + driverOptsObj.type = 'nfs'; + const path = nfsPath.trim().startsWith('/') ? nfsPath.trim() : `/${nfsPath.trim()}`; + driverOptsObj.device = `:${path}`; + const opts = [`addr=${nfsServer.trim()}`, `nfsvers=${nfsVersion}`]; + if (nfsSoft) opts.push('soft'); + if (nfsNolock) opts.push('nolock'); + if (nfsReadOnly) opts.push('ro'); + // Append additional options + driverOpts.forEach(({ key, value }) => { + if (key && value) opts.push(`${key}=${value}`); + else if (key) opts.push(key); + }); + driverOptsObj.o = opts.join(','); + } else { + // Local driver - use generic key-value pairs + driverOpts.forEach(({ key, value }) => { + if (key && value) { + driverOptsObj[key] = value; + } + }); + } const labelsObj: Record = {}; labels.forEach(({ key, value }) => { @@ -95,7 +183,7 @@ headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ name: name.trim(), - driver, + driver: driver === 'nfs' || driver === 'cifs' ? 'local' : driver, driverOpts: driverOptsObj, labels: labelsObj }) @@ -186,53 +274,263 @@

- -
-
- - -
- {#if driverOpts.length > 0} + + {#if driver === 'cifs'} + +
- {#each driverOpts as opt, i} -
- - -
+
+ + errors.share = undefined} + /> + {#if errors.share} +

{errors.share}

+ {/if} +
+
+
+
+ + +
+
+ + +
+
+
+
+ + + + {SMB_VERSIONS.find(v => v.value === cifsVersion)?.label ?? 'Select version'} + + + {#each SMB_VERSIONS as v} + {v.label} + {/each} + + +
+
+ + +

Optional AD/workgroup domain

+
+
+ + +
+ + {#if showAdditionalOpts} +
+
+
- {/each} + {#if driverOpts.length > 0} + {#each driverOpts as opt, i} +
+ + + +
+ {/each} + {:else} +

Extra mount options appended to the mount string

+ {/if} +
+ {/if} +
+ {:else if driver === 'nfs'} + +
+
+ + errors.server = undefined} + /> + {#if errors.server} +

{errors.server}

+ {/if}
- {:else} -

No driver options configured

- {/if} -
+
+ + errors.path = undefined} + /> + {#if errors.path} +

{errors.path}

+ {/if} +
+
+
+ + + + {NFS_VERSIONS.find(v => v.value === nfsVersion)?.label ?? 'Select version'} + + + {#each NFS_VERSIONS as v} + {v.label} + {/each} + + +
+
+
+ + mount +
+
+ + No lock +
+
+ + Read-only +
+
+ + +
+ + {#if showAdditionalOpts} +
+
+ +
+ {#if driverOpts.length > 0} + {#each driverOpts as opt, i} +
+ + + +
+ {/each} + {:else} +

Extra mount options appended to the mount string

+ {/if} +
+ {/if} +
+ {:else} + +
+
+ + +
+ {#if driverOpts.length > 0} +
+ {#each driverOpts as opt, i} +
+ + + +
+ {/each} +
+ {:else} +

No driver options configured

+ {/if} +
+ {/if}