Pulse/frontend-modern/src/components/Docker/DockerUnifiedTable.tsx
rcourtman 5a328637af Improve drawer layout and UX for Docker and Proxmox pages
- Add flex-1 to all drawer cards for consistent space filling
- Implement single-drawer-open behavior (accordion-style)
- Add text truncation to labels and IP badges to prevent overflow
- Replace Map-based state with reactive signals for better performance
2025-11-05 14:46:13 +00:00

2147 lines
86 KiB
TypeScript

import { Component, For, Show, createMemo, createSignal, createEffect } from 'solid-js';
import type { DockerHost, DockerContainer, DockerService, DockerTask } from '@/types/api';
import { Card } from '@/components/shared/Card';
import { ScrollableTable } from '@/components/shared/ScrollableTable';
import { EmptyState } from '@/components/shared/EmptyState';
import { MetricBar } from '@/components/Dashboard/MetricBar';
import { formatBytes, formatPercent, formatUptime, formatRelativeTime, formatAbsoluteTime } from '@/utils/format';
import type { DockerMetadata } from '@/api/dockerMetadata';
import { DockerMetadataAPI } from '@/api/dockerMetadata';
import { resolveHostRuntime } from './runtimeDisplay';
import { showSuccess, showError } from '@/utils/toast';
import { logger } from '@/utils/logger';
const OFFLINE_HOST_STATUSES = new Set(['offline', 'error', 'unreachable', 'down', 'disconnected']);
const DEGRADED_HOST_STATUSES = new Set([
'degraded',
'warning',
'maintenance',
'partial',
'initializing',
'unknown',
]);
const STOPPED_CONTAINER_STATES = new Set(['exited', 'stopped', 'created', 'paused']);
const ERROR_CONTAINER_STATES = new Set([
'restarting',
'dead',
'removing',
'failed',
'error',
'oomkilled',
'unhealthy',
]);
const typeBadgeClass = (type: 'container' | 'service' | 'task' | 'unknown') => {
switch (type) {
case 'container':
return 'bg-blue-100 text-blue-700 dark:bg-blue-900/40 dark:text-blue-200';
case 'service':
return 'bg-purple-50 text-purple-700 dark:bg-purple-900/30 dark:text-purple-200';
case 'task':
return 'bg-slate-200 text-slate-600 dark:bg-slate-700 dark:text-slate-200';
default:
return 'bg-gray-200 text-gray-600 dark:bg-gray-700 dark:text-gray-300';
}
};
type StatsFilter =
| { type: 'host-status'; value: string }
| { type: 'container-state'; value: string }
| { type: 'service-health'; value: string }
| null;
type SearchToken = { key?: string; value: string };
type DockerRow =
| {
kind: 'container';
id: string;
host: DockerHost;
container: DockerContainer;
}
| {
kind: 'service';
id: string;
host: DockerHost;
service: DockerService;
tasks: DockerTask[];
};
interface DockerUnifiedTableProps {
hosts: DockerHost[];
searchTerm?: string;
statsFilter?: StatsFilter;
selectedHostId?: () => string | null;
dockerMetadata?: Record<string, DockerMetadata>;
onCustomUrlUpdate?: (resourceId: string, url: string) => void;
}
// Global state for currently expanded drawer (only one drawer open at a time)
const [currentlyExpandedRowId, setCurrentlyExpandedRowId] = createSignal<string | null>(null);
// Global editing state for Docker resource URLs
const [currentlyEditingDockerResourceId, setCurrentlyEditingDockerResourceId] = createSignal<string | null>(null);
const dockerEditingValues = new Map<string, string>();
const [dockerEditingValuesVersion, setDockerEditingValuesVersion] = createSignal(0);
const toLower = (value?: string | null) => value?.toLowerCase() ?? '';
const ensureMs = (value?: number | string | null): number | null => {
if (!value) return null;
if (typeof value === 'number') {
return value > 1e12 ? value : value * 1000;
}
const parsed = Date.parse(value);
return Number.isNaN(parsed) ? null : parsed;
};
const parseSearchTerm = (term?: string): SearchToken[] => {
if (!term) return [];
return term
.trim()
.split(/\s+/)
.filter(Boolean)
.map((token) => {
const [rawKey, ...rest] = token.split(':');
if (rest.length === 0) {
return { value: token.toLowerCase() };
}
return { key: rawKey.toLowerCase(), value: rest.join(':').toLowerCase() };
});
};
interface PodmanMetadataItem {
label: string;
value?: string;
}
interface PodmanMetadataSection {
title: string;
items: PodmanMetadataItem[];
}
const PODMAN_METADATA_GROUPS: Array<{
title: string;
prefixes?: string[];
keys?: string[];
}> = [
{
title: 'Pod',
prefixes: ['io.podman.annotations.pod.', 'io.podman.pod.', 'net.containers.podman.pod.'],
},
{
title: 'Compose',
prefixes: ['io.podman.compose.'],
},
{
title: 'Auto Update',
prefixes: ['io.containers.autoupdate.'],
keys: ['io.containers.autoupdate'],
},
{
title: 'User Namespace',
keys: ['io.podman.annotations.userns', 'io.containers.userns'],
},
{
title: 'Capabilities',
keys: ['io.containers.capabilities', 'io.containers.selinux', 'io.containers.seccomp'],
},
{
title: 'Podman Annotations',
prefixes: ['io.podman.annotations.'],
},
{
title: 'Container Settings',
prefixes: ['io.containers.'],
},
];
const humanizePodmanKey = (raw: string): string => {
if (!raw) return 'Value';
const cleaned = raw.replace(/[_\-.]+/g, ' ').trim();
if (!cleaned) return 'Value';
return cleaned
.split(' ')
.map((segment) => {
if (!segment) return segment;
if (segment.toUpperCase() === segment) return segment;
return segment.charAt(0).toUpperCase() + segment.slice(1);
})
.join(' ')
.replace(/\bId\b/g, 'ID')
.replace(/\bUrl\b/g, 'URL');
};
const stripPrefix = (key: string, prefixes: string[] = []): string => {
for (const prefix of prefixes) {
if (prefix && key.startsWith(prefix)) {
const stripped = key.slice(prefix.length);
if (stripped) {
return stripped;
}
}
}
const lastDot = key.lastIndexOf('.');
if (lastDot >= 0 && lastDot < key.length - 1) {
return key.slice(lastDot + 1);
}
return key;
};
const buildPodmanMetadataSections = (
metadata?: DockerContainer['podman'],
labels?: Record<string, string>,
): PodmanMetadataSection[] => {
const sections: PodmanMetadataSection[] = [];
const consumed = new Set<string>();
const markConsumed = (...keys: (string | undefined)[]) => {
keys.forEach((key) => {
if (key) consumed.add(key);
});
};
const pushSection = (title: string, items: PodmanMetadataItem[]) => {
if (items.length > 0) {
sections.push({ title, items });
}
};
if (metadata) {
const podItems: PodmanMetadataItem[] = [];
if (metadata.podName) {
podItems.push({ label: 'Pod Name', value: metadata.podName });
markConsumed('io.podman.annotations.pod.name');
}
if (metadata.podId) {
podItems.push({ label: 'Pod ID', value: metadata.podId });
markConsumed('io.podman.annotations.pod.id');
}
if (metadata.infra !== undefined) {
podItems.push({ label: 'Infra Container', value: metadata.infra ? 'true' : 'false' });
markConsumed('io.podman.annotations.pod.infra');
}
pushSection('Pod', podItems);
const composeItems: PodmanMetadataItem[] = [];
if (metadata.composeProject) {
composeItems.push({ label: 'Project', value: metadata.composeProject });
markConsumed('io.podman.compose.project');
}
if (metadata.composeService) {
composeItems.push({ label: 'Service', value: metadata.composeService });
markConsumed('io.podman.compose.service');
}
if (metadata.composeWorkdir) {
composeItems.push({ label: 'Working Dir', value: metadata.composeWorkdir });
markConsumed('io.podman.compose.working_dir');
}
if (metadata.composeConfigHash) {
composeItems.push({ label: 'Config Hash', value: metadata.composeConfigHash });
markConsumed('io.podman.compose.config-hash');
}
pushSection('Compose', composeItems);
const autoUpdateItems: PodmanMetadataItem[] = [];
if (metadata.autoUpdatePolicy) {
autoUpdateItems.push({ label: 'Policy', value: metadata.autoUpdatePolicy });
markConsumed('io.containers.autoupdate');
}
if (metadata.autoUpdateRestart) {
autoUpdateItems.push({ label: 'Restart', value: metadata.autoUpdateRestart });
markConsumed('io.containers.autoupdate.restart');
}
pushSection('Auto Update', autoUpdateItems);
const namespaceItems: PodmanMetadataItem[] = [];
if (metadata.userNamespace) {
namespaceItems.push({ label: 'User Namespace', value: metadata.userNamespace });
markConsumed('io.podman.annotations.userns', 'io.containers.userns');
}
pushSection('Security', namespaceItems);
}
if (!labels || Object.keys(labels).length === 0) {
return sections;
}
const entries = Object.entries(labels);
const remaining = entries.filter(
([key]) =>
!consumed.has(key) && (key.includes('podman') || key.startsWith('io.containers.')),
);
if (remaining.length === 0) {
return sections;
}
const used = new Set<string>();
const addSection = (title: string, prefixes: string[] = [], keys: string[] = []) => {
const items: Array<[string, string]> = [];
for (const [key, value] of remaining) {
if (used.has(key)) continue;
const matchesPrefix = prefixes.some((prefix) => prefix && key.startsWith(prefix));
const matchesKey = keys.includes(key);
if (!matchesPrefix && !matchesKey) continue;
items.push([key, value]);
used.add(key);
}
if (items.length === 0) return;
sections.push({
title,
items: items.map(([key, value]) => ({
label: humanizePodmanKey(stripPrefix(key, prefixes)),
value: value || undefined,
})),
});
};
for (const group of PODMAN_METADATA_GROUPS) {
addSection(group.title, group.prefixes ?? [], group.keys ?? []);
}
const leftovers = remaining.filter(([key]) => !used.has(key));
if (leftovers.length > 0) {
sections.push({
title: 'Additional Podman Labels',
items: leftovers.map(([key, value]) => ({
label: humanizePodmanKey(stripPrefix(key)),
value: value || undefined,
})),
});
}
return sections;
};
const findContainerForTask = (containers: DockerContainer[], task: DockerTask) => {
if (!containers.length) return undefined;
const taskId = task.containerId?.toLowerCase() ?? '';
const taskName = task.containerName?.toLowerCase() ?? '';
const taskNameBase = taskName.split('.')[0] || taskName;
return containers.find((container) => {
const id = container.id?.toLowerCase() ?? '';
const name = container.name?.toLowerCase() ?? '';
const idMatch = !!taskId && (id === taskId || id.includes(taskId) || taskId.includes(id));
const nameMatch =
!!taskName &&
(name === taskName ||
name.includes(taskName) ||
taskName.includes(name) ||
(!!taskNameBase && (name === taskNameBase || name.includes(taskNameBase))));
return idMatch || nameMatch;
});
};
const hostMatchesFilter = (filter: StatsFilter, host: DockerHost) => {
if (!filter || filter.type !== 'host-status') return true;
const status = toLower(host.status);
if (filter.value === 'offline') {
return OFFLINE_HOST_STATUSES.has(status);
}
if (filter.value === 'degraded') {
return DEGRADED_HOST_STATUSES.has(status) || status === 'degraded';
}
if (filter.value === 'online') {
return status === 'online';
}
return true;
};
const containerMatchesStateFilter = (filter: StatsFilter, container: DockerContainer) => {
if (!filter || filter.type !== 'container-state') return true;
const state = toLower(container.state);
if (filter.value === 'running') return state === 'running';
if (filter.value === 'stopped') return STOPPED_CONTAINER_STATES.has(state);
if (filter.value === 'error') {
return ERROR_CONTAINER_STATES.has(state) || toLower(container.health) === 'unhealthy';
}
return true;
};
const serviceMatchesHealthFilter = (filter: StatsFilter, service: DockerService) => {
if (!filter || filter.type !== 'service-health') return true;
const desired = service.desiredTasks ?? 0;
const running = service.runningTasks ?? 0;
if (filter.value === 'degraded') {
return desired > 0 && running < desired;
}
if (filter.value === 'healthy') {
return desired > 0 && running >= desired;
}
return true;
};
const containerMatchesToken = (
token: SearchToken,
host: DockerHost,
container: DockerContainer,
) => {
const state = toLower(container.state);
const health = toLower(container.health);
const hostName = toLower(host.displayName ?? host.hostname ?? host.id);
if (token.key === 'name') {
return (
toLower(container.name).includes(token.value) ||
toLower(container.id).includes(token.value)
);
}
if (token.key === 'image') {
return toLower(container.image).includes(token.value);
}
if (token.key === 'host') {
return hostName.includes(token.value);
}
if (token.key === 'pod') {
const pod = container.podman?.podName?.toLowerCase() ?? '';
return pod.includes(token.value);
}
if (token.key === 'compose') {
const project = container.podman?.composeProject?.toLowerCase() ?? '';
const service = container.podman?.composeService?.toLowerCase() ?? '';
return project.includes(token.value) || service.includes(token.value);
}
if (token.key === 'state') {
return state.includes(token.value) || health.includes(token.value);
}
const fields: string[] = [
container.name,
container.id,
container.image,
container.status,
container.state,
container.health,
host.displayName,
host.hostname,
host.id,
]
.filter(Boolean)
.map((value) => value!.toLowerCase());
if (container.podman) {
[
container.podman.podName,
container.podman.podId,
container.podman.composeProject,
container.podman.composeService,
container.podman.autoUpdatePolicy,
container.podman.userNamespace,
]
.filter(Boolean)
.forEach((value) => fields.push(value!.toLowerCase()));
}
if (container.labels) {
Object.entries(container.labels).forEach(([key, value]) => {
fields.push(key.toLowerCase());
if (value) fields.push(value.toLowerCase());
});
}
if (container.ports) {
container.ports.forEach((port) => {
const parts = [port.privatePort, port.publicPort, port.protocol, port.ip]
.filter(Boolean)
.map(String)
.join(':')
.toLowerCase();
if (parts) fields.push(parts);
});
}
return fields.some((field) => field.includes(token.value));
};
const serviceMatchesToken = (token: SearchToken, host: DockerHost, service: DockerService) => {
const hostName = toLower(host.displayName ?? host.hostname ?? host.id);
const serviceName = toLower(service.name ?? service.id);
const image = toLower(service.image);
if (token.key === 'name') {
return serviceName.includes(token.value);
}
if (token.key === 'image') {
return image.includes(token.value);
}
if (token.key === 'host') {
return hostName.includes(token.value);
}
if (token.key === 'state') {
const desired = service.desiredTasks ?? 0;
const running = service.runningTasks ?? 0;
const status = desired > 0 && running >= desired ? 'healthy' : 'degraded';
return status.includes(token.value);
}
const fields: string[] = [
service.name,
service.id,
service.image,
service.stack,
service.mode,
host.displayName,
host.hostname,
host.id,
]
.filter(Boolean)
.map((value) => value!.toLowerCase());
if (service.labels) {
Object.entries(service.labels).forEach(([key, value]) => {
fields.push(key.toLowerCase());
if (value) fields.push(value.toLowerCase());
});
}
return fields.some((field) => field.includes(token.value));
};
const serviceHealthBadge = (service: DockerService) => {
const desired = service.desiredTasks ?? 0;
const running = service.runningTasks ?? 0;
if (desired === 0) {
return {
label: 'No tasks',
class: 'bg-gray-200 text-gray-600 dark:bg-gray-700 dark:text-gray-300',
};
}
if (running >= desired) {
return {
label: 'Healthy',
class: 'bg-green-100 text-green-700 dark:bg-green-900/40 dark:text-green-300',
};
}
return {
label: `Degraded (${running}/${desired})`,
class: 'bg-amber-100 text-amber-700 dark:bg-amber-900/40 dark:text-amber-300',
};
};
const buildRowId = (host: DockerHost, row: DockerRow) => {
if (row.kind === 'container') {
return `container:${host.id}:${row.container.id ?? row.container.name}`;
}
return `service:${host.id}:${row.service.id ?? row.service.name}`;
};
const GROUPED_RESOURCE_INDENT = 'pl-5 sm:pl-6 lg:pl-8';
const DockerHostGroupHeader: Component<{ host: DockerHost; colspan: number }> = (props) => {
const displayName = props.host.displayName || props.host.hostname || props.host.id;
return (
<tr class="bg-gray-50 dark:bg-gray-900/40">
<td colSpan={props.colspan} class="py-0.5 pr-2 pl-4">
<div class="flex flex-nowrap items-center gap-2 whitespace-nowrap text-sm font-semibold text-slate-700 dark:text-slate-100">
<span>{displayName}</span>
<Show when={props.host.displayName && props.host.displayName !== props.host.hostname}>
<span class="text-[10px] font-medium text-slate-500 dark:text-slate-400">
({props.host.hostname})
</span>
</Show>
</div>
</td>
</tr>
);
};
const DockerContainerRow: Component<{
row: Extract<DockerRow, { kind: 'container' }>;
columns: number;
customUrl?: string;
onCustomUrlUpdate?: (resourceId: string, url: string) => void;
}> = (props) => {
const { host, container } = props.row;
const runtimeInfo = resolveHostRuntime(host);
const runtimeVersion = () => host.runtimeVersion || host.dockerVersion || null;
const rowId = buildRowId(host, props.row);
const resourceId = () => `${host.id}:container:${container.id || container.name}`;
const isEditingUrl = createMemo(() => currentlyEditingDockerResourceId() === resourceId());
const [customUrl, setCustomUrl] = createSignal<string | undefined>(props.customUrl);
const [shouldAnimateIcon, setShouldAnimateIcon] = createSignal(false);
const expanded = createMemo(() => currentlyExpandedRowId() === rowId);
const editingUrlValue = createMemo(() => {
dockerEditingValuesVersion(); // Subscribe to changes
return dockerEditingValues.get(resourceId()) || '';
});
let urlInputRef: HTMLInputElement | undefined;
const writableLayerBytes = createMemo(() => container.writableLayerBytes ?? 0);
const rootFilesystemBytes = createMemo(() => container.rootFilesystemBytes ?? 0);
const hasDiskStats = createMemo(() => writableLayerBytes() > 0 || rootFilesystemBytes() > 0);
const diskPercent = createMemo<number | null>(() => {
const total = rootFilesystemBytes();
if (!total || total <= 0) return null;
const used = writableLayerBytes();
if (used <= 0) return 0;
return Math.min(100, (used / total) * 100);
});
const diskUsageLabel = createMemo(() => {
const used = writableLayerBytes();
if (used <= 0) return '0 B';
return formatBytes(used, 0);
});
const diskSublabel = createMemo<string | undefined>(() => {
const total = rootFilesystemBytes();
if (!total || total <= 0) return undefined;
return `${diskUsageLabel()} / ${formatBytes(total, 0)}`;
});
const createdRelative = createMemo(() => (container.createdAt ? formatRelativeTime(container.createdAt) : null));
const createdAbsolute = createMemo(() => (container.createdAt ? formatAbsoluteTime(container.createdAt) : null));
const startedRelative = createMemo(() =>
container.startedAt ? formatRelativeTime(container.startedAt) : null,
);
const startedAbsolute = createMemo(() =>
container.startedAt ? formatAbsoluteTime(container.startedAt) : null,
);
const mounts = createMemo(() => container.mounts || []);
const hasMounts = createMemo(() => mounts().length > 0);
const blockIo = createMemo(() => container.blockIo);
const blockIoReadBytes = createMemo(() => blockIo()?.readBytes ?? 0);
const blockIoWriteBytes = createMemo(() => blockIo()?.writeBytes ?? 0);
const blockIoReadRate = createMemo(() => blockIo()?.readRateBytesPerSecond ?? null);
const blockIoWriteRate = createMemo(() => blockIo()?.writeRateBytesPerSecond ?? null);
const formatIoRate = (value?: number | null) => {
if (value === undefined || value === null) return undefined;
if (value <= 0) return undefined;
const decimals = value >= 1024 * 1024 ? 1 : value >= 1024 ? 1 : 0;
return `${formatBytes(value, decimals)}/s`;
};
const blockIoReadRateLabel = createMemo(() => formatIoRate(blockIoReadRate()));
const blockIoWriteRateLabel = createMemo(() => formatIoRate(blockIoWriteRate()));
const podmanMetadata = createMemo(() => container.podman);
const podName = createMemo(() => podmanMetadata()?.podName?.trim() || undefined);
const isPodInfra = createMemo(() => podmanMetadata()?.infra ?? false);
const podmanMetadataSections = createMemo(() =>
buildPodmanMetadataSections(podmanMetadata(), container.labels),
);
const hasPodmanMetadata = createMemo(
() => !!podmanMetadata() || podmanMetadataSections().length > 0,
);
const hasBlockIo = createMemo(() => {
const stats = blockIo();
if (!stats) return false;
const read = stats.readBytes ?? 0;
const write = stats.writeBytes ?? 0;
const readRate = stats.readRateBytesPerSecond ?? 0;
const writeRate = stats.writeRateBytesPerSecond ?? 0;
return read > 0 || write > 0 || readRate > 0 || writeRate > 0;
});
const hasDrawerContent = createMemo(() => {
return (
(container.ports && container.ports.length > 0) ||
(container.labels && Object.keys(container.labels).length > 0) ||
(container.networks && container.networks.length > 0) ||
hasMounts() ||
hasBlockIo() ||
hasPodmanMetadata()
);
});
// Update custom URL when prop changes, but only if we're not currently editing
createEffect(() => {
if (currentlyEditingDockerResourceId() !== resourceId()) {
const prevUrl = customUrl();
const newUrl = props.customUrl;
// Only animate when URL transitions from empty to having a value
if (!prevUrl && newUrl) {
setShouldAnimateIcon(true);
setTimeout(() => setShouldAnimateIcon(false), 200);
}
setCustomUrl(newUrl);
}
});
// Auto-focus the input when editing starts
createEffect(() => {
if (isEditingUrl() && urlInputRef) {
urlInputRef.focus();
urlInputRef.select();
}
});
const toggle = (event: MouseEvent) => {
if (!hasDrawerContent()) return;
const target = event.target as HTMLElement;
if (target.closest('a, button, input, [data-prevent-toggle]')) return;
// Toggle: if this row is currently expanded, close it; otherwise open it (closing any other)
setCurrentlyExpandedRowId(prev => prev === rowId ? null : rowId);
};
const startEditingUrl = (event: MouseEvent) => {
event.stopPropagation();
// If another resource is being edited, save it first
const currentEditing = currentlyEditingDockerResourceId();
if (currentEditing !== null && currentEditing !== resourceId()) {
const currentInput = document.querySelector(`input[data-resource-id="${currentEditing}"]`) as HTMLInputElement;
if (currentInput) {
currentInput.blur();
}
}
dockerEditingValues.set(resourceId(), customUrl() || '');
setDockerEditingValuesVersion(v => v + 1);
setCurrentlyEditingDockerResourceId(resourceId());
};
// Add global click handler to close editor
createEffect(() => {
if (isEditingUrl()) {
const handleGlobalClick = (e: MouseEvent) => {
if (currentlyEditingDockerResourceId() !== resourceId()) return;
const target = e.target as HTMLElement;
const isClickingResourceName = target.closest('[data-resource-name-editable]');
if (!target.closest('[data-url-editor]') && !isClickingResourceName) {
e.preventDefault();
e.stopPropagation();
e.stopImmediatePropagation();
cancelEditingUrl();
}
};
const handleGlobalMouseDown = (e: MouseEvent) => {
if (currentlyEditingDockerResourceId() !== resourceId()) return;
const target = e.target as HTMLElement;
const isClickingResourceName = target.closest('[data-resource-name-editable]');
if (!target.closest('[data-url-editor]') && !isClickingResourceName) {
e.preventDefault();
e.stopPropagation();
e.stopImmediatePropagation();
}
};
document.addEventListener('mousedown', handleGlobalMouseDown, true);
document.addEventListener('click', handleGlobalClick, true);
return () => {
document.removeEventListener('mousedown', handleGlobalMouseDown, true);
document.removeEventListener('click', handleGlobalClick, true);
};
}
});
const saveUrl = async () => {
if (currentlyEditingDockerResourceId() !== resourceId()) return;
const newUrl = (dockerEditingValues.get(resourceId()) || '').trim();
// Clear global editing state
dockerEditingValues.delete(resourceId());
setDockerEditingValuesVersion(v => v + 1);
setCurrentlyEditingDockerResourceId(null);
// If URL hasn't changed, don't save
if (newUrl === (customUrl() || '')) return;
// Animate if transitioning from no URL to having a URL
const hadUrl = !!customUrl();
if (!hadUrl && newUrl) {
setShouldAnimateIcon(true);
setTimeout(() => setShouldAnimateIcon(false), 200);
}
// Optimistically update local and parent state immediately
setCustomUrl(newUrl || undefined);
if (props.onCustomUrlUpdate) {
props.onCustomUrlUpdate(resourceId(), newUrl);
}
try {
await DockerMetadataAPI.updateMetadata(resourceId(), { customUrl: newUrl });
if (newUrl) {
showSuccess('Container URL saved');
} else {
showSuccess('Container URL cleared');
}
} catch (err: any) {
logger.error('Failed to save container URL:', err);
showError(err.message || 'Failed to save container URL');
// Revert on error
setCustomUrl(hadUrl ? customUrl() : undefined);
if (props.onCustomUrlUpdate) {
props.onCustomUrlUpdate(resourceId(), hadUrl ? customUrl() || '' : '');
}
}
};
const deleteUrl = async () => {
if (currentlyEditingDockerResourceId() !== resourceId()) return;
// Clear global editing state
dockerEditingValues.delete(resourceId());
setDockerEditingValuesVersion(v => v + 1);
setCurrentlyEditingDockerResourceId(null);
// If there was a URL set, delete it
if (customUrl()) {
try {
await DockerMetadataAPI.updateMetadata(resourceId(), { customUrl: '' });
setCustomUrl(undefined);
// Notify parent to update metadata
if (props.onCustomUrlUpdate) {
props.onCustomUrlUpdate(resourceId(), '');
}
showSuccess('Container URL removed');
} catch (err: any) {
logger.error('Failed to remove container URL:', err);
showError(err.message || 'Failed to remove container URL');
}
}
};
const cancelEditingUrl = () => {
if (currentlyEditingDockerResourceId() !== resourceId()) return;
// Just close without saving
dockerEditingValues.delete(resourceId());
setDockerEditingValuesVersion(v => v + 1);
setCurrentlyEditingDockerResourceId(null);
};
const cpuPercent = () => Math.max(0, Math.min(100, container.cpuPercent ?? 0));
const memPercent = () => Math.max(0, Math.min(100, container.memoryPercent ?? 0));
const memUsageLabel = () => {
if (!container.memoryUsageBytes) return undefined;
const used = formatBytes(container.memoryUsageBytes, 0);
const limit = container.memoryLimitBytes
? formatBytes(container.memoryLimitBytes, 0)
: undefined;
return limit ? `${used} / ${limit}` : used;
};
const uptime = () => (container.uptimeSeconds ? formatUptime(container.uptimeSeconds) : '—');
const restarts = () => container.restartCount ?? 0;
const state = () => toLower(container.state);
const health = () => toLower(container.health);
const isRunning = () => state() === 'running';
const statusBadgeClass = () => {
if (state() === 'running' && (!health() || health() === 'healthy')) {
return 'bg-green-100 text-green-700 dark:bg-green-900/40 dark:text-green-300';
}
if (ERROR_CONTAINER_STATES.has(state()) || health() === 'unhealthy') {
return 'bg-red-100 text-red-700 dark:bg-red-900/40 dark:text-red-300';
}
if (STOPPED_CONTAINER_STATES.has(state())) {
return 'bg-gray-200 text-gray-600 dark:bg-gray-700 dark:text-gray-300';
}
return 'bg-amber-100 text-amber-700 dark:bg-amber-900/40 dark:text-amber-300';
};
const statusLabel = () => {
if (health()) {
return `${container.state ?? 'Unknown'} (${container.health})`;
}
return container.status || container.state || 'Unknown';
};
const containerTitle = () => {
const primary = container.name || container.id || 'Container';
const identifier = container.id && container.name && container.id !== container.name ? container.id : '';
return identifier ? `${primary} \u2014 ${identifier}` : primary;
};
return (
<>
<tr
class={`border-b border-gray-200 dark:border-gray-700 transition-all duration-200 ${
hasDrawerContent() ? 'cursor-pointer' : ''
} ${expanded() ? 'bg-gray-50 dark:bg-gray-800/40' : 'hover:bg-gray-50 dark:hover:bg-gray-800/50'} ${!isRunning() ? 'opacity-60' : ''}`}
onClick={toggle}
aria-expanded={expanded()}
>
<td class={`${GROUPED_RESOURCE_INDENT} pr-2 py-0.5`}>
<div class="flex items-center gap-1.5 min-w-0">
{/* Name - show input when editing, otherwise show name with optional link */}
<Show
when={isEditingUrl()}
fallback={
<div class="flex items-center gap-1.5 flex-1 min-w-0">
<span
class="text-sm font-semibold text-gray-900 dark:text-gray-100 truncate cursor-text select-none"
style="cursor: text;"
title={`${containerTitle()}${customUrl() ? ' - Click to edit URL' : ' - Click to add URL'}`}
onClick={startEditingUrl}
data-resource-name-editable
>
{container.name || container.id}
</span>
<Show when={podName()}>
{(name) => (
<span class="inline-flex items-center gap-1 rounded bg-purple-100 px-1.5 py-0.5 text-[10px] font-medium text-purple-700 dark:bg-purple-900/40 dark:text-purple-200">
Pod: {name()}
<Show when={isPodInfra()}>
<span class="rounded bg-purple-200 px-1 py-0.5 text-[9px] uppercase text-purple-800 dark:bg-purple-800/50 dark:text-purple-200">
infra
</span>
</Show>
</span>
)}
</Show>
<Show when={customUrl()}>
<a
href={customUrl()}
target="_blank"
rel="noopener noreferrer"
class={`flex-shrink-0 text-blue-600 dark:text-blue-400 hover:text-blue-700 dark:hover:text-blue-300 transition-colors ${shouldAnimateIcon() ? 'animate-fadeIn' : ''}`}
title="Open in new tab"
onClick={(event) => event.stopPropagation()}
>
<svg
class="w-3.5 h-3.5"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14"
/>
</svg>
</a>
</Show>
</div>
}
>
<div class="flex-1 flex items-center gap-1 min-w-0" data-url-editor>
<input
ref={urlInputRef}
type="text"
value={editingUrlValue()}
data-resource-id={resourceId()}
onInput={(e) => {
dockerEditingValues.set(resourceId(), e.currentTarget.value);
setDockerEditingValuesVersion(v => v + 1);
}}
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.preventDefault();
saveUrl();
} else if (e.key === 'Escape') {
e.preventDefault();
cancelEditingUrl();
}
}}
onClick={(e) => e.stopPropagation()}
placeholder="https://example.com:8080"
class="flex-1 min-w-0 px-2 py-0.5 text-sm border border-blue-500 rounded bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
<button
type="button"
data-url-editor-button
onClick={(e) => {
e.stopPropagation();
saveUrl();
}}
class="flex-shrink-0 w-6 h-6 flex items-center justify-center text-xs bg-blue-600 text-white rounded hover:bg-blue-700 transition-colors"
title="Save (or press Enter)"
>
</button>
<button
type="button"
data-url-editor-button
onClick={(e) => {
e.stopPropagation();
deleteUrl();
}}
class="flex-shrink-0 w-6 h-6 flex items-center justify-center text-xs bg-red-600 text-white rounded hover:bg-red-700 transition-colors"
title="Delete URL"
>
</button>
</div>
</Show>
</div>
</td>
<td class="px-2 py-0.5">
<span
class={`inline-flex items-center rounded px-2 py-0.5 text-[10px] font-medium whitespace-nowrap ${runtimeInfo.badgeClass}`}
title={
runtimeVersion()
? `${runtimeInfo.label} ${runtimeVersion()}`
: runtimeInfo.raw || runtimeInfo.label
}
>
{runtimeInfo.label}
</span>
</td>
<td class="px-2 py-0.5 text-xs text-gray-700 dark:text-gray-300">
<span class="truncate" title={container.image}>
{container.image || '—'}
</span>
</td>
<td class="px-2 py-0.5 text-xs">
<span class={`rounded px-2 py-0.5 text-[10px] font-medium whitespace-nowrap ${statusBadgeClass()}`}>
{statusLabel()}
</span>
</td>
<td class="px-2 py-0.5 min-w-[160px]">
<Show
when={isRunning() && container.cpuPercent && container.cpuPercent > 0}
fallback={<span class="text-xs text-gray-400"></span>}
>
<MetricBar value={cpuPercent()} label={formatPercent(cpuPercent())} type="cpu" />
</Show>
</td>
<td class="px-2 py-0.5 min-w-[220px]">
<Show
when={isRunning() && container.memoryUsageBytes && container.memoryUsageBytes > 0}
fallback={<span class="text-xs text-gray-400"></span>}
>
<MetricBar
value={memPercent()}
label={formatPercent(memPercent())}
type="memory"
sublabel={memUsageLabel()}
/>
</Show>
</td>
<td class="px-2 py-0.5 min-w-[200px]">
<Show when={hasDiskStats()} fallback={<span class="text-xs text-gray-400"></span>}>
<Show
when={diskPercent() !== null}
fallback={<span class="text-xs text-gray-700 dark:text-gray-300">{diskUsageLabel()}</span>}
>
<MetricBar
value={diskPercent() ?? 0}
label={formatPercent(diskPercent() ?? 0)}
type="disk"
sublabel={diskSublabel() ?? diskUsageLabel()}
/>
</Show>
</Show>
</td>
<td class="px-2 py-0.5 text-xs text-gray-700 dark:text-gray-300">
<Show when={isRunning()} fallback={<span class="text-gray-400"></span>}>
{restarts()}
<span class="text-[10px] text-gray-500 dark:text-gray-400 ml-1">restarts</span>
</Show>
</td>
<td class="px-2 py-0.5 text-xs text-gray-700 dark:text-gray-300">
<Show when={isRunning()} fallback={<span class="text-gray-400"></span>}>
{uptime()}
</Show>
</td>
</tr>
<Show when={expanded() && hasDrawerContent()}>
<tr class="bg-gray-50 dark:bg-gray-900/50">
<td colSpan={props.columns} class="px-4 py-3">
<div class="flex flex-wrap justify-start gap-3">
<div class="min-w-[220px] flex-1 rounded border border-gray-200 bg-white/70 p-2 shadow-sm dark:border-gray-600/70 dark:bg-gray-900/30">
<div class="text-[11px] font-medium uppercase tracking-wide text-gray-700 dark:text-gray-200">
Summary
</div>
<div class="mt-2 space-y-1 text-[11px] text-gray-600 dark:text-gray-300">
<div class="flex items-center justify-between gap-2">
<span class="font-medium text-gray-700 dark:text-gray-200">Runtime</span>
<span
class={`inline-flex items-center gap-2 rounded-full px-2 py-0.5 text-[10px] font-semibold ${runtimeInfo.badgeClass}`}
title={runtimeInfo.raw || runtimeInfo.label}
>
{runtimeInfo.label}
<Show when={runtimeVersion()}>
{(version) => (
<span class="text-[10px] text-gray-500 dark:text-gray-400">{version()}</span>
)}
</Show>
</span>
</div>
<div class="flex items-start justify-between gap-2">
<span class="font-medium text-gray-700 dark:text-gray-200">Image</span>
<span class="flex-1 truncate text-right text-gray-600 dark:text-gray-300" title={container.image}>
{container.image || '—'}
</span>
</div>
<Show when={podName()}>
{(name) => (
<div class="flex items-center justify-between gap-2">
<span class="font-medium text-gray-700 dark:text-gray-200">Pod</span>
<span class="text-right text-gray-600 dark:text-gray-300">
{name()}
<Show when={isPodInfra()}>
<span class="ml-2 rounded bg-purple-100 px-1.5 py-0.5 text-[10px] font-semibold text-purple-700 dark:bg-purple-900/40 dark:text-purple-200">
infra
</span>
</Show>
</span>
</div>
)}
</Show>
<Show when={podmanMetadata()?.composeProject}>
{(project) => (
<div class="flex items-center justify-between gap-2">
<span class="font-medium text-gray-700 dark:text-gray-200">Compose Project</span>
<span class="text-right text-gray-600 dark:text-gray-300">{project()}</span>
</div>
)}
</Show>
<Show when={podmanMetadata()?.composeService}>
{(service) => (
<div class="flex items-center justify-between gap-2">
<span class="font-medium text-gray-700 dark:text-gray-200">Compose Service</span>
<span class="text-right text-gray-600 dark:text-gray-300">{service()}</span>
</div>
)}
</Show>
<Show when={podmanMetadata()?.autoUpdatePolicy}>
{(policy) => (
<div class="flex items-center justify-between gap-2">
<span class="font-medium text-gray-700 dark:text-gray-200">Auto Update</span>
<span class="text-right text-gray-600 dark:text-gray-300">
{policy()}
<Show when={podmanMetadata()?.autoUpdateRestart}>
{(restart) => (
<span class="ml-2 text-[10px] text-gray-500 dark:text-gray-400">restart: {restart()}</span>
)}
</Show>
</span>
</div>
)}
</Show>
<Show when={podmanMetadata()?.userNamespace}>
{(userns) => (
<div class="flex items-center justify-between gap-2">
<span class="font-medium text-gray-700 dark:text-gray-200">User Namespace</span>
<span class="text-right text-gray-600 dark:text-gray-300">{userns()}</span>
</div>
)}
</Show>
<div class="flex items-center justify-between gap-2">
<span class="font-medium text-gray-700 dark:text-gray-200">State</span>
<span class="text-right text-gray-600 dark:text-gray-300">{statusLabel()}</span>
</div>
<div class="flex items-center justify-between gap-2">
<span class="font-medium text-gray-700 dark:text-gray-200">Restarts</span>
<span class="text-right text-gray-600 dark:text-gray-300">{restarts()}</span>
</div>
<Show when={createdRelative()}>
{(created) => (
<div class="flex flex-col gap-0.5">
<span class="font-medium text-gray-700 dark:text-gray-200">Created</span>
<div class="text-right text-gray-600 dark:text-gray-300">
{created()}
<Show when={createdAbsolute()}>
{(abs) => (
<div class="text-[10px] text-gray-500 dark:text-gray-400">{abs()}</div>
)}
</Show>
</div>
</div>
)}
</Show>
<Show when={startedRelative()}>
{(started) => (
<div class="flex flex-col gap-0.5">
<span class="font-medium text-gray-700 dark:text-gray-200">Started</span>
<div class="text-right text-gray-600 dark:text-gray-300">
{started()}
<Show when={startedAbsolute()}>
{(abs) => (
<div class="text-[10px] text-gray-500 dark:text-gray-400">{abs()}</div>
)}
</Show>
</div>
</div>
)}
</Show>
<div class="flex items-center justify-between gap-2">
<span class="font-medium text-gray-700 dark:text-gray-200">Uptime</span>
<span class="text-right text-gray-600 dark:text-gray-300">{uptime()}</span>
</div>
</div>
<Show when={runtimeInfo.id === 'podman'}>
<div class="mt-3 rounded border border-dashed border-purple-200 px-2 py-1 text-[10px] text-purple-700 dark:border-purple-700/60 dark:text-purple-200">
Podman hosts report container metrics, but Swarm services and tasks are unavailable. Runtime annotations and compose metadata appear below when present.
</div>
</Show>
</div>
<Show when={container.ports && container.ports.length > 0}>
<div class="min-w-[220px] flex-1 rounded border border-gray-200 bg-white/70 p-2 shadow-sm dark:border-gray-600/70 dark:bg-gray-900/30">
<div class="text-[11px] font-medium uppercase tracking-wide text-gray-700 dark:text-gray-200">
Ports
</div>
<div class="mt-1 flex flex-wrap gap-1 text-[11px] text-gray-600 dark:text-gray-300">
{container.ports!.map((port) => {
const label = port.publicPort
? `${port.publicPort}:${port.privatePort}/${port.protocol}`
: `${port.privatePort}/${port.protocol}`;
return (
<span class="rounded bg-blue-100 px-1.5 py-0.5 text-blue-700 dark:bg-blue-900/40 dark:text-blue-200">
{label}
</span>
);
})}
</div>
</div>
</Show>
<Show when={container.networks && container.networks.length > 0}>
<div class="min-w-[220px] flex-1 rounded border border-gray-200 bg-white/70 p-2 shadow-sm dark:border-gray-600/70 dark:bg-gray-900/30">
<div class="text-[11px] font-medium uppercase tracking-wide text-gray-700 dark:text-gray-200">
Networks
</div>
<div class="mt-1 space-y-1 text-[11px] text-gray-600 dark:text-gray-300">
{container.networks!.map((network) => (
<div class="rounded border border-dashed border-gray-200 p-2 last:mb-0 dark:border-gray-700/70">
<div class="font-medium text-gray-700 dark:text-gray-200">{network.name}</div>
<div class="mt-0.5 flex flex-wrap gap-1 text-[10px] text-gray-500 dark:text-gray-400">
<Show when={network.ipv4}>
<span class="rounded bg-blue-100 px-1.5 py-0.5 text-blue-700 dark:bg-blue-900/40 dark:text-blue-200">
{network.ipv4}
</span>
</Show>
<Show when={network.ipv6}>
<span class="rounded bg-purple-100 px-1.5 py-0.5 text-purple-700 dark:bg-purple-900/40 dark:text-purple-200">
{network.ipv6}
</span>
</Show>
</div>
</div>
))}
</div>
</div>
</Show>
<Show when={hasPodmanMetadata()}>
<div class="min-w-[220px] flex-1 rounded border border-purple-200 bg-white/70 p-2 shadow-sm dark:border-purple-700/60 dark:bg-purple-950/20">
<div class="text-[11px] font-medium uppercase tracking-wide text-purple-700 dark:text-purple-200">
Podman Metadata
</div>
<div class="mt-1 space-y-2 text-[11px] text-gray-600 dark:text-gray-300">
<For each={podmanMetadataSections()}>
{(section) => (
<div class="space-y-1 border-b border-purple-100 pb-1 last:border-b-0 last:pb-0 dark:border-purple-800/30">
<div class="text-[10px] font-semibold uppercase tracking-wide text-purple-600 dark:text-purple-300">
{section.title}
</div>
<div class="space-y-1">
<For each={section.items}>
{(item) => (
<div class="flex items-start justify-between gap-2">
<span class="font-medium text-gray-700 dark:text-gray-200">{item.label}</span>
<span
class="max-w-[220px] break-all text-right text-gray-600 dark:text-gray-300"
title={item.value || '—'}
>
{item.value || '—'}
</span>
</div>
)}
</For>
</div>
</div>
)}
</For>
</div>
</div>
</Show>
<Show when={hasBlockIo()}>
<div class="min-w-[220px] flex-1 rounded border border-gray-200 bg-white/70 p-2 shadow-sm dark:border-gray-600/70 dark:bg-gray-900/30">
<div class="text-[11px] font-medium uppercase tracking-wide text-gray-700 dark:text-gray-200">
Block I/O
</div>
<div class="mt-1 space-y-1 text-[11px] text-gray-600 dark:text-gray-300">
<div class="flex items-center justify-between">
<span>Read</span>
<div class="text-right">
<div class="font-semibold text-gray-900 dark:text-gray-100">
{formatBytes(blockIoReadBytes())}
</div>
<Show when={blockIoReadRateLabel()}>
<div class="text-[10px] text-gray-500 dark:text-gray-400">
{blockIoReadRateLabel()}
</div>
</Show>
</div>
</div>
<div class="flex items-center justify-between">
<span>Write</span>
<div class="text-right">
<div class="font-semibold text-gray-900 dark:text-gray-100">
{formatBytes(blockIoWriteBytes())}
</div>
<Show when={blockIoWriteRateLabel()}>
<div class="text-[10px] text-gray-500 dark:text-gray-400">
{blockIoWriteRateLabel()}
</div>
</Show>
</div>
</div>
</div>
</div>
</Show>
<Show when={hasMounts()}>
<div class="min-w-[220px] flex-1 rounded border border-gray-200 bg-white/70 p-2 shadow-sm dark:border-gray-600/70 dark:bg-gray-900/30">
<div class="text-[11px] font-medium uppercase tracking-wide text-gray-700 dark:text-gray-200">
Mounts
</div>
<div class="mt-1 space-y-1 text-[11px] text-gray-600 dark:text-gray-300">
<For each={mounts()}>
{(mount) => {
const destination = mount.destination || mount.source || mount.name || 'mount';
const rw = mount.rw === false ? 'read-only' : 'read-write';
return (
<div class="rounded border border-dashed border-gray-200 p-2 last:mb-0 dark:border-gray-700/70">
<div class="flex items-center justify-between gap-2">
<span class="truncate font-medium text-gray-700 dark:text-gray-200" title={destination}>
{destination}
</span>
<Show when={mount.type}>
<span class="text-[10px] uppercase tracking-wide text-gray-500 dark:text-gray-400">
{mount.type}
</span>
</Show>
</div>
<Show when={mount.source}>
<div class="mt-1 truncate text-[11px] text-gray-600 dark:text-gray-300" title={mount.source}>
{mount.source}
</div>
</Show>
<div class="mt-1 flex flex-wrap gap-1 text-[10px] text-gray-500 dark:text-gray-400">
<span
class={`rounded px-1.5 py-0.5 ${
mount.rw === false
? 'bg-gray-200 text-gray-700 dark:bg-gray-700/60 dark:text-gray-200'
: 'bg-green-100 text-green-700 dark:bg-green-900/40 dark:text-green-300'
}`}
>
{rw}
</span>
<Show when={mount.mode}>
<span class="rounded bg-gray-200 px-1.5 py-0.5 text-gray-700 dark:bg-gray-700/60 dark:text-gray-200">
mode: {mount.mode}
</span>
</Show>
<Show when={mount.driver}>
<span class="rounded bg-blue-100 px-1.5 py-0.5 text-blue-700 dark:bg-blue-900/40 dark:text-blue-200">
{mount.driver}
</span>
</Show>
<Show when={mount.name}>
<span class="rounded bg-purple-100 px-1.5 py-0.5 text-purple-700 dark:bg-purple-900/40 dark:text-purple-200">
{mount.name}
</span>
</Show>
<Show when={mount.propagation}>
<span class="rounded bg-gray-100 px-1.5 py-0.5 text-gray-600 dark:bg-gray-800/40 dark:text-gray-300">
{mount.propagation}
</span>
</Show>
</div>
</div>
);
}}
</For>
</div>
</div>
</Show>
<Show when={container.labels && Object.keys(container.labels).length > 0}>
<div class="min-w-[220px] flex-1 rounded border border-gray-200 bg-white/70 p-2 shadow-sm dark:border-gray-600/70 dark:bg-gray-900/30">
<div class="text-[11px] font-medium uppercase tracking-wide text-gray-700 dark:text-gray-200">
Labels
</div>
<div class="mt-1 flex flex-wrap gap-1 text-[11px] text-gray-600 dark:text-gray-300">
{Object.entries(container.labels!).map(([key, value]) => {
const fullLabel = value ? `${key}: ${value}` : key;
return (
<span
class="max-w-full truncate rounded bg-gray-200 px-1.5 py-0.5 text-gray-700 dark:bg-gray-700/60 dark:text-gray-200"
title={fullLabel}
>
{key}
<Show when={value}>: {value}</Show>
</span>
);
})}
</div>
</div>
</Show>
</div>
</td>
</tr>
</Show>
</>
);
};
const DockerServiceRow: Component<{
row: Extract<DockerRow, { kind: 'service' }>;
columns: number;
customUrl?: string;
onCustomUrlUpdate?: (resourceId: string, url: string) => void;
}> = (props) => {
const { host, service, tasks } = props.row;
const rowId = buildRowId(host, props.row);
const resourceId = () => `${host.id}:service:${service.id || service.name}`;
const isEditingUrl = createMemo(() => currentlyEditingDockerResourceId() === resourceId());
const [customUrl, setCustomUrl] = createSignal<string | undefined>(props.customUrl);
const [shouldAnimateIcon, setShouldAnimateIcon] = createSignal(false);
const expanded = createMemo(() => currentlyExpandedRowId() === rowId);
const editingUrlValue = createMemo(() => {
dockerEditingValuesVersion(); // Subscribe to changes
return dockerEditingValues.get(resourceId()) || '';
});
let urlInputRef: HTMLInputElement | undefined;
const hasTasks = () => tasks.length > 0;
// Update custom URL when prop changes, but only if we're not currently editing
createEffect(() => {
if (currentlyEditingDockerResourceId() !== resourceId()) {
const prevUrl = customUrl();
const newUrl = props.customUrl;
// Only animate when URL transitions from empty to having a value
if (!prevUrl && newUrl) {
setShouldAnimateIcon(true);
setTimeout(() => setShouldAnimateIcon(false), 200);
}
setCustomUrl(newUrl);
}
});
// Auto-focus the input when editing starts
createEffect(() => {
if (isEditingUrl() && urlInputRef) {
urlInputRef.focus();
urlInputRef.select();
}
});
const toggle = (event: MouseEvent) => {
if (!hasTasks()) return;
const target = event.target as HTMLElement;
if (target.closest('a, button, input, [data-prevent-toggle]')) return;
// Toggle: if this row is currently expanded, close it; otherwise open it (closing any other)
setCurrentlyExpandedRowId(prev => prev === rowId ? null : rowId);
};
const startEditingUrl = (event: MouseEvent) => {
event.stopPropagation();
// If another resource is being edited, save it first
const currentEditing = currentlyEditingDockerResourceId();
if (currentEditing !== null && currentEditing !== resourceId()) {
const currentInput = document.querySelector(`input[data-resource-id="${currentEditing}"]`) as HTMLInputElement;
if (currentInput) {
currentInput.blur();
}
}
dockerEditingValues.set(resourceId(), customUrl() || '');
setDockerEditingValuesVersion(v => v + 1);
setCurrentlyEditingDockerResourceId(resourceId());
};
// Add global click handler to close editor
createEffect(() => {
if (isEditingUrl()) {
const handleGlobalClick = (e: MouseEvent) => {
if (currentlyEditingDockerResourceId() !== resourceId()) return;
const target = e.target as HTMLElement;
const isClickingResourceName = target.closest('[data-resource-name-editable]');
if (!target.closest('[data-url-editor]') && !isClickingResourceName) {
e.preventDefault();
e.stopPropagation();
e.stopImmediatePropagation();
cancelEditingUrl();
}
};
const handleGlobalMouseDown = (e: MouseEvent) => {
if (currentlyEditingDockerResourceId() !== resourceId()) return;
const target = e.target as HTMLElement;
const isClickingResourceName = target.closest('[data-resource-name-editable]');
if (!target.closest('[data-url-editor]') && !isClickingResourceName) {
e.preventDefault();
e.stopPropagation();
e.stopImmediatePropagation();
}
};
document.addEventListener('mousedown', handleGlobalMouseDown, true);
document.addEventListener('click', handleGlobalClick, true);
return () => {
document.removeEventListener('mousedown', handleGlobalMouseDown, true);
document.removeEventListener('click', handleGlobalClick, true);
};
}
});
const saveUrl = async () => {
if (currentlyEditingDockerResourceId() !== resourceId()) return;
const newUrl = (dockerEditingValues.get(resourceId()) || '').trim();
// Clear global editing state
dockerEditingValues.delete(resourceId());
setDockerEditingValuesVersion(v => v + 1);
setCurrentlyEditingDockerResourceId(null);
// If URL hasn't changed, don't save
if (newUrl === (customUrl() || '')) return;
// Animate if transitioning from no URL to having a URL
const hadUrl = !!customUrl();
if (!hadUrl && newUrl) {
setShouldAnimateIcon(true);
setTimeout(() => setShouldAnimateIcon(false), 200);
}
// Optimistically update local and parent state immediately
setCustomUrl(newUrl || undefined);
if (props.onCustomUrlUpdate) {
props.onCustomUrlUpdate(resourceId(), newUrl);
}
try {
await DockerMetadataAPI.updateMetadata(resourceId(), { customUrl: newUrl });
if (newUrl) {
showSuccess('Service URL saved');
} else {
showSuccess('Service URL cleared');
}
} catch (err: any) {
logger.error('Failed to save service URL:', err);
showError(err.message || 'Failed to save service URL');
// Revert on error
setCustomUrl(hadUrl ? customUrl() : undefined);
if (props.onCustomUrlUpdate) {
props.onCustomUrlUpdate(resourceId(), hadUrl ? customUrl() || '' : '');
}
}
};
const deleteUrl = async () => {
if (currentlyEditingDockerResourceId() !== resourceId()) return;
// Clear global editing state
dockerEditingValues.delete(resourceId());
setDockerEditingValuesVersion(v => v + 1);
setCurrentlyEditingDockerResourceId(null);
// If there was a URL set, delete it
if (customUrl()) {
try {
await DockerMetadataAPI.updateMetadata(resourceId(), { customUrl: '' });
setCustomUrl(undefined);
// Notify parent to update metadata
if (props.onCustomUrlUpdate) {
props.onCustomUrlUpdate(resourceId(), '');
}
showSuccess('Service URL removed');
} catch (err: any) {
logger.error('Failed to remove service URL:', err);
showError(err.message || 'Failed to remove service URL');
}
}
};
const cancelEditingUrl = () => {
if (currentlyEditingDockerResourceId() !== resourceId()) return;
// Just close without saving
dockerEditingValues.delete(resourceId());
setDockerEditingValuesVersion(v => v + 1);
setCurrentlyEditingDockerResourceId(null);
};
const badge = serviceHealthBadge(service);
const updatedAt = ensureMs(service.updatedAt ?? service.createdAt);
const isHealthy = () => {
const desired = service.desiredTasks ?? 0;
const running = service.runningTasks ?? 0;
return desired > 0 && running >= desired;
};
const serviceTitle = () => {
const primary = service.name || service.id || 'Service';
const identifier = service.id && service.name && service.id !== service.name ? service.id : '';
return identifier ? `${primary} \u2014 ${identifier}` : primary;
};
return (
<>
<tr
class={`border-b border-gray-200 dark:border-gray-700 transition-all duration-200 ${
hasTasks() ? 'cursor-pointer' : ''
} ${
expanded()
? 'bg-gray-50 dark:bg-gray-800/40'
: 'hover:bg-gray-50 dark:hover:bg-gray-800/50'
} ${!isHealthy() ? 'opacity-60' : ''}`}
onClick={toggle}
aria-expanded={expanded()}
>
<td class={`${GROUPED_RESOURCE_INDENT} pr-2 py-0.5`}>
<div class="flex items-center gap-1.5 min-w-0">
{/* Name - show input when editing, otherwise show name with optional link */}
<Show
when={isEditingUrl()}
fallback={
<div class="flex items-center gap-1.5 flex-1 min-w-0">
<span
class="text-sm font-semibold text-gray-900 dark:text-gray-100 truncate cursor-text select-none"
style="cursor: text;"
title={`${serviceTitle()}${customUrl() ? ' - Click to edit URL' : ' - Click to add URL'}`}
onClick={startEditingUrl}
data-resource-name-editable
>
{service.name || service.id || 'Service'}
</span>
<Show when={customUrl()}>
<a
href={customUrl()}
target="_blank"
rel="noopener noreferrer"
class={`flex-shrink-0 text-blue-600 dark:text-blue-400 hover:text-blue-700 dark:hover:text-blue-300 transition-colors ${shouldAnimateIcon() ? 'animate-fadeIn' : ''}`}
title="Open in new tab"
onClick={(event) => event.stopPropagation()}
>
<svg
class="w-3.5 h-3.5"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14"
/>
</svg>
</a>
</Show>
<Show when={service.stack && !isEditingUrl()}>
<span class="text-[10px] text-gray-500 dark:text-gray-400 truncate" title={`Stack: ${service.stack}`}>
Stack: {service.stack}
</span>
</Show>
</div>
}
>
<div class="flex-1 flex items-center gap-1 min-w-0" data-url-editor>
<input
ref={urlInputRef}
type="text"
value={editingUrlValue()}
data-resource-id={resourceId()}
onInput={(e) => {
dockerEditingValues.set(resourceId(), e.currentTarget.value);
setDockerEditingValuesVersion(v => v + 1);
}}
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.preventDefault();
saveUrl();
} else if (e.key === 'Escape') {
e.preventDefault();
cancelEditingUrl();
}
}}
onClick={(e) => e.stopPropagation()}
placeholder="https://example.com:8080"
class="flex-1 min-w-0 px-2 py-0.5 text-sm border border-blue-500 rounded bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
<button
type="button"
data-url-editor-button
onClick={(e) => {
e.stopPropagation();
saveUrl();
}}
class="flex-shrink-0 w-6 h-6 flex items-center justify-center text-xs bg-blue-600 text-white rounded hover:bg-blue-700 transition-colors"
title="Save (or press Enter)"
>
</button>
<button
type="button"
data-url-editor-button
onClick={(e) => {
e.stopPropagation();
deleteUrl();
}}
class="flex-shrink-0 w-6 h-6 flex items-center justify-center text-xs bg-red-600 text-white rounded hover:bg-red-700 transition-colors"
title="Delete URL"
>
</button>
</div>
</Show>
</div>
</td>
<td class="px-2 py-0.5">
<span class={`inline-flex items-center rounded px-2 py-0.5 text-[10px] font-medium whitespace-nowrap ${typeBadgeClass('service')}`}>
Service
</span>
</td>
<td class="px-2 py-0.5 text-xs text-gray-700 dark:text-gray-300">
<span class="truncate" title={service.image}>
{service.image || '—'}
</span>
</td>
<td class="px-2 py-0.5 text-xs">
<span class={`rounded px-2 py-0.5 text-[10px] font-medium whitespace-nowrap ${badge.class}`}>
{badge.label}
</span>
</td>
<td class="px-2 py-0.5 text-xs text-gray-400 dark:text-gray-500 min-w-[150px]"></td>
<td class="px-2 py-0.5 text-xs text-gray-400 dark:text-gray-500 min-w-[210px]"></td>
<td class="px-2 py-0.5 text-xs text-gray-400 dark:text-gray-500 min-w-[200px]"></td>
<td class="px-2 py-0.5 text-xs text-gray-700 dark:text-gray-300 whitespace-nowrap">
<span class="font-semibold text-gray-900 dark:text-gray-100">
{(service.runningTasks ?? 0)}/{service.desiredTasks ?? 0}
</span>
<span class="ml-1 text-gray-500 dark:text-gray-400">tasks</span>
</td>
<td class="px-2 py-0.5 text-xs text-gray-700 dark:text-gray-300 whitespace-nowrap">
<Show when={updatedAt} fallback="—">
{(timestamp) => (
<span title={new Date(timestamp()).toLocaleString(undefined, { dateStyle: 'medium', timeStyle: 'short' })}>
{formatRelativeTime(timestamp())}
</span>
)}
</Show>
</td>
</tr>
<Show when={expanded() && hasTasks()}>
<tr class="bg-gray-50 dark:bg-gray-900/60">
<td colSpan={props.columns} class="px-4 py-3">
<div class="flex flex-wrap justify-start gap-3">
<div class="min-w-[320px] flex-1 rounded border border-gray-200 bg-white/70 p-3 shadow-sm dark:border-gray-600/70 dark:bg-gray-900/30">
<div class="flex items-center justify-between text-[11px] font-medium uppercase tracking-wide text-gray-700 dark:text-gray-200">
<span>Tasks</span>
<span class="text-[10px] font-normal text-gray-500 dark:text-gray-400">
{tasks.length} {tasks.length === 1 ? 'entry' : 'entries'}
</span>
</div>
<div class="mt-2 overflow-x-auto">
<table class="min-w-full divide-y divide-gray-100 dark:divide-gray-800/60 text-xs">
<thead class="bg-gray-100 dark:bg-gray-900/40 text-[10px] uppercase tracking-wide text-gray-600 dark:text-gray-200">
<tr>
<th class="py-1 pr-2 text-left font-medium">Task</th>
<th class="py-1 px-2 text-left font-medium w-[80px]">Type</th>
<th class="py-1 px-2 text-left font-medium">Node</th>
<th class="py-1 px-2 text-left font-medium">State</th>
<th class="py-1 px-2 text-left font-medium w-[120px]">CPU</th>
<th class="py-1 px-2 text-left font-medium w-[140px]">Memory</th>
<th class="py-1 px-2 text-left font-medium">Updated</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-100 dark:divide-gray-800/50">
<For each={tasks}>
{(task) => {
const container = findContainerForTask(host.containers || [], task);
const cpu = container?.cpuPercent ?? 0;
const mem = container?.memoryPercent ?? 0;
const updated = ensureMs(task.updatedAt ?? task.createdAt ?? task.startedAt);
const taskLabel = () => {
if (task.containerName) return task.containerName;
if (task.containerId) return task.containerId.slice(0, 12);
if (task.slot !== undefined) return `slot-${task.slot}`;
return task.id ?? 'Task';
};
const taskTitle = () => {
const label = taskLabel();
if (task.containerId && task.containerId !== label) {
return `${label} \u2014 ${task.containerId}`;
}
if (task.id && task.id !== label) {
return `${label} \u2014 ${task.id}`;
}
return label;
};
const state = toLower(task.currentState ?? task.desiredState ?? 'unknown');
const stateClass = () => {
if (state === 'running') {
return 'bg-green-100 text-green-700 dark:bg-green-900/40 dark:text-green-300';
}
if (state === 'failed' || state === 'error') {
return 'bg-red-100 text-red-700 dark:bg-red-900/40 dark:text-red-300';
}
return 'bg-gray-200 text-gray-600 dark:bg-gray-700 dark:text-gray-300';
};
return (
<tr class="hover:bg-gray-100 dark:hover:bg-gray-800/40">
<td class="py-1 pr-2">
<div class="flex items-center gap-1 text-sm text-gray-900 dark:text-gray-100">
<span class="truncate font-medium" title={taskTitle()}>
{taskLabel()}
</span>
</div>
</td>
<td class="py-1 px-2">
<span
class={`inline-flex items-center rounded px-2 py-0.5 text-[10px] font-medium whitespace-nowrap ${typeBadgeClass(
'task',
)}`}
>
Task
</span>
</td>
<td class="py-1 px-2 text-gray-600 dark:text-gray-400">
{task.nodeName || task.nodeId || '—'}
</td>
<td class="py-1 px-2">
<span class={`rounded px-2 py-0.5 text-[10px] font-medium whitespace-nowrap ${stateClass()}`}>
{task.currentState || task.desiredState || 'Unknown'}
</span>
</td>
<td class="py-1 px-2 w-[120px]">
<Show when={cpu > 0} fallback={<span class="text-gray-400"></span>}>
<MetricBar value={Math.min(100, cpu)} label={formatPercent(cpu)} type="cpu" />
</Show>
</td>
<td class="py-1 px-2 w-[140px]">
<Show when={mem > 0} fallback={<span class="text-gray-400"></span>}>
<MetricBar value={Math.min(100, mem)} label={formatPercent(mem)} type="memory" />
</Show>
</td>
<td class="py-1 px-2 text-gray-600 dark:text-gray-400 whitespace-nowrap">
<Show when={updated} fallback="—">
{(timestamp) => (
<span title={new Date(timestamp()).toLocaleString(undefined, { dateStyle: 'medium', timeStyle: 'short' })}>
{formatRelativeTime(timestamp())}
</span>
)}
</Show>
</td>
</tr>
);
}}
</For>
</tbody>
</table>
</div>
</div>
</div>
</td>
</tr>
</Show>
</>
);
};
const DockerUnifiedTable: Component<DockerUnifiedTableProps> = (props) => {
const tokens = createMemo(() => parseSearchTerm(props.searchTerm));
const sortedHosts = createMemo(() => {
const hosts = props.hosts || [];
return [...hosts].sort((a, b) => {
const aName = a.displayName || a.hostname || a.id;
const bName = b.displayName || b.hostname || b.id;
return aName.localeCompare(bName);
});
});
const groupedRows = createMemo(() => {
const groups: Array<{ host: DockerHost; rows: DockerRow[] }> = [];
const filter = props.statsFilter ?? null;
const searchTokens = tokens();
const selectedHostId = props.selectedHostId ? props.selectedHostId() : null;
sortedHosts().forEach((host) => {
if (!hostMatchesFilter(filter, host)) {
return;
}
if (selectedHostId && host.id !== selectedHostId) {
return;
}
const containerRows: Array<Extract<DockerRow, { kind: 'container' }>> = [];
const serviceRows: Array<Extract<DockerRow, { kind: 'service' }>> = [];
const containers = host.containers || [];
const services = host.services || [];
const tasks = host.tasks || [];
const serviceNames = new Set<string>();
const serviceIds = new Set<string>();
services.forEach((service) => {
if (service.name) serviceNames.add(service.name.toLowerCase());
if (service.id) serviceIds.add(service.id.toLowerCase());
});
const serviceOwnedContainers = new Set<string>();
containers.forEach((container) => {
if (!containerMatchesStateFilter(filter, container)) return;
const matchesSearch = searchTokens.every((token) => containerMatchesToken(token, host, container));
if (!matchesSearch) return;
containerRows.push({
kind: 'container',
id: container.id || `${host.id}-container-${container.name}`,
host,
container,
});
});
services.forEach((service) => {
if (!serviceMatchesHealthFilter(filter, service)) return;
const matchesSearch = searchTokens.every((token) => serviceMatchesToken(token, host, service));
if (!matchesSearch) return;
const associatedTasks = tasks.filter((task) => {
if (service.id && task.serviceId) {
return task.serviceId === service.id;
}
if (service.name && task.serviceName) {
return task.serviceName === service.name;
}
return false;
});
associatedTasks.forEach((task) => {
if (task.containerId) serviceOwnedContainers.add(task.containerId.toLowerCase());
if (task.containerName) serviceOwnedContainers.add(task.containerName.toLowerCase());
});
serviceRows.push({
kind: 'service',
id: service.id || `${host.id}-service-${service.name}`,
host,
service,
tasks: associatedTasks,
});
});
if (serviceRows.length > 0) {
serviceRows.sort((a, b) => {
const nameA = a.service.name || a.service.id || '';
const nameB = b.service.name || b.service.id || '';
return nameA.localeCompare(nameB);
});
}
if (containerRows.length > 0) {
containerRows.sort((a, b) => {
const nameA = a.container.name || a.container.id || '';
const nameB = b.container.name || b.container.id || '';
return nameA.localeCompare(nameB);
});
const filtered = containerRows.filter((row) => {
const idKey = (row.container.id || '').toLowerCase();
const nameKey = (row.container.name || '').toLowerCase();
const shortNameKey = nameKey.split('.')[0];
const labelServiceName =
row.container.labels?.['com.docker.swarm.service.name']?.toLowerCase() ?? '';
const labelServiceID =
row.container.labels?.['com.docker.swarm.service.id']?.toLowerCase() ?? '';
const belongsToService =
(idKey && serviceOwnedContainers.has(idKey)) ||
(nameKey && serviceOwnedContainers.has(nameKey)) ||
(shortNameKey && serviceOwnedContainers.has(shortNameKey)) ||
(labelServiceName && serviceNames.has(labelServiceName)) ||
(labelServiceID && serviceIds.has(labelServiceID)) ||
(serviceNames.size > 0 && nameKey && [...serviceNames].some((svc) => nameKey.startsWith(`${svc}.`)));
return !belongsToService;
});
containerRows.length = 0;
containerRows.push(...filtered);
}
const hostRows = [...serviceRows, ...containerRows];
if (hostRows.length > 0) {
groups.push({ host, rows: hostRows });
}
});
return groups;
});
const totalRows = createMemo(() =>
groupedRows().reduce((acc, group) => acc + group.rows.length, 0),
);
const totalContainers = createMemo(() =>
(props.hosts || []).reduce((acc, host) => acc + (host.containers?.length ?? 0), 0),
);
const totalServices = createMemo(() =>
(props.hosts || []).reduce((acc, host) => acc + (host.services?.length ?? 0), 0),
);
const runningContainers = createMemo(() =>
groupedRows().reduce((acc, group) => {
return (
acc +
group.rows
.filter((row): row is Extract<typeof row, { kind: 'container' }> => row.kind === 'container')
.filter((row) => toLower(row.container.state) === 'running').length
);
}, 0),
);
const stoppedContainers = createMemo(() =>
groupedRows().reduce((acc, group) => {
return (
acc +
group.rows
.filter((row): row is Extract<typeof row, { kind: 'container' }> => row.kind === 'container')
.filter((row) => STOPPED_CONTAINER_STATES.has(toLower(row.container.state))).length
);
}, 0),
);
return (
<div class="space-y-4">
<Show
when={totalRows() > 0}
fallback={
<Card padding="lg">
<EmptyState
title="No container workloads found"
description={
totalContainers() === 0 && totalServices() === 0
? 'Add a container agent in Settings to start gathering container and service metrics.'
: props.searchTerm || props.statsFilter
? 'No containers or services match your current filters.'
: 'Container runtime data is currently unavailable.'
}
/>
</Card>
}
>
<Card padding="none" class="overflow-hidden">
<ScrollableTable minWidth="1080px">
<table class="w-full min-w-[1080px] table-fixed border-collapse whitespace-nowrap">
<thead>
<tr class="bg-gray-50 dark:bg-gray-700/50 text-gray-600 dark:text-gray-300 border-b border-gray-200 dark:border-gray-600">
<th class="pl-4 pr-2 py-1 text-left text-[11px] sm:text-xs font-medium uppercase tracking-wider w-[22%]">
Resource
</th>
<th class="px-2 py-1 text-left text-[11px] sm:text-xs font-medium uppercase tracking-wider w-[10%]">
Type
</th>
<th class="px-2 py-1 text-left text-[11px] sm:text-xs font-medium uppercase tracking-wider w-[16%]">
Image / Stack
</th>
<th class="px-2 py-1 text-left text-[11px] sm:text-xs font-medium uppercase tracking-wider w-[14%]">
Status
</th>
<th class="px-2 py-1 text-left text-[11px] sm:text-xs font-medium uppercase tracking-wider w-[13%] min-w-[150px]">
CPU
</th>
<th class="px-2 py-1 text-left text-[11px] sm:text-xs font-medium uppercase tracking-wider w-[15%] min-w-[210px]">
Memory
</th>
<th class="px-2 py-1 text-left text-[11px] sm:text-xs font-medium uppercase tracking-wider w-[16%] min-w-[200px]">
Disk
</th>
<th class="px-2 py-1 text-left text-[11px] sm:text-xs font-medium uppercase tracking-wider w-[9%]">
Tasks / Restarts
</th>
<th class="px-2 py-1 text-left text-[11px] sm:text-xs font-medium uppercase tracking-wider w-[8%]">
Updated / Uptime
</th>
</tr>
</thead>
<tbody>
<For each={groupedRows()}>
{(group) => (
<>
<DockerHostGroupHeader host={group.host} colspan={9} />
<For each={group.rows}>
{(row) => {
// Build resource ID for metadata lookup
const resourceId = row.kind === 'container'
? `${row.host.id}:container:${row.container.id || row.container.name}`
: `${row.host.id}:service:${row.service.id || row.service.name}`;
const metadata = props.dockerMetadata?.[resourceId];
return row.kind === 'container' ? (
<DockerContainerRow
row={row}
columns={9}
customUrl={metadata?.customUrl}
onCustomUrlUpdate={props.onCustomUrlUpdate}
/>
) : (
<DockerServiceRow
row={row}
columns={9}
customUrl={metadata?.customUrl}
onCustomUrlUpdate={props.onCustomUrlUpdate}
/>
);
}}
</For>
</>
)}
</For>
</tbody>
</table>
</ScrollableTable>
</Card>
<div class="flex items-center gap-2 rounded border border-gray-200 bg-gray-50 p-2 text-xs text-gray-600 dark:border-gray-700 dark:bg-gray-800/60 dark:text-gray-300">
<span class="flex items-center gap-1">
<span class="h-2 w-2 rounded-full bg-green-500" aria-hidden="true" />
{runningContainers()} running
</span>
<span class="text-gray-400">|</span>
<span class="flex items-center gap-1">
<span class="h-2 w-2 rounded-full bg-gray-400" aria-hidden="true" />
{stoppedContainers()} stopped
</span>
</div>
</Show>
</div>
);
};
export { DockerUnifiedTable };