feat: add degraded status dot to Proxmox and Docker tabs (fixes #744)

This commit is contained in:
courtmanr@gmail.com 2025-11-23 00:10:58 +00:00
parent 8ab370d4c0
commit 2312345a02
2 changed files with 131 additions and 86 deletions

View file

@ -16,7 +16,7 @@ import { Card } from '@/components/shared/Card';
import { EmptyState } from '@/components/shared/EmptyState'; import { EmptyState } from '@/components/shared/EmptyState';
import { NodeGroupHeader } from '@/components/shared/NodeGroupHeader'; import { NodeGroupHeader } from '@/components/shared/NodeGroupHeader';
import { ProxmoxSectionNav } from '@/components/Proxmox/ProxmoxSectionNav'; import { ProxmoxSectionNav } from '@/components/Proxmox/ProxmoxSectionNav';
import { isNodeOnline } from '@/utils/status'; import { isNodeOnline, OFFLINE_HEALTH_STATUSES, DEGRADED_HEALTH_STATUSES } from '@/utils/status';
import { getNodeDisplayName } from '@/utils/nodes'; import { getNodeDisplayName } from '@/utils/nodes';
import { logger } from '@/utils/logger'; import { logger } from '@/utils/logger';
import { usePersistentSignal } from '@/hooks/usePersistentSignal'; import { usePersistentSignal } from '@/hooks/usePersistentSignal';
@ -683,12 +683,22 @@ export function Dashboard(props: DashboardProps) {
const totalStats = createMemo(() => { const totalStats = createMemo(() => {
const guests = filteredGuests(); const guests = filteredGuests();
const running = guests.filter((g) => g.status === 'running').length; const running = guests.filter((g) => g.status === 'running').length;
const degraded = guests.filter((g) => {
const status = (g.status || '').toLowerCase();
// Count as degraded if explicitly in degraded list, or if not running and not offline/stopped
return (
DEGRADED_HEALTH_STATUSES.has(status) ||
(status !== 'running' && !OFFLINE_HEALTH_STATUSES.has(status))
);
}).length;
const stopped = guests.length - running - degraded;
const vms = guests.filter((g) => g.type === 'qemu').length; const vms = guests.filter((g) => g.type === 'qemu').length;
const containers = guests.filter((g) => g.type === 'lxc').length; const containers = guests.filter((g) => g.type === 'lxc').length;
return { return {
total: guests.length, total: guests.length,
running, running,
stopped: guests.length - running, degraded,
stopped,
vms, vms,
containers, containers,
}; };
@ -1026,38 +1036,38 @@ export function Dashboard(props: DashboardProps) {
const node = nodeByInstance()[instanceId]; const node = nodeByInstance()[instanceId];
return ( return (
<> <>
<Show when={node && groupingMode() === 'grouped'}> <Show when={node && groupingMode() === 'grouped'}>
<NodeGroupHeader node={node!} colspan={11} /> <NodeGroupHeader node={node!} colspan={11} />
</Show> </Show>
<For each={guests} fallback={<></>}> <For each={guests} fallback={<></>}>
{(guest) => { {(guest) => {
// Match backend ID generation logic: stable format is "instance-vmid" // Match backend ID generation logic: stable format is "instance-vmid"
const guestId = const guestId =
guest.id || `${guest.instance}-${guest.vmid}`; guest.id || `${guest.instance}-${guest.vmid}`;
const metadata = const metadata =
guestMetadata()[guestId] || guestMetadata()[guestId] ||
guestMetadata()[`${guest.node}-${guest.vmid}`]; guestMetadata()[`${guest.node}-${guest.vmid}`];
const parentNode = node ?? resolveParentNode(guest); const parentNode = node ?? resolveParentNode(guest);
const parentNodeOnline = parentNode ? isNodeOnline(parentNode) : true; const parentNodeOnline = parentNode ? isNodeOnline(parentNode) : true;
return ( return (
<ComponentErrorBoundary name="GuestRow"> <ComponentErrorBoundary name="GuestRow">
<GuestRow <GuestRow
guest={guest} guest={guest}
alertStyles={getAlertStyles(guestId, activeAlerts, alertsEnabled())} alertStyles={getAlertStyles(guestId, activeAlerts, alertsEnabled())}
customUrl={metadata?.customUrl} customUrl={metadata?.customUrl}
onTagClick={handleTagClick} onTagClick={handleTagClick}
activeSearch={search()} activeSearch={search()}
parentNodeOnline={parentNodeOnline} parentNodeOnline={parentNodeOnline}
onCustomUrlUpdate={handleCustomUrlUpdate} onCustomUrlUpdate={handleCustomUrlUpdate}
isGroupedView={groupingMode() === 'grouped'} isGroupedView={groupingMode() === 'grouped'}
/> />
</ComponentErrorBoundary> </ComponentErrorBoundary>
); );
}} }}
</For> </For>
</> </>
); );
}} }}
</For> </For>
</tbody> </tbody>
@ -1110,6 +1120,13 @@ export function Dashboard(props: DashboardProps) {
<span class="h-2 w-2 bg-green-500 rounded-full"></span> <span class="h-2 w-2 bg-green-500 rounded-full"></span>
{totalStats().running} running {totalStats().running} running
</span> </span>
<Show when={totalStats().degraded > 0}>
<span class="text-gray-400">|</span>
<span class="flex items-center gap-1 text-xs text-gray-600 dark:text-gray-400">
<span class="h-2 w-2 bg-orange-500 rounded-full"></span>
{totalStats().degraded} degraded
</span>
</Show>
<span class="text-gray-400">|</span> <span class="text-gray-400">|</span>
<span class="flex items-center gap-1 text-xs text-gray-600 dark:text-gray-400"> <span class="flex items-center gap-1 text-xs text-gray-600 dark:text-gray-400">
<span class="h-2 w-2 bg-red-500 rounded-full"></span> <span class="h-2 w-2 bg-red-500 rounded-full"></span>

View file

@ -46,18 +46,18 @@ type SearchToken = { key?: string; value: string };
type DockerRow = type DockerRow =
| { | {
kind: 'container'; kind: 'container';
id: string; id: string;
host: DockerHost; host: DockerHost;
container: DockerContainer; container: DockerContainer;
} }
| { | {
kind: 'service'; kind: 'service';
id: string; id: string;
host: DockerHost; host: DockerHost;
service: DockerService; service: DockerService;
tasks: DockerTask[]; tasks: DockerTask[];
}; };
interface DockerUnifiedTableProps { interface DockerUnifiedTableProps {
hosts: DockerHost[]; hosts: DockerHost[];
@ -283,36 +283,36 @@ const PODMAN_METADATA_GROUPS: Array<{
prefixes?: string[]; prefixes?: string[];
keys?: string[]; keys?: string[];
}> = [ }> = [
{ {
title: 'Pod', title: 'Pod',
prefixes: ['io.podman.annotations.pod.', 'io.podman.pod.', 'net.containers.podman.pod.'], prefixes: ['io.podman.annotations.pod.', 'io.podman.pod.', 'net.containers.podman.pod.'],
}, },
{ {
title: 'Compose', title: 'Compose',
prefixes: ['io.podman.compose.'], prefixes: ['io.podman.compose.'],
}, },
{ {
title: 'Auto Update', title: 'Auto Update',
prefixes: ['io.containers.autoupdate.'], prefixes: ['io.containers.autoupdate.'],
keys: ['io.containers.autoupdate'], keys: ['io.containers.autoupdate'],
}, },
{ {
title: 'User Namespace', title: 'User Namespace',
keys: ['io.podman.annotations.userns', 'io.containers.userns'], keys: ['io.podman.annotations.userns', 'io.containers.userns'],
}, },
{ {
title: 'Capabilities', title: 'Capabilities',
keys: ['io.containers.capabilities', 'io.containers.selinux', 'io.containers.seccomp'], keys: ['io.containers.capabilities', 'io.containers.selinux', 'io.containers.seccomp'],
}, },
{ {
title: 'Podman Annotations', title: 'Podman Annotations',
prefixes: ['io.podman.annotations.'], prefixes: ['io.podman.annotations.'],
}, },
{ {
title: 'Container Settings', title: 'Container Settings',
prefixes: ['io.containers.'], prefixes: ['io.containers.'],
}, },
]; ];
const humanizePodmanKey = (raw: string): string => { const humanizePodmanKey = (raw: string): string => {
if (!raw) return 'Value'; if (!raw) return 'Value';
@ -1049,9 +1049,8 @@ const DockerContainerRow: Component<{
return ( return (
<> <>
<tr <tr
class={`border-b border-gray-200 dark:border-gray-700 transition-all duration-200 ${ class={`border-b border-gray-200 dark:border-gray-700 transition-all duration-200 ${hasDrawerContent() ? 'cursor-pointer' : ''
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' : ''}`}
} ${expanded() ? 'bg-gray-50 dark:bg-gray-800/40' : 'hover:bg-gray-50 dark:hover:bg-gray-800/50'} ${!isRunning() ? 'opacity-60' : ''}`}
onClick={toggle} onClick={toggle}
aria-expanded={expanded()} aria-expanded={expanded()}
> >
@ -1539,11 +1538,10 @@ const DockerContainerRow: Component<{
</Show> </Show>
<div class="mt-1 flex flex-wrap gap-1 text-[10px] text-gray-500 dark:text-gray-400"> <div class="mt-1 flex flex-wrap gap-1 text-[10px] text-gray-500 dark:text-gray-400">
<span <span
class={`rounded px-1.5 py-0.5 ${ class={`rounded px-1.5 py-0.5 ${mount.rw === false
mount.rw === false
? 'bg-gray-200 text-gray-700 dark:bg-gray-700/60 dark:text-gray-200' ? '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' : 'bg-green-100 text-green-700 dark:bg-green-900/40 dark:text-green-300'
}`} }`}
> >
{rw} {rw}
</span> </span>
@ -1819,13 +1817,11 @@ const DockerServiceRow: Component<{
return ( return (
<> <>
<tr <tr
class={`border-b border-gray-200 dark:border-gray-700 transition-all duration-200 ${ class={`border-b border-gray-200 dark:border-gray-700 transition-all duration-200 ${hasTasks() ? 'cursor-pointer' : ''
hasTasks() ? 'cursor-pointer' : '' } ${expanded()
} ${
expanded()
? 'bg-gray-50 dark:bg-gray-800/40' ? 'bg-gray-50 dark:bg-gray-800/40'
: 'hover:bg-gray-50 dark:hover:bg-gray-800/50' : 'hover:bg-gray-50 dark:hover:bg-gray-800/50'
} ${!isHealthy() ? 'opacity-60' : ''}`} } ${!isHealthy() ? 'opacity-60' : ''}`}
onClick={toggle} onClick={toggle}
aria-expanded={expanded()} aria-expanded={expanded()}
> >
@ -2356,6 +2352,31 @@ const DockerUnifiedTable: Component<DockerUnifiedTableProps> = (props) => {
}, 0), }, 0),
); );
const degradedContainers = createMemo(() =>
groupedRows().reduce((acc, group) => {
return (
acc +
group.rows
.filter((row): row is Extract<typeof row, { kind: 'container' }> => row.kind === 'container')
.filter((row) => {
const state = toLower(row.container.state);
const health = toLower(row.container.health);
// Explicitly degraded/error states
if (ERROR_CONTAINER_STATES.has(state)) return true;
// Running but unhealthy
if (state === 'running' && health === 'unhealthy') return true;
// Any other state that is NOT running and NOT stopped
if (state !== 'running' && !STOPPED_CONTAINER_STATES.has(state)) return true;
return false;
}).length
);
}, 0),
);
const renderRow = (row: DockerRow, grouped: boolean) => { const renderRow = (row: DockerRow, grouped: boolean) => {
const resourceId = const resourceId =
row.kind === 'container' row.kind === 'container'
@ -2579,6 +2600,13 @@ const DockerUnifiedTable: Component<DockerUnifiedTableProps> = (props) => {
<span class="h-2 w-2 rounded-full bg-green-500" aria-hidden="true" /> <span class="h-2 w-2 rounded-full bg-green-500" aria-hidden="true" />
{runningContainers()} running {runningContainers()} running
</span> </span>
<Show when={degradedContainers() > 0}>
<span class="text-gray-400">|</span>
<span class="flex items-center gap-1">
<span class="h-2 w-2 rounded-full bg-orange-500" aria-hidden="true" />
{degradedContainers()} degraded
</span>
</Show>
<span class="text-gray-400">|</span> <span class="text-gray-400">|</span>
<span class="flex items-center gap-1"> <span class="flex items-center gap-1">
<span class="h-2 w-2 rounded-full bg-gray-400" aria-hidden="true" /> <span class="h-2 w-2 rounded-full bg-gray-400" aria-hidden="true" />