mirror of
https://github.com/Finsys/dockhand.git
synced 2026-06-17 19:09:33 +03:00
1.0.32
This commit is contained in:
+2
-2
@@ -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
@@ -1,3 +1,3 @@
|
||||
module github.com/Finsys/dockhand/collector
|
||||
|
||||
go 1.25.10
|
||||
go 1.25.11
|
||||
|
||||
+1
-1
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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('');
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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
@@ -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 & 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 & 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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>');
|
||||
// Filter pass — array op over LogEntry[], not regex over a string.
|
||||
function filterByQuery(arr: LogEntry[]): LogEntry[] {
|
||||
const query = logSearchQuery.trim();
|
||||
if (!logSearchFilterMode || !query) return arr;
|
||||
const q = query.toLowerCase();
|
||||
return arr.filter(e => e.text.toLowerCase().includes(q));
|
||||
}
|
||||
|
||||
function ansiToHtml(text: string): string {
|
||||
return ansiUp.ansi_to_html(text);
|
||||
}
|
||||
let filteredLogs = $derived(filterByQuery(logs));
|
||||
let filteredMerged = $derived(filterByQuery(mergedLogs));
|
||||
|
||||
// Highlighted logs with search matches and ANSI color support (single container mode)
|
||||
let highlightedLogs = $derived(() => {
|
||||
let text = logs || '';
|
||||
const query = logSearchQuery.trim();
|
||||
|
||||
// Filter lines before ANSI conversion (plain text matching)
|
||||
if (logSearchFilterMode && query) {
|
||||
const escapedForRegex = query.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
const filterRegex = new RegExp(escapedForRegex, 'i');
|
||||
text = text.split('\n').filter(line => filterRegex.test(line)).join('\n');
|
||||
}
|
||||
|
||||
const withAnsi = ansiToHtml(text);
|
||||
if (!query) return withAnsi;
|
||||
|
||||
const escapedForRegex = query.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
const escapedQuery = escapeHtml(escapedForRegex);
|
||||
|
||||
const parts = withAnsi.split(/(<[^>]*>)/);
|
||||
return parts.map(part => {
|
||||
if (part.startsWith('<')) return part;
|
||||
const regex = new RegExp(`(${escapedQuery})`, 'gi');
|
||||
return part.replace(regex, '<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
@@ -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, '&').replace(/</g, '<').replace(/>/g, '>');
|
||||
|
||||
// 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;
|
||||
|
||||
@@ -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">
|
||||
|
||||
Reference in New Issue
Block a user