diff --git a/Dockerfile b/Dockerfile index 8d79c08..9cd95d9 100644 --- a/Dockerfile +++ b/Dockerfile @@ -37,7 +37,7 @@ RUN APKO_ARCH=$([ "$TARGETARCH" = "arm64" ] && echo "aarch64" || echo "x86_64") " - busybox" \ " - tzdata" \ " - docker-cli" \ - " - docker-compose=5.1.4-r3" \ + " - docker-compose=5.1.4-r4" \ " - docker-cli-buildx" \ " - sqlite" \ " - postgresql-client" \ @@ -93,7 +93,7 @@ RUN cp -r node_modules/better-sqlite3/build /tmp/better-sqlite3-build \ && rm -rf node_modules/@types /tmp/better-sqlite3-build # Build Go collector -FROM --platform=$BUILDPLATFORM golang:1.25.10 AS go-builder +FROM --platform=$BUILDPLATFORM golang:1.25.11 AS go-builder ARG TARGETARCH WORKDIR /app COPY collector/ ./collector/ diff --git a/VERSION b/VERSION index 58156ec..d11e881 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -v1.0.31 +v1.0.32 diff --git a/collector/go.mod b/collector/go.mod index ffe64b0..1953331 100644 --- a/collector/go.mod +++ b/collector/go.mod @@ -1,3 +1,3 @@ module github.com/Finsys/dockhand/collector -go 1.25.10 +go 1.25.11 diff --git a/package.json b/package.json index 791fe39..6b14f4a 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "dockhand", "private": true, - "version": "1.0.31", + "version": "1.0.27", "type": "module", "scripts": { "dev": "npx vite dev", diff --git a/src/app.css b/src/app.css index fc3f5c2..4650027 100644 --- a/src/app.css +++ b/src/app.css @@ -74,28 +74,31 @@ html { max-width: calc(90px * var(--grid-font-size-scale, 1)) !important; } -/* Scrollbar theming */ -* { - scrollbar-color: hsl(var(--border) / 0.5) transparent; - scrollbar-width: thin; -} - +/* Scrollbar theming — WebKit only (Sencho-style). No global * selector and + * no scrollbar-width override, so Firefox/native scrollbars render at OS + * default width. Dark-mode thumb bumped to be visible on dark surfaces. */ ::-webkit-scrollbar { width: 8px; height: 8px; } - ::-webkit-scrollbar-track { background: transparent; } - ::-webkit-scrollbar-thumb { - background: hsl(var(--border) / 0.5); + /* Light mode: medium gray that holds up against white. Pale border-color + * at 50% was nearly invisible. */ + background: hsl(0 0% 60% / 0.6); border-radius: 4px; + transition: background 150ms ease; } - ::-webkit-scrollbar-thumb:hover { - background: hsl(var(--border) / 0.7); + background: hsl(0 0% 40% / 0.8); +} +.dark ::-webkit-scrollbar-thumb { + background: hsl(0 0% 50% / 0.5); +} +.dark ::-webkit-scrollbar-thumb:hover { + background: hsl(0 0% 65% / 0.7); } :root { diff --git a/src/lib/data/changelog.json b/src/lib/data/changelog.json index ca35078..0612bc8 100644 --- a/src/lib/data/changelog.json +++ b/src/lib/data/changelog.json @@ -1,4 +1,22 @@ [ + { + "version": "1.0.32", + "date": "2026-06-06", + "changes": [ + { "type": "feature", "text": "container details tweaks: process count, label filter, copy all labels (#812)" }, + { "type": "feature", "text": "log improvements (#1130)" }, + { "type": "fix", "text": "cleared Resources fields not persisted on container edit (#1119)" }, + { "type": "fix", "text": "long container names overflowed in activity event details dialog (#1129)" }, + { "type": "fix", "text": "git stack recreate and start operations ignored Dockhand-stored env vars (#1132)" }, + { "type": "fix", "text": "dashboard stopped count reset to 0 after refresh for gracefully stopped containers (#1133)" }, + { "type": "fix", "text": "auto-update preserves runtime `-e` env and `-l` label overrides (#1135)" }, + { "type": "fix", "text": "git stack volume binds resolved to wrong host path when compose was in a subdirectory (#1139)" }, + { "type": "fix", "text": "git stacks: subdir compose files now find their adjacent env files (#1136)" }, + { "type": "feature", "text": "env editor doesn't flag Docker/Compose built-in variables as unused (#141)" }, + { "type": "feature", "text": "container network mode: share another container's network namespace (#161)" } + ], + "imageTag": "fnsys/dockhand:v1.0.32" + }, { "version": "1.0.31", "date": "2026-05-30", diff --git a/src/lib/server/container-env-merge.ts b/src/lib/server/container-env-merge.ts deleted file mode 100644 index 7353498..0000000 --- a/src/lib/server/container-env-merge.ts +++ /dev/null @@ -1,34 +0,0 @@ -/** - * Merge container env vars with new image env vars during auto-update. - * Image-baked vars get updated to the new image's values. - * User-set vars (not present in old image) are preserved. - * Env vars removed from the new image are dropped. - */ -export function mergeImageEnvVars( - containerEnv: string[], - oldImageEnv: string[], - newImageEnv: string[] -): string[] { - const getKey = (entry: string) => entry.split('=')[0]; - const oldImageKeys = new Set(oldImageEnv.map(getKey)); - - const merged: string[] = []; - - // Keep user-set env vars (key not present in old image) - for (const entry of containerEnv) { - if (!oldImageKeys.has(getKey(entry))) { - merged.push(entry); - } - } - - // Add all new image env vars (updates changed values, adds new ones) - for (const entry of newImageEnv) { - const key = getKey(entry); - // Skip if user already set this key (user wins) - if (!merged.some(e => getKey(e) === key)) { - merged.push(entry); - } - } - - return merged; -} diff --git a/src/lib/server/container-image-divergence.ts b/src/lib/server/container-image-divergence.ts new file mode 100644 index 0000000..50fc129 --- /dev/null +++ b/src/lib/server/container-image-divergence.ts @@ -0,0 +1,72 @@ +/** + * Helpers for surfacing env/label divergence between a running + * container and its image. Pure read-only — never used to mutate + * the container; used only to power UI hints. + * + * Background: as of #1135 / commit 0f989bd7 revert, Dockhand no + * longer "merges" image-baked env or labels into a container during + * auto-update. The container's Config.Env and Config.Labels are + * preserved verbatim (so a user's runtime `-e` / `-l` override is + * never silently wiped). The trade-off, originally raised by #1061, + * is that an image's updated default env/label values do not + * automatically propagate to running containers. + * + * These helpers let the UI surface "this container's value differs + * from the image's current value" so users can decide whether to + * Remove & Deploy. We do NOT try to classify "user-set vs + * image-baked" — that information isn't recoverable from Docker. + */ + +/** Parse a Docker env list (`KEY=value` strings) into a Map. */ +function parseEnv(entries: string[]): Map { + const m = new Map(); + for (const e of entries) { + const i = e.indexOf('='); + if (i === -1) { + m.set(e, ''); + } else { + m.set(e.slice(0, i), e.slice(i + 1)); + } + } + return m; +} + +/** + * Keys where the container's env value differs from the image's + * CURRENT env value. Keys present in only one side are excluded — + * they're either user-only or image-only, neither of which is + * "divergence" we can usefully act on. + */ +export function detectImageEnvDivergence( + containerEnv: string[], + imageEnv: string[] +): string[] { + const cont = parseEnv(containerEnv); + const img = parseEnv(imageEnv); + const diff: string[] = []; + for (const [k, v] of cont) { + if (img.has(k) && img.get(k) !== v) { + diff.push(k); + } + } + return diff; +} + +/** + * Keys where the container's label value differs from the image's + * CURRENT label value. Same semantics as detectImageEnvDivergence. + */ +export function detectImageLabelDivergence( + containerLabels: Record | null | undefined, + imageLabels: Record | null | undefined +): string[] { + const cont = containerLabels || {}; + const img = imageLabels || {}; + const diff: string[] = []; + for (const [k, v] of Object.entries(cont)) { + if (k in img && img[k] !== v) { + diff.push(k); + } + } + return diff; +} diff --git a/src/lib/server/docker.ts b/src/lib/server/docker.ts index 1f74f5e..428d2d2 100644 --- a/src/lib/server/docker.ts +++ b/src/lib/server/docker.ts @@ -6,7 +6,6 @@ */ import { homedir } from 'node:os'; -import { mergeImageEnvVars } from './container-env-merge'; import { existsSync, mkdirSync, rmSync, readdirSync } from 'node:fs'; import { join, resolve } from 'node:path'; import * as http from 'node:http'; @@ -1299,6 +1298,11 @@ export interface CreateContainerOptions { restartPolicy?: string; restartMaxRetries?: number; networkMode?: string; + /** Additional networks attached after creation via POST /networks//connect. + * Must NOT include the primary (which lives in networkMode + EndpointsConfig). */ + additionalNetworks?: string[]; + /** @deprecated use networkMode + additionalNetworks. Kept only so old callers + * (auto-update inspect path) keep working — see backward-compat block below. */ networks?: string[]; /** Network aliases for the primary network */ networkAliases?: string[]; @@ -1454,92 +1458,67 @@ export async function createContainer(options: CreateContainerOptions, envId?: n containerConfig.Volumes = options.volumes; } - if (options.networkMode) { - containerConfig.HostConfig.NetworkMode = options.networkMode; - - // Build endpoint config for primary network with aliases, static IP, and gateway priority - const hasNetworkConfig = options.networkAliases?.length || options.networkIpv4Address || options.networkIpv6Address || options.networkGwPriority !== undefined; - if (hasNetworkConfig) { - const endpointConfig: any = {}; - - if (options.networkAliases && options.networkAliases.length > 0) { - endpointConfig.Aliases = options.networkAliases; - } - - if (options.networkIpv4Address || options.networkIpv6Address) { - endpointConfig.IPAMConfig = {}; - if (options.networkIpv4Address) { - endpointConfig.IPAMConfig.IPv4Address = options.networkIpv4Address; - } - if (options.networkIpv6Address) { - endpointConfig.IPAMConfig.IPv6Address = options.networkIpv6Address; - } - } - - // Gateway priority (Docker Engine 28+) - if (options.networkGwPriority !== undefined) { - endpointConfig.GwPriority = options.networkGwPriority; - } - - containerConfig.NetworkingConfig = { - EndpointsConfig: { - [options.networkMode]: endpointConfig - } - }; - } + // Backward-compat: callers (and the auto-update inspect path) may still pass + // the legacy "networks" array which conflated primary + extras. Normalize to + // the new model: anything that isn't the primary becomes an additional network. + if (options.networks && !options.additionalNetworks) { + const primary = options.networkMode || ''; + options.additionalNetworks = options.networks.filter(n => n !== primary); } - if (options.networks && options.networks.length > 0) { - containerConfig.HostConfig.NetworkMode = options.networks[0]; + // Networking model: + // - networkMode is the SINGLE source of truth for the primary network + // - EndpointsConfig has exactly ONE entry, keyed by networkMode, carrying + // the form's IPv4/IPv6/aliases/gwPriority + // - additionalNetworks (extras only — NEVER includes the primary) are + // attached after container creation via POST /networks/{name}/connect + // - Shared modes (host / none / container:X / service:X) skip both + // EndpointsConfig and additionalNetworks: Docker rejects them. + const primaryMode = options.networkMode || ''; + const isSharedMode = primaryMode === 'host' || primaryMode === 'none' || primaryMode.startsWith('container:') || primaryMode.startsWith('service:'); - // Build endpoint configs for all networks - const endpointsConfig: Record = {}; + if (primaryMode) { + containerConfig.HostConfig.NetworkMode = primaryMode; + } - for (const network of options.networks) { - const isFirstNetwork = network === options.networks[0]; - const netCfg = options.networkConfigs?.[network]; + // Build the single EndpointsConfig entry for the primary (only for non-shared modes). + if (primaryMode && !isSharedMode) { + const endpointConfig: any = {}; + if (options.networkAliases?.length) { + endpointConfig.Aliases = options.networkAliases; + } + if (options.networkIpv4Address || options.networkIpv6Address) { + endpointConfig.IPAMConfig = {}; + if (options.networkIpv4Address) endpointConfig.IPAMConfig.IPv4Address = options.networkIpv4Address; + if (options.networkIpv6Address) endpointConfig.IPAMConfig.IPv6Address = options.networkIpv6Address; + } + if (options.networkGwPriority !== undefined) { + endpointConfig.GwPriority = options.networkGwPriority; + } + containerConfig.NetworkingConfig = { + EndpointsConfig: { + [primaryMode]: endpointConfig + } + }; + } + + // Extras attached after creation. Empty array for shared modes. + const extraNetworks: { name: string; config: any }[] = []; + if (!isSharedMode && options.additionalNetworks?.length) { + for (const netName of options.additionalNetworks) { + if (netName === primaryMode) continue; // primary lives in EndpointsConfig, not here + const netCfg = options.networkConfigs?.[netName]; const endpointConfig: any = {}; - - // Per-network config from networkConfigs (takes precedence) if (netCfg) { - if (netCfg.aliases && netCfg.aliases.length > 0) { - endpointConfig.Aliases = netCfg.aliases; - } + if (netCfg.aliases?.length) endpointConfig.Aliases = netCfg.aliases; if (netCfg.ipv4Address || netCfg.ipv6Address) { endpointConfig.IPAMConfig = {}; - if (netCfg.ipv4Address) { - endpointConfig.IPAMConfig.IPv4Address = netCfg.ipv4Address; - } - if (netCfg.ipv6Address) { - endpointConfig.IPAMConfig.IPv6Address = netCfg.ipv6Address; - } - } - } else if (isFirstNetwork) { - // Backward compat: apply flat fields to first network if no networkConfigs - if (options.networkAliases && options.networkAliases.length > 0) { - endpointConfig.Aliases = options.networkAliases; - } - if (options.networkIpv4Address || options.networkIpv6Address) { - endpointConfig.IPAMConfig = {}; - if (options.networkIpv4Address) { - endpointConfig.IPAMConfig.IPv4Address = options.networkIpv4Address; - } - if (options.networkIpv6Address) { - endpointConfig.IPAMConfig.IPv6Address = options.networkIpv6Address; - } - } - // Gateway priority (Docker Engine 28+) - if (options.networkGwPriority !== undefined) { - endpointConfig.GwPriority = options.networkGwPriority; + if (netCfg.ipv4Address) endpointConfig.IPAMConfig.IPv4Address = netCfg.ipv4Address; + if (netCfg.ipv6Address) endpointConfig.IPAMConfig.IPv6Address = netCfg.ipv6Address; } } - - endpointsConfig[network] = endpointConfig; + extraNetworks.push({ name: netName, config: endpointConfig }); } - - containerConfig.NetworkingConfig = { - EndpointsConfig: endpointsConfig - }; } if (options.privileged !== undefined) { @@ -1772,6 +1751,39 @@ export async function createContainer(options: CreateContainerOptions, envId?: n containerConfig.Domainname = options.domainname; } + // Shared network modes (host / container:X / service:X) inherit the + // namespace's networking, so Docker rejects any field that would override it. + // Strip them defensively — the merge in updateContainer carries values from + // the old config that may not be valid for the new mode (e.g. switching from + // bridge → container:foo with a leftover Hostname). + // Mirrors the same logic in recreateContainerFromInspect. + { + const primary = containerConfig.HostConfig?.NetworkMode || ''; + const isHost = primary === 'host'; + const isContainer = primary.startsWith('container:'); + const isService = primary.startsWith('service:'); + if (isHost || isContainer || isService) { + delete containerConfig.Hostname; + delete containerConfig.Domainname; + delete containerConfig.MacAddress; + } + if (isContainer || isService) { + // container:X also shares ports / DNS / hosts with the target + delete containerConfig.ExposedPorts; + if (containerConfig.HostConfig) { + delete containerConfig.HostConfig.PortBindings; + delete containerConfig.HostConfig.PublishAllPorts; + delete containerConfig.HostConfig.Dns; + delete containerConfig.HostConfig.DnsOptions; + delete containerConfig.HostConfig.DnsSearch; + delete containerConfig.HostConfig.ExtraHosts; + delete containerConfig.HostConfig.Links; + } + // NetworkingConfig is meaningless when sharing a namespace + delete containerConfig.NetworkingConfig; + } + } + const result = await dockerJsonRequest<{ Id: string }>( `/containers/create?name=${encodeURIComponent(options.name)}`, { @@ -1781,6 +1793,18 @@ export async function createContainer(options: CreateContainerOptions, envId?: n envId ); + // Attach additional networks now that the container exists. Docker only allows + // a single network at create time, so anything beyond the primary is connected here. + for (const extra of extraNetworks) { + try { + await connectContainerToNetworkRaw(extra.name, result.Id, extra.config, envId); + } catch (err: any) { + console.error(`Failed to attach additional network "${extra.name}":`, err?.message || err); + // Don't fail the whole create — primary network is already connected. + // Caller (updateContainer rollback path) will surface this if needed. + } + } + return { id: result.Id, start: () => startContainer(result.Id, envId) }; } @@ -1901,50 +1925,6 @@ export async function recreateContainerFromInspect( HostConfig: hostConfig }; - // 4a. Update image-embedded labels and env vars to match the new image. - // Docker's create API uses exactly the labels/env you pass, ignoring the new image's - // embedded values. We inspect both old and new images to distinguish image-origin - // values from user-set values, then merge accordingly. - try { - const [oldImageInspect, newImageInspect] = await Promise.all([ - inspectImage(config.Image, envId), - inspectImage(newImage, envId) - ]); - - // Merge labels - const oldImageLabels: Record = (oldImageInspect as any)?.Config?.Labels || {}; - const newImageLabels: Record = (newImageInspect as any)?.Config?.Labels || {}; - const containerLabels: Record = createConfig.Labels || {}; - - const mergedLabels: Record = {}; - - // Keep user-set labels (not present in old image) - for (const [k, v] of Object.entries(containerLabels)) { - if (!(k in oldImageLabels)) { - mergedLabels[k] = v; - } - } - - // Add all new image labels (overrides old image labels) - for (const [k, v] of Object.entries(newImageLabels)) { - mergedLabels[k] = v; - } - - createConfig.Labels = mergedLabels; - log?.(`Updated image labels: ${Object.keys(newImageLabels).length} from new image, ${Object.keys(mergedLabels).length} total`); - - // Merge env vars (same logic: image-baked vars get updated, user-set vars preserved) - const oldImageEnv: string[] = (oldImageInspect as any)?.Config?.Env || []; - const newImageEnv: string[] = (newImageInspect as any)?.Config?.Env || []; - const containerEnv: string[] = createConfig.Env || []; - - createConfig.Env = mergeImageEnvVars(containerEnv, oldImageEnv, newImageEnv); - log?.(`Updated image env vars: ${newImageEnv.length} from new image, ${createConfig.Env.length} total`); - } catch (e) { - log?.(`Warning: could not update image labels/env: ${e}`); - // Fall through with old values — non-fatal - } - // Strip default MemorySwappiness — Podman + cgroupv2 rejects it. // Docker returns -1, Podman returns 0 when unset. const swappiness = createConfig.HostConfig?.MemorySwappiness; @@ -2424,20 +2404,44 @@ export async function updateContainer(id: string, options: Partial = {}; + for (const [k, v] of Object.entries(options)) { + userOptions[k] = v === null ? undefined : v; + } const mergedOptions: CreateContainerOptions = { ...existingOptions, - ...options, + ...userOptions, // Replace labels, but preserve Docker internal labels (com.docker.*) - labels: options.labels !== undefined + labels: options.labels !== undefined && options.labels !== null ? { ...Object.fromEntries( Object.entries(existingOptions.labels || {}).filter(([k]) => k.startsWith('com.docker.')) ), ...options.labels } - : existingOptions.labels + : options.labels === null ? {} : existingOptions.labels }; // 1. Stop old container diff --git a/src/lib/server/host-path.ts b/src/lib/server/host-path.ts index 69107d6..fd68a40 100644 --- a/src/lib/server/host-path.ts +++ b/src/lib/server/host-path.ts @@ -385,36 +385,39 @@ export function extractUidFromSocketPath(socketPath: string): string | null { export function rewriteComposeVolumePaths(composeContent: string, workingDir: string): { content: string; modified: boolean; changes: string[] } { const changes: string[] = []; - // Try to translate workingDir to host path using ANY cached mount - // This handles both DATA_DIR mounts and external mounts (e.g., /external-stacks) - const hostWorkingDir = translateContainerPathViaMount(workingDir); - - if (!hostWorkingDir) { - // Can't translate - workingDir is not under any known mount - return { content: composeContent, modified: false, changes }; - } - - // Parse compose content line by line to find and rewrite volume mounts + // Parse compose content line by line to find and rewrite volume mounts. // We look for patterns like: // - ./something:/container/path + // - ../something:/container/path // - "./something:/container/path" - // - './something:/container/path' + // - '../something:/container/path' const lines = composeContent.split('\n'); const modifiedLines: string[] = []; for (const line of lines) { - // Match volume mount patterns with relative paths - // Handles: - ./path:/dest, - "./path:/dest", - './path:/dest' - const volumeMatch = line.match(/^(\s*-\s*)(['"]?)(\.\/[^'":\s]+)(\2)(:.+)$/); + // Match volume mount patterns with relative paths. + // Handles ./path and ../path, optionally quoted with single or double quotes. + const volumeMatch = line.match(/^(\s*-\s*)(['"]?)(\.\.?\/[^'":\s]+)(\2)(:.+)$/); if (volumeMatch) { const [, prefix, quote, relativeSrc, , destPart] = volumeMatch; - // Convert relative path to absolute host path - const absoluteHostPath = hostWorkingDir + '/' + relativeSrc.substring(2); // Remove ./ + // Resolve to an absolute container path, then translate to a host + // path via any known mount. Each line is translated independently so + // `../foo` can escape workingDir into a sibling that may map to a + // different mount than workingDir itself. + const absoluteContainerPath = resolve(workingDir, relativeSrc); + const absoluteHostPath = translateContainerPathViaMount(absoluteContainerPath); - const newLine = `${prefix}${absoluteHostPath}${destPart}`; - modifiedLines.push(newLine); - changes.push(` ${relativeSrc} -> ${absoluteHostPath}`); + if (absoluteHostPath) { + const newLine = `${prefix}${absoluteHostPath}${destPart}`; + modifiedLines.push(newLine); + changes.push(` ${relativeSrc} -> ${absoluteHostPath}`); + } else { + // Can't translate — leave line unchanged. Compose will resolve + // it relative to its cwd; if that's wrong the deploy fails + // loudly, which is better than producing a misleading host path. + modifiedLines.push(line); + } } else { modifiedLines.push(line); } diff --git a/src/lib/server/stacks.ts b/src/lib/server/stacks.ts index d3506e3..a7418cd 100644 --- a/src/lib/server/stacks.ts +++ b/src/lib/server/stacks.ts @@ -855,6 +855,16 @@ function findComposeOverrideFile(stackDir: string, composeFileName: string): str /** * Execute a docker compose command locally via child_process.spawn. * + * Heads up on paths: `stackDir` is the cpSync target / fallback working + * directory, but it's not always where the compose file lives — git stacks + * with a contextDir can put the compose file in a subdirectory. Anything + * compose-adjacent (spawn cwd, .env discovery, compose.override.yaml + * lookup, .env.dockhand write, volume-path rewriter) anchors on + * `composeFileDir = dirname(composeFile)`. The two are equal for the + * common case and the change is transparent; only the subdir case is + * affected. If you add anything new that touches a compose-adjacent file, + * use `composeFileDir`, not `stackDir`. + * * @param tlsConfig - TLS configuration for remote Docker connections (certs written to temp files) * @param envVars - Non-secret environment variables (from .env file, passed for backward compat) * @param secretVars - Secret environment variables (injected via shell env, NEVER written to disk) @@ -907,13 +917,22 @@ async function executeLocalCompose( writeFileSync(composeFile, composeContent); } + // Anchor for everything compose-adjacent: the directory the compose file + // itself lives in. Equal to stackDir for the common case (compose at + // stack root), but different when a git stack puts the compose file in + // a subdirectory of the context dir. Bugs #1136 and #1139 both stemmed + // from anchoring on stackDir instead of this. + const composeFileDir = dirname(composeFile); + // Rewrite relative volume paths for host path translation (in memory only, not saved to disk) // This is needed when Dockhand runs inside Docker - the Docker daemon on the host // can't see container paths like /app/data/..., so we translate them to host paths // Only do this for local Docker (no dockerHost) - for remote Docker the paths wouldn't make sense + // Resolve relative paths against the COMPOSE FILE'S directory, not stackDir, so + // subdir compose files with ./ and ../ binds resolve correctly (#1139). let finalComposeContent = composeContent; if (!dockerHost && getHostDataDir()) { - const rewriteResult = rewriteComposeVolumePaths(composeContent, stackDir); + const rewriteResult = rewriteComposeVolumePaths(composeContent, composeFileDir); if (rewriteResult.modified) { finalComposeContent = rewriteResult.content; console.log(`${logPrefix} [HostPath] Translating relative volume paths for Docker host:`); @@ -951,9 +970,25 @@ async function executeLocalCompose( } // Check if .env file exists on disk (for legacy support decision) - const defaultEnvPath = join(stackDir, '.env'); + const defaultEnvPath = join(composeFileDir, '.env'); const hasEnvFile = existsSync(defaultEnvPath) || (customEnvPath && existsSync(customEnvPath)); + // One-line audit of all path notions used below. Next time something is + // off (compose can't find a file, volume bind points at the wrong + // place, env vars don't reach the container), grep for "[PathAudit]" + // in the log — the mismatch is usually obvious. The "subdir=yes" flag + // is the canary for the case where stackDir and composeFileDir diverge. + console.log( + `${logPrefix} [PathAudit] ` + + `stackDir=${stackDir} ` + + `composeFile=${composeFile} ` + + `composeFileDir=${composeFileDir} ` + + `subdir=${composeFileDir !== stackDir ? 'yes' : 'no'} ` + + `defaultEnvPath=${defaultEnvPath} (exists=${existsSync(defaultEnvPath)}) ` + + `customEnvPath=${customEnvPath ?? '(none)'}` + + (customEnvPath ? ` (exists=${existsSync(customEnvPath)})` : '') + ); + // LEGACY SUPPORT: Only inject envVars via shell if NO .env file exists // This is for stacks created with older Dockhand versions that stored env vars // in DB but didn't write .env files to disk. @@ -1015,14 +1050,14 @@ async function executeLocalCompose( // Host path translation: must pipe modified content via stdin args.push('-f', '-'); // Also include override file if it exists (needs path translation too) - const overrideFile = findComposeOverrideFile(stackDir, basename(composeFile)); + const overrideFile = findComposeOverrideFile(composeFileDir, basename(composeFile)); if (overrideFile) { let overrideContent = readFileSync(overrideFile, 'utf-8'); if (getHostDataDir()) { - const rewrite = rewriteComposeVolumePaths(overrideContent, stackDir); + const rewrite = rewriteComposeVolumePaths(overrideContent, composeFileDir); if (rewrite.modified) overrideContent = rewrite.content; } - tempOverridePath = join(stackDir, '.compose.override.translated.yaml'); + tempOverridePath = join(composeFileDir, '.compose.override.translated.yaml'); writeFileSync(tempOverridePath, overrideContent); args.push('-f', tempOverridePath); console.log(`${logPrefix} Including override file (path-translated): ${basename(overrideFile)}`); @@ -1030,7 +1065,7 @@ async function executeLocalCompose( } else if (customComposePath) { // Custom path (imported/adopted stacks): must use -f to point to non-standard location args.push('-f', composeFile); - const overrideFile = findComposeOverrideFile(stackDir, basename(composeFile)); + const overrideFile = findComposeOverrideFile(composeFileDir, basename(composeFile)); if (overrideFile) { args.push('-f', overrideFile); console.log(`${logPrefix} Including override file: ${basename(overrideFile)}`); @@ -1056,7 +1091,7 @@ async function executeLocalCompose( // 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 overrideEnvPath = join(composeFileDir, '.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}`); writeFileSync(overrideEnvPath, header + lines.join('\n') + '\n'); @@ -1126,9 +1161,9 @@ async function executeLocalCompose( } try { - console.log(`${logPrefix} Spawning docker compose process...`); + console.log(`${logPrefix} Spawning docker compose process from ${composeFileDir}: ${args.join(' ')}`); const proc = nodeSpawn(args[0], args.slice(1), { - cwd: stackDir, + cwd: composeFileDir, env: spawnEnv, stdio: [useStdin ? 'pipe' : 'inherit', 'pipe', 'pipe'] }); @@ -1906,7 +1941,11 @@ export async function startStack( return withContainerFallback(stackName, envId, 'start'); } - const opts = { stackName, envId, workingDir: result.stackDir, composePath: result.composePath, envPath: result.envPath }; + // Check if this is a git stack - git stacks need useOverrideFile to write .env.dockhand + const source = await getStackSource(stackName, envId); + const isGitStack = source?.sourceType === 'git'; + + const opts: ComposeCommandOptions = { stackName, envId, workingDir: result.stackDir, composePath: result.composePath, envPath: result.envPath, useOverrideFile: isGitStack }; // Check if containers exist for this stack. If they do, use 'start' to resume // them (preserves container IDs, avoids Traefik race conditions from recreation). @@ -1974,7 +2013,13 @@ export async function restartStack( return withContainerFallback(stackName, envId, 'restart'); } - const opts: ComposeCommandOptions = { stackName, envId, workingDir: result.stackDir, composePath: result.composePath, envPath: result.envPath }; + // Git stacks need useOverrideFile to write .env.dockhand with DB overrides. + // Non-git stacks still pass nonSecretVars for legacy support (stacks without + // .env files on disk get vars injected via shell env at executeLocalCompose). + const source = await getStackSource(stackName, envId); + const isGitStack = source?.sourceType === 'git'; + + const opts: ComposeCommandOptions = { stackName, envId, workingDir: result.stackDir, composePath: result.composePath, envPath: result.envPath, useOverrideFile: isGitStack }; let composeResult: StackOperationResult; diff --git a/src/lib/stores/settings.ts b/src/lib/stores/settings.ts index b6102de..7b5e038 100644 --- a/src/lib/stores/settings.ts +++ b/src/lib/stores/settings.ts @@ -24,7 +24,8 @@ export interface AppSettings { eventCleanupEnabled: boolean; scannerCleanupCron: string; scannerCleanupEnabled: boolean; - logBufferSizeKb: number; + logBufferSizeKb: number; // legacy, retained for migration — UI uses logMaxLines + logMaxLines: number; // line-count cap for the log buffer (replaces KB-based limit) defaultTimezone: string; eventCollectionMode: EventCollectionMode; eventPollInterval: number; @@ -58,6 +59,7 @@ const DEFAULT_SETTINGS: AppSettings = { scannerCleanupCron: '0 3 * * 0', scannerCleanupEnabled: true, logBufferSizeKb: 500, + logMaxLines: 2000, defaultTimezone: 'UTC', eventCollectionMode: 'stream', eventPollInterval: 60000, @@ -90,6 +92,20 @@ services: ` }; +// Derive logMaxLines from a settings payload — prefers the new field; if absent +// (older DB), converts the legacy KB value at ~8 lines/KB (Docker log lines +// average ~120 chars). Clamps to a sensible range. +function deriveLogMaxLines(s: { logMaxLines?: number; logBufferSizeKb?: number }): number { + // Cap at 2000 — anything larger thrashes the browser when rendering with no + // virtualization. Old DBs may have absurd values from when the cap was 50K; + // snap them down here so users don't get a stuck page after upgrade. + if (typeof s.logMaxLines === 'number' && s.logMaxLines > 0) { + return Math.min(2000, Math.max(100, s.logMaxLines)); + } + const kb = s.logBufferSizeKb ?? DEFAULT_SETTINGS.logBufferSizeKb; + return Math.min(2000, Math.max(100, Math.round(kb * 8))); +} + // Create a writable store for app settings function createSettingsStore() { const { subscribe, set, update } = writable(DEFAULT_SETTINGS); @@ -122,6 +138,7 @@ function createSettingsStore() { scannerCleanupCron: settings.scannerCleanupCron ?? DEFAULT_SETTINGS.scannerCleanupCron, scannerCleanupEnabled: settings.scannerCleanupEnabled ?? DEFAULT_SETTINGS.scannerCleanupEnabled, logBufferSizeKb: settings.logBufferSizeKb ?? DEFAULT_SETTINGS.logBufferSizeKb, + logMaxLines: deriveLogMaxLines(settings), defaultTimezone: settings.defaultTimezone ?? DEFAULT_SETTINGS.defaultTimezone, eventCollectionMode: settings.eventCollectionMode ?? DEFAULT_SETTINGS.eventCollectionMode, eventPollInterval: settings.eventPollInterval ?? DEFAULT_SETTINGS.eventPollInterval, @@ -172,6 +189,7 @@ function createSettingsStore() { scannerCleanupCron: updatedSettings.scannerCleanupCron ?? DEFAULT_SETTINGS.scannerCleanupCron, scannerCleanupEnabled: updatedSettings.scannerCleanupEnabled ?? DEFAULT_SETTINGS.scannerCleanupEnabled, logBufferSizeKb: updatedSettings.logBufferSizeKb ?? DEFAULT_SETTINGS.logBufferSizeKb, + logMaxLines: deriveLogMaxLines(updatedSettings), defaultTimezone: updatedSettings.defaultTimezone ?? DEFAULT_SETTINGS.defaultTimezone, eventCollectionMode: updatedSettings.eventCollectionMode ?? DEFAULT_SETTINGS.eventCollectionMode, eventPollInterval: updatedSettings.eventPollInterval ?? DEFAULT_SETTINGS.eventPollInterval, @@ -330,6 +348,13 @@ function createSettingsStore() { return newSettings; }); }, + setLogMaxLines: (value: number) => { + update((current) => { + const newSettings = { ...current, logMaxLines: value }; + saveSettings({ logMaxLines: value }); + return newSettings; + }); + }, setDefaultTimezone: (value: string) => { update((current) => { const newSettings = { ...current, defaultTimezone: value }; diff --git a/src/lib/utils/log-entry.ts b/src/lib/utils/log-entry.ts new file mode 100644 index 0000000..241679a --- /dev/null +++ b/src/lib/utils/log-entry.ts @@ -0,0 +1,77 @@ +// Per-line log model shared by LogsPanel and the /logs page. The buffer is an +// array of these — the toggle/search/filter UI is a pure derivation of the +// array, no string concatenation. Avoids regex passes over the whole buffer +// every render and lets Svelte's keyed {#each} diff append-only updates cheaply. + +import { AnsiUp } from 'ansi_up'; + +export interface LogEntry { + id: number; + timestamp?: string; + text: string; + // Optional fields for grouped/multi-container views + containerId?: string; + containerName?: string; + color?: string; + stream?: string; +} + +const ansiUp = new AnsiUp(); +ansiUp.use_classes = true; + +const TS_RE = /^(\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?Z)\s/; + +export function parseDockerLine(raw: string): { timestamp?: string; text: string } { + const m = raw.match(TS_RE); + if (m) return { timestamp: m[1], text: raw.slice(m[0].length) }; + return { text: raw }; +} + +let nextId = 0; +export function nextLogId(): number { + return nextId++; +} + +// Split a chunk of raw log text into LogEntry items. The last item may be +// partial (no trailing newline), so it's returned as carryover for the next +// chunk to prepend. +export function parseLines( + raw: string, + carryover: string, + extra: Partial = {} +): { entries: LogEntry[]; carryover: string } { + const combined = carryover + raw; + const lines = combined.split('\n'); + const tail = lines.pop() ?? ''; + const entries: LogEntry[] = []; + for (const line of lines) { + if (line === '') continue; + const { timestamp, text } = parseDockerLine(line); + entries.push({ id: nextId++, timestamp, text, ...extra }); + } + return { entries, carryover: tail }; +} + +// Per-entry ANSI HTML cache. WeakMap so entries evicted by buffer compaction +// can be GC'd along with their cached HTML. +const ansiCache = new WeakMap(); +export function entryHtml(e: LogEntry): string { + const cached = ansiCache.get(e); + if (cached !== undefined) return cached; + const html = ansiUp.ansi_to_html(e.text); + ansiCache.set(e, html); + return html; +} + +// Render the ANSI HTML for an entry and splice spans for a search match. +// Splits by HTML tags so substitution doesn't run inside attribute values. +export function renderLineHtml(e: LogEntry, query: string): string { + const ansi = entryHtml(e); + if (!query) return ansi; + const escapedForRegex = query.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + const regex = new RegExp(`(${escapedForRegex})`, 'gi'); + const parts = ansi.split(/(<[^>]*>)/); + return parts + .map(part => (part.startsWith('<') ? part : part.replace(regex, '$1'))) + .join(''); +} diff --git a/src/routes/activity/+page.svelte b/src/routes/activity/+page.svelte index 71bbe02..aa89f40 100644 --- a/src/routes/activity/+page.svelte +++ b/src/routes/activity/+page.svelte @@ -910,11 +910,11 @@

