feat: add degraded status dot to Proxmox and Docker tabs (fixes #744)
This commit is contained in:
parent
8ab370d4c0
commit
2312345a02
2 changed files with 131 additions and 86 deletions
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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" />
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue