Pulse/frontend-modern/src/components/Docker/DockerHosts.tsx
2025-11-14 12:42:08 +00:00

350 lines
12 KiB
TypeScript

import type { Component } from 'solid-js';
import { Show, createMemo, createSignal, createEffect, onMount, onCleanup } from 'solid-js';
import { useNavigate } from '@solidjs/router';
import type { DockerHost } from '@/types/api';
import { Card } from '@/components/shared/Card';
import { EmptyState } from '@/components/shared/EmptyState';
import { DockerFilter } from './DockerFilter';
import { DockerHostSummaryTable, type DockerHostSummary } from './DockerHostSummaryTable';
import { DockerUnifiedTable } from './DockerUnifiedTable';
import { useWebSocket } from '@/App';
import { useDebouncedValue } from '@/hooks/useDebouncedValue';
import { formatBytes, formatRelativeTime } from '@/utils/format';
import { DockerMetadataAPI, type DockerMetadata } from '@/api/dockerMetadata';
import { logger } from '@/utils/logger';
import { STORAGE_KEYS } from '@/utils/localStorage';
import { DEGRADED_HEALTH_STATUSES, OFFLINE_HEALTH_STATUSES } from '@/utils/status';
type DockerMetadataRecord = Record<string, DockerMetadata>;
interface DockerHostsProps {
hosts: DockerHost[];
activeAlerts?: Record<string, unknown> | any;
}
export const DockerHosts: Component<DockerHostsProps> = (props) => {
const navigate = useNavigate();
const { initialDataReceived, reconnecting, connected } = useWebSocket();
// Load docker metadata from localStorage or API
const loadInitialDockerMetadata = (): DockerMetadataRecord => {
try {
const cached = localStorage.getItem(STORAGE_KEYS.DOCKER_METADATA);
if (cached) {
return JSON.parse(cached);
}
} catch (err) {
logger.warn('Failed to parse cached docker metadata', err);
}
return {};
};
const [dockerMetadata, setDockerMetadata] = createSignal<DockerMetadataRecord>(
loadInitialDockerMetadata(),
);
const sortedHosts = createMemo(() => {
const hosts = props.hosts || [];
return [...hosts].sort((a, b) => {
const aName = a.customDisplayName || a.displayName || a.hostname || a.id || '';
const bName = b.customDisplayName || b.displayName || b.hostname || b.id || '';
return aName.localeCompare(bName);
});
});
const isLoading = createMemo(() => {
if (typeof initialDataReceived === 'function') {
const hostCount = Array.isArray(props.hosts) ? props.hosts.length : 0;
return !initialDataReceived() && hostCount === 0;
}
return false;
});
const [search, setSearch] = createSignal('');
const debouncedSearch = useDebouncedValue(search, 250);
const [selectedHostId, setSelectedHostId] = createSignal<string | null>(null);
const [statusFilter, setStatusFilter] = createSignal<'all' | 'online' | 'degraded' | 'offline'>('all');
const clampPercent = (value: number | undefined | null) => {
if (value === undefined || value === null || Number.isNaN(value)) return 0;
if (!Number.isFinite(value)) return 0;
if (value < 0) return 0;
if (value > 100) return 100;
return value;
};
const hostSummaries = createMemo<DockerHostSummary[]>(() => {
return sortedHosts().map((host) => {
const totalContainers = host.containers?.length ?? 0;
const runningContainers =
host.containers?.filter((container) => container.state?.toLowerCase() === 'running').length ?? 0;
const runningPercent = totalContainers > 0 ? clampPercent((runningContainers / totalContainers) * 100) : 0;
const cpuPercent = clampPercent(host.cpuUsagePercent ?? 0);
const memoryUsed = host.memory?.used ?? 0;
const memoryTotal = host.memory?.total ?? host.totalMemoryBytes ?? 0;
const memoryPercent = host.memory?.usage
? clampPercent(host.memory.usage)
: memoryTotal > 0
? clampPercent((memoryUsed / memoryTotal) * 100)
: 0;
const memoryLabel =
memoryTotal > 0 ? `${formatBytes(memoryUsed, 0)} / ${formatBytes(memoryTotal, 0)}` : undefined;
let diskPercent = 0;
let diskLabel: string | undefined;
if (host.disks && host.disks.length > 0) {
const totals = host.disks.reduce(
(acc, disk) => {
acc.used += disk.used ?? 0;
acc.total += disk.total ?? 0;
return acc;
},
{ used: 0, total: 0 },
);
if (totals.total > 0) {
diskPercent = clampPercent((totals.used / totals.total) * 100);
diskLabel = `${formatBytes(totals.used, 0)} / ${formatBytes(totals.total, 0)}`;
}
}
const uptimeSeconds = host.uptimeSeconds ?? 0;
const lastSeenRelative = host.lastSeen ? formatRelativeTime(host.lastSeen) : '—';
const lastSeenAbsolute = host.lastSeen ? new Date(host.lastSeen).toLocaleString() : '';
return {
host,
cpuPercent,
memoryPercent,
memoryLabel,
diskPercent,
diskLabel,
runningPercent,
runningCount: runningContainers,
totalCount: totalContainers,
uptimeSeconds,
lastSeenRelative,
lastSeenAbsolute,
};
});
});
let searchInputRef: HTMLInputElement | undefined;
const focusSearchInput = () => {
queueMicrotask(() => searchInputRef?.focus());
};
const handleKeyDown = (event: KeyboardEvent) => {
const target = event.target as HTMLElement;
if (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.isContentEditable) {
return;
}
if (event.ctrlKey || event.metaKey || event.altKey) {
return;
}
if (event.key.length === 1 && searchInputRef) {
event.preventDefault();
focusSearchInput();
setSearch((prev) => prev + event.key);
}
};
onMount(() => {
document.addEventListener('keydown', handleKeyDown);
// Load docker metadata from API
DockerMetadataAPI.getAllMetadata()
.then((metadata) => {
setDockerMetadata(metadata || {});
try {
localStorage.setItem(STORAGE_KEYS.DOCKER_METADATA, JSON.stringify(metadata || {}));
} catch (err) {
logger.warn('Failed to cache docker metadata', err);
}
})
.catch((err) => {
logger.debug('Failed to load docker metadata', err);
});
});
onCleanup(() => document.removeEventListener('keydown', handleKeyDown));
// Handler to update docker resource custom URL
const handleCustomUrlUpdate = (resourceId: string, url: string) => {
const trimmedUrl = url.trim();
const nextUrl = trimmedUrl === '' ? undefined : trimmedUrl;
setDockerMetadata((prev) => {
const updated = { ...prev };
if (nextUrl === undefined) {
delete updated[resourceId];
} else {
updated[resourceId] = {
...(prev[resourceId] || { id: resourceId }),
customUrl: nextUrl,
};
}
// Cache to localStorage
try {
localStorage.setItem(STORAGE_KEYS.DOCKER_METADATA, JSON.stringify(updated));
} catch (err) {
logger.warn('Failed to cache docker metadata', err);
}
return updated;
});
};
createEffect(() => {
const hostId = selectedHostId();
if (!hostId) {
return;
}
if (!sortedHosts().some((host) => host.id === hostId)) {
setSelectedHostId(null);
}
});
const hostMatchesStatus = (host: DockerHost) => {
const status = statusFilter();
if (status === 'all') return true;
const normalized = host.status?.toLowerCase() ?? '';
if (status === 'online') return normalized === 'online';
if (status === 'offline') return OFFLINE_HEALTH_STATUSES.has(normalized);
if (status === 'degraded') return DEGRADED_HEALTH_STATUSES.has(normalized);
return true;
};
const filteredHostSummaries = createMemo(() => {
const summaries = hostSummaries();
if (statusFilter() === 'all') return summaries;
return summaries.filter((summary) => hostMatchesStatus(summary.host));
});
createEffect(() => {
const hostId = selectedHostId();
if (!hostId) return;
if (!filteredHostSummaries().some((summary) => summary.host.id === hostId)) {
setSelectedHostId(null);
}
});
const statsFilter = createMemo(() => {
const status = statusFilter();
if (status === 'all') return null;
return { type: 'host-status' as const, value: status };
});
const handleHostSelect = (hostId: string) => {
setSelectedHostId((current) => (current === hostId ? null : hostId));
};
const renderFilter = () => (
<DockerFilter
search={search}
setSearch={setSearch}
statusFilter={statusFilter}
setStatusFilter={setStatusFilter}
onReset={() => {
setSearch('');
setSelectedHostId(null);
setStatusFilter('all');
}}
searchInputRef={(el) => {
searchInputRef = el;
}}
/>
);
return (
<div class="space-y-0">
<Show when={isLoading()}>
<Card padding="lg">
<EmptyState
icon={
<svg class="h-12 w-12 animate-spin text-blue-500" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4" />
<path
class="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
/>
</svg>
}
title={reconnecting() ? 'Reconnecting to container agents...' : 'Loading container data...'}
description={
reconnecting()
? 'Re-establishing metrics from the monitoring service.'
: connected()
? 'Waiting for the first container update.'
: 'Connecting to the monitoring service.'
}
/>
</Card>
</Show>
<Show when={!isLoading()}>
<Show
when={sortedHosts().length > 0}
fallback={
<>
{renderFilter()}
<Card padding="lg">
<EmptyState
icon={
<svg class="h-12 w-12 text-gray-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"
/>
</svg>
}
title="No container runtimes reporting"
description="Deploy the Pulse container agent (Docker or Podman) on at least one host to light up this tab. As soon as an agent reports in, runtime metrics appear automatically."
actions={
<button
type="button"
onClick={() => navigate('/settings/docker')}
class="inline-flex items-center gap-2 rounded-lg bg-blue-600 px-4 py-2 text-sm font-medium text-white transition-colors hover:bg-blue-700"
>
<span>Set up container agent</span>
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7" />
</svg>
</button>
}
/>
</Card>
</>
}
>
<Show when={hostSummaries().length > 0}>
<DockerHostSummaryTable
summaries={filteredHostSummaries}
selectedHostId={selectedHostId}
onSelect={handleHostSelect}
/>
</Show>
{renderFilter()}
<DockerUnifiedTable
hosts={sortedHosts()}
searchTerm={debouncedSearch()}
statsFilter={statsFilter()}
selectedHostId={selectedHostId}
dockerMetadata={dockerMetadata()}
onCustomUrlUpdate={handleCustomUrlUpdate}
/>
</Show>
</Show>
</div>
);
};