-
+
-

- - {selectedEvent.containerName || '-'} +

+ + {selectedEvent.containerName || '-'}

diff --git a/src/routes/api/containers/[id]/inspect/+server.ts b/src/routes/api/containers/[id]/inspect/+server.ts index 5d38c76..91eb38a 100644 --- a/src/routes/api/containers/[id]/inspect/+server.ts +++ b/src/routes/api/containers/[id]/inspect/+server.ts @@ -1,10 +1,14 @@ import { json } from '@sveltejs/kit'; import type { RequestHandler } from './$types'; -import { inspectContainer } from '$lib/server/docker'; +import { inspectContainer, inspectImage } from '$lib/server/docker'; 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'; +import { + detectImageEnvDivergence, + detectImageLabelDivergence +} from '$lib/server/container-image-divergence'; export const GET: RequestHandler = async ({ params, url, cookies }) => { const invalid = validateDockerIdParam(params.id, 'container'); @@ -23,6 +27,25 @@ export const GET: RequestHandler = async ({ params, url, cookies }) => { try { const containerData = await inspectContainer(params.id, envIdNum); + // Compute env/label divergence BEFORE masking, so the comparison + // uses real values. Failure to inspect the image is non-fatal — + // the field is omitted in that case. + let divergence: { env: string[]; labels: string[] } | undefined; + try { + const imageRef = containerData.Config?.Image; + if (imageRef) { + const imageData: any = await inspectImage(imageRef, envIdNum); + const imageEnv: string[] = imageData?.Config?.Env || []; + const imageLabels: Record = imageData?.Config?.Labels || {}; + divergence = { + env: detectImageEnvDivergence(containerData.Config?.Env || [], imageEnv), + labels: detectImageLabelDivergence(containerData.Config?.Labels, imageLabels) + }; + } + } catch { + // image not present / not pullable / etc — drop the field + } + // 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']; @@ -42,7 +65,7 @@ export const GET: RequestHandler = async ({ params, url, cookies }) => { } } - return json(containerData); + return json({ ...containerData, divergence }); } catch (error) { console.error('Failed to inspect container:', error); return json({ error: 'Failed to inspect container' }, { status: 500 }); diff --git a/src/routes/api/dashboard/stats/stream/+server.ts b/src/routes/api/dashboard/stats/stream/+server.ts index 36214e9..48c73c7 100644 --- a/src/routes/api/dashboard/stats/stream/+server.ts +++ b/src/routes/api/dashboard/stats/stream/+server.ts @@ -303,7 +303,7 @@ async function getEnvironmentStatsProgressive( envStats.containers.total = containers.length; envStats.containers.running = containers.filter((c: any) => c.state === 'running').length; - envStats.containers.stopped = containers.filter((c: any) => c.state === 'exited' && c.exitCode !== 0).length; + envStats.containers.stopped = containers.filter((c: any) => c.state === 'exited').length; envStats.containers.paused = containers.filter((c: any) => c.state === 'paused').length; envStats.containers.restarting = containers.filter((c: any) => c.state === 'restarting').length; envStats.containers.unhealthy = containers.filter((c: any) => c.health === 'unhealthy').length; diff --git a/src/routes/api/settings/general/+server.ts b/src/routes/api/settings/general/+server.ts index 68795a9..9dbb472 100644 --- a/src/routes/api/settings/general/+server.ts +++ b/src/routes/api/settings/general/+server.ts @@ -58,7 +58,8 @@ export interface GeneralSettings { eventCleanupEnabled: boolean; scannerCleanupCron: string; scannerCleanupEnabled: boolean; - logBufferSizeKb: number; + logBufferSizeKb: number; // legacy + logMaxLines: number; // line-count cap for log buffer defaultTimezone: string; // Background monitoring settings eventCollectionMode: EventCollectionMode; @@ -101,6 +102,7 @@ const DEFAULT_SETTINGS: Omit { scannerCleanupCron, scannerCleanupEnabled, logBufferSizeKb, + logMaxLines, defaultTimezone, eventCollectionMode, eventPollInterval, @@ -215,6 +218,7 @@ export const GET: RequestHandler = async ({ cookies }) => { getScannerCleanupCron(), getScannerCleanupEnabled(), getSetting('log_buffer_size_kb'), + getSetting('log_max_lines'), getDefaultTimezone(), getEventCollectionMode(), getEventPollInterval(), @@ -255,6 +259,9 @@ export const GET: RequestHandler = async ({ cookies }) => { scannerCleanupCron, scannerCleanupEnabled, logBufferSizeKb: logBufferSizeKb ?? DEFAULT_SETTINGS.logBufferSizeKb, + logMaxLines: (typeof logMaxLines === 'number' && logMaxLines > 0) + ? Math.min(2000, Math.max(100, logMaxLines)) + : Math.min(2000, Math.max(100, Math.round((logBufferSizeKb ?? DEFAULT_SETTINGS.logBufferSizeKb) * 8))), defaultTimezone: defaultTimezone ?? DEFAULT_SETTINGS.defaultTimezone, eventCollectionMode: (eventCollectionMode ?? DEFAULT_SETTINGS.eventCollectionMode) as EventCollectionMode, eventPollInterval: eventPollInterval ?? DEFAULT_SETTINGS.eventPollInterval, @@ -292,7 +299,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, scannerCleanupCron, scannerCleanupEnabled, logBufferSizeKb, defaultTimezone, eventCollectionMode, eventPollInterval, metricsCollectionInterval, lightTheme, darkTheme, font, fontSize, gridFontSize, terminalFont, editorFont, compactPorts, showExposedPorts, formatLogTimestamps, externalStackPaths, primaryStackLocation, defaultGrypeImage, defaultTrivyImage, defaultComposeTemplate, labelFilterMode } = body; + const { confirmDestructive, showStoppedContainers, highlightUpdates, timeFormat, dateFormat, downloadFormat, defaultGrypeArgs, defaultTrivyArgs, scheduleRetentionDays, eventRetentionDays, scheduleCleanupCron, eventCleanupCron, scheduleCleanupEnabled, eventCleanupEnabled, scannerCleanupCron, scannerCleanupEnabled, logBufferSizeKb, logMaxLines, defaultTimezone, eventCollectionMode, eventPollInterval, metricsCollectionInterval, lightTheme, darkTheme, font, fontSize, gridFontSize, terminalFont, editorFont, compactPorts, showExposedPorts, formatLogTimestamps, externalStackPaths, primaryStackLocation, defaultGrypeImage, defaultTrivyImage, defaultComposeTemplate, labelFilterMode } = body; if (confirmDestructive !== undefined) { await setSetting('confirm_destructive', confirmDestructive); @@ -345,9 +352,13 @@ export const POST: RequestHandler = async ({ request, cookies }) => { await refreshSystemJobs(); } if (logBufferSizeKb !== undefined && typeof logBufferSizeKb === 'number') { - // Clamp to reasonable range: 100KB - 5000KB (5MB) + // Legacy: clamp to 100KB-5MB range. await setSetting('log_buffer_size_kb', Math.max(100, Math.min(5000, logBufferSizeKb))); } + if (logMaxLines !== undefined && typeof logMaxLines === 'number') { + // Clamp to 100 - 50000 lines. + await setSetting('log_max_lines', Math.max(100, Math.min(2000, logMaxLines))); + } if (defaultTimezone !== undefined && typeof defaultTimezone === 'string') { await setDefaultTimezone(defaultTimezone); // Refresh system jobs to use the new timezone @@ -448,6 +459,7 @@ export const POST: RequestHandler = async ({ request, cookies }) => { scannerCleanupCronVal, scannerCleanupEnabledVal, logBufferSizeKbVal, + logMaxLinesVal, defaultTimezoneVal, eventCollectionModeVal, eventPollIntervalVal, @@ -486,6 +498,7 @@ export const POST: RequestHandler = async ({ request, cookies }) => { getScannerCleanupCron(), getScannerCleanupEnabled(), getSetting('log_buffer_size_kb'), + getSetting('log_max_lines'), getDefaultTimezone(), getEventCollectionMode(), getEventPollInterval(), @@ -526,6 +539,9 @@ export const POST: RequestHandler = async ({ request, cookies }) => { scannerCleanupCron: scannerCleanupCronVal, scannerCleanupEnabled: scannerCleanupEnabledVal, logBufferSizeKb: logBufferSizeKbVal ?? DEFAULT_SETTINGS.logBufferSizeKb, + logMaxLines: (typeof logMaxLinesVal === 'number' && logMaxLinesVal > 0) + ? Math.min(2000, Math.max(100, logMaxLinesVal)) + : Math.min(2000, Math.max(100, Math.round((logBufferSizeKbVal ?? DEFAULT_SETTINGS.logBufferSizeKb) * 8))), defaultTimezone: defaultTimezoneVal ?? DEFAULT_SETTINGS.defaultTimezone, eventCollectionMode: (eventCollectionModeVal ?? DEFAULT_SETTINGS.eventCollectionMode) as EventCollectionMode, eventPollInterval: eventPollIntervalVal ?? DEFAULT_SETTINGS.eventPollInterval, diff --git a/src/routes/api/stacks/[name]/env/validate/+server.ts b/src/routes/api/stacks/[name]/env/validate/+server.ts index 7dbab30..1809852 100644 --- a/src/routes/api/stacks/[name]/env/validate/+server.ts +++ b/src/routes/api/stacks/[name]/env/validate/+server.ts @@ -13,6 +13,22 @@ interface ValidationResult { unused: string[]; } +/** Docker and Compose built-in env vars consumed implicitly at runtime (not via ${} interpolation) */ +const DOCKER_COMPOSE_BUILTIN_VARS = new Set([ + // Docker Compose + 'COMPOSE_PROJECT_NAME', 'COMPOSE_FILE', 'COMPOSE_PROFILES', + 'COMPOSE_CONVERT_WINDOWS_PATHS', 'COMPOSE_PATH_SEPARATOR', + 'COMPOSE_IGNORE_ORPHANS', 'COMPOSE_REMOVE_ORPHANS', + 'COMPOSE_PARALLEL_LIMIT', 'COMPOSE_ANSI', 'COMPOSE_STATUS_STDOUT', + 'COMPOSE_ENV_FILES', 'COMPOSE_DISABLE_ENV_FILE', 'COMPOSE_MENU', + 'COMPOSE_EXPERIMENTAL', 'COMPOSE_PROGRESS', + // Docker CLI + 'DOCKER_API_VERSION', 'DOCKER_CERT_PATH', 'DOCKER_CONFIG', + 'DOCKER_CONTEXT', 'DOCKER_CUSTOM_HEADERS', 'DOCKER_DEFAULT_PLATFORM', + 'DOCKER_HIDE_LEGACY_COMMANDS', 'DOCKER_HOST', 'DOCKER_TLS', + 'DOCKER_TLS_VERIFY', 'BUILDKIT_PROGRESS', 'NO_COLOR', +]); + /** * Extract environment variables from compose YAML content. * Matches ${VAR_NAME} and ${VAR_NAME:-default} patterns. @@ -144,7 +160,7 @@ export const POST: RequestHandler = async ({ params, url, cookies, request }) => // Calculate missing and unused const missing = required.filter(v => !defined.includes(v)); - const unused = defined.filter(v => !required.includes(v) && !optional.includes(v)); + const unused = defined.filter(v => !required.includes(v) && !optional.includes(v) && !DOCKER_COMPOSE_BUILTIN_VARS.has(v)); const result: ValidationResult = { valid: missing.length === 0, diff --git a/src/routes/containers/ContainerInspectModal.svelte b/src/routes/containers/ContainerInspectModal.svelte index 8d27743..01720cc 100644 --- a/src/routes/containers/ContainerInspectModal.svelte +++ b/src/routes/containers/ContainerInspectModal.svelte @@ -38,6 +38,23 @@ let loading = $state(true); let error = $state(''); let containerData = $state(null); + // Peer containers in the current env — used to resolve "container:" mode to a friendly name + let peerContainers = $state>([]); + + const networkModeLabel = $derived.by(() => { + const raw = containerData?.HostConfig?.NetworkMode || 'default'; + if (!raw.startsWith('container:')) return raw; + const ref = raw.slice('container:'.length); + const match = peerContainers.find(c => c.id === ref || c.id.startsWith(ref)); + return match ? `container:${match.name}` : raw; + }); + + // Docker rejects attaching extra networks when the container shares another + // namespace (host / none / container:X / service:X). Hide join controls then. + const isSharedNetworkMode = $derived.by(() => { + const mode = containerData?.HostConfig?.NetworkMode || ''; + return mode === 'host' || mode === 'none' || mode.startsWith('container:') || mode.startsWith('service:'); + }); // Active tab state for layers visibility let activeTab = $state('overview'); @@ -52,6 +69,8 @@ // Label copy state let copiedLabel = $state(null); let copyLabelFailed = $state(false); + let labelFilter = $state(''); + let copiedAllLabels = $state(false); async function copyLabel(key: string, value: string) { const ok = await copyToClipboard(`${key}=${value}`); @@ -64,6 +83,19 @@ } } + async function copyAllLabels(entries: [string, string][]) { + if (entries.length === 0) return; + const text = entries.map(([k, v]) => `${k}=${v}`).join('\n'); + const ok = await copyToClipboard(text); + if (ok) { + copiedAllLabels = true; + setTimeout(() => copiedAllLabels = false, 2000); + } else { + copyLabelFailed = true; + setTimeout(() => copyLabelFailed = false, 2000); + } + } + // Processes state interface ProcessesData { Titles: string[]; @@ -281,6 +313,9 @@ $effect(() => { if (open && containerData?.State?.Running) { startStatsCollection(); + // One-shot fetch so the Overview's process count tile renders + // immediately, even if the user never opens the Processes tab. + if (!processesData) fetchProcesses(); } else { stopStatsCollection(); } @@ -317,6 +352,7 @@ lastFetchedId = ''; isLiveConnected = false; lastStatsUpdate = 0; + labelFilter = ''; displayName = ''; isEditing = false; editName = ''; @@ -335,6 +371,19 @@ throw new Error('Failed to fetch container details'); } containerData = await response.json(); + // Fetch peers only when this container shares another container's namespace — + // keeps the dialog snappy when the network mode is bridge/host/none/custom. + if (containerData?.HostConfig?.NetworkMode?.startsWith('container:')) { + try { + const peersRes = await fetch(appendEnvParam('/api/containers', envId)); + if (peersRes.ok) { + const list = await peersRes.json(); + peerContainers = list.map((c: any) => ({ id: c.id, name: c.name })); + } + } catch { + // Non-fatal — falls back to the raw SHA + } + } } catch (err: any) { error = err.message || 'Failed to load container details'; console.error('Failed to fetch container inspect:', err); @@ -583,11 +632,11 @@ variant="outline" size="sm" onclick={() => showRawJson = true} - title="View raw JSON" + title="View raw inspect data" class="ml-auto mr-6" > - JSON + Inspect {/if} @@ -623,7 +672,7 @@ {#if containerData.State?.Running} -
+
@@ -709,6 +758,30 @@
+ +
+
+ + Processes + +
+
+ {#if processesData?.Processes?.length} + running in container + {:else if processesLoading} + + {:else} + — + {/if} +
+
{/if} @@ -865,7 +938,7 @@

Network mode

- {containerData.HostConfig?.NetworkMode || 'default'} + {networkModeLabel}
@@ -918,7 +991,11 @@

Connected networks

- {#if containerData.NetworkSettings?.Networks && Object.keys(containerData.NetworkSettings.Networks).length > 0} + {#if isSharedNetworkMode} +

+ Network namespace is shared via {containerData.HostConfig?.NetworkMode} — additional networks cannot be attached. +

+ {:else if containerData.NetworkSettings?.Networks && Object.keys(containerData.NetworkSettings.Networks).length > 0}
{#each Object.entries(containerData.NetworkSettings.Networks) as [networkName, networkData]} {@const netData = networkData as any} @@ -985,7 +1062,7 @@ {/if} - {#if containerData.State?.Running} + {#if containerData.State?.Running && !isSharedNetworkMode}
@@ -1152,12 +1229,23 @@ + {#if containerData.divergence?.env?.length > 0} +
+ +
+ {containerData.divergence.env.length} env var{containerData.divergence.env.length === 1 ? '' : 's'} differ from the image: + {containerData.divergence.env.join(', ')}. + Values set by you at create time will stay. To reset to the image's current values, Remove & Deploy. +
+
+ {/if} {#if containerData.Config?.Env && containerData.Config.Env.length > 0}
{#each [...containerData.Config.Env].sort((a, b) => a.split('=')[0].localeCompare(b.split('=')[0])) as envVar} {@const [key, ...valueParts] = envVar.split('=')} {@const value = valueParts.join('=')} -
+ {@const diverges = containerData.divergence?.env?.includes(key)} +
{key} = {value} @@ -1170,31 +1258,79 @@ - - {#if containerData.Config?.Labels && Object.keys(containerData.Config.Labels).length > 0} -
- {#each Object.entries(containerData.Config.Labels).sort((a, b) => a[0].localeCompare(b[0])) as [key, value]} -
-
- {key} - = - {value} -
- -
- {/each} + + {#if containerData.divergence?.labels?.length > 0} +
+ +
+ {containerData.divergence.labels.length} label{containerData.divergence.labels.length === 1 ? '' : 's'} differ from the image: + {containerData.divergence.labels.join(', ')}. + Values set by you at create time will stay. To reset to the image's current values, Remove & Deploy. +
+ {/if} + {#if containerData.Config?.Labels && Object.keys(containerData.Config.Labels).length > 0} + {@const allLabels = Object.entries(containerData.Config.Labels).sort((a, b) => a[0].localeCompare(b[0]))} + {@const filter = labelFilter.trim().toLowerCase()} + {@const visibleLabels = filter + ? allLabels.filter(([k, v]) => k.toLowerCase().includes(filter) || String(v).toLowerCase().includes(filter)) + : allLabels} +
+ + + {visibleLabels.length === allLabels.length + ? `${allLabels.length} label${allLabels.length === 1 ? '' : 's'}` + : `${visibleLabels.length} of ${allLabels.length}`} + + +
+ {#if visibleLabels.length > 0} +
+ {#each visibleLabels as [key, value]} + {@const diverges = containerData.divergence?.labels?.includes(key)} +
+
+ {key} + = + {value} +
+ +
+ {/each} +
+ {:else} +

No labels match "{labelFilter}"

+ {/if} {:else}

No labels

{/if} @@ -1532,13 +1668,13 @@ - + - Raw JSON + Inspect
- - - - - {#if networkMode === 'bridge'} - - {:else if networkMode === 'host'} - - {:else} - - {/if} - {networkMode === 'bridge' ? 'Bridge' : networkMode === 'host' ? 'Host' : 'None'} - - - - - {#snippet children()} - - Bridge + + + + {#snippet child({ props })} + + {/snippet} + + + + + + No networks found. + + { networkMode = 'bridge'; closeAndFocusNetworkModePicker(); }}> + + Bridge + + { networkMode = 'host'; closeAndFocusNetworkModePicker(); }}> + + Host + + { networkMode = 'none'; closeAndFocusNetworkModePicker(); }}> + + None + + { if (!networkMode.startsWith('container:')) networkMode = 'container:'; closeAndFocusNetworkModePicker(); }}> + + Container + + + {#if customNetworks.length > 0} + + + {#each customNetworks as n (n.name)} + { networkMode = n.name; closeAndFocusNetworkModePicker(); }}> + + {n.name} + {n.driver} + + {/each} + + {/if} + + + + + {#if networkModeType === 'container'} + + + {#snippet child({ props })} + {/snippet} - - - {#snippet children()} - - Host - {/snippet} - - - {#snippet children()} - - None - {/snippet} - - - + + + + + + No containers found. + + {#each availableContainers as c (c.id)} + { + networkMode = `container:${c.name}`; + closeAndFocusContainerPicker(); + }} + > + + + {c.name} + {c.image} + + {/each} + + + + + + {#if !containerRef} +

Select a container to share its network namespace

+ {/if} + {/if}
@@ -767,38 +962,65 @@
- - {#if availableNetworks.length > 0} + + {#if availableNetworks.length > 0 && networkModeType !== 'host' && networkModeType !== 'none' && networkModeType !== 'container'}
-

Networks

+

Additional networks

- - - Select network to add... - - - {#each availableNetworks.filter(n => !selectedNetworks.includes(n.name) && !['bridge', 'host', 'none'].includes(n.name)) as network} - - {#snippet children()} -
- {network.name} - {network.driver} -
- {/snippet} -
- {/each} -
-
+ {#if selectableNetworks.length === 0} + + {:else} + + + {#snippet child({ props })} + + {/snippet} + + + + + + No networks found. + + {#each selectableNetworks as network (network.name)} + { + addNetwork(network.name); + closeAndFocusNetworkPicker(); + }} + > + {network.name} + {network.driver} + + {/each} + + + + + + {/if} - {#if selectedNetworks.length > 0} + {#if selectedNetworks.filter(n => n !== networkMode).length > 0}
- {#each selectedNetworks as networkName} + {#each selectedNetworks.filter(n => n !== networkMode) as networkName} {@const network = availableNetworks.find(n => n.name === networkName)} {@const isExpanded = expandedNetworks.has(networkName)}
diff --git a/src/routes/containers/CreateContainerModal.svelte b/src/routes/containers/CreateContainerModal.svelte index 3f82210..3b4b6b0 100644 --- a/src/routes/containers/CreateContainerModal.svelte +++ b/src/routes/containers/CreateContainerModal.svelte @@ -106,12 +106,6 @@ let labels = $state<{ key: string; value: string }[]>([{ key: '', value: '' }]); // Networks - interface DockerNetwork { - id: string; - name: string; - driver: string; - } - let availableNetworks = $state([]); let selectedNetworks = $state([]); let networkConfigs = $state>({}); let macAddress = $state(''); @@ -192,7 +186,6 @@ $effect(() => { if (open) { fetchConfigSets(); - fetchNetworks(); fetchScannerSettings(); } }); @@ -281,18 +274,6 @@ scanStatus = status; } - async function fetchNetworks() { - try { - const envParam = $currentEnvironment ? `?env=${$currentEnvironment.id}` : ''; - const response = await fetch(`/api/networks${envParam}`); - if (response.ok) { - availableNetworks = await response.json(); - } - } catch (err) { - console.error('Failed to fetch networks:', err); - } - } - async function fetchConfigSets() { try { const response = await fetch('/api/config-sets'); @@ -457,7 +438,11 @@ restartPolicy, restartMaxRetries: restartPolicy === 'on-failure' && restartMaxRetries !== '' ? Number(restartMaxRetries) : undefined, networkMode, - networks: selectedNetworks.length > 0 ? selectedNetworks : undefined, + additionalNetworks: (() => { + const isSharedMode = networkMode === 'host' || networkMode === 'none' || networkMode.startsWith('container:'); + if (isSharedMode) return undefined; + return selectedNetworks.length > 0 ? selectedNetworks : undefined; + })(), networkConfigs: Object.keys(netConfigs).length > 0 ? netConfigs : undefined, macAddress: macAddress.trim() || undefined, startAfterCreate, @@ -740,7 +725,6 @@ bind:volumeMappings bind:envVars bind:labels - {availableNetworks} bind:selectedNetworks bind:networkConfigs bind:macAddress diff --git a/src/routes/containers/EditContainerModal.svelte b/src/routes/containers/EditContainerModal.svelte index a82b4ad..e8db8c7 100644 --- a/src/routes/containers/EditContainerModal.svelte +++ b/src/routes/containers/EditContainerModal.svelte @@ -108,12 +108,6 @@ let labels = $state<{ key: string; value: string }[]>([{ key: '', value: '' }]); // Networks - interface DockerNetwork { - id: string; - name: string; - driver: string; - } - let availableNetworks = $state([]); let selectedNetworks = $state([]); let networkConfigs = $state>({}); let macAddress = $state(''); @@ -246,18 +240,6 @@ let editTitleName = $state(''); let renamingTitle = $state(false); - async function fetchNetworks() { - try { - const envParam = currentEnvId ? `?env=${currentEnvId}` : ''; - const response = await fetch(`/api/networks${envParam}`); - if (response.ok) { - availableNetworks = await response.json(); - } - } catch (err) { - console.error('Failed to fetch networks:', err); - } - } - // Inline title rename functions let titleInputRef: HTMLInputElement | null = null; @@ -358,13 +340,13 @@ restartPolicy = data.HostConfig.RestartPolicy?.Name || 'no'; restartMaxRetries = data.HostConfig.RestartPolicy?.MaximumRetryCount ?? ''; - // Normalize network mode + // Normalize network mode. NetworkMode can be: + // - bridge / host / none / default → built-in modes + // - container: → shared namespace + // - → primary network is that custom network + // We preserve the raw value (except map "default" → "bridge" since they're equivalent). const rawNetworkMode = data.HostConfig.NetworkMode || 'bridge'; - if (['bridge', 'host', 'none', 'default'].includes(rawNetworkMode)) { - networkMode = rawNetworkMode === 'default' ? 'bridge' : rawNetworkMode; - } else { - networkMode = 'bridge'; - } + networkMode = rawNetworkMode === 'default' ? 'bridge' : rawNetworkMode; // Parse port mappings (include HostIp if present) const ports = data.HostConfig.PortBindings || {}; @@ -424,9 +406,10 @@ composeStackName = ''; } - // Parse connected networks + // Parse connected networks. selectedNetworks holds ADDITIONAL networks only — + // the primary lives in networkMode, never in this list (Portainer-style). const networks = data.NetworkSettings?.Networks || {}; - selectedNetworks = Object.keys(networks); + selectedNetworks = Object.keys(networks).filter(n => n !== networkMode); // Parse per-network IP/alias config from NetworkSettings const parsedNetConfigs: Record = {}; @@ -563,8 +546,7 @@ runtime = (data.HostConfig?.Runtime && data.HostConfig.Runtime !== 'runc') ? data.HostConfig.Runtime : ''; - // Fetch available networks and auto-update settings - await fetchNetworks(); + // Fetch auto-update settings await fetchAutoUpdateSettings(name); // Store original config for change detection @@ -944,36 +926,47 @@ image: image.trim(), ports: Object.keys(ports).length > 0 ? ports : null, volumeBinds: volumeBinds.length > 0 ? volumeBinds : null, - env: env.length > 0 ? env : undefined, + // Fields the form manages: send `null` when the user cleared them + // so the server can distinguish "cleared" from "absent" (#1119). + // Server's updateContainer merges payload over existing config and + // treats explicit null as "clear this field" — undefined would be + // dropped by JSON.stringify and the merge would preserve the old value. + env: env.length > 0 ? env : null, labels: labelsObj, cmd, restartPolicy, - restartMaxRetries: restartPolicy === 'on-failure' && restartMaxRetries !== '' ? Number(restartMaxRetries) : undefined, + restartMaxRetries: restartPolicy === 'on-failure' && restartMaxRetries !== '' ? Number(restartMaxRetries) : null, networkMode, - networks: selectedNetworks.length > 0 ? selectedNetworks : undefined, - networkConfigs: Object.keys(netConfigs).length > 0 ? netConfigs : undefined, - macAddress: macAddress.trim() || undefined, + // Additional networks only — primary is networkMode. Shared modes + // (host/none/container:X) reject extras; UI already hides the section. + additionalNetworks: (() => { + const isSharedMode = networkMode === 'host' || networkMode === 'none' || networkMode.startsWith('container:'); + if (isSharedMode) return null; + return selectedNetworks.length > 0 ? selectedNetworks : null; + })(), + networkConfigs: Object.keys(netConfigs).length > 0 ? netConfigs : null, + macAddress: macAddress.trim() || null, startAfterUpdate, repullImage, user: containerUser.trim() || null, privileged: privilegedMode, healthcheck, - memory: parseMemory(memoryLimit), - memoryReservation: parseMemory(memoryReservation), - cpuShares: cpuShares ? parseInt(cpuShares) : undefined, - nanoCpus: parseNanoCpus(nanoCpus), - cpuQuota: cpuQuota ? parseInt(cpuQuota) : undefined, - cpuPeriod: cpuPeriod ? parseInt(cpuPeriod) : undefined, - capAdd: capAdd.length > 0 ? capAdd : undefined, - capDrop: capDrop.length > 0 ? capDrop : undefined, - devices: devices.length > 0 ? devices : undefined, - deviceRequests, + memory: parseMemory(memoryLimit) ?? null, + memoryReservation: parseMemory(memoryReservation) ?? null, + cpuShares: cpuShares ? parseInt(cpuShares) : null, + nanoCpus: parseNanoCpus(nanoCpus) ?? null, + cpuQuota: cpuQuota ? parseInt(cpuQuota) : null, + cpuPeriod: cpuPeriod ? parseInt(cpuPeriod) : null, + capAdd: capAdd.length > 0 ? capAdd : null, + capDrop: capDrop.length > 0 ? capDrop : null, + devices: devices.length > 0 ? devices : null, + deviceRequests: deviceRequests ?? null, runtime: runtime || null, - dns: dnsServers.length > 0 ? dnsServers : undefined, - dnsSearch: dnsSearch.length > 0 ? dnsSearch : undefined, - dnsOptions: dnsOptions.length > 0 ? dnsOptions : undefined, - securityOpt: securityOptions.length > 0 ? securityOptions : undefined, - ulimits: ulimitsArray.length > 0 ? ulimitsArray : undefined + dns: dnsServers.length > 0 ? dnsServers : null, + dnsSearch: dnsSearch.length > 0 ? dnsSearch : null, + dnsOptions: dnsOptions.length > 0 ? dnsOptions : null, + securityOpt: securityOptions.length > 0 ? securityOptions : null, + ulimits: ulimitsArray.length > 0 ? ulimitsArray : null }; const response = await fetch(appendEnvParam(`/api/containers/${containerId}/update`, $currentEnvironment?.id), { @@ -1129,7 +1122,6 @@ bind:volumeMappings bind:envVars bind:labels - {availableNetworks} bind:selectedNetworks bind:networkConfigs bind:macAddress diff --git a/src/routes/images/+page.svelte b/src/routes/images/+page.svelte index ab676fc..cdc67e1 100644 --- a/src/routes/images/+page.svelte +++ b/src/routes/images/+page.svelte @@ -729,7 +729,7 @@ />
- + {#if usageFilter === 'all'} All diff --git a/src/routes/logs/+page.svelte b/src/routes/logs/+page.svelte index ccbdf8a..d142314 100644 --- a/src/routes/logs/+page.svelte +++ b/src/routes/logs/+page.svelte @@ -11,7 +11,6 @@ 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, Filter, GripHorizontal, Terminal, ArrowDown, ArrowRight, Clock, Tag, Hash } from 'lucide-svelte'; - import { wrapHtmlLines } from '$lib/utils/log-lines'; import LogTimeRangeFilter from './LogTimeRangeFilter.svelte'; import { copyToClipboard } from '$lib/utils/clipboard'; import PageHeader from '$lib/components/PageHeader.svelte'; @@ -22,16 +21,22 @@ import type { FavoriteGroup } from '../api/preferences/favorite-groups/+server'; import { currentEnvironment, environments, appendEnvParam } from '$lib/stores/environment'; import { appSettings, formatLogTimestamps } from '$lib/stores/settings'; import { NoEnvironment } from '$lib/components/ui/empty-state'; - import { AnsiUp } from 'ansi_up'; - const ansiUp = new AnsiUp(); - ansiUp.use_classes = true; + import { parseLines, renderLineHtml, type LogEntry } from '$lib/utils/log-entry'; + + function renderTimestamp(ts: string | undefined): string { + if (!ts) return ''; + if ($appSettings.formatLogTimestamps) { + return formatLogTimestamps(ts + ' ').trimEnd(); + } + return ts; + } // Track if we've handled the initial container from URL let initialContainerHandled = $state(false); let containers = $state([]); let selectedContainer = $state(null); - let logs = $state(''); + let logs = $state([]); let loading = $state(false); let autoScroll = $state(true); let fontSize = $state(12); @@ -51,39 +56,41 @@ import type { FavoriteGroup } from '../api/preferences/favorite-groups/+server'; const MAX_RECONNECT_ATTEMPTS = 5; const RECONNECT_DELAY = 3000; - // Grouped mode state + // Grouped mode state. mergedLogs is the LogEntry[] equivalent for the merged + // view — same shape as single-mode logs but every entry has containerId/Name/color. + // Per-stream carryover preserves partial lines across SSE chunks. let selectedContainerIds = $state>(new Set()); let groupedContainerInfo = $state>(new Map()); - let mergedLogs = $state>([]); - let mergedHtml = $state(''); // Pre-built HTML string for fast rendering (like single mode's `logs`) + let mergedLogs = $state([]); + const groupedCarryover = new Map(); // containerId → leftover let stackName = $state(null); - // Batching for grouped mode log updates to prevent UI blocking - let pendingLogs: Array<{ containerId: string; containerName: string; color: string; text: string; timestamp?: string; stream?: string }> = []; - let batchTimeout: ReturnType | null = null; - const BATCH_INTERVAL = 50; // ms - batch logs for 50ms before updating state - // Initial buffering: accumulate all tail lines before first render to avoid line-by-line appearance + // Microtask flush coalesces bursts within a tick; initial buffering window + // avoids the line-by-line appearance for the tail-N replay. + let pendingGroupedEntries: LogEntry[] = []; + let groupedFlushScheduled = false; let initialBuffering = false; let initialBufferTimeout: ReturnType | null = null; - const INITIAL_BUFFER_DELAY = 400; // ms - wait for all initial tail lines before first render + const INITIAL_BUFFER_DELAY = 400; - // Batching for single mode SSE logs - let pendingText = ''; - let flushTimer: ReturnType | null = null; - const FLUSH_INTERVAL = 100; // ms + // Single mode flush — same microtask pattern + per-line carryover. + let pendingEntries: LogEntry[] = []; + let streamCarryover = ''; + let flushScheduled = false; + const COMPACT_FACTOR = 2; // RAF-based auto-scroll let scrollRafPending = false; // Tail count and since filter + // Capped at 1000 to avoid the frozen-browser hang from rendering thousands of + // nodes at once (no virtualization yet). const tailOptions = [ { value: '100', label: '100' }, { value: '500', label: '500' }, - { value: '1000', label: '1K' }, - { value: '5000', label: '5K' }, - { value: '10000', label: '10K' }, - { value: 'all', label: 'All' } + { value: '1000', label: '1K' } ]; + const VALID_TAIL_VALUES = new Set(tailOptions.map(o => o.value)); let tailCount = $state('500'); let sinceDate = $state(''); let sinceTime = $state(''); @@ -105,9 +112,12 @@ import type { FavoriteGroup } from '../api/preferences/favorite-groups/+server'; } function reloadAllLogs() { - logs = ''; - pendingText = ''; - mergedLogs = []; mergedHtml = ''; + logs = []; + pendingEntries = []; + streamCarryover = ''; + mergedLogs = []; + pendingGroupedEntries = []; + groupedCarryover.clear(); if (layoutMode === 'grouped') { if (streamingEnabled) startGroupedStreaming(); } else if (selectedContainer) { @@ -116,73 +126,90 @@ import type { FavoriteGroup } from '../api/preferences/favorite-groups/+server'; } } - // Flush pending logs to state (called on timer) - function flushPendingLogs() { - if (pendingLogs.length === 0) { - batchTimeout = null; - return; - } - - // Build HTML for new lines and append (like single mode's logs += pendingText) - let newHtml = ''; - for (const log of pendingLogs) { - const content = ansiToHtml(log.text); - newHtml += `[${escapeHtml(log.containerName)}] ${content}`; - } - - // Push into array (kept for copy/download) - mergedLogs.push(...pendingLogs); - pendingLogs = []; - - // Keep only last 2000 lines to prevent memory issues - if (mergedLogs.length > 2000) { - const removed = mergedLogs.length - 1600; - mergedLogs.splice(0, removed); - // Rebuild HTML from trimmed array - mergedHtml = ''; - for (const log of mergedLogs) { - mergedHtml += `[${escapeHtml(log.containerName)}] ${ansiToHtml(log.text)}`; - } - } else { - mergedHtml += newHtml; - } - - batchTimeout = null; - scrollToBottom(); + // Compact a LogEntry[] in place by slicing to the last maxLines when it + // grows beyond the soft 2x threshold. The 2x slack amortizes the slice cost. + function compact(arr: LogEntry[], maxLines: number): LogEntry[] { + if (arr.length <= maxLines * COMPACT_FACTOR) return arr; + return arr.slice(arr.length - maxLines); + } + + function getMaxLines(): number { + return $appSettings.logMaxLines; + } + + // Microtask-scheduled flush — coalesces all SSE events arriving in the same + // tick into a single state update. Grouped and single modes share the same + // pattern; the flush callback differs per mode. + function scheduleSingleFlush() { + if (flushScheduled) return; + flushScheduled = true; + queueMicrotask(flushSingleLogs); } - // Flush buffered single-mode text to state function flushSingleLogs() { - if (flushTimer) { - clearTimeout(flushTimer); - flushTimer = null; - } - if (!pendingText) return; - - logs += pendingText; - pendingText = ''; - - // Apply log buffer size limit (convert KB to characters) - const maxSize = $appSettings.logBufferSizeKb * 1024; - if (logs.length > maxSize) { - logs = logs.substring(logs.length - Math.floor(maxSize * 0.8)); - } - + flushScheduled = false; + if (pendingEntries.length === 0) return; + const maxLines = getMaxLines(); + // Trim oversize incoming batches before append — see LogsPanel for rationale. + const incoming = pendingEntries.length > maxLines + ? pendingEntries.slice(pendingEntries.length - maxLines) + : pendingEntries; + logs = compact([...logs, ...incoming], maxLines); + pendingEntries = []; scrollToBottom(); } - // Scroll to bottom after Svelte finishes rendering. - // Uses tick() to wait for DOM update, then RAF for smooth visual timing. + function scheduleGroupedFlush() { + if (groupedFlushScheduled) return; + groupedFlushScheduled = true; + queueMicrotask(flushGroupedLogs); + } + + function flushGroupedLogs() { + groupedFlushScheduled = false; + if (pendingGroupedEntries.length === 0) return; + const maxLines = getMaxLines(); + const incoming = pendingGroupedEntries.length > maxLines + ? pendingGroupedEntries.slice(pendingGroupedEntries.length - maxLines) + : pendingGroupedEntries; + mergedLogs = compact([...mergedLogs, ...incoming], maxLines); + pendingGroupedEntries = []; + scrollToBottom(); + } + + // Auto-disable autoscroll when the user scrolls up, re-enable when they + // land back near the bottom. programmaticScroll guards against our own + // scrollTop writes re-enabling auto-scroll the moment the user paused it. + const BOTTOM_STICKINESS_PX = 40; + let programmaticScroll = false; + async function scrollToBottom(force = false) { if ((!force && !autoScroll) || !logsRef || scrollRafPending) return; scrollRafPending = true; await tick(); requestAnimationFrame(() => { - if (logsRef) logsRef.scrollTop = logsRef.scrollHeight; + if (logsRef) { + programmaticScroll = true; + logsRef.scrollTop = logsRef.scrollHeight; + requestAnimationFrame(() => { programmaticScroll = false; }); + } scrollRafPending = false; }); } + function handleLogsScroll() { + if (programmaticScroll || !logsRef) return; + const distance = logsRef.scrollHeight - logsRef.scrollTop - logsRef.clientHeight; + const atBottom = distance < BOTTOM_STICKINESS_PX; + if (atBottom && !autoScroll) { + autoScroll = true; + saveState(); + } else if (!atBottom && autoScroll) { + autoScroll = false; + saveState(); + } + } + // Multi-mode selection state (for merge feature) let multiModeSelections = $state>(new Set()); @@ -303,7 +330,7 @@ import type { FavoriteGroup } from '../api/preferences/favorite-groups/+server'; // selectedContainer stays as is, streaming continues // Just clear grouped mode data selectedContainerIds = new Set(); - mergedLogs = []; mergedHtml = ''; + mergedLogs = []; pendingGroupedEntries = []; groupedCarryover.clear(); // Save state if we have a container selected (carrying over selection) if (selectedContainer) { saveState(); @@ -318,7 +345,7 @@ import type { FavoriteGroup } from '../api/preferences/favorite-groups/+server'; // Stop grouped streaming and start single streaming stopStreaming(); selectedContainerIds = new Set(); - mergedLogs = []; mergedHtml = ''; + mergedLogs = []; pendingGroupedEntries = []; groupedCarryover.clear(); selectedContainer = container; if (streamingEnabled) { startStreaming(container); @@ -329,7 +356,7 @@ import type { FavoriteGroup } from '../api/preferences/favorite-groups/+server'; // Multiple containers - just stop streaming stopStreaming(); selectedContainerIds = new Set(); - mergedLogs = []; mergedHtml = ''; + mergedLogs = []; pendingGroupedEntries = []; groupedCarryover.clear(); } // If selectedContainer already exists (from multi mode), keep it streaming // Save state if we have a container selected (carrying over selection) @@ -457,7 +484,9 @@ import type { FavoriteGroup } from '../api/preferences/favorite-groups/+server'; if (savedUiState.fontSize !== undefined) fontSize = savedUiState.fontSize; if (savedUiState.autoScroll !== undefined) autoScroll = savedUiState.autoScroll; if (savedUiState.streamingEnabled !== undefined) streamingEnabled = savedUiState.streamingEnabled; - if (savedUiState.tailCount !== undefined) tailCount = savedUiState.tailCount; + if (savedUiState.tailCount !== undefined) { + tailCount = VALID_TAIL_VALUES.has(savedUiState.tailCount) ? savedUiState.tailCount : '1000'; + } initialStateLoaded = true; // Fetch data for this environment @@ -492,7 +521,7 @@ import type { FavoriteGroup } from '../api/preferences/favorite-groups/+server'; } else { // No running containers found - show empty state for this stack selectedContainerIds = new Set(); - mergedLogs = []; mergedHtml = ''; + mergedLogs = []; pendingGroupedEntries = []; groupedCarryover.clear(); saveState(); } return; @@ -507,7 +536,7 @@ import type { FavoriteGroup } from '../api/preferences/favorite-groups/+server'; } else { // Container not running - clear selection and logs selectedContainer = null; - logs = ''; + logs = []; } return; } @@ -583,7 +612,7 @@ import type { FavoriteGroup } from '../api/preferences/favorite-groups/+server'; // If selected container is no longer available, clear selection if (selectedContainer && !containers.find((c) => c.id === selectedContainer?.id)) { selectedContainer = null; - logs = ''; + logs = []; } // Grouped mode: restart stream if the running/stopped split changed @@ -853,11 +882,13 @@ import type { FavoriteGroup } from '../api/preferences/favorite-groups/+server'; const until = getUntilParam(); const response = await fetch(appendEnvParam(`/api/containers/${selectedContainer.id}/logs?tail=${t}${since ? `&since=${since}` : ''}${until ? `&until=${until}` : ''}`, envId)); const data = await response.json(); - logs = data.logs || 'No logs available'; - scrollToBottom(true); // Force scroll on initial load + const { entries } = parseLines(data.logs || '', ''); + logs = entries; + scrollToBottom(true); } catch (error) { console.error('Failed to fetch logs:', error); - logs = 'Failed to fetch logs: ' + String(error); + connectionError = 'Failed to fetch logs: ' + String(error); + logs = []; } finally { loading = false; } @@ -897,26 +928,11 @@ import type { FavoriteGroup } from '../api/preferences/favorite-groups/+server'; try { const data = JSON.parse(event.data); if (data.text) { - // Add container name prefix to each line if available and enabled - let text = data.text; - if (data.containerName && showContainerName) { - const lines = text.split('\n'); - text = lines.map((line: string, i: number) => { - if (line === '' && i === lines.length - 1) return line; - if (line === '') return line; - return `[${data.containerName}] ${line}`; - }).join('\n'); - } - // Strip or format timestamps - if (!showTimestamps) { - text = text.replace(/^(\[.*?\] )?\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?Z /gm, '$1'); - } else if ($appSettings.formatLogTimestamps) { - text = formatLogTimestamps(text); - } - // Buffer text and schedule flush - pendingText += text; - if (!flushTimer) { - flushTimer = setTimeout(flushSingleLogs, FLUSH_INTERVAL); + const { entries, carryover } = parseLines(data.text, streamCarryover); + streamCarryover = carryover; + if (entries.length > 0) { + pendingEntries.push(...entries); + scheduleSingleFlush(); } } } catch { @@ -979,20 +995,8 @@ import type { FavoriteGroup } from '../api/preferences/favorite-groups/+server'; groupedContainerInfo = new Map([...groupedContainerInfo, [containerId, { name: containerName, color }]]); if (data.logs) { - // Parse and add logs - const lines = data.logs.split('\n').filter((line: string) => line.trim()); - for (const line of lines) { - const text = line + '\n'; - mergedLogs.push({ - containerId, - containerName, - color, - text, - timestamp: new Date().toISOString() - }); - mergedHtml += `[${escapeHtml(containerName)}] ${ansiToHtml(text)}`; - } - mergedLogs = mergedLogs; + const { entries } = parseLines(data.logs, '', { containerId, containerName, color }); + mergedLogs = compact([...mergedLogs, ...entries], getMaxLines()); } } catch (error) { console.error(`Failed to fetch logs for stopped container ${containerId}:`, error); @@ -1028,7 +1032,7 @@ import type { FavoriteGroup } from '../api/preferences/favorite-groups/+server'; loading = true; // Clear container info for fresh selection (prevents icon accumulation) groupedContainerInfo = new Map(); - mergedLogs = []; mergedHtml = ''; + mergedLogs = []; pendingGroupedEntries = []; groupedCarryover.clear(); // Separate running and stopped containers const allIds = Array.from(selectedContainerIds); @@ -1090,35 +1094,34 @@ import type { FavoriteGroup } from '../api/preferences/favorite-groups/+server'; initialBuffering = false; initialBufferTimeout = null; loading = false; - flushPendingLogs(); + flushGroupedLogs(); }, INITIAL_BUFFER_DELAY); }); eventSource.addEventListener('log', (event) => { try { const data = JSON.parse(event.data); - if (data.text) { - // Use consistent color based on position in all selected containers + if (data.text && data.containerId) { const color = getContainerColor(data.containerId); - let logText = data.text; - if (!showTimestamps) { - logText = logText.replace(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?Z /gm, ''); - } else if ($appSettings.formatLogTimestamps) { - logText = formatLogTimestamps(logText); - } - // Add to pending batch instead of updating state immediately - pendingLogs.push({ + const carry = groupedCarryover.get(data.containerId) ?? ''; + const { entries, carryover } = parseLines(data.text, carry, { containerId: data.containerId, containerName: data.containerName, color, - text: logText, - timestamp: data.timestamp, stream: data.stream }); - // During initial buffering, just accumulate - don't schedule flushes - // The initial buffer timeout will flush everything in one go - if (!initialBuffering && !batchTimeout) { - batchTimeout = setTimeout(flushPendingLogs, BATCH_INTERVAL); + // /api/logs/merged strips Docker's ISO timestamp into a + // separate field, so parseLines can't recover it from text. + // Backfill from data.timestamp so the timestamp toggle works. + if (data.timestamp) { + for (const e of entries) { + if (!e.timestamp) e.timestamp = data.timestamp; + } + } + groupedCarryover.set(data.containerId, carryover); + if (entries.length > 0) { + pendingGroupedEntries.push(...entries); + if (!initialBuffering) scheduleGroupedFlush(); } } } catch { @@ -1147,7 +1150,7 @@ import type { FavoriteGroup } from '../api/preferences/favorite-groups/+server'; initialBufferTimeout = null; } loading = false; - flushPendingLogs(); + flushGroupedLogs(); } }); @@ -1174,7 +1177,7 @@ import type { FavoriteGroup } from '../api/preferences/favorite-groups/+server'; clearTimeout(initialBufferTimeout); initialBufferTimeout = null; } - flushPendingLogs(); + flushGroupedLogs(); } // Close the broken connection @@ -1222,8 +1225,8 @@ import type { FavoriteGroup } from '../api/preferences/favorite-groups/+server'; function retryConnection() { reconnectAttempts = 0; connectionError = null; - logs = ''; - mergedLogs = []; mergedHtml = ''; + logs = []; + mergedLogs = []; pendingGroupedEntries = []; groupedCarryover.clear(); loading = true; if (layoutMode === 'grouped') { startGroupedStreaming(); @@ -1241,17 +1244,13 @@ import type { FavoriteGroup } from '../api/preferences/favorite-groups/+server'; initialBufferTimeout = null; } flushSingleLogs(); - flushPendingLogs(); + flushGroupedLogs(); if (reconnectTimeout) { clearTimeout(reconnectTimeout); reconnectTimeout = null; } - // Clear batch timeout and pending logs - if (batchTimeout) { - clearTimeout(batchTimeout); - batchTimeout = null; - } - pendingLogs = []; + // Drop any pending grouped entries (the microtask flush will see empty) + pendingGroupedEntries = []; if (eventSource) { eventSource.close(); eventSource = null; @@ -1268,8 +1267,10 @@ import type { FavoriteGroup } from '../api/preferences/favorite-groups/+server'; streamingEnabled = !streamingEnabled; saveState(); if (streamingEnabled) { - logs = ''; - mergedLogs = []; mergedHtml = ''; + logs = []; + pendingEntries = []; + streamCarryover = ''; + mergedLogs = []; pendingGroupedEntries = []; groupedCarryover.clear(); reconnectAttempts = 0; connectionError = null; loading = true; @@ -1295,7 +1296,7 @@ import type { FavoriteGroup } from '../api/preferences/favorite-groups/+server'; selectedContainer = container; searchQuery = ''; dropdownOpen = false; - logs = ''; // Clear previous logs + logs = []; // Clear previous logs // Save selection for persistence saveState(); @@ -1329,14 +1330,14 @@ import type { FavoriteGroup } from '../api/preferences/favorite-groups/+server'; startGroupedStreaming(); } else if (newSet.size === 0) { stopStreaming(); - mergedLogs = []; mergedHtml = ''; + mergedLogs = []; pendingGroupedEntries = []; groupedCarryover.clear(); } } // Start grouped streaming with current selection function startGroupedView() { if (selectedContainerIds.size === 0) return; - mergedLogs = []; mergedHtml = ''; + mergedLogs = []; pendingGroupedEntries = []; groupedCarryover.clear(); loading = true; startGroupedStreaming(); } @@ -1351,7 +1352,7 @@ import type { FavoriteGroup } from '../api/preferences/favorite-groups/+server'; function clearContainerSelection() { selectedContainerIds = new Set(); stopStreaming(); - mergedLogs = []; mergedHtml = ''; + mergedLogs = []; pendingGroupedEntries = []; groupedCarryover.clear(); } // Multi-mode: toggle a container in the multi-select list @@ -1391,7 +1392,7 @@ import type { FavoriteGroup } from '../api/preferences/favorite-groups/+server'; function clearSelection() { stopStreaming(); selectedContainer = null; - logs = ''; + logs = []; searchQuery = ''; } @@ -1416,45 +1417,54 @@ import type { FavoriteGroup } from '../api/preferences/favorite-groups/+server'; } - // Copy logs to clipboard - async function copyLogs() { - const textToCopy = layoutMode === 'grouped' - ? mergedLogs.map(l => `[${l.containerName}] ${l.text}`).join('') - : logs; - if (textToCopy) { - await copyToClipboard(textToCopy); - } + // Serialize the displayed buffer to plain text, mirroring what's on screen + // (honors the timestamp toggle; grouped mode includes container-name prefix). + function entriesToText(arr: LogEntry[], includeContainerName: boolean): string { + return arr + .map(e => { + const parts: string[] = []; + if (showTimestamps && e.timestamp) parts.push(e.timestamp); + if (includeContainerName && e.containerName) parts.push(`[${e.containerName}]`); + parts.push(e.text); + return parts.join(' '); + }) + .join('\n'); + } + + async function copyLogs() { + const text = layoutMode === 'grouped' + ? entriesToText(mergedLogs, true) + : entriesToText(logs, false); + if (text) await copyToClipboard(text); } - // Download logs as txt file function downloadLogs() { - const textToDownload = layoutMode === 'grouped' - ? mergedLogs.map(l => `[${l.containerName}] ${l.text}`).join('') - : logs; + const text = layoutMode === 'grouped' + ? entriesToText(mergedLogs, true) + : entriesToText(logs, false); const filename = layoutMode === 'grouped' ? 'merged-logs.txt' : selectedContainer ? `${selectedContainer.name}-logs.txt` : 'logs.txt'; - - if (textToDownload) { - const blob = new Blob([textToDownload], { type: 'text/plain' }); - const url = URL.createObjectURL(blob); - const a = document.createElement('a'); - a.href = url; - a.download = filename; - document.body.appendChild(a); - a.click(); - document.body.removeChild(a); - URL.revokeObjectURL(url); - } + if (!text) return; + const blob = new Blob([text], { type: 'text/plain' }); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = filename; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + URL.revokeObjectURL(url); } // Clear displayed logs function clearLogs() { - logs = ''; - pendingText = ''; + logs = []; + pendingEntries = []; + streamCarryover = ''; mergedLogs = []; - mergedHtml = ''; - pendingLogs = []; + pendingGroupedEntries = []; + groupedCarryover.clear(); } // Log search functions @@ -1517,78 +1527,22 @@ import type { FavoriteGroup } from '../api/preferences/favorite-groups/+server'; } } - // Escape HTML to prevent XSS - function escapeHtml(text: string): string { - return text - .replace(/&/g, '&') - .replace(//g, '>'); + // Filter pass — array op over LogEntry[], not regex over a string. + function filterByQuery(arr: LogEntry[]): LogEntry[] { + const query = logSearchQuery.trim(); + if (!logSearchFilterMode || !query) return arr; + const q = query.toLowerCase(); + return arr.filter(e => e.text.toLowerCase().includes(q)); } - function ansiToHtml(text: string): string { - return ansiUp.ansi_to_html(text); - } + let filteredLogs = $derived(filterByQuery(logs)); + let filteredMerged = $derived(filterByQuery(mergedLogs)); - // Highlighted logs with search matches and ANSI color support (single container mode) - let highlightedLogs = $derived(() => { - let text = logs || ''; - const query = logSearchQuery.trim(); - - // 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); - - const parts = withAnsi.split(/(<[^>]*>)/); - return parts.map(part => { - if (part.startsWith('<')) return part; - const regex = new RegExp(`(${escapedQuery})`, 'gi'); - return part.replace(regex, '$1'); - }).join(''); - }); - - // Format merged logs HTML — uses pre-built mergedHtml string, only applies search highlighting when needed - let formattedMergedHtml = $derived(() => { - if (!mergedHtml) return ''; - const query = logSearchQuery.trim(); - - // 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 = html.split(/(<[^>]*>)/); - return parts.map(part => { - if (part.startsWith('<')) return part; - return part.replace(searchRegex, '$1'); - }).join(''); - }); - - // Update match count after render + // Update match count after render. Tracks the entries currently displayed so + // it re-runs on append / toggle / search change. $effect(() => { - // Track highlighted logs to re-run when content changes - const html = layoutMode === 'grouped' ? formattedMergedHtml() : highlightedLogs(); + layoutMode === 'grouped' ? filteredMerged : filteredLogs; + logSearchQuery; if (logSearchQuery && logsRef) { setTimeout(() => { @@ -1626,7 +1580,7 @@ import type { FavoriteGroup } from '../api/preferences/favorite-groups/+server'; } // Flush pending text and clean up timers flushSingleLogs(); - flushPendingLogs(); + flushGroupedLogs(); stopStreaming(); }); @@ -2005,7 +1959,7 @@ import type { FavoriteGroup } from '../api/preferences/favorite-groups/+server'; {#if multiModeSelections.size >= 2} {/if}
@@ -2206,7 +2160,7 @@ import type { FavoriteGroup } from '../api/preferences/favorite-groups/+server';
-
+
{#if loading && mergedLogs.length === 0}
@@ -2216,7 +2170,7 @@ import type { FavoriteGroup } from '../api/preferences/favorite-groups/+server';
{/if} -
{@html wrapHtmlLines(formattedMergedHtml())}
+
{#each filteredMerged as e (e.id)}
{#if showTimestamps && e.timestamp}{renderTimestamp(e.timestamp)}{' '}{/if}{#if showContainerName && e.containerName}[{e.containerName}]{' '}{/if}{@html renderLineHtml(e, logSearchQuery.trim())}
{/each}
{/if} {:else if !selectedContainer} @@ -2475,8 +2429,8 @@ import type { FavoriteGroup } from '../api/preferences/favorite-groups/+server'; Loading logs...
{:else} -
-
{@html wrapHtmlLines(highlightedLogs())}
+
+
{#each filteredLogs as e (e.id)}
{#if showTimestamps && e.timestamp}{renderTimestamp(e.timestamp)}{' '}{/if}{#if showContainerName && selectedContainer?.name}[{selectedContainer.name}]{' '}{/if}{@html renderLineHtml(e, logSearchQuery.trim())}
{/each}
{/if} {/if} @@ -2511,6 +2465,12 @@ import type { FavoriteGroup } from '../api/preferences/favorite-groups/+server'; {/if}