This commit is contained in:
jarek
2026-06-06 16:18:05 +02:00
parent 00bd09df55
commit 3cbcfa3cdb
26 changed files with 1370 additions and 768 deletions
+2 -2
View File
@@ -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/
+1 -1
View File
@@ -1 +1 @@
v1.0.31
v1.0.32
+1 -1
View File
@@ -1,3 +1,3 @@
module github.com/Finsys/dockhand/collector
go 1.25.10
go 1.25.11
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "dockhand",
"private": true,
"version": "1.0.31",
"version": "1.0.27",
"type": "module",
"scripts": {
"dev": "npx vite dev",
+14 -11
View File
@@ -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 {
+18
View File
@@ -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",
-34
View File
@@ -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;
}
@@ -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<string, string> {
const m = new Map<string, string>();
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<string, string> | null | undefined,
imageLabels: Record<string, string> | 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;
}
+129 -125
View File
@@ -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/<name>/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<string, any> = {};
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<string, string> = (oldImageInspect as any)?.Config?.Labels || {};
const newImageLabels: Record<string, string> = (newImageInspect as any)?.Config?.Labels || {};
const containerLabels: Record<string, string> = createConfig.Labels || {};
const mergedLabels: Record<string, string> = {};
// 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<CreateContain
// Extract ALL existing container options
const existingOptions = extractContainerOptions(oldContainerInfo);
// Merge user-provided options on top of existing options
// User options take precedence, but we preserve everything not explicitly provided
// Per-network fields (aliases, static IPs, MAC, gateway priority) are scoped to
// a single network. When the user switches the primary network, the values
// extracted from the old network must NOT follow the container — e.g. compose
// service aliases from "anton" applied to the default bridge fail with
// "invalid endpoint settings" because the default bridge doesn't accept aliases.
if (options.networkMode && options.networkMode !== networkMode) {
existingOptions.networkAliases = undefined;
existingOptions.networkIpv4Address = undefined;
existingOptions.networkIpv6Address = undefined;
existingOptions.networkGwPriority = undefined;
existingOptions.macAddress = undefined;
}
// Merge user-provided options on top of existing options.
// Semantics (#1119):
// - key absent from `options` → preserve existingOptions[key]
// - key present with explicit `null` → CLEAR the field (treat as undefined)
// - key present with any other value → use the user's value
//
// Without the null-as-clear handling, clearing a Resources field in the UI
// (which sends null) would merge over the existing value identically to a
// preserved field — Memory limit, etc. would silently stick around.
const userOptions: Record<string, unknown> = {};
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
+22 -19
View File
@@ -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);
}
+56 -11
View File
@@ -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;
+26 -1
View File
@@ -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<AppSettings>(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 };
+77
View File
@@ -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<LogEntry> = {}
): { 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<LogEntry, string>();
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 <mark> 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, '<mark class="search-match">$1</mark>')))
.join('');
}
+4 -4
View File
@@ -910,11 +910,11 @@
</Badge>
</p>
</div>
<div>
<div class="min-w-0">
<label class="text-sm font-medium text-muted-foreground">Container name</label>
<p class="flex items-center gap-1">
<Box class="w-4 h-4 text-muted-foreground" />
{selectedEvent.containerName || '-'}
<p class="flex items-start gap-1">
<Box class="w-4 h-4 mt-0.5 shrink-0 text-muted-foreground" />
<span class="font-mono text-sm break-all min-w-0">{selectedEvent.containerName || '-'}</span>
</p>
</div>
<div>
@@ -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<string, string> = 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 });
@@ -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;
+19 -3
View File
@@ -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<GeneralSettings, 'scheduleRetentionDays' | 'eventRe
defaultGrypeArgs: '-o json -v {image}',
defaultTrivyArgs: 'image --format json {image}',
logBufferSizeKb: 500,
logMaxLines: 2000,
defaultTimezone: 'UTC',
eventCollectionMode: 'stream',
eventPollInterval: 60000,
@@ -177,6 +179,7 @@ export const GET: RequestHandler = async ({ cookies }) => {
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,
+17 -1
View File
@@ -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,
@@ -38,6 +38,23 @@
let loading = $state(true);
let error = $state('');
let containerData = $state<any>(null);
// Peer containers in the current env — used to resolve "container:<sha>" mode to a friendly name
let peerContainers = $state<Array<{ id: string; name: string }>>([]);
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<string | null>(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"
>
<Code class="w-4 h-4 mr-1.5" />
JSON
Inspect
</Button>
{/if}
</Dialog.Title>
@@ -623,7 +672,7 @@
<Tabs.Content value="overview" class="space-y-4 overflow-auto">
<!-- Real-time Stats (only for running containers) -->
{#if containerData.State?.Running}
<div class="grid grid-cols-2 lg:grid-cols-4 gap-3">
<div class="grid grid-cols-2 lg:grid-cols-5 gap-3">
<!-- CPU -->
<div class="p-3 border border-border rounded-lg">
<div class="flex items-center gap-2 mb-2">
@@ -709,6 +758,30 @@
</div>
</div>
</div>
<!-- Processes -->
<div class="p-3 border border-border rounded-lg">
<div class="flex items-center gap-2 mb-2">
<Activity class="w-4 h-4 text-pink-500" />
<span class="text-xs font-medium">Processes</span>
<button
type="button"
class="ml-auto text-sm font-bold hover:text-foreground/80 transition-colors"
onclick={() => activeTab = 'processes'}
title="View process list"
>
{processesData?.Processes?.length ?? '—'}
</button>
</div>
<div class="h-8 flex items-center justify-center text-2xs text-muted-foreground">
{#if processesData?.Processes?.length}
running in container
{:else if processesLoading}
<Loader2 class="w-3 h-3 animate-spin" />
{:else}
{/if}
</div>
</div>
</div>
{/if}
@@ -865,7 +938,7 @@
<!-- Network Mode -->
<div class="space-y-2">
<h3 class="text-sm font-semibold">Network mode</h3>
<Badge variant="outline">{containerData.HostConfig?.NetworkMode || 'default'}</Badge>
<Badge variant="outline">{networkModeLabel}</Badge>
</div>
<!-- DNS Settings -->
@@ -918,7 +991,11 @@
<!-- Networks -->
<div class="space-y-2">
<h3 class="text-sm font-semibold">Connected networks</h3>
{#if containerData.NetworkSettings?.Networks && Object.keys(containerData.NetworkSettings.Networks).length > 0}
{#if isSharedNetworkMode}
<p class="text-xs text-muted-foreground">
Network namespace is shared via <code class="px-1 py-0.5 rounded bg-muted">{containerData.HostConfig?.NetworkMode}</code> — additional networks cannot be attached.
</p>
{:else if containerData.NetworkSettings?.Networks && Object.keys(containerData.NetworkSettings.Networks).length > 0}
<div class="space-y-2">
{#each Object.entries(containerData.NetworkSettings.Networks) as [networkName, networkData]}
{@const netData = networkData as any}
@@ -985,7 +1062,7 @@
{/if}
<!-- Join network dropdown -->
{#if containerData.State?.Running}
{#if containerData.State?.Running && !isSharedNetworkMode}
<div class="flex items-center gap-2 pt-1">
<Select.Root type="single" bind:value={selectedNetwork}>
<Select.Trigger class="flex-1 h-8 text-xs">
@@ -1152,12 +1229,23 @@
<!-- Environment Tab -->
<Tabs.Content value="env" class="space-y-4 overflow-auto">
{#if containerData.divergence?.env?.length > 0}
<div class="flex items-start gap-2 text-xs p-2.5 rounded border border-amber-500/30 bg-amber-500/5 text-amber-700 dark:text-amber-300">
<Info class="w-3.5 h-3.5 shrink-0 mt-0.5" />
<div class="min-w-0">
{containerData.divergence.env.length} env var{containerData.divergence.env.length === 1 ? '' : 's'} differ from the image:
<span class="font-mono">{containerData.divergence.env.join(', ')}</span>.
Values set by you at create time will stay. To reset to the image's current values, Remove &amp; Deploy.
</div>
</div>
{/if}
{#if containerData.Config?.Env && containerData.Config.Env.length > 0}
<div class="space-y-1">
{#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('=')}
<div class="text-xs p-2 bg-muted rounded">
{@const diverges = containerData.divergence?.env?.includes(key)}
<div class="text-xs p-2 rounded {diverges ? 'bg-amber-500/10 border border-amber-500/30' : 'bg-muted'}">
<code class="text-muted-foreground font-medium">{key}</code>
<code class="text-muted-foreground">=</code>
<code class="break-all">{value}</code>
@@ -1170,31 +1258,79 @@
</Tabs.Content>
<!-- Labels Tab -->
<Tabs.Content value="labels" class="space-y-4 overflow-auto">
{#if containerData.Config?.Labels && Object.keys(containerData.Config.Labels).length > 0}
<div class="space-y-1">
{#each Object.entries(containerData.Config.Labels).sort((a, b) => a[0].localeCompare(b[0])) as [key, value]}
<div class="text-xs p-2 bg-muted rounded flex items-start gap-2 group">
<div class="flex-1 min-w-0">
<code class="text-muted-foreground font-medium">{key}</code>
<code class="text-muted-foreground">=</code>
<code class="break-all">{value}</code>
</div>
<button
type="button"
onclick={() => copyLabel(key, value)}
class="shrink-0 p-1 rounded hover:bg-background/50 transition-colors opacity-0 group-hover:opacity-100 {copiedLabel === key ? '!opacity-100' : ''}"
title={copiedLabel === key ? 'Copied!' : 'Copy label'}
>
{#if copiedLabel === key}
<Check class="w-3 h-3 text-green-500" />
{:else}
<Copy class="w-3 h-3 text-muted-foreground" />
{/if}
</button>
</div>
{/each}
<Tabs.Content value="labels" class="space-y-3 overflow-auto">
{#if containerData.divergence?.labels?.length > 0}
<div class="flex items-start gap-2 text-xs p-2.5 rounded border border-amber-500/30 bg-amber-500/5 text-amber-700 dark:text-amber-300">
<Info class="w-3.5 h-3.5 shrink-0 mt-0.5" />
<div class="min-w-0">
{containerData.divergence.labels.length} label{containerData.divergence.labels.length === 1 ? '' : 's'} differ from the image:
<span class="font-mono">{containerData.divergence.labels.join(', ')}</span>.
Values set by you at create time will stay. To reset to the image's current values, Remove &amp; Deploy.
</div>
</div>
{/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}
<div class="flex items-center gap-2">
<Input
type="search"
placeholder="Filter labels..."
bind:value={labelFilter}
class="h-8 text-xs flex-1"
/>
<span class="text-xs text-muted-foreground shrink-0">
{visibleLabels.length === allLabels.length
? `${allLabels.length} label${allLabels.length === 1 ? '' : 's'}`
: `${visibleLabels.length} of ${allLabels.length}`}
</span>
<Button
variant="outline"
size="sm"
onclick={() => copyAllLabels(visibleLabels)}
disabled={visibleLabels.length === 0}
title={copiedAllLabels ? 'Copied!' : 'Copy visible labels as key=value lines'}
>
{#if copiedAllLabels}
<Check class="w-3 h-3 mr-1.5 text-green-500" />
Copied
{:else}
<Copy class="w-3 h-3 mr-1.5" />
Copy all
{/if}
</Button>
</div>
{#if visibleLabels.length > 0}
<div class="space-y-1">
{#each visibleLabels as [key, value]}
{@const diverges = containerData.divergence?.labels?.includes(key)}
<div class="text-xs p-2 rounded flex items-start gap-2 group {diverges ? 'bg-amber-500/10 border border-amber-500/30' : 'bg-muted'}">
<div class="flex-1 min-w-0">
<code class="text-muted-foreground font-medium">{key}</code>
<code class="text-muted-foreground">=</code>
<code class="break-all">{value}</code>
</div>
<button
type="button"
onclick={() => copyLabel(key, value)}
class="shrink-0 p-1 rounded hover:bg-background/50 transition-colors opacity-0 group-hover:opacity-100 {copiedLabel === key ? '!opacity-100' : ''}"
title={copiedLabel === key ? 'Copied!' : 'Copy label'}
>
{#if copiedLabel === key}
<Check class="w-3 h-3 text-green-500" />
{:else}
<Copy class="w-3 h-3 text-muted-foreground" />
{/if}
</button>
</div>
{/each}
</div>
{:else}
<p class="text-sm text-muted-foreground">No labels match "{labelFilter}"</p>
{/if}
{:else}
<p class="text-sm text-muted-foreground">No labels</p>
{/if}
@@ -1532,13 +1668,13 @@
</Dialog.Content>
</Dialog.Root>
<!-- Raw JSON Modal -->
<!-- Inspect (raw) modal -->
<Dialog.Root bind:open={showRawJson}>
<Dialog.Content class="max-w-4xl max-h-[90vh] sm:max-h-[80vh] flex flex-col">
<Dialog.Header class="shrink-0">
<Dialog.Title class="flex items-center gap-2">
<Code class="w-5 h-5" />
Raw JSON
Inspect
<Button
variant="outline"
size="sm"
+281 -59
View File
@@ -1,11 +1,14 @@
<script lang="ts">
import * as Select from '$lib/components/ui/select';
import * as Popover from '$lib/components/ui/popover';
import * as Command from '$lib/components/ui/command';
import { tick } from 'svelte';
import { Label } from '$lib/components/ui/label';
import { Input } from '$lib/components/ui/input';
import { Button } from '$lib/components/ui/button';
import { Checkbox } from '$lib/components/ui/checkbox';
import { TogglePill, ToggleGroup } from '$lib/components/ui/toggle-pill';
import { Plus, Trash2, Settings2, RefreshCw, Network, X, Ban, RotateCw, AlertTriangle, PauseCircle, Share2, Server, CircleOff, ChevronDown, ChevronRight, Cpu, Shield, HeartPulse, Wifi, HardDrive, Lock, Loader2, CheckCircle2, Package, Gpu, Search, CircleHelp } from 'lucide-svelte';
import { Plus, Trash2, Settings2, RefreshCw, Network, X, Ban, RotateCw, AlertTriangle, PauseCircle, Share2, Server, CircleOff, Box, ChevronDown, ChevronsUpDown, Check, ChevronRight, Cpu, Shield, HeartPulse, Wifi, HardDrive, Lock, Loader2, CheckCircle2, Package, Gpu, Search, CircleHelp } from 'lucide-svelte';
import { parseHostPort, validatePort, validateIp, formatHostPort, expandPortBindings } from '$lib/utils/port-parse';
import * as Tooltip from '$lib/components/ui/tooltip';
import { currentEnvironment } from '$lib/stores/environment';
@@ -63,6 +66,13 @@
driver: string;
}
interface ContainerItem {
id: string;
name: string;
image: string;
state: string;
}
interface NetworkEndpointConfig {
ipv4Address: string;
ipv6Address: string;
@@ -89,7 +99,6 @@
// Labels
labels: { key: string; value: string }[];
// Networks
availableNetworks: DockerNetwork[];
selectedNetworks: string[];
networkConfigs: Record<string, NetworkEndpointConfig>;
macAddress: string;
@@ -166,7 +175,6 @@
volumeMappings = $bindable(),
envVars = $bindable(),
labels = $bindable(),
availableNetworks,
selectedNetworks = $bindable(),
networkConfigs = $bindable(),
macAddress = $bindable(),
@@ -208,6 +216,113 @@
imageSummary
}: Props = $props();
// Fetch networks and containers from current environment
let availableNetworks = $state<DockerNetwork[]>([]);
let availableContainers = $state<ContainerItem[]>([]);
// Container picker (Popover + Command combobox)
let containerPickerOpen = $state(false);
let containerPickerTriggerRef = $state<HTMLButtonElement>(null!);
function closeAndFocusContainerPicker() {
containerPickerOpen = false;
tick().then(() => containerPickerTriggerRef?.focus());
}
// Networks picker (Popover + Command combobox)
let networkPickerOpen = $state(false);
let networkPickerTriggerRef = $state<HTMLButtonElement>(null!);
function closeAndFocusNetworkPicker() {
networkPickerOpen = false;
tick().then(() => networkPickerTriggerRef?.focus());
}
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 fetchContainers() {
try {
const envParam = $currentEnvironment ? `?env=${$currentEnvironment.id}` : '';
const response = await fetch(`/api/containers${envParam}`);
if (response.ok) {
const containers: any[] = await response.json();
availableContainers = containers
.map(c => ({
id: c.id,
name: c.name,
image: c.image,
state: c.state
}))
.filter(c => c.name && c.name !== name);
}
} catch (err) {
console.error('Failed to fetch containers:', err);
}
}
// Fetch both on mount so the dropdowns are ready when the user opens them
fetchNetworks();
fetchContainers();
// Container network mode helpers
// `networkModeType` reduces the raw NetworkMode to a logical group for branching:
// bridge | host | none | container | custom
const networkModeType = $derived.by(() => {
if (networkMode.startsWith('container:')) return 'container';
if (['bridge', 'host', 'none'].includes(networkMode)) return networkMode;
return 'custom';
});
// Raw value from networkMode — Docker stores either a name or a 64-char container ID
const containerRefRaw = $derived(networkMode.startsWith('container:') ? networkMode.slice('container:'.length) : '');
// Resolve ID → name when possible so the trigger shows "container:redis" not "container:<sha>"
const containerRef = $derived.by(() => {
if (!containerRefRaw) return '';
const match = availableContainers.find(c => c.id === containerRefRaw || c.id.startsWith(containerRefRaw));
return match ? match.name : containerRefRaw;
});
// Network mode picker (Popover + Command combobox) — flat list of bridge/host/none/Container + custom networks
let networkModePickerOpen = $state(false);
let networkModePickerTriggerRef = $state<HTMLButtonElement>(null!);
function closeAndFocusNetworkModePicker() {
networkModePickerOpen = false;
tick().then(() => networkModePickerTriggerRef?.focus());
}
// Additional networks: custom networks NOT used as the primary mode and NOT already attached
const selectableNetworks = $derived(
availableNetworks.filter(n =>
!selectedNetworks.includes(n.name) &&
!['bridge', 'host', 'none'].includes(n.name) &&
n.name !== networkMode // exclude the primary
)
);
// Custom networks available for the primary mode picker
const customNetworks = $derived(
availableNetworks.filter(n => !['bridge', 'host', 'none'].includes(n.name))
);
// Display label for the current network mode in the trigger
const networkModeLabel = $derived.by(() => {
if (networkModeType === 'bridge') return 'Bridge';
if (networkModeType === 'host') return 'Host';
if (networkModeType === 'none') return 'None';
if (networkModeType === 'container') return containerRef ? `Container: ${containerRef}` : 'Container';
return networkMode; // custom network name
});
// Expanded network config rows
let expandedNetworks = $state<Set<string>>(new Set());
@@ -718,41 +833,121 @@
</div>
<div class="space-y-1.5">
<Label class="text-xs font-medium">Network mode</Label>
<Select.Root type="single" bind:value={networkMode}>
<Select.Trigger id="networkMode" tabindex={0} class="w-full h-9">
<span class="flex items-center">
{#if networkMode === 'bridge'}
<Share2 class="w-3.5 h-3.5 mr-2 text-emerald-500" />
{:else if networkMode === 'host'}
<Server class="w-3.5 h-3.5 mr-2 text-sky-500" />
{:else}
<CircleOff class="w-3.5 h-3.5 mr-2 text-muted-foreground" />
{/if}
{networkMode === 'bridge' ? 'Bridge' : networkMode === 'host' ? 'Host' : 'None'}
</span>
</Select.Trigger>
<Select.Content>
<Select.Item value="bridge">
{#snippet children()}
<Share2 class="w-3.5 h-3.5 mr-2 text-emerald-500" />
Bridge
<Label class="text-xs font-medium">Network</Label>
<Popover.Root bind:open={networkModePickerOpen}>
<Popover.Trigger bind:ref={networkModePickerTriggerRef}>
{#snippet child({ props })}
<Button
{...props}
variant="outline"
class="w-full justify-between font-normal"
role="combobox"
aria-expanded={networkModePickerOpen}
>
<span class="flex items-center min-w-0 flex-1">
{#if networkModeType === 'bridge'}
<Share2 class="w-3.5 h-3.5 mr-2 shrink-0 text-emerald-500" />
{:else if networkModeType === 'host'}
<Server class="w-3.5 h-3.5 mr-2 shrink-0 text-sky-500" />
{:else if networkModeType === 'none'}
<CircleOff class="w-3.5 h-3.5 mr-2 shrink-0 text-muted-foreground" />
{:else if networkModeType === 'container'}
<Box class="w-3.5 h-3.5 mr-2 shrink-0 text-violet-500" />
{:else}
<Network class="w-3.5 h-3.5 mr-2 shrink-0 text-orange-500" />
{/if}
<span class="truncate">{networkModeLabel}</span>
</span>
<ChevronsUpDown class="w-4 h-4 shrink-0 opacity-50" />
</Button>
{/snippet}
</Popover.Trigger>
<Popover.Content class="w-[var(--bits-popover-anchor-width)] p-0" align="start">
<Command.Root>
<Command.Input placeholder="Filter networks..." />
<Command.List class="max-h-64">
<Command.Empty>No networks found.</Command.Empty>
<Command.Group>
<Command.Item value="bridge" onSelect={() => { networkMode = 'bridge'; closeAndFocusNetworkModePicker(); }}>
<Share2 class="text-emerald-500" />
<span>Bridge</span>
</Command.Item>
<Command.Item value="host" onSelect={() => { networkMode = 'host'; closeAndFocusNetworkModePicker(); }}>
<Server class="text-sky-500" />
<span>Host</span>
</Command.Item>
<Command.Item value="none" onSelect={() => { networkMode = 'none'; closeAndFocusNetworkModePicker(); }}>
<CircleOff class="text-muted-foreground" />
<span>None</span>
</Command.Item>
<Command.Item value="container" onSelect={() => { if (!networkMode.startsWith('container:')) networkMode = 'container:'; closeAndFocusNetworkModePicker(); }}>
<Box class="text-violet-500" />
<span>Container</span>
</Command.Item>
</Command.Group>
{#if customNetworks.length > 0}
<Command.Separator />
<Command.Group heading="Custom networks">
{#each customNetworks as n (n.name)}
<Command.Item value={n.name} onSelect={() => { networkMode = n.name; closeAndFocusNetworkModePicker(); }}>
<Network class="text-orange-500" />
<span class="font-medium">{n.name}</span>
<span class="{getDriverBadgeClasses(n.driver)} ml-auto">{n.driver}</span>
</Command.Item>
{/each}
</Command.Group>
{/if}
</Command.List>
</Command.Root>
</Popover.Content>
</Popover.Root>
{#if networkModeType === 'container'}
<Popover.Root bind:open={containerPickerOpen}>
<Popover.Trigger bind:ref={containerPickerTriggerRef}>
{#snippet child({ props })}
<Button
{...props}
variant="outline"
class="w-full mt-2 justify-between font-normal"
role="combobox"
aria-expanded={containerPickerOpen}
>
<span class="truncate min-w-0 flex-1 text-left {containerRef ? '' : 'text-muted-foreground'}">
{containerRef || 'Select a container...'}
</span>
<ChevronsUpDown class="w-4 h-4 shrink-0 opacity-50" />
</Button>
{/snippet}
</Select.Item>
<Select.Item value="host">
{#snippet children()}
<Server class="w-3.5 h-3.5 mr-2 text-sky-500" />
Host
{/snippet}
</Select.Item>
<Select.Item value="none">
{#snippet children()}
<CircleOff class="w-3.5 h-3.5 mr-2 text-muted-foreground" />
None
{/snippet}
</Select.Item>
</Select.Content>
</Select.Root>
</Popover.Trigger>
<Popover.Content class="w-[var(--bits-popover-anchor-width)] p-0" align="start">
<Command.Root>
<Command.Input placeholder="Filter by name..." />
<Command.List class="max-h-64">
<Command.Empty>No containers found.</Command.Empty>
<Command.Group>
{#each availableContainers as c (c.id)}
<Command.Item
value={c.name}
onSelect={() => {
networkMode = `container:${c.name}`;
closeAndFocusContainerPicker();
}}
>
<Check class={containerRef !== c.name ? 'text-transparent' : ''} />
<span class="w-1.5 h-1.5 shrink-0 rounded-full {c.state === 'running' ? 'bg-green-500' : 'bg-muted-foreground/40'}"></span>
<span class="font-medium">{c.name}</span>
<span class="text-muted-foreground text-xs ml-auto truncate">{c.image}</span>
</Command.Item>
{/each}
</Command.Group>
</Command.List>
</Command.Root>
</Popover.Content>
</Popover.Root>
{#if !containerRef}
<p class="text-xs text-amber-600 mt-1">Select a container to share its network namespace</p>
{/if}
{/if}
</div>
</div>
@@ -767,38 +962,65 @@
</div>
</div>
<!-- Networks -->
{#if availableNetworks.length > 0}
<!-- Additional networks (hidden for host/none/container:X modes — Docker rejects extras) -->
{#if availableNetworks.length > 0 && networkModeType !== 'host' && networkModeType !== 'none' && networkModeType !== 'container'}
<div class="space-y-2">
<div class="flex justify-between items-center pb-2 border-b">
<div class="flex items-center gap-2">
<Network class="w-4 h-4 text-muted-foreground" />
<h3 class="text-sm font-semibold text-foreground">Networks</h3>
<h3 class="text-sm font-semibold text-foreground">Additional networks</h3>
</div>
</div>
<div class="space-y-2">
<Select.Root type="single" value="" onValueChange={addNetwork}>
<Select.Trigger tabindex={0} class="w-full h-9">
<span class="text-muted-foreground">Select network to add...</span>
</Select.Trigger>
<Select.Content>
{#each availableNetworks.filter(n => !selectedNetworks.includes(n.name) && !['bridge', 'host', 'none'].includes(n.name)) as network}
<Select.Item value={network.name}>
{#snippet children()}
<div class="flex items-center justify-between w-full">
<span>{network.name}</span>
<span class={getDriverBadgeClasses(network.driver)}>{network.driver}</span>
</div>
{/snippet}
</Select.Item>
{/each}
</Select.Content>
</Select.Root>
{#if selectableNetworks.length === 0}
<Button variant="outline" disabled class="w-full justify-start font-normal text-muted-foreground">
All networks already attached
</Button>
{:else}
<Popover.Root bind:open={networkPickerOpen}>
<Popover.Trigger bind:ref={networkPickerTriggerRef}>
{#snippet child({ props })}
<Button
{...props}
variant="outline"
class="w-full justify-between font-normal"
role="combobox"
aria-expanded={networkPickerOpen}
>
<span class="text-muted-foreground">Select network to add...</span>
<ChevronsUpDown class="w-4 h-4 opacity-50" />
</Button>
{/snippet}
</Popover.Trigger>
<Popover.Content class="w-[var(--bits-popover-anchor-width)] p-0" align="start">
<Command.Root>
<Command.Input placeholder="Filter networks..." />
<Command.List class="max-h-64">
<Command.Empty>No networks found.</Command.Empty>
<Command.Group>
{#each selectableNetworks as network (network.name)}
<Command.Item
value={network.name}
onSelect={() => {
addNetwork(network.name);
closeAndFocusNetworkPicker();
}}
>
<span class="font-medium">{network.name}</span>
<span class="{getDriverBadgeClasses(network.driver)} ml-auto">{network.driver}</span>
</Command.Item>
{/each}
</Command.Group>
</Command.List>
</Command.Root>
</Popover.Content>
</Popover.Root>
{/if}
{#if selectedNetworks.length > 0}
{#if selectedNetworks.filter(n => n !== networkMode).length > 0}
<div class="space-y-1 pt-1">
{#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)}
<div class="border rounded-md">
@@ -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<DockerNetwork[]>([]);
let selectedNetworks = $state<string[]>([]);
let networkConfigs = $state<Record<string, { ipv4Address: string; ipv6Address: string; aliases: string }>>({});
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
+41 -49
View File
@@ -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<DockerNetwork[]>([]);
let selectedNetworks = $state<string[]>([]);
let networkConfigs = $state<Record<string, { ipv4Address: string; ipv6Address: string; aliases: string }>>({});
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:<id|name> → shared namespace
// - <custom-network-name> → 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<string, { ipv4Address: string; ipv6Address: string; aliases: string }> = {};
@@ -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
+1 -1
View File
@@ -729,7 +729,7 @@
/>
</div>
<Select.Root type="single" bind:value={usageFilter}>
<Select.Trigger size="sm" class="w-28 text-sm">
<Select.Trigger size="sm" class="w-36 text-sm">
{#if usageFilter === 'all'}
<Filter class="w-3.5 h-3.5 mr-1.5 text-muted-foreground shrink-0" />
<span class="text-muted-foreground">All</span>
+219 -259
View File
@@ -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<ContainerInfo[]>([]);
let selectedContainer = $state<ContainerInfo | null>(null);
let logs = $state('');
let logs = $state<LogEntry[]>([]);
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<Set<string>>(new Set());
let groupedContainerInfo = $state<Map<string, { name: string; color: string }>>(new Map());
let mergedLogs = $state<Array<{ containerId: string; containerName: string; color: string; text: string; timestamp?: string; stream?: string }>>([]);
let mergedHtml = $state(''); // Pre-built HTML string for fast rendering (like single mode's `logs`)
let mergedLogs = $state<LogEntry[]>([]);
const groupedCarryover = new Map<string, string>(); // containerId → leftover
let stackName = $state<string | null>(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<typeof setTimeout> | 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<typeof setTimeout> | 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<typeof setTimeout> | 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 += `<span style="color:${log.color};font-weight:600">[${escapeHtml(log.containerName)}]</span> ${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 += `<span style="color:${log.color};font-weight:600">[${escapeHtml(log.containerName)}]</span> ${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<Set<string>>(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 += `<span style="color:${color};font-weight:600">[${escapeHtml(containerName)}]</span> ${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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;');
// 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, '<mark class="search-match">$1</mark>');
}).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 <br/> 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, '<mark class="search-match">$1</mark>');
}).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();
});
</script>
@@ -2005,7 +1959,7 @@ import type { FavoriteGroup } from '../api/preferences/favorite-groups/+server';
{#if multiModeSelections.size >= 2}
<Button size="sm" variant="default" onclick={mergeSelectedContainers} class="w-full h-7 gap-1.5 text-xs">
<Layers class="w-3 h-3" />
Merge {multiModeSelections.size} containers
Merge {multiModeSelections.size} containers logs
</Button>
{/if}
</div>
@@ -2206,7 +2160,7 @@ import type { FavoriteGroup } from '../api/preferences/favorite-groups/+server';
</button>
</div>
</div>
<div class="flex-1 overflow-auto p-4 relative" bind:this={logsRef}>
<div class="flex-1 overflow-auto p-4 relative" bind:this={logsRef} onscroll={handleLogsScroll}>
{#if loading && mergedLogs.length === 0}
<div class="absolute inset-0 flex items-center justify-center z-10">
<RefreshCw class="w-8 h-8 animate-spin {darkMode ? 'text-zinc-400' : 'text-gray-500'}" />
@@ -2216,7 +2170,7 @@ import type { FavoriteGroup } from '../api/preferences/favorite-groups/+server';
<RefreshCw class="w-8 h-8 animate-spin {darkMode ? 'text-zinc-400' : 'text-gray-500'}" />
</div>
{/if}
<pre class="font-mono {wordWrap ? 'whitespace-pre-wrap' : 'whitespace-pre'} {showLineNumbers ? 'show-line-numbers' : ''} {darkMode ? 'text-zinc-50' : 'text-gray-900'}" style="font-size: {fontSize}px;">{@html wrapHtmlLines(formattedMergedHtml())}</pre>
<pre class="font-mono {wordWrap ? 'whitespace-pre-wrap' : 'whitespace-pre'} {showLineNumbers ? 'show-line-numbers' : ''} {darkMode ? 'text-zinc-50' : 'text-gray-900'}" style="font-size: {fontSize}px;">{#each filteredMerged as e (e.id)}<div class="log-line">{#if showTimestamps && e.timestamp}<span class="log-ts">{renderTimestamp(e.timestamp)}</span>{' '}{/if}{#if showContainerName && e.containerName}<span class="log-cname" style="color:{e.color}">[{e.containerName}]</span>{' '}{/if}<span>{@html renderLineHtml(e, logSearchQuery.trim())}</span></div>{/each}</pre>
</div>
{/if}
{:else if !selectedContainer}
@@ -2475,8 +2429,8 @@ import type { FavoriteGroup } from '../api/preferences/favorite-groups/+server';
Loading logs...
</div>
{:else}
<div bind:this={logsRef} class="flex-1 overflow-auto p-4">
<pre class="font-mono {wordWrap ? 'whitespace-pre-wrap' : 'whitespace-pre'} {showLineNumbers ? 'show-line-numbers' : ''} {darkMode ? 'text-zinc-50' : 'text-gray-900'}" style="font-size: {fontSize}px;">{@html wrapHtmlLines(highlightedLogs())}</pre>
<div bind:this={logsRef} onscroll={handleLogsScroll} class="flex-1 overflow-auto p-4">
<pre class="font-mono {wordWrap ? 'whitespace-pre-wrap' : 'whitespace-pre'} {showLineNumbers ? 'show-line-numbers' : ''} {darkMode ? 'text-zinc-50' : 'text-gray-900'}" style="font-size: {fontSize}px;">{#each filteredLogs as e (e.id)}<div class="log-line">{#if showTimestamps && e.timestamp}<span class="log-ts">{renderTimestamp(e.timestamp)}</span>{' '}{/if}{#if showContainerName && selectedContainer?.name}<span class="log-cname">[{selectedContainer.name}]</span>{' '}{/if}<span>{@html renderLineHtml(e, logSearchQuery.trim())}</span></div>{/each}</pre>
</div>
{/if}
{/if}
@@ -2511,6 +2465,12 @@ import type { FavoriteGroup } from '../api/preferences/favorite-groups/+server';
{/if}
<style>
:global(.log-ts) {
color: rgb(113, 113, 122);
}
:global(.log-cname) {
font-weight: 600;
}
:global(.search-match) {
background-color: rgba(234, 179, 8, 0.4);
color: #fef3c7;
+140 -104
View File
@@ -1,16 +1,13 @@
<script lang="ts">
import { onMount, onDestroy, tick } from 'svelte';
import { X, GripHorizontal, RefreshCw, Copy, Download, WrapText, ArrowDownToLine, Search, ChevronUp, ChevronDown, Sun, Moon, Wifi, WifiOff, Pause, Play, Eraser, Filter, 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 * as Select from '$lib/components/ui/select';
import { appSettings, formatLogTimestamps } from '$lib/stores/settings';
import { themeStore } from '$lib/stores/theme';
import { getMonospaceFont } from '$lib/themes';
import { AnsiUp } from 'ansi_up';
const ansiUp = new AnsiUp();
ansiUp.use_classes = true;
import { parseLines, renderLineHtml, type LogEntry } from '$lib/utils/log-entry';
interface Props {
containerId: string;
@@ -24,7 +21,7 @@
let { containerId, containerName, visible, envId, fillHeight = false, showCloseButton = true, onClose }: Props = $props();
let logs = $state('');
let logs = $state<LogEntry[]>([]);
let loading = $state(false);
let logsRef: HTMLDivElement;
let panelRef: HTMLDivElement;
@@ -35,6 +32,14 @@
let showContainerName = $state(typeof localStorage !== 'undefined' ? localStorage.getItem('dockhand-log-container-name') !== 'false' : true);
let showLineNumbers = $state(false);
function renderTimestamp(ts: string | undefined): string {
if (!ts) return '';
if ($appSettings.formatLogTimestamps) {
return formatLogTimestamps(ts + ' ').trimEnd();
}
return ts;
}
// SSE Streaming state
let streamingEnabled = $state(true);
let isConnected = $state(false);
@@ -47,10 +52,13 @@
const OFFLINE_POLL_INTERVAL = 5000; // Check every 5 seconds when offline
let offlinePollingInterval: ReturnType<typeof setInterval> | null = null;
// SSE batching - buffer incoming text and flush to state periodically
let pendingText = '';
let flushTimer: ReturnType<typeof setTimeout> | null = null;
const FLUSH_INTERVAL = 100; // ms
// SSE batching — collect parsed entries (and a carryover for split-across-chunk
// lines) and flush via microtask to coalesce a burst of "log" events into one
// state update. Compaction (slice to last MAX_LINES) runs at 2x cap.
let pendingEntries: LogEntry[] = [];
let streamCarryover = '';
let flushScheduled = false;
const COMPACT_FACTOR = 2;
// RAF-based auto-scroll
let scrollRafPending = false;
@@ -64,14 +72,14 @@
let logSearchInputRef: HTMLInputElement | undefined;
const fontSizeOptions = [10, 12, 14, 16];
// Capped at 1000 — larger initial replays freeze the browser since rendering
// isn't virtualized.
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));
// Tail count and time range filter
let tailCount = $state('500');
@@ -95,8 +103,9 @@
}
function reloadLogs() {
logs = '';
pendingText = '';
logs = [];
pendingEntries = [];
streamCarryover = '';
if (streamingEnabled && containerId && visible) {
loading = true;
startStreaming();
@@ -144,7 +153,10 @@
if (settings.autoScroll !== undefined) autoScroll = settings.autoScroll;
if (settings.streamingEnabled !== undefined) streamingEnabled = settings.streamingEnabled;
if (settings.logSearchFilterMode !== undefined) logSearchFilterMode = settings.logSearchFilterMode;
if (settings.tailCount !== undefined) tailCount = settings.tailCount;
// Old saved value may be '5000'/'10000'/'all' — snap down to a supported option.
if (settings.tailCount !== undefined) {
tailCount = VALID_TAIL_VALUES.has(settings.tailCount) ? settings.tailCount : '1000';
}
if (settings.showLineNumbers !== undefined) showLineNumbers = settings.showLineNumbers;
} catch {
// Ignore parse errors
@@ -209,37 +221,74 @@
return `${url}${separator}env=${envId}`;
}
// Flush buffered text to state
// Schedule a microtask flush — coalesces multiple SSE log events arriving in
// the same tick. queueMicrotask is preferred over setTimeout(0) because it
// runs before the next paint, so autoscroll lands in the same frame.
function scheduleFlush() {
if (flushScheduled) return;
flushScheduled = true;
queueMicrotask(flushLogs);
}
function flushLogs() {
if (flushTimer) {
clearTimeout(flushTimer);
flushTimer = null;
}
if (!pendingText) return;
logs += pendingText;
pendingText = '';
// Apply log buffer size limit (convert KB to characters, roughly 1 char = 1 byte)
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 = $appSettings.logMaxLines;
// If the incoming batch alone exceeds the cap (initial tail replay with
// a big `tail=` value), trim it BEFORE concatenating. Otherwise we'd
// briefly grow the buffer to logs.length + pendingEntries.length entries
// — for tail=5000 that means 5000 DOM nodes appear in one frame.
const incoming = pendingEntries.length > maxLines
? pendingEntries.slice(pendingEntries.length - maxLines)
: pendingEntries;
// 2x soft cap on the retained buffer to amortize slice cost across flushes.
const next = logs.length + incoming.length <= maxLines * COMPACT_FACTOR
? [...logs, ...incoming]
: [...logs.slice(Math.max(0, logs.length + incoming.length - maxLines)), ...incoming];
logs = next;
pendingEntries = [];
scrollToBottom();
}
// RAF-based scroll to bottom (coalesces multiple calls into one frame)
// Threshold (px) for "still at the bottom". Wheel events and momentum scrolling
// can land a few px off — keep it generous enough to feel sticky.
const BOTTOM_STICKINESS_PX = 40;
// Suppress the scroll listener while WE are writing scrollTop. Without this,
// our own programmatic scroll fires the handler, sees distanceFromBottom≈0,
// and re-enables autoScroll the moment the user paused it.
let programmaticScroll = false;
async function scrollToBottom() {
if (!autoScroll || !logsRef || scrollRafPending) return;
scrollRafPending = true;
await tick();
requestAnimationFrame(() => {
if (logsRef) logsRef.scrollTop = logsRef.scrollHeight;
if (logsRef) {
programmaticScroll = true;
logsRef.scrollTop = logsRef.scrollHeight;
// Clear the flag on the next frame so user-initiated scroll after
// our write is treated as user input.
requestAnimationFrame(() => { programmaticScroll = false; });
}
scrollRafPending = false;
});
}
// Auto-disable auto-scroll when the user scrolls up; re-enable when they
// return to the bottom. Lets the user read history without fighting the stream.
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;
saveSettings();
} else if (!atBottom && autoScroll) {
autoScroll = false;
saveSettings();
}
}
// Start SSE streaming for logs
function startStreaming() {
if (!containerId || !streamingEnabled) return;
@@ -266,20 +315,14 @@
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');
}
// Buffer text and schedule flush
pendingText += text;
if (!flushTimer) {
flushTimer = setTimeout(flushLogs, FLUSH_INTERVAL);
// Parse incoming text into discrete LogEntry items. A streaming chunk
// may begin/end mid-line; streamCarryover preserves the partial tail
// across chunks so we don't split a line in the middle.
const { entries, carryover } = parseLines(data.text, streamCarryover);
streamCarryover = carryover;
if (entries.length > 0) {
pendingEntries.push(...entries);
scheduleFlush();
}
}
} catch {
@@ -370,7 +413,8 @@
function retryConnection() {
reconnectAttempts = 0;
connectionError = null;
logs = '';
logs = [];
streamCarryover = '';
loading = true;
startStreaming();
}
@@ -422,7 +466,8 @@
streamingEnabled = !streamingEnabled;
saveSettings();
if (streamingEnabled && containerId && visible) {
logs = ''; // Clear logs and start fresh stream
logs = [];
streamCarryover = '';
reconnectAttempts = 0;
connectionError = null;
loading = true;
@@ -455,7 +500,8 @@
}
}
// Fallback fetch logs (for manual refresh or when streaming unavailable)
// Errors aren't log content — surface them via connectionError so the panel
// can show them outside the LogEntry stream.
async function fetchLogs() {
if (!containerId) return;
loading = true;
@@ -466,14 +512,17 @@
const response = await fetch(appendEnvParam(`/api/containers/${containerId}/logs?tail=${tailCount}${since ? `&since=${since}` : ''}${until ? `&until=${until}` : ''}`, envId));
const data = await response.json();
if (!response.ok) {
logs = `Failed to fetch logs: ${data.error || response.statusText}`;
connectionError = `Failed to fetch logs: ${data.error || response.statusText}`;
logs = [];
return;
}
logs = data.logs || '';
const { entries } = parseLines(data.logs || '');
logs = entries;
scrollToBottom();
} catch (error) {
console.error('Failed to fetch logs:', error);
logs = `Failed to fetch logs: ${error instanceof Error ? error.message : 'Unknown error'}`;
connectionError = `Failed to fetch logs: ${error instanceof Error ? error.message : 'Unknown error'}`;
logs = [];
} finally {
loading = false;
}
@@ -481,7 +530,8 @@
function handleClose() {
stopStreaming();
logs = '';
logs = [];
streamCarryover = '';
onClose();
}
@@ -503,17 +553,23 @@
saveSettings();
}
// Copy logs to clipboard
// Serialize the current buffer to plain text. Honors the timestamp toggle so
// users get what they see on screen.
function logsToText(): string {
return logs
.map(e => (showTimestamps && e.timestamp ? `${e.timestamp} ${e.text}` : e.text))
.join('\n');
}
async function copyLogs() {
if (logs) {
await copyToClipboard(logs);
if (logs.length > 0) {
await copyToClipboard(logsToText());
}
}
// Download logs as txt file
function downloadLogs() {
if (logs && containerName) {
const blob = new Blob([logs], { type: 'text/plain' });
if (logs.length > 0 && containerName) {
const blob = new Blob([logsToText()], { type: 'text/plain' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
@@ -527,8 +583,9 @@
// Clear logs buffer
function clearLogs() {
logs = '';
pendingText = '';
logs = [];
pendingEntries = [];
streamCarryover = '';
}
// Search functions
@@ -590,47 +647,19 @@
}
}
// Highlighted logs with search matches and ANSI color support
let highlightedLogs = $derived(() => {
let text = logs || '';
if (!showTimestamps) {
// Strip ISO 8601 timestamps from start of each line (Docker log format)
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);
}
// Filter pass — array op, not regex over a string. Empty query short-circuits.
let filteredLogs = $derived.by(() => {
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');
const lines = text.split('\n');
text = lines.filter(line => filterRegex.test(line)).join('\n');
}
const withAnsi = ansiUp.ansi_to_html(text);
if (!query) return withAnsi;
const escapedForRegex = query.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
const escapedQuery = escapedForRegex.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
// Split by HTML tags and only process text parts
const parts = withAnsi.split(/(<[^>]*>)/);
const highlighted = parts.map(part => {
if (part.startsWith('<')) return part;
const regex = new RegExp(`(${escapedQuery})`, 'gi');
return part.replace(regex, '<mark class="search-match">$1</mark>');
}).join('');
return highlighted;
if (!logSearchFilterMode || !query) return logs;
const q = query.toLowerCase();
return logs.filter(e => e.text.toLowerCase().includes(q));
});
// Update match count after render
// Update match count after render. Track filteredLogs + query so this re-runs
// when the displayed buffer or the search input changes.
$effect(() => {
const html = highlightedLogs();
filteredLogs;
logSearchQuery;
if (logSearchQuery && logsRef) {
setTimeout(() => {
const matches = logsRef.querySelectorAll('.search-match');
@@ -650,7 +679,8 @@
// Start streaming when container changes and is visible
$effect(() => {
if (containerId && visible && streamingEnabled) {
logs = ''; // Clear previous logs
logs = [];
streamCarryover = '';
loading = true;
reconnectAttempts = 0;
connectionError = null;
@@ -932,9 +962,9 @@
</div>
<!-- Logs content -->
<div bind:this={logsRef} class="flex-1 overflow-auto p-3">
{#if logs}
<pre class="logs-fade-in {wordWrap ? 'whitespace-pre-wrap' : 'whitespace-pre'} {showLineNumbers ? 'show-line-numbers' : ''} {darkMode ? 'text-zinc-50' : 'text-gray-900'}" style="font-size: {fontSize}px; font-family: {terminalFontFamily()};">{@html wrapHtmlLines(highlightedLogs())}</pre>
<div bind:this={logsRef} onscroll={handleLogsScroll} class="flex-1 overflow-auto p-3">
{#if logs.length > 0}
<pre class="logs-fade-in {wordWrap ? 'whitespace-pre-wrap' : 'whitespace-pre'} {showLineNumbers ? 'show-line-numbers' : ''} {darkMode ? 'text-zinc-50' : 'text-gray-900'}" style="font-size: {fontSize}px; font-family: {terminalFontFamily()};">{#each filteredLogs as e (e.id)}<div class="log-line">{#if showTimestamps && e.timestamp}<span class="log-ts">{renderTimestamp(e.timestamp)}</span>{' '}{/if}{#if showContainerName && containerName}<span class="log-cname">[{containerName}]</span>{' '}{/if}<span>{@html renderLineHtml(e, logSearchQuery.trim())}</span></div>{/each}</pre>
{:else if loading}
<p class="text-xs {darkMode ? 'text-zinc-500' : 'text-gray-500'}">Connecting to log stream...</p>
{:else}
@@ -944,6 +974,12 @@
</div>
<style>
:global(.log-ts) {
color: rgb(113, 113, 122);
}
:global(.log-cname) {
color: rgb(161, 161, 170);
}
:global(.search-match) {
background-color: rgba(234, 179, 8, 0.4);
color: #fef3c7;
+29 -25
View File
@@ -77,7 +77,7 @@ services:
let eventCleanupEnabled = $derived($appSettings.eventCleanupEnabled);
let scannerCleanupCron = $derived($appSettings.scannerCleanupCron);
let scannerCleanupEnabled = $derived($appSettings.scannerCleanupEnabled);
let logBufferSizeKb = $derived($appSettings.logBufferSizeKb);
let logMaxLines = $derived($appSettings.logMaxLines);
let formatLogTimestamps = $derived($appSettings.formatLogTimestamps);
let defaultTimezone = $derived($appSettings.defaultTimezone);
let eventCollectionMode = $derived($appSettings.eventCollectionMode);
@@ -192,9 +192,17 @@ services:
}
}
function handleLogBufferSizeChange(e: Event) {
const value = Math.max(100, Math.min(5000, parseInt((e.target as HTMLInputElement).value) || 500));
appSettings.setLogBufferSizeKb(value);
// Anything above 2K starts feeling laggy in browsers without virtualized rendering.
const logMaxLinesOptions = [
{ value: '500', label: '500 lines' },
{ value: '1000', label: '1,000 lines' },
{ value: '2000', label: '2,000 lines' }
];
function handleLogMaxLinesChange(value: string | undefined) {
const n = parseInt(value ?? '');
if (!Number.isFinite(n) || n <= 0) return;
appSettings.setLogMaxLines(Math.min(2000, Math.max(100, n)));
toast.success('Log buffer size updated');
}
@@ -439,27 +447,23 @@ services:
<div class="grid grid-cols-1 md:grid-cols-2 gap-x-6 gap-y-4">
<div class="space-y-4">
<div class="space-y-2">
<Label for="log-buffer-size">Log buffer size (KB)</Label>
<div class="flex items-center gap-2">
<Input
id="log-buffer-size"
type="number"
min="100"
max="5000"
value={logBufferSizeKb}
onchange={handleLogBufferSizeChange}
disabled={!$canAccess('settings', 'edit')}
class="w-24"
/>
<span class="text-sm text-muted-foreground">KB</span>
</div>
<p class="text-xs text-muted-foreground">Maximum log buffer per container panel. Older logs are truncated when exceeded.</p>
{#if logBufferSizeKb > 1000}
<div class="flex items-start gap-2 p-2 rounded-md bg-amber-500/10 border border-amber-500/20">
<AlertTriangle class="w-4 h-4 text-amber-500 shrink-0 mt-0.5" />
<p class="text-xs text-amber-600 dark:text-amber-400">High values may degrade browser performance with verbose containers. Recommended: 250-1000 KB.</p>
</div>
{/if}
<Label for="log-max-lines">Log buffer size</Label>
<Select.Root
type="single"
value={String(logMaxLines)}
onValueChange={handleLogMaxLinesChange}
disabled={!$canAccess('settings', 'edit')}
>
<Select.Trigger id="log-max-lines" class="w-48">
{logMaxLines.toLocaleString()} lines
</Select.Trigger>
<Select.Content>
{#each logMaxLinesOptions as opt}
<Select.Item value={opt.value}>{opt.label}</Select.Item>
{/each}
</Select.Content>
</Select.Root>
<p class="text-xs text-muted-foreground">Maximum number of log lines kept per container panel. Older lines are dropped when the limit is exceeded.</p>
</div>
<div class="space-y-1">
<div class="flex items-center gap-3">