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}
-
+
+
+
+
+
+
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
@@ -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}