This commit is contained in:
jarek committed 2026-06-06 16:18:05 +02:00
1 parent 00bd09df55
commit 3cbcfa3cdb
26 files changed
+1367 -765

No files matched your search

+4 -4
View File
@@ -910,11 +910,11 @@
</Badge>
</p>
</div>
<div>
<div class="min-w-0">
<label class="text-sm font-medium text-muted-foreground">Container name</label>
<p class="flex items-center gap-1">
<Box class="w-4 h-4 text-muted-foreground" />
{selectedEvent.containerName || '-'}
<p class="flex items-start gap-1">
<Box class="w-4 h-4 mt-0.5 shrink-0 text-muted-foreground" />
<span class="font-mono text-sm break-all min-w-0">{selectedEvent.containerName || '-'}</span>
</p>
</div>
<div>
@@ -1,10 +1,14 @@
import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { inspectContainer } from '$lib/server/docker';
import { inspectContainer, inspectImage } from '$lib/server/docker';
import { getSecretKeysToMask } from '$lib/server/db';
import { getStackComposeFile } from '$lib/server/stacks';
import { authorize } from '$lib/server/authorize';
import { validateDockerIdParam } from '$lib/server/docker-validation';
import {
detectImageEnvDivergence,
detectImageLabelDivergence
} from '$lib/server/container-image-divergence';
export const GET: RequestHandler = async ({ params, url, cookies }) => {
const invalid = validateDockerIdParam(params.id, 'container');
@@ -23,6 +27,25 @@ export const GET: RequestHandler = async ({ params, url, cookies }) => {
try {
const containerData = await inspectContainer(params.id, envIdNum);
// Compute env/label divergence BEFORE masking, so the comparison
// uses real values. Failure to inspect the image is non-fatal —
// the field is omitted in that case.
let divergence: { env: string[]; labels: string[] } | undefined;
try {
const imageRef = containerData.Config?.Image;
if (imageRef) {
const imageData: any = await inspectImage(imageRef, envIdNum);
const imageEnv: string[] = imageData?.Config?.Env || [];
const imageLabels: Record<string, string> = imageData?.Config?.Labels || {};
divergence = {
env: detectImageEnvDivergence(containerData.Config?.Env || [], imageEnv),
labels: detectImageLabelDivergence(containerData.Config?.Labels, imageLabels)
};
}
} catch {
// image not present / not pullable / etc — drop the field
}
// Mask secret env vars for containers belonging to a Compose stack.
// Uses compose file parsing to detect interpolation (e.g., MYSQL_PASSWORD=${db_secret}).
const stackName = containerData.Config?.Labels?.['com.docker.compose.project'];
@@ -42,7 +65,7 @@ export const GET: RequestHandler = async ({ params, url, cookies }) => {
}
}
return json(containerData);
return json({ ...containerData, divergence });
} catch (error) {
console.error('Failed to inspect container:', error);
return json({ error: 'Failed to inspect container' }, { status: 500 });
@@ -303,7 +303,7 @@ async function getEnvironmentStatsProgressive(
envStats.containers.total = containers.length;
envStats.containers.running = containers.filter((c: any) => c.state === 'running').length;
envStats.containers.stopped = containers.filter((c: any) => c.state === 'exited' && c.exitCode !== 0).length;
envStats.containers.stopped = containers.filter((c: any) => c.state === 'exited').length;
envStats.containers.paused = containers.filter((c: any) => c.state === 'paused').length;
envStats.containers.restarting = containers.filter((c: any) => c.state === 'restarting').length;
envStats.containers.unhealthy = containers.filter((c: any) => c.health === 'unhealthy').length;
+19 -3
View File
@@ -58,7 +58,8 @@ export interface GeneralSettings {
eventCleanupEnabled: boolean;
scannerCleanupCron: string;
scannerCleanupEnabled: boolean;
logBufferSizeKb: number;
logBufferSizeKb: number; // legacy
logMaxLines: number; // line-count cap for log buffer
defaultTimezone: string;
// Background monitoring settings
eventCollectionMode: EventCollectionMode;
@@ -101,6 +102,7 @@ const DEFAULT_SETTINGS: Omit<GeneralSettings, 'scheduleRetentionDays' | 'eventRe
defaultGrypeArgs: '-o json -v {image}',
defaultTrivyArgs: 'image --format json {image}',
logBufferSizeKb: 500,
logMaxLines: 2000,
defaultTimezone: 'UTC',
eventCollectionMode: 'stream',
eventPollInterval: 60000,
@@ -177,6 +179,7 @@ export const GET: RequestHandler = async ({ cookies }) => {
scannerCleanupCron,
scannerCleanupEnabled,
logBufferSizeKb,
logMaxLines,
defaultTimezone,
eventCollectionMode,
eventPollInterval,
@@ -215,6 +218,7 @@ export const GET: RequestHandler = async ({ cookies }) => {
getScannerCleanupCron(),
getScannerCleanupEnabled(),
getSetting('log_buffer_size_kb'),
getSetting('log_max_lines'),
getDefaultTimezone(),
getEventCollectionMode(),
getEventPollInterval(),
@@ -255,6 +259,9 @@ export const GET: RequestHandler = async ({ cookies }) => {
scannerCleanupCron,
scannerCleanupEnabled,
logBufferSizeKb: logBufferSizeKb ?? DEFAULT_SETTINGS.logBufferSizeKb,
logMaxLines: (typeof logMaxLines === 'number' && logMaxLines > 0)
? Math.min(2000, Math.max(100, logMaxLines))
: Math.min(2000, Math.max(100, Math.round((logBufferSizeKb ?? DEFAULT_SETTINGS.logBufferSizeKb) * 8))),
defaultTimezone: defaultTimezone ?? DEFAULT_SETTINGS.defaultTimezone,
eventCollectionMode: (eventCollectionMode ?? DEFAULT_SETTINGS.eventCollectionMode) as EventCollectionMode,
eventPollInterval: eventPollInterval ?? DEFAULT_SETTINGS.eventPollInterval,
@@ -292,7 +299,7 @@ export const POST: RequestHandler = async ({ request, cookies }) => {
try {
const body = await request.json();
const { confirmDestructive, showStoppedContainers, highlightUpdates, timeFormat, dateFormat, downloadFormat, defaultGrypeArgs, defaultTrivyArgs, scheduleRetentionDays, eventRetentionDays, scheduleCleanupCron, eventCleanupCron, scheduleCleanupEnabled, eventCleanupEnabled, scannerCleanupCron, scannerCleanupEnabled, logBufferSizeKb, defaultTimezone, eventCollectionMode, eventPollInterval, metricsCollectionInterval, lightTheme, darkTheme, font, fontSize, gridFontSize, terminalFont, editorFont, compactPorts, showExposedPorts, formatLogTimestamps, externalStackPaths, primaryStackLocation, defaultGrypeImage, defaultTrivyImage, defaultComposeTemplate, labelFilterMode } = body;
const { confirmDestructive, showStoppedContainers, highlightUpdates, timeFormat, dateFormat, downloadFormat, defaultGrypeArgs, defaultTrivyArgs, scheduleRetentionDays, eventRetentionDays, scheduleCleanupCron, eventCleanupCron, scheduleCleanupEnabled, eventCleanupEnabled, scannerCleanupCron, scannerCleanupEnabled, logBufferSizeKb, logMaxLines, defaultTimezone, eventCollectionMode, eventPollInterval, metricsCollectionInterval, lightTheme, darkTheme, font, fontSize, gridFontSize, terminalFont, editorFont, compactPorts, showExposedPorts, formatLogTimestamps, externalStackPaths, primaryStackLocation, defaultGrypeImage, defaultTrivyImage, defaultComposeTemplate, labelFilterMode } = body;
if (confirmDestructive !== undefined) {
await setSetting('confirm_destructive', confirmDestructive);
@@ -345,9 +352,13 @@ export const POST: RequestHandler = async ({ request, cookies }) => {
await refreshSystemJobs();
}
if (logBufferSizeKb !== undefined && typeof logBufferSizeKb === 'number') {
// Clamp to reasonable range: 100KB - 5000KB (5MB)
// Legacy: clamp to 100KB-5MB range.
await setSetting('log_buffer_size_kb', Math.max(100, Math.min(5000, logBufferSizeKb)));
}
if (logMaxLines !== undefined && typeof logMaxLines === 'number') {
// Clamp to 100 - 50000 lines.
await setSetting('log_max_lines', Math.max(100, Math.min(2000, logMaxLines)));
}
if (defaultTimezone !== undefined && typeof defaultTimezone === 'string') {
await setDefaultTimezone(defaultTimezone);
// Refresh system jobs to use the new timezone
@@ -448,6 +459,7 @@ export const POST: RequestHandler = async ({ request, cookies }) => {
scannerCleanupCronVal,
scannerCleanupEnabledVal,
logBufferSizeKbVal,
logMaxLinesVal,
defaultTimezoneVal,
eventCollectionModeVal,
eventPollIntervalVal,
@@ -486,6 +498,7 @@ export const POST: RequestHandler = async ({ request, cookies }) => {
getScannerCleanupCron(),
getScannerCleanupEnabled(),
getSetting('log_buffer_size_kb'),
getSetting('log_max_lines'),
getDefaultTimezone(),
getEventCollectionMode(),
getEventPollInterval(),
@@ -526,6 +539,9 @@ export const POST: RequestHandler = async ({ request, cookies }) => {
scannerCleanupCron: scannerCleanupCronVal,
scannerCleanupEnabled: scannerCleanupEnabledVal,
logBufferSizeKb: logBufferSizeKbVal ?? DEFAULT_SETTINGS.logBufferSizeKb,
logMaxLines: (typeof logMaxLinesVal === 'number' && logMaxLinesVal > 0)
? Math.min(2000, Math.max(100, logMaxLinesVal))
: Math.min(2000, Math.max(100, Math.round((logBufferSizeKbVal ?? DEFAULT_SETTINGS.logBufferSizeKb) * 8))),
defaultTimezone: defaultTimezoneVal ?? DEFAULT_SETTINGS.defaultTimezone,
eventCollectionMode: (eventCollectionModeVal ?? DEFAULT_SETTINGS.eventCollectionMode) as EventCollectionMode,
eventPollInterval: eventPollIntervalVal ?? DEFAULT_SETTINGS.eventPollInterval,
+17 -1
View File
@@ -13,6 +13,22 @@ interface ValidationResult {
unused: string[];
}
/** Docker and Compose built-in env vars consumed implicitly at runtime (not via ${} interpolation) */
const DOCKER_COMPOSE_BUILTIN_VARS = new Set([
// Docker Compose
'COMPOSE_PROJECT_NAME', 'COMPOSE_FILE', 'COMPOSE_PROFILES',
'COMPOSE_CONVERT_WINDOWS_PATHS', 'COMPOSE_PATH_SEPARATOR',
'COMPOSE_IGNORE_ORPHANS', 'COMPOSE_REMOVE_ORPHANS',
'COMPOSE_PARALLEL_LIMIT', 'COMPOSE_ANSI', 'COMPOSE_STATUS_STDOUT',
'COMPOSE_ENV_FILES', 'COMPOSE_DISABLE_ENV_FILE', 'COMPOSE_MENU',
'COMPOSE_EXPERIMENTAL', 'COMPOSE_PROGRESS',
// Docker CLI
'DOCKER_API_VERSION', 'DOCKER_CERT_PATH', 'DOCKER_CONFIG',
'DOCKER_CONTEXT', 'DOCKER_CUSTOM_HEADERS', 'DOCKER_DEFAULT_PLATFORM',
'DOCKER_HIDE_LEGACY_COMMANDS', 'DOCKER_HOST', 'DOCKER_TLS',
'DOCKER_TLS_VERIFY', 'BUILDKIT_PROGRESS', 'NO_COLOR',
]);
/**
* Extract environment variables from compose YAML content.
* Matches ${VAR_NAME} and ${VAR_NAME:-default} patterns.
@@ -144,7 +160,7 @@ export const POST: RequestHandler = async ({ params, url, cookies, request }) =>
// Calculate missing and unused
const missing = required.filter(v => !defined.includes(v));
const unused = defined.filter(v => !required.includes(v) && !optional.includes(v));
const unused = defined.filter(v => !required.includes(v) && !optional.includes(v) && !DOCKER_COMPOSE_BUILTIN_VARS.has(v));
const result: ValidationResult = {
valid: missing.length === 0,
@@ -38,6 +38,23 @@
let loading = $state(true);
let error = $state('');
let containerData = $state<any>(null);
// Peer containers in the current env — used to resolve "container:<sha>" mode to a friendly name
let peerContainers = $state<Array<{ id: string; name: string }>>([]);
const networkModeLabel = $derived.by(() => {
const raw = containerData?.HostConfig?.NetworkMode || 'default';
if (!raw.startsWith('container:')) return raw;
const ref = raw.slice('container:'.length);
const match = peerContainers.find(c => c.id === ref || c.id.startsWith(ref));
return match ? `container:${match.name}` : raw;
});
// Docker rejects attaching extra networks when the container shares another
// namespace (host / none / container:X / service:X). Hide join controls then.
const isSharedNetworkMode = $derived.by(() => {
const mode = containerData?.HostConfig?.NetworkMode || '';
return mode === 'host' || mode === 'none' || mode.startsWith('container:') || mode.startsWith('service:');
});
// Active tab state for layers visibility
let activeTab = $state('overview');
@@ -52,6 +69,8 @@
// Label copy state
let copiedLabel = $state<string | null>(null);
let copyLabelFailed = $state(false);
let labelFilter = $state('');
let copiedAllLabels = $state(false);
async function copyLabel(key: string, value: string) {
const ok = await copyToClipboard(`${key}=${value}`);
@@ -64,6 +83,19 @@
}
}
async function copyAllLabels(entries: [string, string][]) {
if (entries.length === 0) return;
const text = entries.map(([k, v]) => `${k}=${v}`).join('\n');
const ok = await copyToClipboard(text);
if (ok) {
copiedAllLabels = true;
setTimeout(() => copiedAllLabels = false, 2000);
} else {
copyLabelFailed = true;
setTimeout(() => copyLabelFailed = false, 2000);
}
}
// Processes state
interface ProcessesData {
Titles: string[];
@@ -281,6 +313,9 @@
$effect(() => {
if (open && containerData?.State?.Running) {
startStatsCollection();
// One-shot fetch so the Overview's process count tile renders
// immediately, even if the user never opens the Processes tab.
if (!processesData) fetchProcesses();
} else {
stopStatsCollection();
}
@@ -317,6 +352,7 @@
lastFetchedId = '';
isLiveConnected = false;
lastStatsUpdate = 0;
labelFilter = '';
displayName = '';
isEditing = false;
editName = '';
@@ -335,6 +371,19 @@
throw new Error('Failed to fetch container details');
}
containerData = await response.json();
// Fetch peers only when this container shares another container's namespace —
// keeps the dialog snappy when the network mode is bridge/host/none/custom.
if (containerData?.HostConfig?.NetworkMode?.startsWith('container:')) {
try {
const peersRes = await fetch(appendEnvParam('/api/containers', envId));
if (peersRes.ok) {
const list = await peersRes.json();
peerContainers = list.map((c: any) => ({ id: c.id, name: c.name }));
}
} catch {
// Non-fatal — falls back to the raw SHA
}
}
} catch (err: any) {
error = err.message || 'Failed to load container details';
console.error('Failed to fetch container inspect:', err);
@@ -583,11 +632,11 @@
variant="outline"
size="sm"
onclick={() => showRawJson = true}
title="View raw JSON"
title="View raw inspect data"
class="ml-auto mr-6"
>
<Code class="w-4 h-4 mr-1.5" />
JSON
Inspect
</Button>
{/if}
</Dialog.Title>
@@ -623,7 +672,7 @@
<Tabs.Content value="overview" class="space-y-4 overflow-auto">
<!-- Real-time Stats (only for running containers) -->
{#if containerData.State?.Running}
<div class="grid grid-cols-2 lg:grid-cols-4 gap-3">
<div class="grid grid-cols-2 lg:grid-cols-5 gap-3">
<!-- CPU -->
<div class="p-3 border border-border rounded-lg">
<div class="flex items-center gap-2 mb-2">
@@ -709,6 +758,30 @@
</div>
</div>
</div>
<!-- Processes -->
<div class="p-3 border border-border rounded-lg">
<div class="flex items-center gap-2 mb-2">
<Activity class="w-4 h-4 text-pink-500" />
<span class="text-xs font-medium">Processes</span>
<button
type="button"
class="ml-auto text-sm font-bold hover:text-foreground/80 transition-colors"
onclick={() => activeTab = 'processes'}
title="View process list"
>
{processesData?.Processes?.length ?? '—'}
</button>
</div>
<div class="h-8 flex items-center justify-center text-2xs text-muted-foreground">
{#if processesData?.Processes?.length}
running in container
{:else if processesLoading}
<Loader2 class="w-3 h-3 animate-spin" />
{:else}
—
{/if}
</div>
</div>
</div>
{/if}
@@ -865,7 +938,7 @@
<!-- Network Mode -->
<div class="space-y-2">
<h3 class="text-sm font-semibold">Network mode</h3>
<Badge variant="outline">{containerData.HostConfig?.NetworkMode || 'default'}</Badge>
<Badge variant="outline">{networkModeLabel}</Badge>
</div>
<!-- DNS Settings -->
@@ -918,7 +991,11 @@
<!-- Networks -->
<div class="space-y-2">
<h3 class="text-sm font-semibold">Connected networks</h3>
{#if containerData.NetworkSettings?.Networks && Object.keys(containerData.NetworkSettings.Networks).length > 0}
{#if isSharedNetworkMode}
<p class="text-xs text-muted-foreground">
Network namespace is shared via <code class="px-1 py-0.5 rounded bg-muted">{containerData.HostConfig?.NetworkMode}</code> — additional networks cannot be attached.
</p>
{:else if containerData.NetworkSettings?.Networks && Object.keys(containerData.NetworkSettings.Networks).length > 0}
<div class="space-y-2">
{#each Object.entries(containerData.NetworkSettings.Networks) as [networkName, networkData]}
{@const netData = networkData as any}
@@ -985,7 +1062,7 @@
{/if}
<!-- Join network dropdown -->
{#if containerData.State?.Running}
{#if containerData.State?.Running && !isSharedNetworkMode}
<div class="flex items-center gap-2 pt-1">
<Select.Root type="single" bind:value={selectedNetwork}>
<Select.Trigger class="flex-1 h-8 text-xs">
@@ -1152,12 +1229,23 @@
<!-- Environment Tab -->
<Tabs.Content value="env" class="space-y-4 overflow-auto">
{#if containerData.divergence?.env?.length > 0}
<div class="flex items-start gap-2 text-xs p-2.5 rounded border border-amber-500/30 bg-amber-500/5 text-amber-700 dark:text-amber-300">
<Info class="w-3.5 h-3.5 shrink-0 mt-0.5" />
<div class="min-w-0">
{containerData.divergence.env.length} env var{containerData.divergence.env.length === 1 ? '' : 's'} differ from the image:
<span class="font-mono">{containerData.divergence.env.join(', ')}</span>.
Values set by you at create time will stay. To reset to the image's current values, Remove &amp; Deploy.
</div>
</div>
{/if}
{#if containerData.Config?.Env && containerData.Config.Env.length > 0}
<div class="space-y-1">
{#each [...containerData.Config.Env].sort((a, b) => a.split('=')[0].localeCompare(b.split('=')[0])) as envVar}
{@const [key, ...valueParts] = envVar.split('=')}
{@const value = valueParts.join('=')}
<div class="text-xs p-2 bg-muted rounded">
{@const diverges = containerData.divergence?.env?.includes(key)}
<div class="text-xs p-2 rounded {diverges ? 'bg-amber-500/10 border border-amber-500/30' : 'bg-muted'}">
<code class="text-muted-foreground font-medium">{key}</code>
<code class="text-muted-foreground">=</code>
<code class="break-all">{value}</code>
@@ -1170,31 +1258,79 @@
</Tabs.Content>
<!-- Labels Tab -->
<Tabs.Content value="labels" class="space-y-4 overflow-auto">
{#if containerData.Config?.Labels && Object.keys(containerData.Config.Labels).length > 0}
<div class="space-y-1">
{#each Object.entries(containerData.Config.Labels).sort((a, b) => a[0].localeCompare(b[0])) as [key, value]}
<div class="text-xs p-2 bg-muted rounded flex items-start gap-2 group">
<div class="flex-1 min-w-0">
<code class="text-muted-foreground font-medium">{key}</code>
<code class="text-muted-foreground">=</code>
<code class="break-all">{value}</code>
</div>
<button
type="button"
onclick={() => copyLabel(key, value)}
class="shrink-0 p-1 rounded hover:bg-background/50 transition-colors opacity-0 group-hover:opacity-100 {copiedLabel === key ? '!opacity-100' : ''}"
title={copiedLabel === key ? 'Copied!' : 'Copy label'}
>
{#if copiedLabel === key}
<Check class="w-3 h-3 text-green-500" />
{:else}
<Copy class="w-3 h-3 text-muted-foreground" />
{/if}
</button>
</div>
{/each}
<Tabs.Content value="labels" class="space-y-3 overflow-auto">
{#if containerData.divergence?.labels?.length > 0}
<div class="flex items-start gap-2 text-xs p-2.5 rounded border border-amber-500/30 bg-amber-500/5 text-amber-700 dark:text-amber-300">
<Info class="w-3.5 h-3.5 shrink-0 mt-0.5" />
<div class="min-w-0">
{containerData.divergence.labels.length} label{containerData.divergence.labels.length === 1 ? '' : 's'} differ from the image:
<span class="font-mono">{containerData.divergence.labels.join(', ')}</span>.
Values set by you at create time will stay. To reset to the image's current values, Remove &amp; Deploy.
</div>
</div>
{/if}
{#if containerData.Config?.Labels && Object.keys(containerData.Config.Labels).length > 0}
{@const allLabels = Object.entries(containerData.Config.Labels).sort((a, b) => a[0].localeCompare(b[0]))}
{@const filter = labelFilter.trim().toLowerCase()}
{@const visibleLabels = filter
? allLabels.filter(([k, v]) => k.toLowerCase().includes(filter) || String(v).toLowerCase().includes(filter))
: allLabels}
<div class="flex items-center gap-2">
<Input
type="search"
placeholder="Filter labels..."
bind:value={labelFilter}
class="h-8 text-xs flex-1"
/>
<span class="text-xs text-muted-foreground shrink-0">
{visibleLabels.length === allLabels.length
? `${allLabels.length} label${allLabels.length === 1 ? '' : 's'}`
: `${visibleLabels.length} of ${allLabels.length}`}
</span>
<Button
variant="outline"
size="sm"
onclick={() => copyAllLabels(visibleLabels)}
disabled={visibleLabels.length === 0}
title={copiedAllLabels ? 'Copied!' : 'Copy visible labels as key=value lines'}
>
{#if copiedAllLabels}
<Check class="w-3 h-3 mr-1.5 text-green-500" />
Copied
{:else}
<Copy class="w-3 h-3 mr-1.5" />
Copy all
{/if}
</Button>
</div>
{#if visibleLabels.length > 0}
<div class="space-y-1">
{#each visibleLabels as [key, value]}
{@const diverges = containerData.divergence?.labels?.includes(key)}
<div class="text-xs p-2 rounded flex items-start gap-2 group {diverges ? 'bg-amber-500/10 border border-amber-500/30' : 'bg-muted'}">
<div class="flex-1 min-w-0">
<code class="text-muted-foreground font-medium">{key}</code>
<code class="text-muted-foreground">=</code>
<code class="break-all">{value}</code>
</div>
<button
type="button"
onclick={() => copyLabel(key, value)}
class="shrink-0 p-1 rounded hover:bg-background/50 transition-colors opacity-0 group-hover:opacity-100 {copiedLabel === key ? '!opacity-100' : ''}"
title={copiedLabel === key ? 'Copied!' : 'Copy label'}
>
{#if copiedLabel === key}
<Check class="w-3 h-3 text-green-500" />
{:else}
<Copy class="w-3 h-3 text-muted-foreground" />
{/if}
</button>
</div>
{/each}
</div>
{:else}
<p class="text-sm text-muted-foreground">No labels match "{labelFilter}"</p>
{/if}
{:else}
<p class="text-sm text-muted-foreground">No labels</p>
{/if}
@@ -1532,13 +1668,13 @@
</Dialog.Content>
</Dialog.Root>
<!-- Raw JSON Modal -->
<!-- Inspect (raw) modal -->
<Dialog.Root bind:open={showRawJson}>
<Dialog.Content class="max-w-4xl max-h-[90vh] sm:max-h-[80vh] flex flex-col">
<Dialog.Header class="shrink-0">
<Dialog.Title class="flex items-center gap-2">
<Code class="w-5 h-5" />
Raw JSON
Inspect
<Button
variant="outline"
size="sm"
+281 -59
View File
@@ -1,11 +1,14 @@
<script lang="ts">
import * as Select from '$lib/components/ui/select';
import * as Popover from '$lib/components/ui/popover';
import * as Command from '$lib/components/ui/command';
import { tick } from 'svelte';
import { Label } from '$lib/components/ui/label';
import { Input } from '$lib/components/ui/input';
import { Button } from '$lib/components/ui/button';
import { Checkbox } from '$lib/components/ui/checkbox';
import { TogglePill, ToggleGroup } from '$lib/components/ui/toggle-pill';
import { Plus, Trash2, Settings2, RefreshCw, Network, X, Ban, RotateCw, AlertTriangle, PauseCircle, Share2, Server, CircleOff, ChevronDown, ChevronRight, Cpu, Shield, HeartPulse, Wifi, HardDrive, Lock, Loader2, CheckCircle2, Package, Gpu, Search, CircleHelp } from 'lucide-svelte';
import { Plus, Trash2, Settings2, RefreshCw, Network, X, Ban, RotateCw, AlertTriangle, PauseCircle, Share2, Server, CircleOff, Box, ChevronDown, ChevronsUpDown, Check, ChevronRight, Cpu, Shield, HeartPulse, Wifi, HardDrive, Lock, Loader2, CheckCircle2, Package, Gpu, Search, CircleHelp } from 'lucide-svelte';
import { parseHostPort, validatePort, validateIp, formatHostPort, expandPortBindings } from '$lib/utils/port-parse';
import * as Tooltip from '$lib/components/ui/tooltip';
import { currentEnvironment } from '$lib/stores/environment';
@@ -63,6 +66,13 @@
driver: string;
}
interface ContainerItem {
id: string;
name: string;
image: string;
state: string;
}
interface NetworkEndpointConfig {
ipv4Address: string;
ipv6Address: string;
@@ -89,7 +99,6 @@
// Labels
labels: { key: string; value: string }[];
// Networks
availableNetworks: DockerNetwork[];
selectedNetworks: string[];
networkConfigs: Record<string, NetworkEndpointConfig>;
macAddress: string;
@@ -166,7 +175,6 @@
volumeMappings = $bindable(),
envVars = $bindable(),
labels = $bindable(),
availableNetworks,
selectedNetworks = $bindable(),
networkConfigs = $bindable(),
macAddress = $bindable(),
@@ -208,6 +216,113 @@
imageSummary
}: Props = $props();
// Fetch networks and containers from current environment
let availableNetworks = $state<DockerNetwork[]>([]);
let availableContainers = $state<ContainerItem[]>([]);
// Container picker (Popover + Command combobox)
let containerPickerOpen = $state(false);
let containerPickerTriggerRef = $state<HTMLButtonElement>(null!);
function closeAndFocusContainerPicker() {
containerPickerOpen = false;
tick().then(() => containerPickerTriggerRef?.focus());
}
// Networks picker (Popover + Command combobox)
let networkPickerOpen = $state(false);
let networkPickerTriggerRef = $state<HTMLButtonElement>(null!);
function closeAndFocusNetworkPicker() {
networkPickerOpen = false;
tick().then(() => networkPickerTriggerRef?.focus());
}
async function fetchNetworks() {
try {
const envParam = $currentEnvironment ? `?env=${$currentEnvironment.id}` : '';
const response = await fetch(`/api/networks${envParam}`);
if (response.ok) {
availableNetworks = await response.json();
}
} catch (err) {
console.error('Failed to fetch networks:', err);
}
}
async function fetchContainers() {
try {
const envParam = $currentEnvironment ? `?env=${$currentEnvironment.id}` : '';
const response = await fetch(`/api/containers${envParam}`);
if (response.ok) {
const containers: any[] = await response.json();
availableContainers = containers
.map(c => ({
id: c.id,
name: c.name,
image: c.image,
state: c.state
}))
.filter(c => c.name && c.name !== name);
}
} catch (err) {
console.error('Failed to fetch containers:', err);
}
}
// Fetch both on mount so the dropdowns are ready when the user opens them
fetchNetworks();
fetchContainers();
// Container network mode helpers
// `networkModeType` reduces the raw NetworkMode to a logical group for branching:
// bridge | host | none | container | custom
const networkModeType = $derived.by(() => {
if (networkMode.startsWith('container:')) return 'container';
if (['bridge', 'host', 'none'].includes(networkMode)) return networkMode;
return 'custom';
});
// Raw value from networkMode — Docker stores either a name or a 64-char container ID
const containerRefRaw = $derived(networkMode.startsWith('container:') ? networkMode.slice('container:'.length) : '');
// Resolve ID → name when possible so the trigger shows "container:redis" not "container:<sha>"
const containerRef = $derived.by(() => {
if (!containerRefRaw) return '';
const match = availableContainers.find(c => c.id === containerRefRaw || c.id.startsWith(containerRefRaw));
return match ? match.name : containerRefRaw;
});
// Network mode picker (Popover + Command combobox) — flat list of bridge/host/none/Container + custom networks
let networkModePickerOpen = $state(false);
let networkModePickerTriggerRef = $state<HTMLButtonElement>(null!);
function closeAndFocusNetworkModePicker() {
networkModePickerOpen = false;
tick().then(() => networkModePickerTriggerRef?.focus());
}
// Additional networks: custom networks NOT used as the primary mode and NOT already attached
const selectableNetworks = $derived(
availableNetworks.filter(n =>
!selectedNetworks.includes(n.name) &&
!['bridge', 'host', 'none'].includes(n.name) &&
n.name !== networkMode // exclude the primary
)
);
// Custom networks available for the primary mode picker
const customNetworks = $derived(
availableNetworks.filter(n => !['bridge', 'host', 'none'].includes(n.name))
);
// Display label for the current network mode in the trigger
const networkModeLabel = $derived.by(() => {
if (networkModeType === 'bridge') return 'Bridge';
if (networkModeType === 'host') return 'Host';
if (networkModeType === 'none') return 'None';
if (networkModeType === 'container') return containerRef ? `Container: ${containerRef}` : 'Container';
return networkMode; // custom network name
});
// Expanded network config rows
let expandedNetworks = $state<Set<string>>(new Set());
@@ -718,41 +833,121 @@
</div>
<div class="space-y-1.5">
<Label class="text-xs font-medium">Network mode</Label>
<Select.Root type="single" bind:value={networkMode}>
<Select.Trigger id="networkMode" tabindex={0} class="w-full h-9">
<span class="flex items-center">
{#if networkMode === 'bridge'}
<Share2 class="w-3.5 h-3.5 mr-2 text-emerald-500" />
{:else if networkMode === 'host'}
<Server class="w-3.5 h-3.5 mr-2 text-sky-500" />
{:else}
<CircleOff class="w-3.5 h-3.5 mr-2 text-muted-foreground" />
{/if}
{networkMode === 'bridge' ? 'Bridge' : networkMode === 'host' ? 'Host' : 'None'}
</span>
</Select.Trigger>
<Select.Content>
<Select.Item value="bridge">
{#snippet children()}
<Share2 class="w-3.5 h-3.5 mr-2 text-emerald-500" />
Bridge
<Label class="text-xs font-medium">Network</Label>
<Popover.Root bind:open={networkModePickerOpen}>
<Popover.Trigger bind:ref={networkModePickerTriggerRef}>
{#snippet child({ props })}
<Button
{...props}
variant="outline"
class="w-full justify-between font-normal"
role="combobox"
aria-expanded={networkModePickerOpen}
>
<span class="flex items-center min-w-0 flex-1">
{#if networkModeType === 'bridge'}
<Share2 class="w-3.5 h-3.5 mr-2 shrink-0 text-emerald-500" />
{:else if networkModeType === 'host'}
<Server class="w-3.5 h-3.5 mr-2 shrink-0 text-sky-500" />
{:else if networkModeType === 'none'}
<CircleOff class="w-3.5 h-3.5 mr-2 shrink-0 text-muted-foreground" />
{:else if networkModeType === 'container'}
<Box class="w-3.5 h-3.5 mr-2 shrink-0 text-violet-500" />
{:else}
<Network class="w-3.5 h-3.5 mr-2 shrink-0 text-orange-500" />
{/if}
<span class="truncate">{networkModeLabel}</span>
</span>
<ChevronsUpDown class="w-4 h-4 shrink-0 opacity-50" />
</Button>
{/snippet}
</Popover.Trigger>
<Popover.Content class="w-[var(--bits-popover-anchor-width)] p-0" align="start">
<Command.Root>
<Command.Input placeholder="Filter networks..." />
<Command.List class="max-h-64">
<Command.Empty>No networks found.</Command.Empty>
<Command.Group>
<Command.Item value="bridge" onSelect={() => { networkMode = 'bridge'; closeAndFocusNetworkModePicker(); }}>
<Share2 class="text-emerald-500" />
<span>Bridge</span>
</Command.Item>
<Command.Item value="host" onSelect={() => { networkMode = 'host'; closeAndFocusNetworkModePicker(); }}>
<Server class="text-sky-500" />
<span>Host</span>
</Command.Item>
<Command.Item value="none" onSelect={() => { networkMode = 'none'; closeAndFocusNetworkModePicker(); }}>
<CircleOff class="text-muted-foreground" />
<span>None</span>
</Command.Item>
<Command.Item value="container" onSelect={() => { if (!networkMode.startsWith('container:')) networkMode = 'container:'; closeAndFocusNetworkModePicker(); }}>
<Box class="text-violet-500" />
<span>Container</span>
</Command.Item>
</Command.Group>
{#if customNetworks.length > 0}
<Command.Separator />
<Command.Group heading="Custom networks">
{#each customNetworks as n (n.name)}
<Command.Item value={n.name} onSelect={() => { networkMode = n.name; closeAndFocusNetworkModePicker(); }}>
<Network class="text-orange-500" />
<span class="font-medium">{n.name}</span>
<span class="{getDriverBadgeClasses(n.driver)} ml-auto">{n.driver}</span>
</Command.Item>
{/each}
</Command.Group>
{/if}
</Command.List>
</Command.Root>
</Popover.Content>
</Popover.Root>
{#if networkModeType === 'container'}
<Popover.Root bind:open={containerPickerOpen}>
<Popover.Trigger bind:ref={containerPickerTriggerRef}>
{#snippet child({ props })}
<Button
{...props}
variant="outline"
class="w-full mt-2 justify-between font-normal"
role="combobox"
aria-expanded={containerPickerOpen}
>
<span class="truncate min-w-0 flex-1 text-left {containerRef ? '' : 'text-muted-foreground'}">
{containerRef || 'Select a container...'}
</span>
<ChevronsUpDown class="w-4 h-4 shrink-0 opacity-50" />
</Button>
{/snippet}
</Select.Item>
<Select.Item value="host">
{#snippet children()}
<Server class="w-3.5 h-3.5 mr-2 text-sky-500" />
Host
{/snippet}
</Select.Item>
<Select.Item value="none">
{#snippet children()}
<CircleOff class="w-3.5 h-3.5 mr-2 text-muted-foreground" />
None
{/snippet}
</Select.Item>
</Select.Content>
</Select.Root>
</Popover.Trigger>
<Popover.Content class="w-[var(--bits-popover-anchor-width)] p-0" align="start">
<Command.Root>
<Command.Input placeholder="Filter by name..." />
<Command.List class="max-h-64">
<Command.Empty>No containers found.</Command.Empty>
<Command.Group>
{#each availableContainers as c (c.id)}
<Command.Item
value={c.name}
onSelect={() => {
networkMode = `container:${c.name}`;
closeAndFocusContainerPicker();
}}
>
<Check class={containerRef !== c.name ? 'text-transparent' : ''} />
<span class="w-1.5 h-1.5 shrink-0 rounded-full {c.state === 'running' ? 'bg-green-500' : 'bg-muted-foreground/40'}"></span>
<span class="font-medium">{c.name}</span>
<span class="text-muted-foreground text-xs ml-auto truncate">{c.image}</span>
</Command.Item>
{/each}
</Command.Group>
</Command.List>
</Command.Root>
</Popover.Content>
</Popover.Root>
{#if !containerRef}
<p class="text-xs text-amber-600 mt-1">Select a container to share its network namespace</p>
{/if}
{/if}
</div>
</div>
@@ -767,38 +962,65 @@
</div>
</div>
<!-- Networks -->
{#if availableNetworks.length > 0}
<!-- Additional networks (hidden for host/none/container:X modes — Docker rejects extras) -->
{#if availableNetworks.length > 0 && networkModeType !== 'host' && networkModeType !== 'none' && networkModeType !== 'container'}
<div class="space-y-2">
<div class="flex justify-between items-center pb-2 border-b">
<div class="flex items-center gap-2">
<Network class="w-4 h-4 text-muted-foreground" />
<h3 class="text-sm font-semibold text-foreground">Networks</h3>
<h3 class="text-sm font-semibold text-foreground">Additional networks</h3>
</div>
</div>
<div class="space-y-2">
<Select.Root type="single" value="" onValueChange={addNetwork}>
<Select.Trigger tabindex={0} class="w-full h-9">
<span class="text-muted-foreground">Select network to add...</span>
</Select.Trigger>
<Select.Content>
{#each availableNetworks.filter(n => !selectedNetworks.includes(n.name) && !['bridge', 'host', 'none'].includes(n.name)) as network}
<Select.Item value={network.name}>
{#snippet children()}
<div class="flex items-center justify-between w-full">
<span>{network.name}</span>
<span class={getDriverBadgeClasses(network.driver)}>{network.driver}</span>
</div>
{/snippet}
</Select.Item>
{/each}
</Select.Content>
</Select.Root>
{#if selectableNetworks.length === 0}
<Button variant="outline" disabled class="w-full justify-start font-normal text-muted-foreground">
All networks already attached
</Button>
{:else}
<Popover.Root bind:open={networkPickerOpen}>
<Popover.Trigger bind:ref={networkPickerTriggerRef}>
{#snippet child({ props })}
<Button
{...props}
variant="outline"
class="w-full justify-between font-normal"
role="combobox"
aria-expanded={networkPickerOpen}
>
<span class="text-muted-foreground">Select network to add...</span>
<ChevronsUpDown class="w-4 h-4 opacity-50" />
</Button>
{/snippet}
</Popover.Trigger>
<Popover.Content class="w-[var(--bits-popover-anchor-width)] p-0" align="start">
<Command.Root>
<Command.Input placeholder="Filter networks..." />
<Command.List class="max-h-64">
<Command.Empty>No networks found.</Command.Empty>
<Command.Group>
{#each selectableNetworks as network (network.name)}
<Command.Item
value={network.name}
onSelect={() => {
addNetwork(network.name);
closeAndFocusNetworkPicker();
}}
>
<span class="font-medium">{network.name}</span>
<span class="{getDriverBadgeClasses(network.driver)} ml-auto">{network.driver}</span>
</Command.Item>
{/each}
</Command.Group>
</Command.List>
</Command.Root>
</Popover.Content>
</Popover.Root>
{/if}
{#if selectedNetworks.length > 0}
{#if selectedNetworks.filter(n => n !== networkMode).length > 0}
<div class="space-y-1 pt-1">
{#each selectedNetworks as networkName}
{#each selectedNetworks.filter(n => n !== networkMode) as networkName}
{@const network = availableNetworks.find(n => n.name === networkName)}
{@const isExpanded = expandedNetworks.has(networkName)}
<div class="border rounded-md">
@@ -106,12 +106,6 @@
let labels = $state<{ key: string; value: string }[]>([{ key: '', value: '' }]);
// Networks
interface DockerNetwork {
id: string;
name: string;
driver: string;
}
let availableNetworks = $state<DockerNetwork[]>([]);
let selectedNetworks = $state<string[]>([]);
let networkConfigs = $state<Record<string, { ipv4Address: string; ipv6Address: string; aliases: string }>>({});
let macAddress = $state('');
@@ -192,7 +186,6 @@
$effect(() => {
if (open) {
fetchConfigSets();
fetchNetworks();
fetchScannerSettings();
}
});
@@ -281,18 +274,6 @@
scanStatus = status;
}
async function fetchNetworks() {
try {
const envParam = $currentEnvironment ? `?env=${$currentEnvironment.id}` : '';
const response = await fetch(`/api/networks${envParam}`);
if (response.ok) {
availableNetworks = await response.json();
}
} catch (err) {
console.error('Failed to fetch networks:', err);
}
}
async function fetchConfigSets() {
try {
const response = await fetch('/api/config-sets');
@@ -457,7 +438,11 @@
restartPolicy,
restartMaxRetries: restartPolicy === 'on-failure' && restartMaxRetries !== '' ? Number(restartMaxRetries) : undefined,
networkMode,
networks: selectedNetworks.length > 0 ? selectedNetworks : undefined,
additionalNetworks: (() => {
const isSharedMode = networkMode === 'host' || networkMode === 'none' || networkMode.startsWith('container:');
if (isSharedMode) return undefined;
return selectedNetworks.length > 0 ? selectedNetworks : undefined;
})(),
networkConfigs: Object.keys(netConfigs).length > 0 ? netConfigs : undefined,
macAddress: macAddress.trim() || undefined,
startAfterCreate,
@@ -740,7 +725,6 @@
bind:volumeMappings
bind:envVars
bind:labels
{availableNetworks}
bind:selectedNetworks
bind:networkConfigs
bind:macAddress
+41 -49
View File
@@ -108,12 +108,6 @@
let labels = $state<{ key: string; value: string }[]>([{ key: '', value: '' }]);
// Networks
interface DockerNetwork {
id: string;
name: string;
driver: string;
}
let availableNetworks = $state<DockerNetwork[]>([]);
let selectedNetworks = $state<string[]>([]);
let networkConfigs = $state<Record<string, { ipv4Address: string; ipv6Address: string; aliases: string }>>({});
let macAddress = $state('');
@@ -246,18 +240,6 @@
let editTitleName = $state('');
let renamingTitle = $state(false);
async function fetchNetworks() {
try {
const envParam = currentEnvId ? `?env=${currentEnvId}` : '';
const response = await fetch(`/api/networks${envParam}`);
if (response.ok) {
availableNetworks = await response.json();
}
} catch (err) {
console.error('Failed to fetch networks:', err);
}
}
// Inline title rename functions
let titleInputRef: HTMLInputElement | null = null;
@@ -358,13 +340,13 @@
restartPolicy = data.HostConfig.RestartPolicy?.Name || 'no';
restartMaxRetries = data.HostConfig.RestartPolicy?.MaximumRetryCount ?? '';
// Normalize network mode
// Normalize network mode. NetworkMode can be:
// - bridge / host / none / default → built-in modes
// - container:<id|name> → shared namespace
// - <custom-network-name> → primary network is that custom network
// We preserve the raw value (except map "default" → "bridge" since they're equivalent).
const rawNetworkMode = data.HostConfig.NetworkMode || 'bridge';
if (['bridge', 'host', 'none', 'default'].includes(rawNetworkMode)) {
networkMode = rawNetworkMode === 'default' ? 'bridge' : rawNetworkMode;
} else {
networkMode = 'bridge';
}
networkMode = rawNetworkMode === 'default' ? 'bridge' : rawNetworkMode;
// Parse port mappings (include HostIp if present)
const ports = data.HostConfig.PortBindings || {};
@@ -424,9 +406,10 @@
composeStackName = '';
}
// Parse connected networks
// Parse connected networks. selectedNetworks holds ADDITIONAL networks only —
// the primary lives in networkMode, never in this list (Portainer-style).
const networks = data.NetworkSettings?.Networks || {};
selectedNetworks = Object.keys(networks);
selectedNetworks = Object.keys(networks).filter(n => n !== networkMode);
// Parse per-network IP/alias config from NetworkSettings
const parsedNetConfigs: Record<string, { ipv4Address: string; ipv6Address: string; aliases: string }> = {};
@@ -563,8 +546,7 @@
runtime = (data.HostConfig?.Runtime && data.HostConfig.Runtime !== 'runc')
? data.HostConfig.Runtime : '';
// Fetch available networks and auto-update settings
await fetchNetworks();
// Fetch auto-update settings
await fetchAutoUpdateSettings(name);
// Store original config for change detection
@@ -944,36 +926,47 @@
image: image.trim(),
ports: Object.keys(ports).length > 0 ? ports : null,
volumeBinds: volumeBinds.length > 0 ? volumeBinds : null,
env: env.length > 0 ? env : undefined,
// Fields the form manages: send `null` when the user cleared them
// so the server can distinguish "cleared" from "absent" (#1119).
// Server's updateContainer merges payload over existing config and
// treats explicit null as "clear this field" — undefined would be
// dropped by JSON.stringify and the merge would preserve the old value.
env: env.length > 0 ? env : null,
labels: labelsObj,
cmd,
restartPolicy,
restartMaxRetries: restartPolicy === 'on-failure' && restartMaxRetries !== '' ? Number(restartMaxRetries) : undefined,
restartMaxRetries: restartPolicy === 'on-failure' && restartMaxRetries !== '' ? Number(restartMaxRetries) : null,
networkMode,
networks: selectedNetworks.length > 0 ? selectedNetworks : undefined,
networkConfigs: Object.keys(netConfigs).length > 0 ? netConfigs : undefined,
macAddress: macAddress.trim() || undefined,
// Additional networks only — primary is networkMode. Shared modes
// (host/none/container:X) reject extras; UI already hides the section.
additionalNetworks: (() => {
const isSharedMode = networkMode === 'host' || networkMode === 'none' || networkMode.startsWith('container:');
if (isSharedMode) return null;
return selectedNetworks.length > 0 ? selectedNetworks : null;
})(),
networkConfigs: Object.keys(netConfigs).length > 0 ? netConfigs : null,
macAddress: macAddress.trim() || null,
startAfterUpdate,
repullImage,
user: containerUser.trim() || null,
privileged: privilegedMode,
healthcheck,
memory: parseMemory(memoryLimit),
memoryReservation: parseMemory(memoryReservation),
cpuShares: cpuShares ? parseInt(cpuShares) : undefined,
nanoCpus: parseNanoCpus(nanoCpus),
cpuQuota: cpuQuota ? parseInt(cpuQuota) : undefined,
cpuPeriod: cpuPeriod ? parseInt(cpuPeriod) : undefined,
capAdd: capAdd.length > 0 ? capAdd : undefined,
capDrop: capDrop.length > 0 ? capDrop : undefined,
devices: devices.length > 0 ? devices : undefined,
deviceRequests,
memory: parseMemory(memoryLimit) ?? null,
memoryReservation: parseMemory(memoryReservation) ?? null,
cpuShares: cpuShares ? parseInt(cpuShares) : null,
nanoCpus: parseNanoCpus(nanoCpus) ?? null,
cpuQuota: cpuQuota ? parseInt(cpuQuota) : null,
cpuPeriod: cpuPeriod ? parseInt(cpuPeriod) : null,
capAdd: capAdd.length > 0 ? capAdd : null,
capDrop: capDrop.length > 0 ? capDrop : null,
devices: devices.length > 0 ? devices : null,
deviceRequests: deviceRequests ?? null,
runtime: runtime || null,
dns: dnsServers.length > 0 ? dnsServers : undefined,
dnsSearch: dnsSearch.length > 0 ? dnsSearch : undefined,
dnsOptions: dnsOptions.length > 0 ? dnsOptions : undefined,
securityOpt: securityOptions.length > 0 ? securityOptions : undefined,
ulimits: ulimitsArray.length > 0 ? ulimitsArray : undefined
dns: dnsServers.length > 0 ? dnsServers : null,
dnsSearch: dnsSearch.length > 0 ? dnsSearch : null,
dnsOptions: dnsOptions.length > 0 ? dnsOptions : null,
securityOpt: securityOptions.length > 0 ? securityOptions : null,
ulimits: ulimitsArray.length > 0 ? ulimitsArray : null
};
const response = await fetch(appendEnvParam(`/api/containers/${containerId}/update`, $currentEnvironment?.id), {
@@ -1129,7 +1122,6 @@
bind:volumeMappings
bind:envVars
bind:labels
{availableNetworks}
bind:selectedNetworks
bind:networkConfigs
bind:macAddress
+1 -1
View File
@@ -729,7 +729,7 @@
/>
</div>
<Select.Root type="single" bind:value={usageFilter}>
<Select.Trigger size="sm" class="w-28 text-sm">
<Select.Trigger size="sm" class="w-36 text-sm">
{#if usageFilter === 'all'}
<Filter class="w-3.5 h-3.5 mr-1.5 text-muted-foreground shrink-0" />
<span class="text-muted-foreground">All</span>
+216 -256
View File
@@ -11,7 +11,6 @@
import { Checkbox } from '$lib/components/ui/checkbox';
import { ToggleGroup } from '$lib/components/ui/toggle-pill';
import { RefreshCw, Search, ChevronDown, ChevronUp, Unplug, Copy, Download, WrapText, ArrowDownToLine, X, Sun, Moon, LayoutList, Square, Box, Wifi, WifiOff, Pause, Play, ScrollText, Star, GripVertical, Layers, Check, FolderHeart, Save, Trash2, MoreHorizontal, Eraser, Filter, GripHorizontal, Terminal, ArrowDown, ArrowRight, Clock, Tag, Hash } from 'lucide-svelte';
import { wrapHtmlLines } from '$lib/utils/log-lines';
import LogTimeRangeFilter from './LogTimeRangeFilter.svelte';
import { copyToClipboard } from '$lib/utils/clipboard';
import PageHeader from '$lib/components/PageHeader.svelte';
@@ -22,16 +21,22 @@ import type { FavoriteGroup } from '../api/preferences/favorite-groups/+server';
import { currentEnvironment, environments, appendEnvParam } from '$lib/stores/environment';
import { appSettings, formatLogTimestamps } from '$lib/stores/settings';
import { NoEnvironment } from '$lib/components/ui/empty-state';
import { AnsiUp } from 'ansi_up';
const ansiUp = new AnsiUp();
ansiUp.use_classes = true;
import { parseLines, renderLineHtml, type LogEntry } from '$lib/utils/log-entry';
function renderTimestamp(ts: string | undefined): string {
if (!ts) return '';
if ($appSettings.formatLogTimestamps) {
return formatLogTimestamps(ts + ' ').trimEnd();
}
return ts;
}
// Track if we've handled the initial container from URL
let initialContainerHandled = $state(false);
let containers = $state<ContainerInfo[]>([]);
let selectedContainer = $state<ContainerInfo | null>(null);
let logs = $state('');
let logs = $state<LogEntry[]>([]);
let loading = $state(false);
let autoScroll = $state(true);
let fontSize = $state(12);
@@ -51,39 +56,41 @@ import type { FavoriteGroup } from '../api/preferences/favorite-groups/+server';
const MAX_RECONNECT_ATTEMPTS = 5;
const RECONNECT_DELAY = 3000;
// Grouped mode state
// Grouped mode state. mergedLogs is the LogEntry[] equivalent for the merged
// view — same shape as single-mode logs but every entry has containerId/Name/color.
// Per-stream carryover preserves partial lines across SSE chunks.
let selectedContainerIds = $state<Set<string>>(new Set());
let groupedContainerInfo = $state<Map<string, { name: string; color: string }>>(new Map());
let mergedLogs = $state<Array<{ containerId: string; containerName: string; color: string; text: string; timestamp?: string; stream?: string }>>([]);
let mergedHtml = $state(''); // Pre-built HTML string for fast rendering (like single mode's `logs`)
let mergedLogs = $state<LogEntry[]>([]);
const groupedCarryover = new Map<string, string>(); // containerId → leftover
let stackName = $state<string | null>(null);
// Batching for grouped mode log updates to prevent UI blocking
let pendingLogs: Array<{ containerId: string; containerName: string; color: string; text: string; timestamp?: string; stream?: string }> = [];
let batchTimeout: ReturnType<typeof setTimeout> | null = null;
const BATCH_INTERVAL = 50; // ms - batch logs for 50ms before updating state
// Initial buffering: accumulate all tail lines before first render to avoid line-by-line appearance
// Microtask flush coalesces bursts within a tick; initial buffering window
// avoids the line-by-line appearance for the tail-N replay.
let pendingGroupedEntries: LogEntry[] = [];
let groupedFlushScheduled = false;
let initialBuffering = false;
let initialBufferTimeout: ReturnType<typeof setTimeout> | null = null;
const INITIAL_BUFFER_DELAY = 400; // ms - wait for all initial tail lines before first render
const INITIAL_BUFFER_DELAY = 400;
// Batching for single mode SSE logs
let pendingText = '';
let flushTimer: ReturnType<typeof setTimeout> | null = null;
const FLUSH_INTERVAL = 100; // ms
// Single mode flush — same microtask pattern + per-line carryover.
let pendingEntries: LogEntry[] = [];
let streamCarryover = '';
let flushScheduled = false;
const COMPACT_FACTOR = 2;
// RAF-based auto-scroll
let scrollRafPending = false;
// Tail count and since filter
// Capped at 1000 to avoid the frozen-browser hang from rendering thousands of
// nodes at once (no virtualization yet).
const tailOptions = [
{ value: '100', label: '100' },
{ value: '500', label: '500' },
{ value: '1000', label: '1K' },
{ value: '5000', label: '5K' },
{ value: '10000', label: '10K' },
{ value: 'all', label: 'All' }
{ value: '1000', label: '1K' }
];
const VALID_TAIL_VALUES = new Set(tailOptions.map(o => o.value));
let tailCount = $state('500');
let sinceDate = $state('');
let sinceTime = $state('');
@@ -105,9 +112,12 @@ import type { FavoriteGroup } from '../api/preferences/favorite-groups/+server';
}
function reloadAllLogs() {
logs = '';
pendingText = '';
mergedLogs = []; mergedHtml = '';
logs = [];
pendingEntries = [];
streamCarryover = '';
mergedLogs = [];
pendingGroupedEntries = [];
groupedCarryover.clear();
if (layoutMode === 'grouped') {
if (streamingEnabled) startGroupedStreaming();
} else if (selectedContainer) {
@@ -116,73 +126,90 @@ import type { FavoriteGroup } from '../api/preferences/favorite-groups/+server';
}
}
// Flush pending logs to state (called on timer)
function flushPendingLogs() {
if (pendingLogs.length === 0) {
batchTimeout = null;
return;
}
// 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);
}
// 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}`;
}
function getMaxLines(): number {
return $appSettings.logMaxLines;
}
// 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();
// 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
// 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 textToCopy = layoutMode === 'grouped'
? mergedLogs.map(l => `[${l.containerName}] ${l.text}`).join('')
: logs;
if (textToCopy) {
await copyToClipboard(textToCopy);
}
const text = layoutMode === 'grouped'
? entriesToText(mergedLogs, true)
: entriesToText(logs, false);
if (text) await copyToClipboard(text);
}
// Download logs as txt file
function downloadLogs() {
const textToDownload = layoutMode === 'grouped'
? mergedLogs.map(l => `[${l.containerName}] ${l.text}`).join('')
: logs;
const text = layoutMode === 'grouped'
? entriesToText(mergedLogs, true)
: entriesToText(logs, false);
const filename = layoutMode === 'grouped'
? 'merged-logs.txt'
: selectedContainer ? `${selectedContainer.name}-logs.txt` : 'logs.txt';
if (textToDownload) {
const blob = new Blob([textToDownload], { type: 'text/plain' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = filename;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
}
if (!text) return;
const blob = new Blob([text], { type: 'text/plain' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = filename;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
}
// Clear displayed logs
function clearLogs() {
logs = '';
pendingText = '';
logs = [];
pendingEntries = [];
streamCarryover = '';
mergedLogs = [];
mergedHtml = '';
pendingLogs = [];
pendingGroupedEntries = [];
groupedCarryover.clear();
}
// Log search functions
@@ -1517,78 +1527,22 @@ import type { FavoriteGroup } from '../api/preferences/favorite-groups/+server';
}
}
// Escape HTML to prevent XSS
function escapeHtml(text: string): string {
return text
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;');
// Filter pass — array op over LogEntry[], not regex over a string.
function filterByQuery(arr: LogEntry[]): LogEntry[] {
const query = logSearchQuery.trim();
if (!logSearchFilterMode || !query) return arr;
const q = query.toLowerCase();
return arr.filter(e => e.text.toLowerCase().includes(q));
}
function ansiToHtml(text: string): string {
return ansiUp.ansi_to_html(text);
}
let filteredLogs = $derived(filterByQuery(logs));
let filteredMerged = $derived(filterByQuery(mergedLogs));
// Highlighted logs with search matches and ANSI color support (single container mode)
let highlightedLogs = $derived(() => {
let text = logs || '';
const query = logSearchQuery.trim();
// Filter lines before ANSI conversion (plain text matching)
if (logSearchFilterMode && query) {
const escapedForRegex = query.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
const filterRegex = new RegExp(escapedForRegex, 'i');
text = text.split('\n').filter(line => filterRegex.test(line)).join('\n');
}
const withAnsi = ansiToHtml(text);
if (!query) return withAnsi;
const escapedForRegex = query.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
const escapedQuery = escapeHtml(escapedForRegex);
const parts = withAnsi.split(/(<[^>]*>)/);
return parts.map(part => {
if (part.startsWith('<')) return part;
const regex = new RegExp(`(${escapedQuery})`, 'gi');
return part.replace(regex, '<mark class="search-match">$1</mark>');
}).join('');
});
// Format merged logs HTML — uses pre-built mergedHtml string, only applies search highlighting when needed
let formattedMergedHtml = $derived(() => {
if (!mergedHtml) return '';
const query = logSearchQuery.trim();
// Filter mode: remove non-matching lines from HTML
let html = mergedHtml;
if (logSearchFilterMode && query) {
const escapedForRegex = query.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
const filterRegex = new RegExp(escapedForRegex, 'i');
// Split by <br/> or newlines, filter lines (strip HTML for matching, keep original for display)
const lines = html.split(/\n/);
html = lines.filter(line => {
const plainText = line.replace(/<[^>]*>/g, '');
return filterRegex.test(plainText);
}).join('\n');
}
if (!query) return html;
const escapedForRegex = query.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
const escapedQuery = escapeHtml(escapedForRegex);
const searchRegex = new RegExp(`(${escapedQuery})`, 'gi');
const parts = html.split(/(<[^>]*>)/);
return parts.map(part => {
if (part.startsWith('<')) return part;
return part.replace(searchRegex, '<mark class="search-match">$1</mark>');
}).join('');
});
// Update match count after render
// Update match count after render. Tracks the entries currently displayed so
// it re-runs on append / toggle / search change.
$effect(() => {
// Track highlighted logs to re-run when content changes
const html = layoutMode === 'grouped' ? formattedMergedHtml() : highlightedLogs();
layoutMode === 'grouped' ? filteredMerged : filteredLogs;
logSearchQuery;
if (logSearchQuery && logsRef) {
setTimeout(() => {
@@ -1626,7 +1580,7 @@ import type { FavoriteGroup } from '../api/preferences/favorite-groups/+server';
}
// Flush pending text and clean up timers
flushSingleLogs();
flushPendingLogs();
flushGroupedLogs();
stopStreaming();
});
</script>
@@ -2005,7 +1959,7 @@ import type { FavoriteGroup } from '../api/preferences/favorite-groups/+server';
{#if multiModeSelections.size >= 2}
<Button size="sm" variant="default" onclick={mergeSelectedContainers} class="w-full h-7 gap-1.5 text-xs">
<Layers class="w-3 h-3" />
Merge {multiModeSelections.size} containers
Merge {multiModeSelections.size} containers logs
</Button>
{/if}
</div>
@@ -2206,7 +2160,7 @@ import type { FavoriteGroup } from '../api/preferences/favorite-groups/+server';
</button>
</div>
</div>
<div class="flex-1 overflow-auto p-4 relative" bind:this={logsRef}>
<div class="flex-1 overflow-auto p-4 relative" bind:this={logsRef} onscroll={handleLogsScroll}>
{#if loading && mergedLogs.length === 0}
<div class="absolute inset-0 flex items-center justify-center z-10">
<RefreshCw class="w-8 h-8 animate-spin {darkMode ? 'text-zinc-400' : 'text-gray-500'}" />
@@ -2216,7 +2170,7 @@ import type { FavoriteGroup } from '../api/preferences/favorite-groups/+server';
<RefreshCw class="w-8 h-8 animate-spin {darkMode ? 'text-zinc-400' : 'text-gray-500'}" />
</div>
{/if}
<pre class="font-mono {wordWrap ? 'whitespace-pre-wrap' : 'whitespace-pre'} {showLineNumbers ? 'show-line-numbers' : ''} {darkMode ? 'text-zinc-50' : 'text-gray-900'}" style="font-size: {fontSize}px;">{@html wrapHtmlLines(formattedMergedHtml())}</pre>
<pre class="font-mono {wordWrap ? 'whitespace-pre-wrap' : 'whitespace-pre'} {showLineNumbers ? 'show-line-numbers' : ''} {darkMode ? 'text-zinc-50' : 'text-gray-900'}" style="font-size: {fontSize}px;">{#each filteredMerged as e (e.id)}<div class="log-line">{#if showTimestamps && e.timestamp}<span class="log-ts">{renderTimestamp(e.timestamp)}</span>{' '}{/if}{#if showContainerName && e.containerName}<span class="log-cname" style="color:{e.color}">[{e.containerName}]</span>{' '}{/if}<span>{@html renderLineHtml(e, logSearchQuery.trim())}</span></div>{/each}</pre>
</div>
{/if}
{:else if !selectedContainer}
@@ -2475,8 +2429,8 @@ import type { FavoriteGroup } from '../api/preferences/favorite-groups/+server';
Loading logs...
</div>
{:else}
<div bind:this={logsRef} class="flex-1 overflow-auto p-4">
<pre class="font-mono {wordWrap ? 'whitespace-pre-wrap' : 'whitespace-pre'} {showLineNumbers ? 'show-line-numbers' : ''} {darkMode ? 'text-zinc-50' : 'text-gray-900'}" style="font-size: {fontSize}px;">{@html wrapHtmlLines(highlightedLogs())}</pre>
<div bind:this={logsRef} onscroll={handleLogsScroll} class="flex-1 overflow-auto p-4">
<pre class="font-mono {wordWrap ? 'whitespace-pre-wrap' : 'whitespace-pre'} {showLineNumbers ? 'show-line-numbers' : ''} {darkMode ? 'text-zinc-50' : 'text-gray-900'}" style="font-size: {fontSize}px;">{#each filteredLogs as e (e.id)}<div class="log-line">{#if showTimestamps && e.timestamp}<span class="log-ts">{renderTimestamp(e.timestamp)}</span>{' '}{/if}{#if showContainerName && selectedContainer?.name}<span class="log-cname">[{selectedContainer.name}]</span>{' '}{/if}<span>{@html renderLineHtml(e, logSearchQuery.trim())}</span></div>{/each}</pre>
</div>
{/if}
{/if}
@@ -2511,6 +2465,12 @@ import type { FavoriteGroup } from '../api/preferences/favorite-groups/+server';
{/if}
<style>
:global(.log-ts) {
color: rgb(113, 113, 122);
}
:global(.log-cname) {
font-weight: 600;
}
:global(.search-match) {
background-color: rgba(234, 179, 8, 0.4);
color: #fef3c7;
+140 -104
View File
@@ -1,16 +1,13 @@
<script lang="ts">
import { onMount, onDestroy, tick } from 'svelte';
import { X, GripHorizontal, RefreshCw, Copy, Download, WrapText, ArrowDownToLine, Search, ChevronUp, ChevronDown, Sun, Moon, Wifi, WifiOff, Pause, Play, Eraser, Filter, Clock, Tag, Hash } from 'lucide-svelte';
import { wrapHtmlLines } from '$lib/utils/log-lines';
import LogTimeRangeFilter from './LogTimeRangeFilter.svelte';
import { copyToClipboard } from '$lib/utils/clipboard';
import * as Select from '$lib/components/ui/select';
import { appSettings, formatLogTimestamps } from '$lib/stores/settings';
import { themeStore } from '$lib/stores/theme';
import { getMonospaceFont } from '$lib/themes';
import { AnsiUp } from 'ansi_up';
const ansiUp = new AnsiUp();
ansiUp.use_classes = true;
import { parseLines, renderLineHtml, type LogEntry } from '$lib/utils/log-entry';
interface Props {
containerId: string;
@@ -24,7 +21,7 @@
let { containerId, containerName, visible, envId, fillHeight = false, showCloseButton = true, onClose }: Props = $props();
let logs = $state('');
let logs = $state<LogEntry[]>([]);
let loading = $state(false);
let logsRef: HTMLDivElement;
let panelRef: HTMLDivElement;
@@ -35,6 +32,14 @@
let showContainerName = $state(typeof localStorage !== 'undefined' ? localStorage.getItem('dockhand-log-container-name') !== 'false' : true);
let showLineNumbers = $state(false);
function renderTimestamp(ts: string | undefined): string {
if (!ts) return '';
if ($appSettings.formatLogTimestamps) {
return formatLogTimestamps(ts + ' ').trimEnd();
}
return ts;
}
// SSE Streaming state
let streamingEnabled = $state(true);
let isConnected = $state(false);
@@ -47,10 +52,13 @@
const OFFLINE_POLL_INTERVAL = 5000; // Check every 5 seconds when offline
let offlinePollingInterval: ReturnType<typeof setInterval> | null = null;
// SSE batching - buffer incoming text and flush to state periodically
let pendingText = '';
let flushTimer: ReturnType<typeof setTimeout> | null = null;
const FLUSH_INTERVAL = 100; // ms
// SSE batching — collect parsed entries (and a carryover for split-across-chunk
// lines) and flush via microtask to coalesce a burst of "log" events into one
// state update. Compaction (slice to last MAX_LINES) runs at 2x cap.
let pendingEntries: LogEntry[] = [];
let streamCarryover = '';
let flushScheduled = false;
const COMPACT_FACTOR = 2;
// RAF-based auto-scroll
let scrollRafPending = false;
@@ -64,14 +72,14 @@
let logSearchInputRef: HTMLInputElement | undefined;
const fontSizeOptions = [10, 12, 14, 16];
// Capped at 1000 — larger initial replays freeze the browser since rendering
// isn't virtualized.
const tailOptions = [
{ value: '100', label: '100' },
{ value: '500', label: '500' },
{ value: '1000', label: '1K' },
{ value: '5000', label: '5K' },
{ value: '10000', label: '10K' },
{ value: 'all', label: 'All' }
{ value: '1000', label: '1K' }
];
const VALID_TAIL_VALUES = new Set(tailOptions.map(o => o.value));
// Tail count and time range filter
let tailCount = $state('500');
@@ -95,8 +103,9 @@
}
function reloadLogs() {
logs = '';
pendingText = '';
logs = [];
pendingEntries = [];
streamCarryover = '';
if (streamingEnabled && containerId && visible) {
loading = true;
startStreaming();
@@ -144,7 +153,10 @@
if (settings.autoScroll !== undefined) autoScroll = settings.autoScroll;
if (settings.streamingEnabled !== undefined) streamingEnabled = settings.streamingEnabled;
if (settings.logSearchFilterMode !== undefined) logSearchFilterMode = settings.logSearchFilterMode;
if (settings.tailCount !== undefined) tailCount = settings.tailCount;
// Old saved value may be '5000'/'10000'/'all' — snap down to a supported option.
if (settings.tailCount !== undefined) {
tailCount = VALID_TAIL_VALUES.has(settings.tailCount) ? settings.tailCount : '1000';
}
if (settings.showLineNumbers !== undefined) showLineNumbers = settings.showLineNumbers;
} catch {
// Ignore parse errors
@@ -209,37 +221,74 @@
return `${url}${separator}env=${envId}`;
}
// Flush buffered text to state
// Schedule a microtask flush — coalesces multiple SSE log events arriving in
// the same tick. queueMicrotask is preferred over setTimeout(0) because it
// runs before the next paint, so autoscroll lands in the same frame.
function scheduleFlush() {
if (flushScheduled) return;
flushScheduled = true;
queueMicrotask(flushLogs);
}
function flushLogs() {
if (flushTimer) {
clearTimeout(flushTimer);
flushTimer = null;
}
if (!pendingText) return;
logs += pendingText;
pendingText = '';
// Apply log buffer size limit (convert KB to characters, roughly 1 char = 1 byte)
const maxSize = $appSettings.logBufferSizeKb * 1024;
if (logs.length > maxSize) {
logs = logs.substring(logs.length - Math.floor(maxSize * 0.8));
}
flushScheduled = false;
if (pendingEntries.length === 0) return;
const maxLines = $appSettings.logMaxLines;
// If the incoming batch alone exceeds the cap (initial tail replay with
// a big `tail=` value), trim it BEFORE concatenating. Otherwise we'd
// briefly grow the buffer to logs.length + pendingEntries.length entries
// — for tail=5000 that means 5000 DOM nodes appear in one frame.
const incoming = pendingEntries.length > maxLines
? pendingEntries.slice(pendingEntries.length - maxLines)
: pendingEntries;
// 2x soft cap on the retained buffer to amortize slice cost across flushes.
const next = logs.length + incoming.length <= maxLines * COMPACT_FACTOR
? [...logs, ...incoming]
: [...logs.slice(Math.max(0, logs.length + incoming.length - maxLines)), ...incoming];
logs = next;
pendingEntries = [];
scrollToBottom();
}
// RAF-based scroll to bottom (coalesces multiple calls into one frame)
// Threshold (px) for "still at the bottom". Wheel events and momentum scrolling
// can land a few px off — keep it generous enough to feel sticky.
const BOTTOM_STICKINESS_PX = 40;
// Suppress the scroll listener while WE are writing scrollTop. Without this,
// our own programmatic scroll fires the handler, sees distanceFromBottom≈0,
// and re-enables autoScroll the moment the user paused it.
let programmaticScroll = false;
async function scrollToBottom() {
if (!autoScroll || !logsRef || scrollRafPending) return;
scrollRafPending = true;
await tick();
requestAnimationFrame(() => {
if (logsRef) logsRef.scrollTop = logsRef.scrollHeight;
if (logsRef) {
programmaticScroll = true;
logsRef.scrollTop = logsRef.scrollHeight;
// Clear the flag on the next frame so user-initiated scroll after
// our write is treated as user input.
requestAnimationFrame(() => { programmaticScroll = false; });
}
scrollRafPending = false;
});
}
// Auto-disable auto-scroll when the user scrolls up; re-enable when they
// return to the bottom. Lets the user read history without fighting the stream.
function handleLogsScroll() {
if (programmaticScroll || !logsRef) return;
const distance = logsRef.scrollHeight - logsRef.scrollTop - logsRef.clientHeight;
const atBottom = distance < BOTTOM_STICKINESS_PX;
if (atBottom && !autoScroll) {
autoScroll = true;
saveSettings();
} else if (!atBottom && autoScroll) {
autoScroll = false;
saveSettings();
}
}
// Start SSE streaming for logs
function startStreaming() {
if (!containerId || !streamingEnabled) return;
@@ -266,20 +315,14 @@
try {
const data = JSON.parse(event.data);
if (data.text) {
// Add container name prefix to each line if available and enabled
let text = data.text;
if (data.containerName && showContainerName) {
const lines = text.split('\n');
text = lines.map((line: string, i: number) => {
if (line === '' && i === lines.length - 1) return line;
if (line === '') return line;
return `[${data.containerName}] ${line}`;
}).join('\n');
}
// Buffer text and schedule flush
pendingText += text;
if (!flushTimer) {
flushTimer = setTimeout(flushLogs, FLUSH_INTERVAL);
// Parse incoming text into discrete LogEntry items. A streaming chunk
// may begin/end mid-line; streamCarryover preserves the partial tail
// across chunks so we don't split a line in the middle.
const { entries, carryover } = parseLines(data.text, streamCarryover);
streamCarryover = carryover;
if (entries.length > 0) {
pendingEntries.push(...entries);
scheduleFlush();
}
}
} catch {
@@ -370,7 +413,8 @@
function retryConnection() {
reconnectAttempts = 0;
connectionError = null;
logs = '';
logs = [];
streamCarryover = '';
loading = true;
startStreaming();
}
@@ -422,7 +466,8 @@
streamingEnabled = !streamingEnabled;
saveSettings();
if (streamingEnabled && containerId && visible) {
logs = ''; // Clear logs and start fresh stream
logs = [];
streamCarryover = '';
reconnectAttempts = 0;
connectionError = null;
loading = true;
@@ -455,7 +500,8 @@
}
}
// Fallback fetch logs (for manual refresh or when streaming unavailable)
// Errors aren't log content — surface them via connectionError so the panel
// can show them outside the LogEntry stream.
async function fetchLogs() {
if (!containerId) return;
loading = true;
@@ -466,14 +512,17 @@
const response = await fetch(appendEnvParam(`/api/containers/${containerId}/logs?tail=${tailCount}${since ? `&since=${since}` : ''}${until ? `&until=${until}` : ''}`, envId));
const data = await response.json();
if (!response.ok) {
logs = `Failed to fetch logs: ${data.error || response.statusText}`;
connectionError = `Failed to fetch logs: ${data.error || response.statusText}`;
logs = [];
return;
}
logs = data.logs || '';
const { entries } = parseLines(data.logs || '');
logs = entries;
scrollToBottom();
} catch (error) {
console.error('Failed to fetch logs:', error);
logs = `Failed to fetch logs: ${error instanceof Error ? error.message : 'Unknown error'}`;
connectionError = `Failed to fetch logs: ${error instanceof Error ? error.message : 'Unknown error'}`;
logs = [];
} finally {
loading = false;
}
@@ -481,7 +530,8 @@
function handleClose() {
stopStreaming();
logs = '';
logs = [];
streamCarryover = '';
onClose();
}
@@ -503,17 +553,23 @@
saveSettings();
}
// Copy logs to clipboard
// Serialize the current buffer to plain text. Honors the timestamp toggle so
// users get what they see on screen.
function logsToText(): string {
return logs
.map(e => (showTimestamps && e.timestamp ? `${e.timestamp} ${e.text}` : e.text))
.join('\n');
}
async function copyLogs() {
if (logs) {
await copyToClipboard(logs);
if (logs.length > 0) {
await copyToClipboard(logsToText());
}
}
// Download logs as txt file
function downloadLogs() {
if (logs && containerName) {
const blob = new Blob([logs], { type: 'text/plain' });
if (logs.length > 0 && containerName) {
const blob = new Blob([logsToText()], { type: 'text/plain' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
@@ -527,8 +583,9 @@
// Clear logs buffer
function clearLogs() {
logs = '';
pendingText = '';
logs = [];
pendingEntries = [];
streamCarryover = '';
}
// Search functions
@@ -590,47 +647,19 @@
}
}
// Highlighted logs with search matches and ANSI color support
let highlightedLogs = $derived(() => {
let text = logs || '';
if (!showTimestamps) {
// Strip ISO 8601 timestamps from start of each line (Docker log format)
text = text.replace(/^(\[.*?\] )?\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?Z /gm, '$1');
} else if ($appSettings.formatLogTimestamps) {
text = formatLogTimestamps(text);
}
// Filter pass — array op, not regex over a string. Empty query short-circuits.
let filteredLogs = $derived.by(() => {
const query = logSearchQuery.trim();
// Filter lines before ANSI conversion (plain text matching)
if (logSearchFilterMode && query) {
const escapedForRegex = query.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
const filterRegex = new RegExp(escapedForRegex, 'i');
const lines = text.split('\n');
text = lines.filter(line => filterRegex.test(line)).join('\n');
}
const withAnsi = ansiUp.ansi_to_html(text);
if (!query) return withAnsi;
const escapedForRegex = query.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
const escapedQuery = escapedForRegex.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
// Split by HTML tags and only process text parts
const parts = withAnsi.split(/(<[^>]*>)/);
const highlighted = parts.map(part => {
if (part.startsWith('<')) return part;
const regex = new RegExp(`(${escapedQuery})`, 'gi');
return part.replace(regex, '<mark class="search-match">$1</mark>');
}).join('');
return highlighted;
if (!logSearchFilterMode || !query) return logs;
const q = query.toLowerCase();
return logs.filter(e => e.text.toLowerCase().includes(q));
});
// Update match count after render
// Update match count after render. Track filteredLogs + query so this re-runs
// when the displayed buffer or the search input changes.
$effect(() => {
const html = highlightedLogs();
filteredLogs;
logSearchQuery;
if (logSearchQuery && logsRef) {
setTimeout(() => {
const matches = logsRef.querySelectorAll('.search-match');
@@ -650,7 +679,8 @@
// Start streaming when container changes and is visible
$effect(() => {
if (containerId && visible && streamingEnabled) {
logs = ''; // Clear previous logs
logs = [];
streamCarryover = '';
loading = true;
reconnectAttempts = 0;
connectionError = null;
@@ -932,9 +962,9 @@
</div>
<!-- Logs content -->
<div bind:this={logsRef} class="flex-1 overflow-auto p-3">
{#if logs}
<pre class="logs-fade-in {wordWrap ? 'whitespace-pre-wrap' : 'whitespace-pre'} {showLineNumbers ? 'show-line-numbers' : ''} {darkMode ? 'text-zinc-50' : 'text-gray-900'}" style="font-size: {fontSize}px; font-family: {terminalFontFamily()};">{@html wrapHtmlLines(highlightedLogs())}</pre>
<div bind:this={logsRef} onscroll={handleLogsScroll} class="flex-1 overflow-auto p-3">
{#if logs.length > 0}
<pre class="logs-fade-in {wordWrap ? 'whitespace-pre-wrap' : 'whitespace-pre'} {showLineNumbers ? 'show-line-numbers' : ''} {darkMode ? 'text-zinc-50' : 'text-gray-900'}" style="font-size: {fontSize}px; font-family: {terminalFontFamily()};">{#each filteredLogs as e (e.id)}<div class="log-line">{#if showTimestamps && e.timestamp}<span class="log-ts">{renderTimestamp(e.timestamp)}</span>{' '}{/if}{#if showContainerName && containerName}<span class="log-cname">[{containerName}]</span>{' '}{/if}<span>{@html renderLineHtml(e, logSearchQuery.trim())}</span></div>{/each}</pre>
{:else if loading}
<p class="text-xs {darkMode ? 'text-zinc-500' : 'text-gray-500'}">Connecting to log stream...</p>
{:else}
@@ -944,6 +974,12 @@
</div>
<style>
:global(.log-ts) {
color: rgb(113, 113, 122);
}
:global(.log-cname) {
color: rgb(161, 161, 170);
}
:global(.search-match) {
background-color: rgba(234, 179, 8, 0.4);
color: #fef3c7;
+29 -25
View File
@@ -77,7 +77,7 @@ services:
let eventCleanupEnabled = $derived($appSettings.eventCleanupEnabled);
let scannerCleanupCron = $derived($appSettings.scannerCleanupCron);
let scannerCleanupEnabled = $derived($appSettings.scannerCleanupEnabled);
let logBufferSizeKb = $derived($appSettings.logBufferSizeKb);
let logMaxLines = $derived($appSettings.logMaxLines);
let formatLogTimestamps = $derived($appSettings.formatLogTimestamps);
let defaultTimezone = $derived($appSettings.defaultTimezone);
let eventCollectionMode = $derived($appSettings.eventCollectionMode);
@@ -192,9 +192,17 @@ services:
}
}
function handleLogBufferSizeChange(e: Event) {
const value = Math.max(100, Math.min(5000, parseInt((e.target as HTMLInputElement).value) || 500));
appSettings.setLogBufferSizeKb(value);
// Anything above 2K starts feeling laggy in browsers without virtualized rendering.
const logMaxLinesOptions = [
{ value: '500', label: '500 lines' },
{ value: '1000', label: '1,000 lines' },
{ value: '2000', label: '2,000 lines' }
];
function handleLogMaxLinesChange(value: string | undefined) {
const n = parseInt(value ?? '');
if (!Number.isFinite(n) || n <= 0) return;
appSettings.setLogMaxLines(Math.min(2000, Math.max(100, n)));
toast.success('Log buffer size updated');
}
@@ -439,27 +447,23 @@ services:
<div class="grid grid-cols-1 md:grid-cols-2 gap-x-6 gap-y-4">
<div class="space-y-4">
<div class="space-y-2">
<Label for="log-buffer-size">Log buffer size (KB)</Label>
<div class="flex items-center gap-2">
<Input
id="log-buffer-size"
type="number"
min="100"
max="5000"
value={logBufferSizeKb}
onchange={handleLogBufferSizeChange}
disabled={!$canAccess('settings', 'edit')}
class="w-24"
/>
<span class="text-sm text-muted-foreground">KB</span>
</div>
<p class="text-xs text-muted-foreground">Maximum log buffer per container panel. Older logs are truncated when exceeded.</p>
{#if logBufferSizeKb > 1000}
<div class="flex items-start gap-2 p-2 rounded-md bg-amber-500/10 border border-amber-500/20">
<AlertTriangle class="w-4 h-4 text-amber-500 shrink-0 mt-0.5" />
<p class="text-xs text-amber-600 dark:text-amber-400">High values may degrade browser performance with verbose containers. Recommended: 250-1000 KB.</p>
</div>
{/if}
<Label for="log-max-lines">Log buffer size</Label>
<Select.Root
type="single"
value={String(logMaxLines)}
onValueChange={handleLogMaxLinesChange}
disabled={!$canAccess('settings', 'edit')}
>
<Select.Trigger id="log-max-lines" class="w-48">
{logMaxLines.toLocaleString()} lines
</Select.Trigger>
<Select.Content>
{#each logMaxLinesOptions as opt}
<Select.Item value={opt.value}>{opt.label}</Select.Item>
{/each}
</Select.Content>
</Select.Root>
<p class="text-xs text-muted-foreground">Maximum number of log lines kept per container panel. Older lines are dropped when the limit is exceeded.</p>
</div>
<div class="space-y-1">
<div class="flex items-center gap-3">