1.0.32
This commit is contained in:
26 files changed
+1367
-765
No files matched your search
@@ -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",
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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;
|
||||
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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('');
|
||||
}
|
||||
Reference in new issue
Block a user