- 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
2147 lines
86 KiB
TypeScript
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 };
|