diff --git a/VERSION b/VERSION index f6c99a1..ce77b51 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -v1.0.26 +v1.0.27 diff --git a/docker-entrypoint-node.sh b/docker-entrypoint-node.sh index fe01600..e7119c7 100644 --- a/docker-entrypoint-node.sh +++ b/docker-entrypoint-node.sh @@ -10,7 +10,7 @@ PGID=${PGID:-1001} export BODY_SIZE_LIMIT=${BODY_SIZE_LIMIT:-2G} # Default command (--expose-gc allows forced GC from /api/debug/memory?gc=true) -# Custom CA: set NODE_EXTRA_CA_CERTS=/path/to/ca.crt (appends to built-in CAs) +# Custom CA: set NODE_EXTRA_CA_CERTS=/path/to/ca.crt (appends to built-in CAs, git ops auto-merge with system CAs) # Enterprise (system CA store): set NODE_OPTIONS="--use-openssl-ca" if [ "$MEMORY_MONITOR" = "true" ]; then DEFAULT_CMD="node --dns-result-order=ipv4first --no-network-family-autoselection --expose-gc /app/server.js" diff --git a/package.json b/package.json index b3ff61a..e84b6ad 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "dockhand", "private": true, - "version": "1.0.26", + "version": "1.0.27", "type": "module", "scripts": { "dev": "npx vite dev", diff --git a/src/lib/config/grid-columns.ts b/src/lib/config/grid-columns.ts index e3eda7a..ac49e1e 100644 --- a/src/lib/config/grid-columns.ts +++ b/src/lib/config/grid-columns.ts @@ -10,7 +10,7 @@ export const containerColumns: ColumnConfig[] = [ { id: 'uptime', label: 'Uptime', sortable: true, sortField: 'uptime', width: 80, minWidth: 60 }, { id: 'restartCount', label: 'Restarts', width: 70, minWidth: 50 }, { id: 'cpu', label: 'CPU', sortable: true, sortField: 'cpu', width: 50, minWidth: 40, align: 'right' }, - { id: 'memory', label: 'Memory', sortable: true, sortField: 'memory', width: 60, minWidth: 50, align: 'right' }, + { id: 'memory', label: 'Memory', sortable: true, sortField: 'memory', width: 95, minWidth: 70, align: 'right' }, { id: 'networkIO', label: 'Net I/O', width: 85, minWidth: 70, align: 'right' }, { id: 'diskIO', label: 'Disk I/O', width: 85, minWidth: 70, align: 'right' }, { id: 'ip', label: 'IP', sortable: true, sortField: 'ip', width: 100, minWidth: 80 }, diff --git a/src/lib/data/changelog.json b/src/lib/data/changelog.json index 1e82cf5..07e7619 100644 --- a/src/lib/data/changelog.json +++ b/src/lib/data/changelog.json @@ -1,4 +1,28 @@ [ + { + "version": "1.0.27", + "comingSoon": false, + "date": "2026-04-26", + "changes": [ + { "type": "feature", "text": "network graph visualization on networks page (#894, @Penlane)" }, + { "type": "feature", "text": "customizable compose template for new stacks in settings (#632, @oratory)" }, + { "type": "feature", "text": "Microsoft Teams notifications via Power Automate Workflows (#355, @slokhorst)" }, + { "type": "feature", "text": "container label controls: dockhand.update, dockhand.hidden, dockhand.notify (#6, #53, #94, #215)" }, + { "type": "feature", "text": "configurable label filter matching mode (any/all) for environment dashboard (#607)" }, + { "type": "feature", "text": "log search filter mode to hide non-matching lines (#916)" }, + { "type": "feature", "text": "inline terminal on logs page with resizable split layout (#900)" }, + { "type": "fix", "text": "disable Telegram link preview in notifications (#910, @deenle)" }, + { "type": "fix", "text": "cron editor rejects 6-field expressions with seconds (#839, @GiulioSavini)" }, + { "type": "fix", "text": "mirror Dockhand's ExtraHosts into scanner and self-update containers (#836, @YewFence)" }, + { "type": "fix", "text": "duplicate volume binds during container recreate (#765, @itsDNNS)" }, + { "type": "fix", "text": "log timestamp formatting not applied on main logs page (#882)" }, + { "type": "fix", "text": "uploaded files now inherit container user ownership (#732, @ivanjx)" }, + { "type": "fix", "text": "extraneous backslash in Telegram notification environment name (#955)" }, + { "type": "fix", "text": "collapse ports into ranges only if 3 or more consecutive ports" }, + { "type": "fix", "text": "git operations auto-merge system CAs with custom cert (#967)" } + ], + "imageTag": "fnsys/dockhand:v1.0.27" + }, { "version": "1.0.26", "date": "2026-04-19", @@ -12,7 +36,7 @@ { "type": "fix", "text": "clicking stack name toggles stats accordion instead of just opening editor (#628)" }, { "type": "fix", "text": "scheduled image prune notifications missing environment name (#770)" }, { "type": "fix", "text": "Gotify, ntfy, Pushover, and webhook notifications missing environment name (#943)" }, - { "type": "fix", "text": "MFA code field not recognized by Bitwarden and other password managers (#566)" } + { "type": "fix", "text": "MFA code field not recognized by Bitwarden and other password managers (#566)" } ], "imageTag": "fnsys/dockhand:v1.0.26" }, diff --git a/src/lib/server/auth.ts b/src/lib/server/auth.ts index 075fac6..65fe9a5 100644 --- a/src/lib/server/auth.ts +++ b/src/lib/server/auth.ts @@ -223,7 +223,7 @@ function setSessionCookie(cookies: Cookies, sessionId: string, maxAge: number, r path: '/', httpOnly: true, // Prevents XSS attacks from reading cookie secure: isSecureContext(request), // Protocol-aware: checks x-forwarded-proto or NODE_ENV - sameSite: 'strict', // CSRF protection + sameSite: 'lax', // Lax required for OIDC/SSO cross-site redirects maxAge: maxAge // Session timeout in seconds }); } diff --git a/src/lib/server/container-labels.ts b/src/lib/server/container-labels.ts new file mode 100644 index 0000000..28ea05f --- /dev/null +++ b/src/lib/server/container-labels.ts @@ -0,0 +1,89 @@ +/** + * Dockhand Container Label Controls + * + * Docker container labels that control Dockhand behavior: + * - dockhand.update=false — Skip this container during auto-updates and batch updates + * - dockhand.hidden=true — Hide this container from the Dockhand UI + * - dockhand.notify=false — Suppress notifications for this container's events + * + * All label values are case-insensitive and accept: true/yes/1 and false/no/0. + * The opt-out model means labels override DB settings (label wins). + */ + +/** Recognized Dockhand label keys */ +export const DOCKHAND_LABELS = { + UPDATE: 'dockhand.update', + HIDDEN: 'dockhand.hidden', + NOTIFY: 'dockhand.notify', +} as const; + +const TRUTHY_VALUES = new Set(['true', 'yes', '1']); +const FALSY_VALUES = new Set(['false', 'no', '0']); + +/** + * Parse a label value as a boolean. + * Returns true for: true, TRUE, yes, YES, 1 + * Returns false for: false, FALSE, no, NO, 0 + * Returns undefined for missing or unrecognized values. + */ +function parseLabelBool(value: string | undefined | null): boolean | undefined { + if (value == null) return undefined; + const normalized = value.trim().toLowerCase(); + if (TRUTHY_VALUES.has(normalized)) return true; + if (FALSY_VALUES.has(normalized)) return false; + return undefined; +} + +/** + * Get a label value from a Docker labels object. + */ +function getLabel(labels: Record | undefined | null, key: string): string | undefined { + if (!labels) return undefined; + return labels[key]; +} + +/** + * Check if a container should be skipped during auto-updates. + * Returns true if dockhand.update is explicitly set to false/no/0. + * Default (no label): allow updates (opt-out model). + */ +export function isUpdateDisabledByLabel(labels: Record | undefined | null): boolean { + const value = parseLabelBool(getLabel(labels, DOCKHAND_LABELS.UPDATE)); + return value === false; // explicitly disabled +} + +/** + * Check if a container should be hidden from the UI. + * Returns true if dockhand.hidden is explicitly set to true/yes/1. + * Default (no label): visible (opt-out model). + */ +export function isHiddenByLabel(labels: Record | undefined | null): boolean { + const value = parseLabelBool(getLabel(labels, DOCKHAND_LABELS.HIDDEN)); + return value === true; // explicitly hidden +} + +/** + * Check if notifications should be suppressed for this container. + * Returns true if dockhand.notify is explicitly set to false/no/0. + * Default (no label): send notifications (opt-out model). + */ +export function isNotifyDisabledByLabel(labels: Record | undefined | null): boolean { + const value = parseLabelBool(getLabel(labels, DOCKHAND_LABELS.NOTIFY)); + return value === false; // explicitly disabled +} + +/** + * Extract all Dockhand label states from a container's labels. + * Useful for including in API responses so the frontend knows about label overrides. + */ +export function getDockhandLabels(labels: Record | undefined | null): { + updateDisabled: boolean; + hidden: boolean; + notifyDisabled: boolean; +} { + return { + updateDisabled: isUpdateDisabledByLabel(labels), + hidden: isHiddenByLabel(labels), + notifyDisabled: isNotifyDisabledByLabel(labels), + }; +} diff --git a/src/lib/server/db.ts b/src/lib/server/db.ts index 89b7088..4016815 100644 --- a/src/lib/server/db.ts +++ b/src/lib/server/db.ts @@ -78,6 +78,7 @@ import { import type { AllGridPreferences, GridId, GridColumnPreferences } from '$lib/types'; import { encrypt, decrypt } from './encryption.js'; +import { parseEnvInterpolation } from './env-interpolation'; // Re-export for backwards compatibility export { db, isPostgres, isSqlite }; @@ -2066,6 +2067,7 @@ export async function getGitStacksByRepositoryId(repositoryId: number): Promise< } export async function deleteGitRepository(id: number): Promise { + console.log(`[GitStack] Deleting git repository id=${id} (will cascade-delete git_stacks, set null on stack_sources FKs)`); await db.delete(gitRepositories).where(eq(gitRepositories.id, id)); return true; } @@ -2522,6 +2524,7 @@ export async function updateGitStack(id: number, data: Partial): P } export async function deleteGitStack(id: number): Promise { + console.log(`[GitStack] Deleting git_stacks row id=${id}`); await db.delete(gitStacks).where(eq(gitStacks.id, id)); return true; } @@ -2781,11 +2784,21 @@ export async function upsertStackSource(data: { const existing = await getStackSource(data.stackName, data.environmentId); if (existing) { + const newRepoId = data.gitRepositoryId || null; + const newStackId = data.gitStackId || null; + const changes: string[] = []; + if (data.sourceType !== existing.sourceType) changes.push(`sourceType: ${existing.sourceType} → ${data.sourceType}`); + if (newRepoId !== existing.gitRepositoryId) changes.push(`gitRepoId: ${existing.gitRepositoryId} → ${newRepoId}`); + if (newStackId !== existing.gitStackId) changes.push(`gitStackId: ${existing.gitStackId} → ${newStackId}`); + if (changes.length > 0) { + console.log(`[GitStack] Updating stack_sources "${data.stackName}" env=${data.environmentId}: ${changes.join(', ')}`); + } + await db.update(stackSources) .set({ sourceType: data.sourceType, - gitRepositoryId: data.gitRepositoryId || null, - gitStackId: data.gitStackId || null, + gitRepositoryId: newRepoId, + gitStackId: newStackId, composePath: data.composePath ?? null, envPath: data.envPath ?? null, updatedAt: new Date().toISOString() @@ -2793,6 +2806,7 @@ export async function upsertStackSource(data: { .where(eq(stackSources.id, existing.id)); return getStackSource(data.stackName, data.environmentId) as Promise; } else { + console.log(`[GitStack] Creating stack_sources "${data.stackName}" env=${data.environmentId} type=${data.sourceType} repoId=${data.gitRepositoryId || null} stackId=${data.gitStackId || null}`); await db.insert(stackSources).values({ stackName: data.stackName, environmentId: data.environmentId ?? null, @@ -2826,6 +2840,7 @@ export async function updateStackSource( } export async function deleteStackSource(stackName: string, environmentId?: number | null): Promise { + console.log(`[GitStack] Deleting stack_sources "${stackName}" env=${environmentId}`); // Delete matching record (either with specific envId or NULL) await db.delete(stackSources) .where(and( @@ -3193,14 +3208,16 @@ export async function getAuditLogs(filters: AuditLogFilters = {}): Promise 0) { - // Get environments that have ANY of the specified labels + const labelFilterMode = await getSetting('label_filter_mode') ?? 'any'; const allEnvs = await db.select({ id: environments.id, labels: environments.labels }).from(environments); labelFilteredEnvIds = allEnvs .filter(env => { if (!env.labels) return false; try { const envLabels = JSON.parse(env.labels) as string[]; - return filters.labels!.some(label => envLabels.includes(label)); + return labelFilterMode === 'all' + ? filters.labels!.every(label => envLabels.includes(label)) + : filters.labels!.some(label => envLabels.includes(label)); } catch { return false; } @@ -3408,14 +3425,16 @@ export async function getContainerEvents(filters: ContainerEventFilters = {}): P // Labels filter - find environments with matching labels first let labelFilteredEnvIds: number[] | undefined; if (filters.labels && filters.labels.length > 0) { - // Get environments that have ANY of the specified labels + const labelFilterMode = await getSetting('label_filter_mode') ?? 'any'; const allEnvs = await db.select({ id: environments.id, labels: environments.labels }).from(environments); labelFilteredEnvIds = allEnvs .filter(env => { if (!env.labels) return false; try { const envLabels = JSON.parse(env.labels) as string[]; - return filters.labels!.some(label => envLabels.includes(label)); + return labelFilterMode === 'all' + ? filters.labels!.every(label => envLabels.includes(label)) + : filters.labels!.some(label => envLabels.includes(label)); } catch { return false; } @@ -4629,6 +4648,43 @@ export async function getSecretKeyNames( return new Set(vars.filter(v => v.isSecret).map(v => v.key)); } +/** + * Get the set of env var keys that should be masked in container inspect responses. + * Handles two cases: + * 1. Direct match: env var key == secret key in DB (e.g., DB_PASS=${DB_PASS}) + * 2. Interpolation: env var key differs from secret key (e.g., MYSQL_PASSWORD=${db_secret}) + * Detected by parsing the compose file for ${variable} references in environment: sections. + * + * @param composeContent - Optional compose file content. If provided, interpolation + * references are parsed to detect secrets injected under different key names. + */ +export async function getSecretKeysToMask( + stackName: string, + environmentId?: number | null, + composeContent?: string | null +): Promise> { + const vars = await getStackEnvVars(stackName, environmentId, true); + const secretKeyNames = new Set(vars.filter(v => v.isSecret).map(v => v.key)); + + if (secretKeyNames.size === 0) return secretKeyNames; + + // If we have compose content, parse interpolation references to find + // container env keys that map to secret interpolation variables. + // e.g., "MYSQL_PASSWORD=${db_secret}" → if db_secret is a secret, mask MYSQL_PASSWORD too. + if (composeContent) { + const interpolated = parseEnvInterpolation(composeContent); + for (const [containerKey, varName] of interpolated) { + if (secretKeyNames.has(varName)) { + secretKeyNames.add(containerKey); + } + } + } + + return secretKeyNames; +} + +export { parseEnvInterpolation } from './env-interpolation'; + /** * Get count of environment variables for a stack. * @param stackName - Name of the stack diff --git a/src/lib/server/env-interpolation.ts b/src/lib/server/env-interpolation.ts new file mode 100644 index 0000000..3ef48bd --- /dev/null +++ b/src/lib/server/env-interpolation.ts @@ -0,0 +1,36 @@ +/** + * Parse compose YAML to extract environment variable interpolation mappings. + * Returns pairs of [containerEnvKey, interpolationVariable]. + * + * Handles patterns: + * - VAR=${ref} + * - VAR=${ref:-default} + * - VAR=${ref:+alt} + * - VAR=${ref?error} + * + * Only extracts from `environment:` sections (list format: `- KEY=value`). + */ +export function parseEnvInterpolation(composeContent: string): Array<[string, string]> { + const results: Array<[string, string]> = []; + + // Step 1: Find lines matching `- ENV_KEY=...${...}...` + const linePattern = /^\s*-\s*([A-Za-z_][A-Za-z0-9_]*)=(.*)/gm; + let lineMatch; + while ((lineMatch = linePattern.exec(composeContent)) !== null) { + const containerKey = lineMatch[1]; + const valueStr = lineMatch[2]; + + // Step 2: Extract all ${VAR} references from the value + const varPattern = /\$\{([A-Za-z_][A-Za-z0-9_]*)(?:[:\-\+\?][^}]*)?\}/g; + let varMatch; + while ((varMatch = varPattern.exec(valueStr)) !== null) { + const varName = varMatch[1]; + // Only add if names differ — same-name case handled by direct key matching + if (containerKey !== varName) { + results.push([containerKey, varName]); + } + } + } + + return results; +} diff --git a/src/lib/server/git.ts b/src/lib/server/git.ts index 142e159..4ab59ee 100644 --- a/src/lib/server/git.ts +++ b/src/lib/server/git.ts @@ -16,6 +16,70 @@ import { } from './db'; import { deployStack, getStackDir } from './stacks'; +const MERGED_CA_BUNDLE_PATH = '/tmp/dockhand-merged-ca-bundle.crt'; +let mergedCaBundleReady = false; + +/** + * Create a merged CA bundle combining system CAs with the custom cert from + * NODE_EXTRA_CA_CERTS. GIT_SSL_CAINFO replaces the default CA store, so without + * merging, public CAs (GitHub, GitLab) break. + */ +function getMergedCaBundlePath(): string { + if (mergedCaBundleReady && existsSync(MERGED_CA_BUNDLE_PATH)) { + console.log(`[Git] Using cached merged CA bundle: ${MERGED_CA_BUNDLE_PATH}`); + return MERGED_CA_BUNDLE_PATH; + } + + const customCertPath = process.env.NODE_EXTRA_CA_CERTS!; + console.log(`[Git] NODE_EXTRA_CA_CERTS set to: ${customCertPath}`); + + const systemCaPaths = [ + process.env.SSL_CERT_FILE, + '/etc/ssl/certs/ca-certificates.crt', + '/etc/pki/tls/certs/ca-bundle.crt', + '/etc/ssl/cert.pem' + ]; + + let systemCaContent = ''; + let systemCaSource = ''; + for (const caPath of systemCaPaths) { + if (caPath && existsSync(caPath)) { + try { + systemCaContent = readFileSync(caPath, 'utf-8'); + systemCaSource = caPath; + console.log(`[Git] Found system CA bundle: ${caPath} (${systemCaContent.split('-----BEGIN CERTIFICATE-----').length - 1} certs)`); + break; + } catch (err) { + console.log(`[Git] Failed to read system CA bundle ${caPath}: ${err}`); + } + } + } + + if (!systemCaSource) { + console.log(`[Git] No system CA bundle found, using custom cert only: ${customCertPath}`); + } + + try { + const customCaContent = readFileSync(customCertPath, 'utf-8'); + const customCertCount = customCaContent.split('-----BEGIN CERTIFICATE-----').length - 1; + console.log(`[Git] Custom CA file contains ${customCertCount} cert(s)`); + + const merged = systemCaContent + ? systemCaContent.trimEnd() + '\n' + customCaContent.trimEnd() + '\n' + : customCaContent; + writeFileSync(MERGED_CA_BUNDLE_PATH, merged); + mergedCaBundleReady = true; + + const totalCerts = merged.split('-----BEGIN CERTIFICATE-----').length - 1; + console.log(`[Git] Created merged CA bundle: ${MERGED_CA_BUNDLE_PATH} (${totalCerts} total certs — system from ${systemCaSource || 'none'} + custom from ${customCertPath})`); + } catch (err) { + console.warn(`[Git] Failed to create merged CA bundle, falling back to custom cert only: ${customCertPath}`, err); + return customCertPath; + } + + return MERGED_CA_BUNDLE_PATH; +} + /** * Collect stdout, stderr and exit code from a spawned process. */ @@ -153,9 +217,11 @@ async function buildGitEnv(credential: GitCredential | null): Promise { SSH_AUTH_SOCK: '' }; - // Pass custom CA certificate to git CLI (NODE_EXTRA_CA_CERTS only affects Node.js) + // Pass custom CA certificate to git CLI (NODE_EXTRA_CA_CERTS only affects Node.js). + // GIT_SSL_CAINFO replaces the default CA store, so we merge system CAs with the + // custom cert so both self-signed repos and public repos (GitHub etc.) work (#967). if (process.env.NODE_EXTRA_CA_CERTS) { - env.GIT_SSL_CAINFO = process.env.NODE_EXTRA_CA_CERTS; + env.GIT_SSL_CAINFO = getMergedCaBundlePath(); } // Ensure current UID is resolvable for SSH/git operations diff --git a/src/lib/server/hawser.ts b/src/lib/server/hawser.ts index c0768bd..ef246d3 100644 --- a/src/lib/server/hawser.ts +++ b/src/lib/server/hawser.ts @@ -9,6 +9,7 @@ import { db, hawserTokens, environments, eq, and } from './db/drizzle.js'; import { logContainerEvent, type ContainerEventAction } from './db.js'; import { containerEventEmitter } from './event-collector.js'; import { sendEnvironmentNotification } from './notifications.js'; +import { isNotifyDisabledByLabel } from './container-labels.js'; import { pushMetric } from './metrics-store.js'; import { secureGetRandomValues, secureRandomUUID } from './crypto-fallback.js'; import { hashPassword, verifyPassword } from './auth.js'; @@ -191,24 +192,26 @@ export async function handleEdgeContainerEvent( // Broadcast to SSE clients containerEventEmitter.emit('event', savedEvent); - // Prepare notification - const actionLabel = event.action.charAt(0).toUpperCase() + event.action.slice(1); - const containerLabel = event.containerName || event.containerId.substring(0, 12); - const notificationType = - event.action === 'die' || event.action === 'kill' || event.action === 'oom' - ? 'error' - : event.action === 'stop' - ? 'warning' - : event.action === 'start' - ? 'success' - : 'info'; + // Check dockhand.notify label before sending notification + // Docker includes container labels in actorAttributes + if (!isNotifyDisabledByLabel(event.actorAttributes)) { + const actionLabel = event.action.charAt(0).toUpperCase() + event.action.slice(1); + const containerLabel = event.containerName || event.containerId.substring(0, 12); + const notificationType = + event.action === 'die' || event.action === 'kill' || event.action === 'oom' + ? 'error' + : event.action === 'stop' + ? 'warning' + : event.action === 'start' + ? 'success' + : 'info'; - // Send notification - await sendEnvironmentNotification(environmentId, event.action as ContainerEventAction, { - title: `Container ${actionLabel}`, - message: `Container "${containerLabel}" ${event.action}${event.image ? ` (${event.image})` : ''}`, - type: notificationType as 'success' | 'error' | 'warning' | 'info' - }, event.image); + await sendEnvironmentNotification(environmentId, event.action as ContainerEventAction, { + title: `Container ${actionLabel}`, + message: `Container "${containerLabel}" ${event.action}${event.image ? ` (${event.image})` : ''}`, + type: notificationType as 'success' | 'error' | 'warning' | 'info' + }, event.image); + } } catch (error) { const errorMsg = error instanceof Error ? error.message : String(error); console.error('[Hawser] Error handling container event:', errorMsg); diff --git a/src/lib/server/mount-dedupe.test.ts b/src/lib/server/mount-dedupe.test.ts deleted file mode 100644 index f5dceb6..0000000 --- a/src/lib/server/mount-dedupe.test.ts +++ /dev/null @@ -1,33 +0,0 @@ -import assert from 'node:assert/strict'; -import { describe, it } from 'node:test'; - -import { getAdditionalVolumeBinds } from './mount-dedupe'; - -describe('getAdditionalVolumeBinds', () => { - it('skips volume mounts when the target already exists in HostConfig.Mounts', () => { - const additionalBinds = getAdditionalVolumeBinds( - { - Binds: ['/volume1/backups:/backup'], - Mounts: [{ Target: '/data' }] - }, - [ - { Type: 'volume', Name: 'docsight_docsis_data', Destination: '/data' }, - { Type: 'bind', Name: 'ignored', Destination: '/backup' } - ] - ); - - assert.deepEqual(additionalBinds, []); - }); - - it('adds volume mounts that are missing from HostConfig', () => { - const additionalBinds = getAdditionalVolumeBinds( - { - Binds: ['/volume1/backups:/backup'], - Mounts: [] - }, - [{ Type: 'volume', Name: 'docsight_docsis_data', Destination: '/data' }] - ); - - assert.deepEqual(additionalBinds, ['docsight_docsis_data:/data']); - }); -}); diff --git a/src/lib/server/notifications.ts b/src/lib/server/notifications.ts index cc1533e..299c0fa 100644 --- a/src/lib/server/notifications.ts +++ b/src/lib/server/notifications.ts @@ -9,17 +9,7 @@ import { type NotificationEventType } from './db'; -// Escape special characters for Telegram Markdown -function escapeTelegramMarkdown(text: string): string { - // Escape characters that have special meaning in Telegram Markdown - return text - .replace(/\\/g, '\\\\') // Escape backslashes first - .replace(/_/g, '\\_') // Underscore (italic) - .replace(/\*/g, '\\*') // Asterisk (bold) - .replace(/\[/g, '\\[') // Opening bracket (link) - .replace(/\]/g, '\\]') // Closing bracket (link) - .replace(/`/g, '\\`'); // Backtick (code) -} +import { escapeTelegramMarkdown, parseTelegramUrl, buildGotifyUrl, parseWorkflowsUrl, buildWorkflowsHttpUrl } from '$lib/utils/notification-parsers'; /** Drain a response body to release the underlying socket/TLS connection. */ async function drainResponse(response: Response): Promise { @@ -279,21 +269,18 @@ async function sendMattermost(appriseUrl: string, payload: NotificationPayload): // Telegram async function sendTelegram(appriseUrl: string, payload: NotificationPayload): Promise { - // tgram://bot_token/chat_id:topic_id? - const match = appriseUrl.match(/^tgram:\/\/([^/]+)\/([^:\/]+)(?::(\d+))?$/); - if (!match) { + const parsed = parseTelegramUrl(appriseUrl); + if (!parsed) { return { success: false, error: 'Invalid Telegram URL format. Expected: tgram://bot_token/chat_id or tgram://bot_token/chat_id:topic_id' }; } - const [, botToken, chatId, topicIdStr] = match; + const { botToken, chatId, topicId } = parsed; const url = `https://api.telegram.org/bot${botToken}/sendMessage`; // Escape markdown special characters in title and message const escapedTitle = escapeTelegramMarkdown(payload.title); const escapedMessage = escapeTelegramMarkdown(payload.message); - const envTag = payload.environmentName ? ` \\[${escapeTelegramMarkdown(payload.environmentName)}\\]` : ''; - - const topicId = topicIdStr ? parseInt(topicIdStr, 10) : undefined; + const envTag = payload.environmentName ? ` [${escapeTelegramMarkdown(payload.environmentName)}]` : ''; try { const response = await fetch(url, { @@ -324,21 +311,11 @@ async function sendTelegram(appriseUrl: string, payload: NotificationPayload): P // Gotify async function sendGotify(appriseUrl: string, payload: NotificationPayload): Promise { - // gotify://hostname/token or gotifys://hostname/token - // gotify://hostname/subpath/token (subpath support) - const match = appriseUrl.match(/^gotifys?:\/\/([^/]+)\/(.+)/); - if (!match) { + const url = buildGotifyUrl(appriseUrl); + if (!url) { return { success: false, error: 'Invalid Gotify URL format. Expected: gotify://hostname/token' }; } - const [, hostname, pathPart] = match; - const protocol = appriseUrl.startsWith('gotifys') ? 'https' : 'http'; - // Token is always the last path segment; anything before it is a subpath - const lastSlash = pathPart.lastIndexOf('/'); - const subpath = lastSlash >= 0 ? pathPart.substring(0, lastSlash) : ''; - const token = lastSlash >= 0 ? pathPart.substring(lastSlash + 1) : pathPart; - const url = `${protocol}://${hostname}${subpath ? '/' + subpath : ''}/message?token=${token}`; - const titleWithEnv = payload.environmentName ? `${payload.title} [${payload.environmentName}]` : payload.title; try { @@ -496,47 +473,57 @@ async function sendGenericWebhook(appriseUrl: string, payload: NotificationPaylo } } // Microsoft Power Automate Workflows, for e.g. Microsoft Teams -async function sendWorkflows(appriseUrl: string, payload: NotificationPayload): Promise { - // workflows://hostname/workflow/signature - const match = appriseUrl.match(/^workflows?:\/\/([^/]+)\/(.+)\/(.+)/); - if (!match) return false; +async function sendWorkflows(appriseUrl: string, payload: NotificationPayload): Promise { + const parsed = parseWorkflowsUrl(appriseUrl); + if (!parsed) { + return { success: false, error: 'Invalid Workflows URL format. Expected: workflows://hostname/workflow/signature' }; + } - const [, hostname, workflow, signature] = match; - const url = `https://${hostname}/powerautomate/automations/direct/workflows/${workflow}/triggers/manual/paths/invoke?api-version=1&sp=%2Ftriggers%2Fmanual%2Frun&sv=1.0&sig=${signature}`; + const url = buildWorkflowsHttpUrl(parsed.hostname, parsed.workflow, parsed.signature); + 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({ - type: 'message', - attachments: [ - { - contentType: 'application/vnd.microsoft.card.adaptive', - content: { - $schema: 'https://adaptivecards.io/schemas/adaptive-card.json', - type: 'AdaptiveCard', - version: '1.2', - body: [ - { - type: 'TextBlock', - style: 'heading', - wrap: true, - text: payload.title - }, - { - type: 'TextBlock', - style: 'default', - wrap: true, - text: payload.message - } - ] + try { + const response = await fetch(url, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + type: 'message', + attachments: [ + { + contentType: 'application/vnd.microsoft.card.adaptive', + content: { + $schema: 'https://adaptivecards.io/schemas/adaptive-card.json', + type: 'AdaptiveCard', + version: '1.2', + body: [ + { + type: 'TextBlock', + style: 'heading', + wrap: true, + text: titleWithEnv + }, + { + type: 'TextBlock', + style: 'default', + wrap: true, + text: payload.message + } + ] + } } - } - ] - }) - }); + ] + }) + }); - return response.ok; + if (!response.ok) { + const text = await response.text().catch(() => ''); + return { success: false, error: `Workflows error ${response.status}: ${text || response.statusText}` }; + } + await drainResponse(response); + return { success: true }; + } catch (error) { + return { success: false, error: `Workflows connection failed: ${error instanceof Error ? error.message : String(error)}` }; + } } // Send notification to all enabled channels diff --git a/src/lib/server/scheduler/tasks/container-update.ts b/src/lib/server/scheduler/tasks/container-update.ts index 22bcc4c..59bf1d1 100644 --- a/src/lib/server/scheduler/tasks/container-update.ts +++ b/src/lib/server/scheduler/tasks/container-update.ts @@ -38,6 +38,7 @@ import { import { getScannerSettings, scanImage, type ScanResult, type VulnerabilitySeverity } from '../../scanner'; import { sendEventNotification } from '../../notifications'; import { parseImageNameAndTag, shouldBlockUpdate, combineScanSummaries, isSystemContainer } from './update-utils'; +import { isUpdateDisabledByLabel } from '../../container-labels'; // ============================================================================= // TYPES @@ -369,6 +370,18 @@ export async function runContainerUpdate( return; } + // Check dockhand.update label (label wins over DB settings) + if (isUpdateDisabledByLabel(inspectData.Config?.Labels)) { + log(`Skipping - dockhand.update=false label set on container`); + await updateScheduleExecution(execution.id, { + status: 'skipped', + completedAt: new Date().toISOString(), + duration: Date.now() - startTime, + details: { reason: 'Skipped by dockhand.update=false label' } + }); + return; + } + // Skip digest-pinned images - they are explicitly locked to a specific version if (isDigestBasedImage(imageNameFromConfig)) { log(`Skipping ${containerName} - image pinned to specific digest`); diff --git a/src/lib/server/scheduler/tasks/env-update-check.ts b/src/lib/server/scheduler/tasks/env-update-check.ts index f8bca9b..5d832ca 100644 --- a/src/lib/server/scheduler/tasks/env-update-check.ts +++ b/src/lib/server/scheduler/tasks/env-update-check.ts @@ -31,6 +31,7 @@ import { import { sendEventNotification } from '../../notifications'; import { getScannerSettings, scanImage, type VulnerabilitySeverity } from '../../scanner'; import { parseImageNameAndTag, shouldBlockUpdate, combineScanSummaries, isSystemContainer } from './update-utils'; +import { isUpdateDisabledByLabel } from '../../container-labels'; import { recreateContainer } from './container-update'; interface UpdateInfo { @@ -129,6 +130,12 @@ export async function runEnvUpdateCheckJob( continue; } + // Check dockhand.update label (label wins over DB settings) + if (isUpdateDisabledByLabel(inspectData.Config?.Labels)) { + await log(` [${container.name}] Skipping - dockhand.update=false label`); + continue; + } + checkedCount++; await log(` Checking: ${container.name} (${imageName})`); diff --git a/src/lib/server/subprocess-manager.ts b/src/lib/server/subprocess-manager.ts index b7bed6e..fb9c03f 100644 --- a/src/lib/server/subprocess-manager.ts +++ b/src/lib/server/subprocess-manager.ts @@ -24,6 +24,7 @@ import { type ContainerEventAction } from './db'; import { sendEnvironmentNotification, sendEventNotification } from './notifications'; +import { isNotifyDisabledByLabel } from './container-labels'; import { rssBeforeOp, rssAfterOp } from './rss-tracker'; import { pushMetric } from './metrics-store'; @@ -285,24 +286,28 @@ async function handleContainerEvent(msg: GoMessage): Promise { // Sub-category: notification const notifBefore = rssBeforeOp(); - const actionLabel = action.startsWith('health_status') - ? action.includes('unhealthy') ? 'Unhealthy' : 'Healthy' - : action.charAt(0).toUpperCase() + action.slice(1); - const containerLabel = containerName || containerId.substring(0, 12); - const notificationType = - action === 'die' || action === 'kill' || action === 'oom' || action.includes('unhealthy') - ? 'error' - : action === 'stop' - ? 'warning' - : action === 'start' || (action.includes('healthy') && !action.includes('unhealthy')) - ? 'success' - : 'info'; - sendEnvironmentNotification(msg.envId, action, { - title: `Container ${actionLabel}`, - message: `Container "${containerLabel}" ${action}${image ? ` (${image})` : ''}`, - type: notificationType - }, image).catch(() => {}); + // Check dockhand.notify label — Docker includes container labels in event Actor.Attributes + if (!isNotifyDisabledByLabel(event.Actor?.Attributes)) { + const actionLabel = action.startsWith('health_status') + ? action.includes('unhealthy') ? 'Unhealthy' : 'Healthy' + : action.charAt(0).toUpperCase() + action.slice(1); + const containerLabel = containerName || containerId.substring(0, 12); + const notificationType = + action === 'die' || action === 'kill' || action === 'oom' || action.includes('unhealthy') + ? 'error' + : action === 'stop' + ? 'warning' + : action === 'start' || (action.includes('healthy') && !action.includes('unhealthy')) + ? 'success' + : 'info'; + + sendEnvironmentNotification(msg.envId, action, { + title: `Container ${actionLabel}`, + message: `Container "${containerLabel}" ${action}${image ? ` (${image})` : ''}`, + type: notificationType + }, image).catch(() => {}); + } rssAfterOp('events_notif', notifBefore); rssAfterOp('events', before); } diff --git a/src/lib/stores/settings.ts b/src/lib/stores/settings.ts index 8597774..b5cd9e5 100644 --- a/src/lib/stores/settings.ts +++ b/src/lib/stores/settings.ts @@ -5,6 +5,7 @@ export type TimeFormat = '12h' | '24h'; export type DateFormat = 'MM/DD/YYYY' | 'DD/MM/YYYY' | 'YYYY-MM-DD' | 'DD.MM.YYYY'; export type DownloadFormat = 'tar' | 'tar.gz'; export type EventCollectionMode = 'stream' | 'poll'; +export type LabelFilterMode = 'any' | 'all'; export interface AppSettings { confirmDestructive: boolean; @@ -32,6 +33,8 @@ export interface AppSettings { primaryStackLocation: string | null; defaultGrypeImage: string; defaultTrivyImage: string; + defaultComposeTemplate: string; + labelFilterMode: LabelFilterMode; } const DEFAULT_SETTINGS: AppSettings = { @@ -59,7 +62,26 @@ const DEFAULT_SETTINGS: AppSettings = { externalStackPaths: [], primaryStackLocation: null, defaultGrypeImage: 'anchore/grype:v0.110.0', - defaultTrivyImage: 'aquasec/trivy:0.69.3' + defaultTrivyImage: 'aquasec/trivy:0.69.3', + labelFilterMode: 'any', + defaultComposeTemplate: `version: "3.8" + +services: + app: + image: nginx:alpine + ports: + - "8080:80" + environment: + - APP_ENV=\${APP_ENV:-production} + volumes: + - ./html:/usr/share/nginx/html:ro + restart: unless-stopped + +# Add more services as needed +# networks: +# default: +# driver: bridge +` }; // Create a writable store for app settings @@ -101,7 +123,9 @@ function createSettingsStore() { externalStackPaths: settings.externalStackPaths ?? DEFAULT_SETTINGS.externalStackPaths, primaryStackLocation: settings.primaryStackLocation ?? DEFAULT_SETTINGS.primaryStackLocation, defaultGrypeImage: settings.defaultGrypeImage ?? DEFAULT_SETTINGS.defaultGrypeImage, - defaultTrivyImage: settings.defaultTrivyImage ?? DEFAULT_SETTINGS.defaultTrivyImage + defaultTrivyImage: settings.defaultTrivyImage ?? DEFAULT_SETTINGS.defaultTrivyImage, + defaultComposeTemplate: settings.defaultComposeTemplate ?? DEFAULT_SETTINGS.defaultComposeTemplate, + labelFilterMode: settings.labelFilterMode ?? DEFAULT_SETTINGS.labelFilterMode }); } } catch { @@ -146,7 +170,9 @@ function createSettingsStore() { externalStackPaths: updatedSettings.externalStackPaths ?? DEFAULT_SETTINGS.externalStackPaths, primaryStackLocation: updatedSettings.primaryStackLocation ?? DEFAULT_SETTINGS.primaryStackLocation, defaultGrypeImage: updatedSettings.defaultGrypeImage ?? DEFAULT_SETTINGS.defaultGrypeImage, - defaultTrivyImage: updatedSettings.defaultTrivyImage ?? DEFAULT_SETTINGS.defaultTrivyImage + defaultTrivyImage: updatedSettings.defaultTrivyImage ?? DEFAULT_SETTINGS.defaultTrivyImage, + defaultComposeTemplate: updatedSettings.defaultComposeTemplate ?? DEFAULT_SETTINGS.defaultComposeTemplate, + labelFilterMode: updatedSettings.labelFilterMode ?? DEFAULT_SETTINGS.labelFilterMode }); } } catch (error) { @@ -348,6 +374,20 @@ function createSettingsStore() { return newSettings; }); }, + setDefaultComposeTemplate: (value: string) => { + update((current) => { + const newSettings = { ...current, defaultComposeTemplate: value }; + saveSettings({ defaultComposeTemplate: value }); + return newSettings; + }); + }, + setLabelFilterMode: (value: LabelFilterMode) => { + update((current) => { + const newSettings = { ...current, labelFilterMode: value }; + saveSettings({ labelFilterMode: value }); + return newSettings; + }); + }, // Manual refresh from database refresh: () => { initialized = false; diff --git a/src/lib/utils/notification-parsers.ts b/src/lib/utils/notification-parsers.ts new file mode 100644 index 0000000..44468a3 --- /dev/null +++ b/src/lib/utils/notification-parsers.ts @@ -0,0 +1,47 @@ +// Pure parsing/building functions for notification providers. +// Extracted from notifications.ts so unit tests can import without pulling in DB deps. + +// --- Telegram --- + +// Escape special characters for Telegram legacy Markdown (parse_mode: 'Markdown') +// Only _ * ` [ need escaping — ] and other chars are not special in legacy mode +export function escapeTelegramMarkdown(text: string): string { + return text + .replace(/_/g, '\\_') // Underscore (italic) + .replace(/\*/g, '\\*') // Asterisk (bold) + .replace(/`/g, '\\`') // Backtick (code) + .replace(/\[/g, '\\['); // Opening bracket (link) +} + +export function parseTelegramUrl(url: string): { botToken: string; chatId: string; topicId?: number } | null { + const match = url.match(/^tgram:\/\/([^/]+)\/([^:\/]+)(?::(\d+))?$/); + if (!match) return null; + const [, botToken, chatId, topicIdStr] = match; + return { botToken, chatId, topicId: topicIdStr ? parseInt(topicIdStr, 10) : undefined }; +} + +// --- Gotify --- + +export function buildGotifyUrl(appriseUrl: string): string | null { + const match = appriseUrl.match(/^gotifys?:\/\/([^/]+)\/(.+)/); + if (!match) return null; + const [, hostname, pathPart] = match; + const protocol = appriseUrl.startsWith('gotifys') ? 'https' : 'http'; + const lastSlash = pathPart.lastIndexOf('/'); + const subpath = lastSlash >= 0 ? pathPart.substring(0, lastSlash) : ''; + const token = lastSlash >= 0 ? pathPart.substring(lastSlash + 1) : pathPart; + return `${protocol}://${hostname}${subpath ? '/' + subpath : ''}/message?token=${token}`; +} + +// --- Workflows (Microsoft Power Automate) --- + +export function parseWorkflowsUrl(appriseUrl: string): { hostname: string; workflow: string; signature: string } | null { + const match = appriseUrl.match(/^workflows?:\/\/([^/]+)\/(.+)\/(.+)/); + if (!match) return null; + const [, hostname, workflow, signature] = match; + return { hostname, workflow, signature }; +} + +export function buildWorkflowsHttpUrl(hostname: string, workflow: string, signature: string): string { + return `https://${hostname}/powerautomate/automations/direct/workflows/${workflow}/triggers/manual/paths/invoke?api-version=1&sp=%2Ftriggers%2Fmanual%2Frun&sv=1.0&sig=${signature}`; +} diff --git a/src/lib/utils/port-format.ts b/src/lib/utils/port-format.ts index 0d13444..427fc5d 100644 --- a/src/lib/utils/port-format.ts +++ b/src/lib/utils/port-format.ts @@ -13,9 +13,10 @@ interface PortInfo { } /** - * Format Docker port mappings, collapsing consecutive ranges. + * Format Docker port mappings, collapsing consecutive ranges of 3+ ports. * Accepts both Docker API format (PublicPort/PrivatePort) and camelCase (publicPort/privatePort). * e.g. 8080:8080, 8081:8081, 8082:8082 → 8080-8082:8080-8082 + * But 80:80, 81:81 stay as individual ports (only 2 consecutive). */ export function formatPorts(ports: PortInfo[] | undefined | null): PortMapping[] { if (!ports || ports.length === 0) return []; @@ -35,30 +36,46 @@ export function formatPorts(ports: PortInfo[] | undefined | null): PortMapping[] }) .sort((a, b) => a.publicPort - b.publicPort); - // Collapse consecutive port ranges + // Collapse consecutive port ranges (3+ ports only) if (individual.length <= 1) return individual; const result: PortMapping[] = []; - let rangeStart = individual[0]; - let rangeEnd = individual[0]; + let rangeStart = 0; + let rangeEnd = 0; for (let i = 1; i < individual.length; i++) { const curr = individual[i]; - const offset = curr.publicPort - rangeStart.publicPort; - const expectedPrivate = rangeStart.privatePort + offset; - if (curr.publicPort === rangeEnd.publicPort + 1 && curr.privatePort === expectedPrivate) { - rangeEnd = curr; + const start = individual[rangeStart]; + const prev = individual[rangeEnd]; + const offset = curr.publicPort - start.publicPort; + const expectedPrivate = start.privatePort + offset; + if (curr.publicPort === prev.publicPort + 1 && curr.privatePort === expectedPrivate) { + rangeEnd = i; } else { - result.push(rangeStart.publicPort === rangeEnd.publicPort - ? rangeStart - : { publicPort: rangeStart.publicPort, privatePort: rangeStart.privatePort, display: `${rangeStart.publicPort}-${rangeEnd.publicPort}:${rangeStart.privatePort}-${rangeEnd.privatePort}`, isRange: true }); - rangeStart = curr; - rangeEnd = curr; + flushRange(individual, rangeStart, rangeEnd, result); + rangeStart = i; + rangeEnd = i; } } - result.push(rangeStart.publicPort === rangeEnd.publicPort - ? rangeStart - : { publicPort: rangeStart.publicPort, privatePort: rangeStart.privatePort, display: `${rangeStart.publicPort}-${rangeEnd.publicPort}:${rangeStart.privatePort}-${rangeEnd.privatePort}`, isRange: true }); + flushRange(individual, rangeStart, rangeEnd, result); return result; } + +function flushRange(items: PortMapping[], start: number, end: number, result: PortMapping[]) { + const rangeLen = end - start + 1; + if (rangeLen >= 3) { + // Collapse into range + result.push({ + publicPort: items[start].publicPort, + privatePort: items[start].privatePort, + display: `${items[start].publicPort}-${items[end].publicPort}:${items[start].privatePort}-${items[end].privatePort}`, + isRange: true + }); + } else { + // Keep as individual ports + for (let i = start; i <= end; i++) { + result.push(items[i]); + } + } +} diff --git a/src/routes/+page.svelte b/src/routes/+page.svelte index 682502a..36ae05c 100644 --- a/src/routes/+page.svelte +++ b/src/routes/+page.svelte @@ -22,6 +22,7 @@ import { getLabelColor, getLabelBgColor } from '$lib/utils/label-colors'; import { Input } from '$lib/components/ui/input'; import MultiSelectFilter from '$lib/components/MultiSelectFilter.svelte'; + import { appSettings } from '$lib/stores/settings'; const LABEL_FILTER_STORAGE_KEY = 'dockhand-dashboard-label-filter'; @@ -210,10 +211,10 @@ if (filterLabels.length === 0) { return tiles; } - return tiles.filter(t => { - const tileLabels = t.stats?.labels || []; - return tileLabels.some(label => filterLabels.includes(label)); - }); + const matchFn = $appSettings.labelFilterMode === 'all' + ? (tileLabels: string[]) => filterLabels.every(label => tileLabels.includes(label)) + : (tileLabels: string[]) => filterLabels.some(label => tileLabels.includes(label)); + return tiles.filter(t => matchFn(t.stats?.labels || [])); }); // Filter grid items based on selected labels @@ -221,11 +222,12 @@ if (filterLabels.length === 0) { return gridItems; } - // Filter to only show tiles whose environments have at least one matching label + const matchFn = $appSettings.labelFilterMode === 'all' + ? (tileLabels: string[]) => filterLabels.every(label => tileLabels.includes(label)) + : (tileLabels: string[]) => filterLabels.some(label => tileLabels.includes(label)); return gridItems.filter(item => { const tile = tiles.find(t => t.id === item.id); - const tileLabels = tile?.stats?.labels || []; - return tileLabels.some(label => filterLabels.includes(label)); + return matchFn(tile?.stats?.labels || []); }); }); const orderedGridItems = $derived.by(() => { diff --git a/src/routes/api/containers/+server.ts b/src/routes/api/containers/+server.ts index 8c1e170..d0d1651 100644 --- a/src/routes/api/containers/+server.ts +++ b/src/routes/api/containers/+server.ts @@ -3,6 +3,7 @@ import { listContainers, createContainer, pullImage, EnvironmentNotFoundError, D import { authorize } from '$lib/server/authorize'; import { auditContainer } from '$lib/server/audit'; import { hasEnvironments } from '$lib/server/db'; +import { isHiddenByLabel } from '$lib/server/container-labels'; import type { RequestHandler } from './$types'; export const GET: RequestHandler = async ({ url, cookies }) => { @@ -34,7 +35,9 @@ export const GET: RequestHandler = async ({ url, cookies }) => { try { const containers = await listContainers(all, envIdNum); - return json(containers); + // Filter out containers with dockhand.hidden=true label + const visible = containers.filter(c => !isHiddenByLabel(c.labels)); + return json(visible); } catch (error: any) { // Return 404 for missing environment so frontend can clear stale localStorage if (error instanceof EnvironmentNotFoundError) { diff --git a/src/routes/api/containers/[id]/+server.ts b/src/routes/api/containers/[id]/+server.ts index 48dc348..3fc6435 100644 --- a/src/routes/api/containers/[id]/+server.ts +++ b/src/routes/api/containers/[id]/+server.ts @@ -4,7 +4,8 @@ import { removeContainer, getContainerLogs } from '$lib/server/docker'; -import { deleteAutoUpdateSchedule, getAutoUpdateSetting, getSecretKeyNames, removePendingContainerUpdate } from '$lib/server/db'; +import { deleteAutoUpdateSchedule, getAutoUpdateSetting, getSecretKeysToMask, removePendingContainerUpdate } from '$lib/server/db'; +import { getStackComposeFile } from '$lib/server/stacks'; import { authorize } from '$lib/server/authorize'; import { auditContainer } from '$lib/server/audit'; import { unregisterSchedule } from '$lib/server/scheduler'; @@ -34,10 +35,12 @@ export const GET: RequestHandler = async ({ params, url, cookies }) => { const details = await inspectContainer(params.id, envIdNum); - // Mask secret env vars for containers belonging to a Compose stack + // Mask secret env vars for containers belonging to a Compose stack. + // Uses compose file parsing to detect interpolation (e.g., MYSQL_PASSWORD=${db_secret}). const stackName = details.Config?.Labels?.['com.docker.compose.project']; if (stackName && Array.isArray(details.Config?.Env)) { - const secretKeys = await getSecretKeyNames(stackName, envIdNum); + const composeResult = await getStackComposeFile(stackName, envIdNum).catch(() => null); + const secretKeys = await getSecretKeysToMask(stackName, envIdNum, composeResult?.content); if (secretKeys.size > 0) { details.Config.Env = details.Config.Env.map((entry: string) => { const eqIdx = entry.indexOf('='); diff --git a/src/routes/api/containers/[id]/inspect/+server.ts b/src/routes/api/containers/[id]/inspect/+server.ts index 2e6a9b7..5d38c76 100644 --- a/src/routes/api/containers/[id]/inspect/+server.ts +++ b/src/routes/api/containers/[id]/inspect/+server.ts @@ -1,7 +1,8 @@ import { json } from '@sveltejs/kit'; import type { RequestHandler } from './$types'; import { inspectContainer } from '$lib/server/docker'; -import { getSecretKeyNames } from '$lib/server/db'; +import { getSecretKeysToMask } from '$lib/server/db'; +import { getStackComposeFile } from '$lib/server/stacks'; import { authorize } from '$lib/server/authorize'; import { validateDockerIdParam } from '$lib/server/docker-validation'; @@ -22,10 +23,12 @@ export const GET: RequestHandler = async ({ params, url, cookies }) => { try { const containerData = await inspectContainer(params.id, envIdNum); - // Mask secret env vars for containers belonging to a Compose stack + // Mask secret env vars for containers belonging to a Compose stack. + // Uses compose file parsing to detect interpolation (e.g., MYSQL_PASSWORD=${db_secret}). const stackName = containerData.Config?.Labels?.['com.docker.compose.project']; if (stackName && Array.isArray(containerData.Config?.Env)) { - const secretKeys = await getSecretKeyNames(stackName, envIdNum); + const composeResult = await getStackComposeFile(stackName, envIdNum).catch(() => null); + const secretKeys = await getSecretKeysToMask(stackName, envIdNum, composeResult?.content); if (secretKeys.size > 0) { containerData.Config.Env = containerData.Config.Env.map((entry: string) => { const eqIdx = entry.indexOf('='); diff --git a/src/routes/api/containers/batch-update-stream/+server.ts b/src/routes/api/containers/batch-update-stream/+server.ts index 705d5ea..69c6642 100644 --- a/src/routes/api/containers/batch-update-stream/+server.ts +++ b/src/routes/api/containers/batch-update-stream/+server.ts @@ -15,6 +15,7 @@ import { auditContainer } from '$lib/server/audit'; import { getScannerSettings, scanImage } from '$lib/server/scanner'; import { saveVulnerabilityScan, removePendingContainerUpdate, type VulnerabilityCriteria } from '$lib/server/db'; import { parseImageNameAndTag, shouldBlockUpdate, combineScanSummaries, isSystemContainer } from '$lib/server/scheduler/tasks/update-utils'; +import { isUpdateDisabledByLabel } from '$lib/server/container-labels'; import { recreateContainer } from '$lib/server/scheduler/tasks/container-update'; import { createJob, appendLine, completeJob, failJob } from '$lib/server/jobs'; @@ -173,6 +174,22 @@ export const POST: RequestHandler = async (event) => { continue; } + // Skip containers with dockhand.update=false label + if (isUpdateDisabledByLabel(config.Labels)) { + sendData({ + type: 'progress', + containerId, + containerName, + step: 'skipped', + current: i + 1, + total: containerIds.length, + success: true, + message: `Skipping ${containerName} - dockhand.update=false label` + }); + skippedCount++; + continue; + } + // Skip digest-pinned images - they are explicitly locked to a specific version if (isDigestBasedImage(imageName)) { sendData({ diff --git a/src/routes/api/containers/batch-update/+server.ts b/src/routes/api/containers/batch-update/+server.ts index 4cad151..21cc582 100644 --- a/src/routes/api/containers/batch-update/+server.ts +++ b/src/routes/api/containers/batch-update/+server.ts @@ -4,6 +4,7 @@ import { authorize } from '$lib/server/authorize'; import { listContainers, pullImage, inspectContainer } from '$lib/server/docker'; import { auditContainer } from '$lib/server/audit'; import { recreateContainer } from '$lib/server/scheduler/tasks/container-update'; +import { isUpdateDisabledByLabel } from '$lib/server/container-labels'; export interface BatchUpdateResult { containerId: string; @@ -62,6 +63,17 @@ export const POST: RequestHandler = async (event) => { const imageName = config.Image; const containerName = container.name; + // Skip containers with dockhand.update=false label + if (isUpdateDisabledByLabel(config.Labels)) { + results.push({ + containerId, + containerName, + success: true, + error: 'Skipped - dockhand.update=false label' + }); + continue; + } + // Pull latest image first try { await pullImage(imageName, undefined, envIdNum); diff --git a/src/routes/api/containers/check-updates/+server.ts b/src/routes/api/containers/check-updates/+server.ts index e627aa3..9c8a4ae 100644 --- a/src/routes/api/containers/check-updates/+server.ts +++ b/src/routes/api/containers/check-updates/+server.ts @@ -4,6 +4,7 @@ import { authorize } from '$lib/server/authorize'; import { listContainers, inspectContainer, checkImageUpdateAvailable } from '$lib/server/docker'; import { clearPendingContainerUpdates, addPendingContainerUpdate } from '$lib/server/db'; import { isSystemContainer } from '$lib/server/scheduler/tasks/update-utils'; +import { isUpdateDisabledByLabel } from '$lib/server/container-labels'; import { createJobResponse } from '$lib/server/sse'; export interface UpdateCheckResult { @@ -16,6 +17,7 @@ export interface UpdateCheckResult { error?: string; isLocalImage?: boolean; systemContainer?: 'dockhand' | 'hawser' | null; + updateDisabled?: boolean; } /** @@ -64,6 +66,7 @@ export const POST: RequestHandler = async ({ url, cookies, request }) => { } const result = await checkImageUpdateAvailable(imageName, currentImageId, envIdNum); + const updateDisabled = isUpdateDisabledByLabel(inspectData.Config?.Labels); return { containerId: container.id, @@ -74,7 +77,8 @@ export const POST: RequestHandler = async ({ url, cookies, request }) => { newDigest: result.registryDigest, error: result.error, isLocalImage: result.isLocalImage, - systemContainer: isSystemContainer(imageName) || null + systemContainer: isSystemContainer(imageName) || null, + updateDisabled }; } catch (error: any) { return { @@ -102,12 +106,12 @@ export const POST: RequestHandler = async ({ url, cookies, request }) => { } await Promise.all(Array.from({ length: Math.min(CONCURRENCY, containers.length) }, () => runNext())); - const updatesFound = results.filter(r => r.hasUpdate && !r.systemContainer).length; + const updatesFound = results.filter(r => r.hasUpdate && !r.systemContainer && !r.updateDisabled).length; // Save containers with updates to the database for persistence if (envIdNum) { for (const result of results) { - if (result.hasUpdate && !result.systemContainer) { + if (result.hasUpdate && !result.systemContainer && !result.updateDisabled) { await addPendingContainerUpdate( envIdNum, result.containerId, diff --git a/src/routes/api/git/repositories/[id]/+server.ts b/src/routes/api/git/repositories/[id]/+server.ts index e58579c..4c246e2 100644 --- a/src/routes/api/git/repositories/[id]/+server.ts +++ b/src/routes/api/git/repositories/[id]/+server.ts @@ -115,6 +115,7 @@ export const DELETE: RequestHandler = async (event) => { // Delete git stack clone directories before cascade deletes the DB rows const stacks = await getGitStacksByRepositoryId(id); + console.log(`[GitStack] Repository "${repository.name}" (id=${id}) deletion affects ${stacks.length} stacks: ${stacks.map(s => s.stackName).join(', ')}`); for (const stack of stacks) { await deleteGitStackFiles(stack.id, stack.stackName, stack.environmentId); } diff --git a/src/routes/api/settings/general/+server.ts b/src/routes/api/settings/general/+server.ts index 8178374..c522b51 100644 --- a/src/routes/api/settings/general/+server.ts +++ b/src/routes/api/settings/general/+server.ts @@ -77,6 +77,10 @@ export interface GeneralSettings { // Scanner images defaultGrypeImage: string; defaultTrivyImage: string; + // Compose template + defaultComposeTemplate: string; + // Label filter mode + labelFilterMode: 'any' | 'all'; } const DEFAULT_SETTINGS: Omit = { @@ -105,7 +109,26 @@ const DEFAULT_SETTINGS: Omit { externalStackPaths, primaryStackLocation, defaultGrypeImage, - defaultTrivyImage + defaultTrivyImage, + defaultComposeTemplate, + labelFilterMode ] = await Promise.all([ getSetting('confirm_destructive'), getSetting('show_stopped_containers'), @@ -192,7 +217,9 @@ export const GET: RequestHandler = async ({ cookies }) => { getExternalStackPaths(), getPrimaryStackLocation(), getSetting('default_grype_image'), - getSetting('default_trivy_image') + getSetting('default_trivy_image'), + getSetting('default_compose_template'), + getSetting('label_filter_mode') ]); const settings: GeneralSettings = { @@ -227,7 +254,9 @@ export const GET: RequestHandler = async ({ cookies }) => { externalStackPaths, primaryStackLocation, defaultGrypeImage: defaultGrypeImage ?? DEFAULT_GRYPE_IMAGE, - defaultTrivyImage: defaultTrivyImage ?? DEFAULT_TRIVY_IMAGE + defaultTrivyImage: defaultTrivyImage ?? DEFAULT_TRIVY_IMAGE, + defaultComposeTemplate: defaultComposeTemplate ?? DEFAULT_SETTINGS.defaultComposeTemplate, + labelFilterMode: labelFilterMode ?? DEFAULT_SETTINGS.labelFilterMode }; return json(settings); @@ -245,7 +274,7 @@ export const POST: RequestHandler = async ({ request, cookies }) => { try { const body = await request.json(); - const { confirmDestructive, showStoppedContainers, highlightUpdates, timeFormat, dateFormat, downloadFormat, defaultGrypeArgs, defaultTrivyArgs, scheduleRetentionDays, eventRetentionDays, scheduleCleanupCron, eventCleanupCron, scheduleCleanupEnabled, eventCleanupEnabled, logBufferSizeKb, defaultTimezone, eventCollectionMode, eventPollInterval, metricsCollectionInterval, lightTheme, darkTheme, font, fontSize, gridFontSize, terminalFont, editorFont, compactPorts, formatLogTimestamps, externalStackPaths, primaryStackLocation, defaultGrypeImage, defaultTrivyImage } = body; + const { confirmDestructive, showStoppedContainers, highlightUpdates, timeFormat, dateFormat, downloadFormat, defaultGrypeArgs, defaultTrivyArgs, scheduleRetentionDays, eventRetentionDays, scheduleCleanupCron, eventCleanupCron, scheduleCleanupEnabled, eventCleanupEnabled, logBufferSizeKb, defaultTimezone, eventCollectionMode, eventPollInterval, metricsCollectionInterval, lightTheme, darkTheme, font, fontSize, gridFontSize, terminalFont, editorFont, compactPorts, formatLogTimestamps, externalStackPaths, primaryStackLocation, defaultGrypeImage, defaultTrivyImage, defaultComposeTemplate, labelFilterMode } = body; if (confirmDestructive !== undefined) { await setSetting('confirm_destructive', confirmDestructive); @@ -364,6 +393,12 @@ export const POST: RequestHandler = async ({ request, cookies }) => { if (defaultTrivyImage !== undefined && typeof defaultTrivyImage === 'string') { await setSetting('default_trivy_image', defaultTrivyImage); } + if (defaultComposeTemplate !== undefined && typeof defaultComposeTemplate === 'string') { + await setSetting('default_compose_template', defaultComposeTemplate); + } + if (labelFilterMode !== undefined && (labelFilterMode === 'any' || labelFilterMode === 'all')) { + await setSetting('label_filter_mode', labelFilterMode); + } // Fetch all settings in parallel for the response const [ @@ -398,7 +433,9 @@ export const POST: RequestHandler = async ({ request, cookies }) => { externalStackPathsVal, primaryStackLocationVal, defaultGrypeImageVal, - defaultTrivyImageVal + defaultTrivyImageVal, + defaultComposeTemplateVal, + labelFilterModeVal ] = await Promise.all([ getSetting('confirm_destructive'), getSetting('show_stopped_containers'), @@ -431,7 +468,9 @@ export const POST: RequestHandler = async ({ request, cookies }) => { getExternalStackPaths(), getPrimaryStackLocation(), getSetting('default_grype_image'), - getSetting('default_trivy_image') + getSetting('default_trivy_image'), + getSetting('default_compose_template'), + getSetting('label_filter_mode') ]); const settings: GeneralSettings = { @@ -466,7 +505,9 @@ export const POST: RequestHandler = async ({ request, cookies }) => { externalStackPaths: externalStackPathsVal, primaryStackLocation: primaryStackLocationVal, defaultGrypeImage: defaultGrypeImageVal ?? DEFAULT_GRYPE_IMAGE, - defaultTrivyImage: defaultTrivyImageVal ?? DEFAULT_TRIVY_IMAGE + defaultTrivyImage: defaultTrivyImageVal ?? DEFAULT_TRIVY_IMAGE, + defaultComposeTemplate: defaultComposeTemplateVal ?? DEFAULT_SETTINGS.defaultComposeTemplate, + labelFilterMode: labelFilterModeVal ?? DEFAULT_SETTINGS.labelFilterMode }; return json(settings); diff --git a/src/routes/containers/+page.svelte b/src/routes/containers/+page.svelte index 6fa084a..1bed158 100644 --- a/src/routes/containers/+page.svelte +++ b/src/routes/containers/+page.svelte @@ -12,6 +12,7 @@ import * as Select from '$lib/components/ui/select'; import * as Tooltip from '$lib/components/ui/tooltip'; import ConfirmPopover from '$lib/components/ConfirmPopover.svelte'; + import { formatPorts, type PortMapping } from '$lib/utils/port-format'; import MultiSelectFilter from '$lib/components/MultiSelectFilter.svelte'; import PageHeader from '$lib/components/PageHeader.svelte'; import { Badge } from '$lib/components/ui/badge'; @@ -1149,8 +1150,6 @@ } } - import { formatPorts, type PortMapping } from '$lib/utils/port-format'; - function extractHostFromUrl(urlString: string): string | null { if (!urlString) return null; @@ -1861,7 +1860,7 @@ {@const remainingCount = ports.length - 1}
{#each displayPorts as port} - {@const url = !port.isRange && currentEnvDetails ? getPortUrl(port.publicPort) : null} + {@const url = currentEnvDetails ? getPortUrl(port.publicPort) : null} {#if url} ([]); let progressListEl = $state(null); let scrollTick = $state(0); + let userScrolledUp = $state(false); let currentIndex = $state(0); let totalCount = $state(0); let summary = $state<{ total: number; success: number; failed: number; blocked: number } | null>(null); let errorMessage = $state(''); let forceUpdating = $state>(new Set()); // Track containers being force-updated + let filterMode = $state<'updated' | 'failed'>('updated'); + + let filteredProgress = $derived( + !summary + ? progress + : filterMode === 'failed' + ? progress.filter(p => p.step === 'failed' || p.step === 'blocked') + : progress.filter(p => p.step === 'done' || p.success) + ); + + $effect(() => { + // Only track filterMode, not progress — avoid re-running on every SSE update + const mode = filterMode; + if (mode === 'updated') { + untrack(() => { + for (const item of progress) { + item.showLogs = false; + } + }); + } + }); function formatPullLog(entry: PullLogEntry): string { // Clarify potentially confusing Docker messages @@ -241,6 +264,9 @@ } else if (data.type === 'complete') { status = 'complete'; summary = data.summary; + for (const item of progress) { + item.showLogs = false; + } onComplete({ success: successIds, failed: failedIds, blocked: blockedIds }); } else if (data.type === 'error') { status = 'error'; @@ -266,6 +292,7 @@ currentIndex = 0; summary = null; errorMessage = ''; + filterMode = 'updated'; } function handleOpenChange(isOpen: boolean) { @@ -384,10 +411,18 @@ const severityOrder: Record = { critical: 0, high: 1, medium: 2, } }); - // Auto-scroll progress list to bottom on SSE data (not UI toggles) + // Track whether user has scrolled up to read earlier output + function handleProgressScroll() { + if (!progressListEl) return; + const { scrollTop, scrollHeight, clientHeight } = progressListEl; + // Consider "at bottom" if within 50px of the end + userScrolledUp = scrollHeight - scrollTop - clientHeight > 50; + } + + // Auto-scroll progress list to bottom on SSE data, but only if user hasn't scrolled up $effect(() => { scrollTick; - if (progressListEl) { + if (progressListEl && !userScrolledUp) { requestAnimationFrame(() => { progressListEl?.scrollTo({ top: progressListEl.scrollHeight, behavior: 'smooth' }); }); @@ -438,10 +473,33 @@ const severityOrder: Record = { critical: 0, high: 1, medium: 2,
- + {#if progress.length > 0} -
- {#each progress as item (item.containerId)} + {#if summary && (summary.failed > 0 || summary.blocked > 0) && summary.success > 0} +
+ + +
+ {/if} + +
+ {#each filteredProgress as item (item.containerId)} {@const StepIcon = getStepIcon(item.step)} {@const isActive = item.step !== 'done' && item.step !== 'failed' && item.step !== 'blocked'} {@const hasLogs = item.pullLogs.length > 0 || item.scanLogs.length > 0 || (item.vulnerabilities && item.vulnerabilities.length > 0)} diff --git a/src/routes/dashboard/DraggableGrid.svelte b/src/routes/dashboard/DraggableGrid.svelte index d20ed68..6a8683b 100644 --- a/src/routes/dashboard/DraggableGrid.svelte +++ b/src/routes/dashboard/DraggableGrid.svelte @@ -162,20 +162,37 @@ // Push colliding items down (returns new array) function pushCollidingItems(movedItem: GridItemLayout, sourceItems: GridItemLayout[]): GridItemLayout[] { const newItems = sourceItems.map(item => ({ ...item })); + + // Step 1: Push items that directly collide with the moved item + for (const item of newItems) { + if (item.id === movedItem.id) continue; + const overlaps = !(item.x + item.w <= movedItem.x || item.x >= movedItem.x + movedItem.w || + item.y + item.h <= movedItem.y || item.y >= movedItem.y + movedItem.h); + if (overlaps) { + item.y = movedItem.y + movedItem.h; + } + } + + // Step 2: Resolve cascading collisions by sorting top-to-bottom and pushing down let changed = true; let iterations = 0; - const maxIterations = 100; // Prevent infinite loops - - while (changed && iterations < maxIterations) { + while (changed && iterations < 100) { changed = false; iterations++; - for (const item of newItems) { - if (item.id === movedItem.id) continue; + const sorted = newItems + .filter(i => i.id !== movedItem.id) + .sort((a, b) => a.y - b.y || a.x - b.x); - if (hasCollision(item, movedItem)) { - // Push this item down - item.y = movedItem.y + movedItem.h; - changed = true; + for (let i = 0; i < sorted.length; i++) { + for (let j = i + 1; j < sorted.length; j++) { + const upper = sorted[i]; + const lower = sorted[j]; + const overlaps = !(upper.x + upper.w <= lower.x || upper.x >= lower.x + lower.w || + upper.y + upper.h <= lower.y || upper.y >= lower.y + lower.h); + if (overlaps) { + lower.y = upper.y + upper.h; + changed = true; + } } } } diff --git a/src/routes/logs/+page.svelte b/src/routes/logs/+page.svelte index 87b0362..7c15b72 100644 --- a/src/routes/logs/+page.svelte +++ b/src/routes/logs/+page.svelte @@ -10,13 +10,15 @@ import * as Select from '$lib/components/ui/select'; import { Checkbox } from '$lib/components/ui/checkbox'; import { ToggleGroup } from '$lib/components/ui/toggle-pill'; - import { RefreshCw, Search, ChevronDown, ChevronUp, Unplug, Copy, Download, WrapText, ArrowDownToLine, X, Sun, Moon, LayoutList, Square, Box, Wifi, WifiOff, Pause, Play, ScrollText, Star, GripVertical, Layers, Check, FolderHeart, Save, Trash2, MoreHorizontal, Eraser } from 'lucide-svelte'; + import { RefreshCw, Search, ChevronDown, ChevronUp, Unplug, Copy, Download, WrapText, ArrowDownToLine, X, Sun, Moon, LayoutList, Square, Box, Wifi, WifiOff, Pause, Play, ScrollText, Star, GripVertical, Layers, Check, FolderHeart, Save, Trash2, MoreHorizontal, Eraser, Filter, GripHorizontal, Terminal, ArrowDown, ArrowRight } from 'lucide-svelte'; import { copyToClipboard } from '$lib/utils/clipboard'; import PageHeader from '$lib/components/PageHeader.svelte'; + import TerminalPanel from '../terminal/TerminalPanel.svelte'; + import { detectShells, getBestShell, getSavedUser } from '$lib/utils/shell-detection'; import type { FavoriteGroup } from '../api/preferences/favorite-groups/+server'; import type { ContainerInfo } from '$lib/types'; import { currentEnvironment, environments, appendEnvParam } from '$lib/stores/environment'; - import { appSettings } from '$lib/stores/settings'; + import { appSettings, formatLogTimestamps } from '$lib/stores/settings'; import { NoEnvironment } from '$lib/components/ui/empty-state'; import { AnsiUp } from 'ansi_up'; const ansiUp = new AnsiUp(); @@ -306,12 +308,94 @@ import type { FavoriteGroup } from '../api/preferences/favorite-groups/+server'; // Log search state let logSearchActive = $state(false); let logSearchQuery = $state(''); + let logSearchFilterMode = $state(false); let currentMatchIndex = $state(0); let matchCount = $state(0); let logSearchInputRef: HTMLInputElement | undefined; const fontSizeOptions = [10, 12, 14, 16]; + // Terminal state + let terminalOpen = $state(false); + let terminalContainerId = $state(null); + let terminalContainerName = $state(''); + let terminalShell = $state('/bin/bash'); + let terminalUser = $state('root'); + let terminalLayout = $state<'below' | 'right'>('below'); + let terminalSplitRatio = $state(0.5); // 0-1, ratio of logs panel + let isResizingTerminal = $state(false); + let terminalSplitRef: HTMLDivElement | undefined; + + const TERMINAL_LAYOUT_KEY = 'dockhand-logs-terminal-layout'; + const TERMINAL_SPLIT_KEY = 'dockhand-logs-terminal-split'; + + function loadTerminalSettings() { + if (typeof window === 'undefined') return; + const savedLayout = localStorage.getItem(TERMINAL_LAYOUT_KEY); + if (savedLayout === 'below' || savedLayout === 'right') terminalLayout = savedLayout; + const savedSplit = localStorage.getItem(TERMINAL_SPLIT_KEY); + if (savedSplit) { + const r = parseFloat(savedSplit); + if (!isNaN(r) && r >= 0.2 && r <= 0.8) terminalSplitRatio = r; + } + } + + function saveTerminalSettings() { + if (typeof window === 'undefined') return; + localStorage.setItem(TERMINAL_LAYOUT_KEY, terminalLayout); + localStorage.setItem(TERMINAL_SPLIT_KEY, String(terminalSplitRatio)); + } + + async function openTerminal(containerId: string, containerName: string, layout?: 'below' | 'right') { + if (terminalOpen && terminalContainerId === containerId && (!layout || layout === terminalLayout)) { + closeTerminal(); + return; + } + if (layout) { + terminalLayout = layout; + saveTerminalSettings(); + } + terminalContainerId = containerId; + terminalContainerName = containerName; + const savedUser = getSavedUser(containerId); + if (savedUser) terminalUser = savedUser; + const result = await detectShells(containerId, envId); + const best = getBestShell(result, terminalShell); + if (best) terminalShell = best; + terminalOpen = true; + } + + function closeTerminal() { + terminalOpen = false; + terminalContainerId = null; + } + + function startTerminalResize(e: MouseEvent) { + e.preventDefault(); + isResizingTerminal = true; + document.addEventListener('mousemove', handleTerminalResize); + document.addEventListener('mouseup', stopTerminalResize); + } + + function handleTerminalResize(e: MouseEvent) { + if (!isResizingTerminal || !terminalSplitRef) return; + const rect = terminalSplitRef.getBoundingClientRect(); + let ratio: number; + if (terminalLayout === 'below') { + ratio = (e.clientY - rect.top) / rect.height; + } else { + ratio = (e.clientX - rect.left) / rect.width; + } + terminalSplitRatio = Math.max(0.2, Math.min(0.8, ratio)); + } + + function stopTerminalResize() { + isResizingTerminal = false; + document.removeEventListener('mousemove', handleTerminalResize); + document.removeEventListener('mouseup', stopTerminalResize); + saveTerminalSettings(); + } + // Subscribe to environment changes - restore state and fetch data const unsubscribeEnv = currentEnvironment.subscribe(async (env) => { envId = env?.id ?? null; @@ -769,6 +853,10 @@ import type { FavoriteGroup } from '../api/preferences/favorite-groups/+server'; return `[${data.containerName}] ${line}`; }).join('\n'); } + // Format timestamps if enabled + if ($appSettings.formatLogTimestamps) { + text = formatLogTimestamps(text); + } // Buffer text and schedule flush pendingText += text; if (!flushTimer) { @@ -953,12 +1041,13 @@ import type { FavoriteGroup } from '../api/preferences/favorite-groups/+server'; if (data.text) { // Use consistent color based on position in all selected containers const color = getContainerColor(data.containerId); + const logText = $appSettings.formatLogTimestamps ? formatLogTimestamps(data.text) : data.text; // Add to pending batch instead of updating state immediately pendingLogs.push({ containerId: data.containerId, containerName: data.containerName, color, - text: data.text, + text: logText, timestamp: data.timestamp, stream: data.stream }); @@ -1134,6 +1223,11 @@ import type { FavoriteGroup } from '../api/preferences/favorite-groups/+server'; // Stop any existing stream stopStreaming(); + // Close terminal when switching containers + if (terminalOpen && terminalContainerId !== container.id) { + closeTerminal(); + } + selectedContainer = container; searchQuery = ''; dropdownOpen = false; @@ -1314,10 +1408,15 @@ import type { FavoriteGroup } from '../api/preferences/favorite-groups/+server'; function closeLogSearch() { logSearchActive = false; logSearchQuery = ''; + logSearchFilterMode = false; currentMatchIndex = 0; matchCount = 0; } + function toggleSearchFilterMode() { + logSearchFilterMode = !logSearchFilterMode; + } + function navigateMatch(direction: 'prev' | 'next') { if (!logsRef || matchCount === 0) return; @@ -1368,38 +1467,54 @@ import type { FavoriteGroup } from '../api/preferences/favorite-groups/+server'; // Highlighted logs with search matches and ANSI color support (single container mode) let highlightedLogs = $derived(() => { - // First convert ANSI codes to HTML - const withAnsi = ansiToHtml(logs || ''); - if (!logSearchQuery.trim()) return withAnsi; + let text = logs || ''; + const query = logSearchQuery.trim(); - // For search, we need to highlight matches while preserving HTML tags - // We'll only highlight text outside of HTML tags - const query = logSearchQuery.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); - const escapedQuery = escapeHtml(query); + // Filter lines before ANSI conversion (plain text matching) + if (logSearchFilterMode && query) { + const escapedForRegex = query.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + const filterRegex = new RegExp(escapedForRegex, 'i'); + text = text.split('\n').filter(line => filterRegex.test(line)).join('\n'); + } + + const withAnsi = ansiToHtml(text); + if (!query) return withAnsi; + + const escapedForRegex = query.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + const escapedQuery = escapeHtml(escapedForRegex); - // Split by HTML tags and only process text parts const parts = withAnsi.split(/(<[^>]*>)/); - const highlighted = parts.map(part => { - // Skip HTML tags + return parts.map(part => { if (part.startsWith('<')) return part; - // Highlight matches in text const regex = new RegExp(`(${escapedQuery})`, 'gi'); return part.replace(regex, '$1'); }).join(''); - - return highlighted; }); // Format merged logs HTML — uses pre-built mergedHtml string, only applies search highlighting when needed let formattedMergedHtml = $derived(() => { if (!mergedHtml) return ''; - if (!logSearchQuery.trim()) return mergedHtml; + const query = logSearchQuery.trim(); - // Apply search highlighting (same approach as single mode's highlightedLogs) - const query = logSearchQuery.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); - const escapedQuery = escapeHtml(query); + // Filter mode: remove non-matching lines from HTML + let html = mergedHtml; + if (logSearchFilterMode && query) { + const escapedForRegex = query.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + const filterRegex = new RegExp(escapedForRegex, 'i'); + // Split by
or newlines, filter lines (strip HTML for matching, keep original for display) + const lines = html.split(/\n/); + html = lines.filter(line => { + const plainText = line.replace(/<[^>]*>/g, ''); + return filterRegex.test(plainText); + }).join('\n'); + } + + if (!query) return html; + + const escapedForRegex = query.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + const escapedQuery = escapeHtml(escapedForRegex); const searchRegex = new RegExp(`(${escapedQuery})`, 'gi'); - const parts = mergedHtml.split(/(<[^>]*>)/); + const parts = html.split(/(<[^>]*>)/); return parts.map(part => { if (part.startsWith('<')) return part; return part.replace(searchRegex, '$1'); @@ -1430,6 +1545,7 @@ import type { FavoriteGroup } from '../api/preferences/favorite-groups/+server'; onMount(() => { + loadTerminalSettings(); // All initialization is handled in currentEnvironment.subscribe // This just sets up the refresh interval containerInterval = setInterval(fetchContainers, 10000); @@ -1437,6 +1553,8 @@ import type { FavoriteGroup } from '../api/preferences/favorite-groups/+server'; }); onDestroy(() => { + document.removeEventListener('mousemove', handleTerminalResize); + document.removeEventListener('mouseup', stopTerminalResize); unsubscribeEnv(); if (containerInterval) { clearInterval(containerInterval); @@ -1831,8 +1949,9 @@ import type { FavoriteGroup } from '../api/preferences/favorite-groups/+server';
{/if} - -
+ +
+
{#if layoutMode === 'grouped'} {#if selectedContainerIds.size === 0}
@@ -1840,8 +1959,8 @@ import type { FavoriteGroup } from '../api/preferences/favorite-groups/+server';
{:else} -
-
+
+
{#if streamingEnabled} {#if isConnected}
@@ -1885,7 +2004,7 @@ import type { FavoriteGroup } from '../api/preferences/favorite-groups/+server'; {/if}
-
+
{#if matchCount > 0} {currentMatchIndex + 1}/{matchCount} {:else if logSearchQuery} @@ -1993,8 +2119,8 @@ import type { FavoriteGroup } from '../api/preferences/favorite-groups/+server';
{:else} -
-
+
+
{#if streamingEnabled} {#if isConnected} @@ -2028,14 +2154,34 @@ import type { FavoriteGroup } from '../api/preferences/favorite-groups/+server'; Paused
{/if} - + {#if selectedContainer} -
+
{selectedContainer.name} + +
{/if}
-
+
{#if matchCount > 0} {currentMatchIndex + 1}/{matchCount} {:else if logSearchQuery} @@ -2177,6 +2330,31 @@ import type { FavoriteGroup } from '../api/preferences/favorite-groups/+server'; {/if} {/if}
+ + {#if terminalOpen && terminalContainerId} + + + +
+ +
+ {/if} +
{/if} diff --git a/src/routes/logs/LogViewer.svelte b/src/routes/logs/LogViewer.svelte index 55e9cf0..ccdf8ee 100644 --- a/src/routes/logs/LogViewer.svelte +++ b/src/routes/logs/LogViewer.svelte @@ -1,5 +1,5 @@