Optimistically update local and parent state before the API call completes, so the external link icon appears instantly when the user saves a URL. If the API fails, the state reverts automatically. Before: Link icon appeared after API response (~100-500ms delay) After: Link icon appears immediately with smooth fade-in animation
1536 lines
58 KiB
TypeScript
1536 lines
58 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 } from '@/utils/format';
|
|
import type { DockerMetadata } from '@/api/dockerMetadata';
|
|
import { DockerMetadataAPI } from '@/api/dockerMetadata';
|
|
import { showSuccess, showError } from '@/utils/toast';
|
|
|
|
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;
|
|
}
|
|
|
|
const rowExpandState = new Map<string, boolean>();
|
|
|
|
// 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() };
|
|
});
|
|
};
|
|
|
|
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 === '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.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 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 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, setExpanded] = createSignal(rowExpandState.get(rowId) ?? false);
|
|
const editingUrlValue = createMemo(() => {
|
|
dockerEditingValuesVersion(); // Subscribe to changes
|
|
return dockerEditingValues.get(resourceId()) || '';
|
|
});
|
|
let urlInputRef: HTMLInputElement | undefined;
|
|
|
|
const hasDrawerContent = createMemo(() => {
|
|
return (
|
|
(container.ports && container.ports.length > 0) ||
|
|
(container.labels && Object.keys(container.labels).length > 0) ||
|
|
(container.networks && container.networks.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 (!hasDrawerContent()) return;
|
|
const target = event.target as HTMLElement;
|
|
if (target.closest('a, button, input, [data-prevent-toggle]')) return;
|
|
setExpanded((prev) => {
|
|
const next = !prev;
|
|
rowExpandState.set(rowId, next);
|
|
return next;
|
|
});
|
|
};
|
|
|
|
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) {
|
|
console.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) {
|
|
console.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);
|
|
const limit = container.memoryLimitBytes
|
|
? formatBytes(container.memoryLimitBytes)
|
|
: 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="pl-5 sm:pl-6 lg:pl-8 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={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 ${typeBadgeClass(
|
|
'container',
|
|
)}`}
|
|
>
|
|
Container
|
|
</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">
|
|
<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">
|
|
<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 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="grid gap-3 md:grid-cols-2">
|
|
<Show when={container.ports && container.ports.length > 0}>
|
|
<div>
|
|
<div class="text-[11px] font-semibold uppercase tracking-wide text-gray-600 dark:text-gray-400">
|
|
Ports
|
|
</div>
|
|
<div class="mt-1 flex flex-wrap gap-1">
|
|
{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-[11px] 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>
|
|
<div class="text-[11px] font-semibold uppercase tracking-wide text-gray-600 dark:text-gray-400">
|
|
Networks
|
|
</div>
|
|
<div class="mt-1 space-y-1 text-xs text-gray-700 dark:text-gray-300">
|
|
{container.networks!.map((network) => (
|
|
<div>
|
|
<span class="font-medium">{network.name}</span>
|
|
<Show when={network.ipv4}>
|
|
<span class="ml-2 text-gray-500 dark:text-gray-400">IPv4: {network.ipv4}</span>
|
|
</Show>
|
|
<Show when={network.ipv6}>
|
|
<span class="ml-2 text-gray-500 dark:text-gray-400">IPv6: {network.ipv6}</span>
|
|
</Show>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
</Show>
|
|
|
|
<Show when={container.labels && Object.keys(container.labels).length > 0}>
|
|
<div class="md:col-span-2">
|
|
<div class="text-[11px] font-semibold uppercase tracking-wide text-gray-600 dark:text-gray-400">
|
|
Labels
|
|
</div>
|
|
<div class="mt-1 flex flex-wrap gap-1">
|
|
{Object.entries(container.labels!).map(([key, value]) => (
|
|
<span class="rounded bg-gray-200 px-1.5 py-0.5 text-[11px] text-gray-700 dark:bg-gray-700/60 dark:text-gray-200">
|
|
{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, setExpanded] = createSignal(rowExpandState.get(rowId) ?? false);
|
|
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;
|
|
setExpanded((prev) => {
|
|
const next = !prev;
|
|
rowExpandState.set(rowId, next);
|
|
return next;
|
|
});
|
|
};
|
|
|
|
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) {
|
|
console.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) {
|
|
console.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="pl-5 sm:pl-6 lg:pl-8 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">—</td>
|
|
<td class="px-2 py-0.5 text-xs text-gray-400 dark:text-gray-500">—</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="space-y-2 border-l-4 border-gray-200 dark:border-gray-700 pl-3">
|
|
<div class="flex items-center justify-between text-[11px] font-semibold uppercase tracking-wide text-gray-600 dark:text-gray-300">
|
|
<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="overflow-x-auto border border-gray-200 dark:border-gray-700/60 rounded-lg bg-white dark:bg-gray-900/60">
|
|
<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">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-2 text-sm text-gray-900 dark:text-gray-100">
|
|
<span class="truncate font-medium" title={taskTitle()}>
|
|
{taskLabel()}
|
|
</span>
|
|
<span
|
|
class={`hidden sm:inline-flex items-center rounded px-2 py-0.5 text-[10px] font-medium whitespace-nowrap ${typeBadgeClass(
|
|
'task',
|
|
)}`}
|
|
>
|
|
Task
|
|
</span>
|
|
</div>
|
|
</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>
|
|
</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 Docker workloads found"
|
|
description={
|
|
totalContainers() === 0 && totalServices() === 0
|
|
? 'Add a Docker agent in Settings to start gathering container and service metrics.'
|
|
: props.searchTerm || props.statsFilter
|
|
? 'No Docker containers or services match your current filters.'
|
|
: 'Docker data is currently unavailable.'
|
|
}
|
|
/>
|
|
</Card>
|
|
}
|
|
>
|
|
<Card padding="none" class="overflow-hidden">
|
|
<ScrollableTable minWidth="900px">
|
|
<table class="w-full min-w-[900px] 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-[24%]">
|
|
Resource
|
|
</th>
|
|
<th class="px-2 py-1 text-left text-[11px] sm:text-xs font-medium uppercase tracking-wider w-[11%]">
|
|
Type
|
|
</th>
|
|
<th class="px-2 py-1 text-left text-[11px] sm:text-xs font-medium uppercase tracking-wider w-[17%]">
|
|
Image / Stack
|
|
</th>
|
|
<th class="px-2 py-1 text-left text-[11px] sm:text-xs font-medium uppercase tracking-wider w-[15%]">
|
|
Status
|
|
</th>
|
|
<th class="px-2 py-1 text-left text-[11px] sm:text-xs font-medium uppercase tracking-wider w-[10%]">
|
|
CPU
|
|
</th>
|
|
<th class="px-2 py-1 text-left text-[11px] sm:text-xs font-medium uppercase tracking-wider w-[11%]">
|
|
Memory
|
|
</th>
|
|
<th class="px-2 py-1 text-left text-[11px] sm:text-xs font-medium uppercase tracking-wider w-[10%]">
|
|
Tasks / Restarts
|
|
</th>
|
|
<th class="px-2 py-1 text-left text-[11px] sm:text-xs font-medium uppercase tracking-wider w-[10%]">
|
|
Updated / Uptime
|
|
</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
<For each={groupedRows()}>
|
|
{(group) => (
|
|
<>
|
|
<DockerHostGroupHeader host={group.host} colspan={8} />
|
|
<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={8}
|
|
customUrl={metadata?.customUrl}
|
|
onCustomUrlUpdate={props.onCustomUrlUpdate}
|
|
/>
|
|
) : (
|
|
<DockerServiceRow
|
|
row={row}
|
|
columns={8}
|
|
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 };
|