Remove unwanted table animations on data updates

- Remove animate-enter class from all table row components
- Prevents animations on routine WebSocket data updates
- Animations now only occur on initial load/new items
- Fix test expectation for Docker monitoring checkbox default state

Components updated:
- DockerUnifiedTable (container/service rows)
- DockerHostSummaryTable
- NodeSummaryTable
- GuestRow
- ConfiguredNodeTables (PVE/PBS/PMG)
- Storage and DiskList
- APITokenManager
- UnifiedAgents

All tests passing.
This commit is contained in:
courtmanr@gmail.com 2025-11-27 18:52:43 +00:00
parent cf62ebae27
commit b0ff539fcc
17 changed files with 252 additions and 160 deletions

View file

@ -869,7 +869,7 @@ export function ResourceTable(props: ResourceTableProps) {
return (
<tr
class={`hover:bg-gray-50 dark:hover:bg-gray-900/50 transition-colors animate-enter ${resource.disabled || props.globalDisableFlag?.() ? 'opacity-40' : ''}`}
class={`hover:bg-gray-50 dark:hover:bg-gray-900/50 transition-colors ${resource.disabled || props.globalDisableFlag?.() ? 'opacity-40' : ''}`}
>
{/* Alert toggle column */}
<td class="p-1 px-2 text-center align-middle">
@ -1380,7 +1380,7 @@ export function ResourceTable(props: ResourceTableProps) {
return (
<tr
class={`hover:bg-gray-50 dark:hover:bg-gray-900/50 transition-colors animate-enter ${resource.disabled || props.globalDisableFlag?.() ? 'opacity-40' : ''}`}
class={`hover:bg-gray-50 dark:hover:bg-gray-900/50 transition-colors ${resource.disabled || props.globalDisableFlag?.() ? 'opacity-40' : ''}`}
>
{/* Alert toggle column */}
<td class="p-1 px-2 text-center align-middle">

View file

@ -2137,7 +2137,7 @@ const UnifiedBackups: Component = () => {
</tr>
<For each={group.items}>
{(item) => (
<tr class="border-t border-gray-200 dark:border-gray-700 hover:bg-gray-50 dark:hover:bg-gray-700/30 animate-enter">
<tr class="border-t border-gray-200 dark:border-gray-700 hover:bg-gray-50 dark:hover:bg-gray-700/30">
<td class="p-0.5 pl-5 pr-1.5 text-sm align-middle">{item.vmid}</td>
<td class="p-0.5 px-1.5 align-middle">
<span

View file

@ -456,7 +456,7 @@ export function GuestRow(props: GuestRowProps) {
});
const rowClass = createMemo(() => {
const base = 'transition-all duration-200 relative animate-enter';
const base = 'transition-all duration-200 relative';
const hover = 'hover:shadow-sm';
const alertBg = hasUnacknowledgedAlert()
? props.alertStyles?.severity === 'critical'

View file

@ -227,7 +227,7 @@ export const DockerHostSummaryTable: Component<DockerHostSummaryTableProps> = (p
};
const rowClass = () => {
const baseHover = 'cursor-pointer transition-all duration-200 relative hover:bg-gray-50 dark:hover:bg-gray-700/50 hover:shadow-sm animate-enter';
const baseHover = 'cursor-pointer transition-all duration-200 relative hover:bg-gray-50 dark:hover:bg-gray-700/50 hover:shadow-sm';
if (selected) {
return 'cursor-pointer transition-all duration-200 relative bg-blue-50 dark:bg-blue-900/20 hover:bg-blue-100 dark:hover:bg-blue-900/30 hover:shadow-sm z-10';

View file

@ -1,5 +1,6 @@
import type { Component } from 'solid-js';
import { Show, createMemo, createSignal, createEffect, onMount, onCleanup } from 'solid-js';
import { createStore } from 'solid-js/store';
import { useNavigate } from '@solidjs/router';
import type { DockerHost } from '@/types/api';
import { Card } from '@/components/shared/Card';
@ -73,8 +74,13 @@ export const DockerHosts: Component<DockerHostsProps> = (props) => {
return value;
};
// Cache for stable summary objects to prevent re-animations
const summaryCache = new Map<string, [DockerHostSummary, any]>();
const hostSummaries = createMemo<DockerHostSummary[]>(() => {
return sortedHosts().map((host) => {
const usedKeys = new Set<string>();
const result = sortedHosts().map((host) => {
const totalContainers = host.containers?.length ?? 0;
const runningContainers =
host.containers?.filter((container) => container.state?.toLowerCase() === 'running').length ?? 0;
@ -113,7 +119,7 @@ export const DockerHosts: Component<DockerHostsProps> = (props) => {
const lastSeenRelative = host.lastSeen ? formatRelativeTime(host.lastSeen) : '—';
const lastSeenAbsolute = host.lastSeen ? new Date(host.lastSeen).toLocaleString() : '';
return {
const newSummary: DockerHostSummary = {
host,
cpuPercent,
memoryPercent,
@ -127,7 +133,29 @@ export const DockerHosts: Component<DockerHostsProps> = (props) => {
lastSeenRelative,
lastSeenAbsolute,
};
const key = host.id;
usedKeys.add(key);
let entry = summaryCache.get(key);
if (!entry) {
entry = createStore(newSummary);
summaryCache.set(key, entry);
} else {
const [_, setState] = entry;
setState(newSummary);
}
return entry[0];
});
// Prune cache
for (const key of summaryCache.keys()) {
if (!usedKeys.has(key)) {
summaryCache.delete(key);
}
}
return result;
});
let searchInputRef: HTMLInputElement | undefined;
@ -168,7 +196,7 @@ export const DockerHosts: Component<DockerHostsProps> = (props) => {
}
})
.catch((err) => {
logger.debug('Failed to load docker metadata', err);
logger.debug('Failed to load docker metadata', err);
});
});
onCleanup(() => document.removeEventListener('keydown', handleKeyDown));

View file

@ -1253,7 +1253,7 @@ const DockerContainerRow: Component<{
return (
<>
<div
class={`grid items-center transition-all duration-200 animate-enter ${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' : ''}`}
class={`grid items-center 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' : ''}`}
style={{ 'grid-template-columns': props.gridTemplate() }}
onClick={toggle}
aria-expanded={expanded()}
@ -1950,7 +1950,7 @@ const DockerServiceRow: Component<{
return (
<>
<div
class={`grid items-center transition-all duration-200 animate-enter ${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' : ''}`}
class={`grid items-center 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' : ''}`}
style={{ 'grid-template-columns': props.gridTemplate() }}
onClick={toggle}
aria-expanded={expanded()}
@ -2087,10 +2087,22 @@ const DockerServiceRow: Component<{
);
};
const areTasksEqual = (a: DockerTask[], b: DockerTask[]) => {
if (a.length !== b.length) return false;
for (let i = 0; i < a.length; i++) {
if (a[i] !== b[i]) return false;
}
return true;
};
const DockerUnifiedTable: Component<DockerUnifiedTableProps> = (props) => {
// Use the responsive grid template hook for dynamic column visibility
const { gridTemplate, visibleColumns, isMobile } = useGridTemplate({ columns: DOCKER_COLUMNS });
// Caches for stable object references to prevent re-animations
const rowCache = new Map<string, DockerRow>();
const tasksCache = new Map<string, DockerTask[]>();
const tokens = createMemo(() => parseSearchTerm(props.searchTerm));
const [sortKey, setSortKey] = usePersistentSignal<SortKey>('dockerUnifiedSortKey', 'host', {
deserialize: (value) => (SORT_KEYS.includes(value as SortKey) ? (value as SortKey) : 'host'),
@ -2146,6 +2158,8 @@ const DockerUnifiedTable: Component<DockerUnifiedTableProps> = (props) => {
const filter = props.statsFilter ?? null;
const searchTokens = tokens();
const selectedHostId = props.selectedHostId ? props.selectedHostId() : null;
const usedCacheKeys = new Set<string>();
const usedTaskCacheKeys = new Set<string>();
sortedHosts().forEach((host) => {
if (!hostMatchesFilter(filter, host)) {
@ -2177,12 +2191,22 @@ const DockerUnifiedTable: Component<DockerUnifiedTableProps> = (props) => {
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,
});
const rowId = container.id || `${host.id}-container-${container.name}`;
const cacheKey = `c:${host.id}:${rowId}`;
usedCacheKeys.add(cacheKey);
let row = rowCache.get(cacheKey);
if (!row || row.kind !== 'container' || row.host !== host || row.container !== container) {
row = {
kind: 'container',
id: rowId,
host,
container,
};
rowCache.set(cacheKey, row);
}
containerRows.push(row as Extract<DockerRow, { kind: 'container' }>);
});
services.forEach((service) => {
@ -2190,7 +2214,7 @@ const DockerUnifiedTable: Component<DockerUnifiedTableProps> = (props) => {
const matchesSearch = searchTokens.every((token) => serviceMatchesToken(token, host, service));
if (!matchesSearch) return;
const associatedTasks = tasks.filter((task) => {
let associatedTasks = tasks.filter((task) => {
if (service.id && task.serviceId) {
return task.serviceId === service.id;
}
@ -2200,18 +2224,39 @@ const DockerUnifiedTable: Component<DockerUnifiedTableProps> = (props) => {
return false;
});
// Use stable array reference for tasks if content matches
const taskCacheKey = `s:${host.id}:${service.id || service.name}`;
usedTaskCacheKeys.add(taskCacheKey);
const cachedTasks = tasksCache.get(taskCacheKey);
if (cachedTasks && areTasksEqual(cachedTasks, associatedTasks)) {
associatedTasks = cachedTasks;
} else {
tasksCache.set(taskCacheKey, associatedTasks);
}
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,
});
const rowId = service.id || `${host.id}-service-${service.name}`;
const cacheKey = `s:${host.id}:${rowId}`;
usedCacheKeys.add(cacheKey);
let row = rowCache.get(cacheKey);
// Check if row needs update (host/service changed, or tasks array changed)
if (!row || row.kind !== 'service' || row.host !== host || row.service !== service || row.tasks !== associatedTasks) {
row = {
kind: 'service',
id: rowId,
host,
service,
tasks: associatedTasks,
};
rowCache.set(cacheKey, row);
}
serviceRows.push(row as Extract<DockerRow, { kind: 'service' }>);
});
if (serviceRows.length > 0) {
@ -2260,6 +2305,18 @@ const DockerUnifiedTable: Component<DockerUnifiedTableProps> = (props) => {
}
});
// Prune caches
for (const key of rowCache.keys()) {
if (!usedCacheKeys.has(key)) {
rowCache.delete(key);
}
}
for (const key of tasksCache.keys()) {
if (!usedTaskCacheKeys.has(key)) {
tasksCache.delete(key);
}
}
return groups;
});

View file

@ -243,7 +243,7 @@ export const HostsOverview: Component<HostsOverviewProps> = (props) => {
});
const rowClass = () => {
const base = 'border-b border-gray-200 dark:border-gray-700 transition-all duration-200 animate-enter';
const base = 'border-b border-gray-200 dark:border-gray-700 transition-all duration-200';
const hover = 'hover:bg-gray-50 dark:hover:bg-gray-800/50';
const clickable = hasDrawerContent() ? 'cursor-pointer' : '';
const expanded = drawerOpen() ? 'bg-gray-50 dark:bg-gray-800/40' : '';

View file

@ -133,14 +133,14 @@ const MailGateway: Component = () => {
</div>
<table class="w-full text-xs">
<tbody class="divide-y divide-gray-200 dark:divide-gray-700">
<tr class="animate-enter">
<tr>
<td class="px-2 py-1.5">
<div class="text-xs font-semibold text-gray-900 dark:text-gray-100">{formatNum(total)}</div>
<div class="text-[11px] text-gray-500 dark:text-gray-400">{formatDec(total / 24)}/hr</div>
</td>
<td class="px-2 py-1.5 text-[11px] text-gray-500 dark:text-gray-400">Total processed</td>
</tr>
<tr class="animate-enter">
<tr>
<td class="px-2 py-1.5">
<div class="text-xs text-gray-900 dark:text-gray-100">{formatNum(inbound)}</div>
<div class="text-[11px] text-gray-500 dark:text-gray-400">
@ -149,7 +149,7 @@ const MailGateway: Component = () => {
</td>
<td class="px-2 py-1.5 text-[11px] text-gray-500 dark:text-gray-400">Inbound</td>
</tr>
<tr class="animate-enter">
<tr>
<td class="px-2 py-1.5">
<div class="text-xs text-gray-900 dark:text-gray-100">{formatNum(outbound)}</div>
<div class="text-[11px] text-gray-500 dark:text-gray-400">
@ -158,7 +158,7 @@ const MailGateway: Component = () => {
</td>
<td class="px-2 py-1.5 text-[11px] text-gray-500 dark:text-gray-400">Outbound</td>
</tr>
<tr class="animate-enter">
<tr>
<td class="px-2 py-1.5">
<div class="text-xs font-medium text-orange-600 dark:text-orange-400">{formatNum(spam)}</div>
<div class="text-[11px] text-gray-500 dark:text-gray-400">
@ -167,7 +167,7 @@ const MailGateway: Component = () => {
</td>
<td class="px-2 py-1.5 text-[11px] text-gray-500 dark:text-gray-400">Spam caught</td>
</tr>
<tr class="animate-enter">
<tr>
<td class="px-2 py-1.5">
<div class="text-xs font-medium text-red-600 dark:text-red-400">{formatNum(virus)}</div>
<div class="text-[11px] text-gray-500 dark:text-gray-400">
@ -189,7 +189,7 @@ const MailGateway: Component = () => {
</div>
<table class="w-full text-xs">
<tbody class="divide-y divide-gray-200 dark:divide-gray-700">
<tr class="animate-enter">
<tr>
<td class="px-2 py-1.5">
<div class="text-xs text-gray-900 dark:text-gray-100">{formatDec(bytesIn / 1024 / 1024)} MB</div>
<div class="text-[11px] text-gray-500 dark:text-gray-400">
@ -198,7 +198,7 @@ const MailGateway: Component = () => {
</td>
<td class="px-2 py-1.5 text-[11px] text-gray-500 dark:text-gray-400">Inbound bytes</td>
</tr>
<tr class="animate-enter">
<tr>
<td class="px-2 py-1.5">
<div class="text-xs text-gray-900 dark:text-gray-100">{formatDec(bytesOut / 1024 / 1024)} MB</div>
<div class="text-[11px] text-gray-500 dark:text-gray-400">
@ -223,7 +223,7 @@ const MailGateway: Component = () => {
</div>
<table class="w-full text-xs">
<tbody class="divide-y divide-gray-200 dark:divide-gray-700">
<tr class="animate-enter">
<tr>
<td class="px-2 py-1.5">
<div class="text-xs font-medium text-orange-600 dark:text-orange-400">{formatNum(qSpam)}</div>
<div class="text-[11px] text-gray-500 dark:text-gray-400">
@ -232,7 +232,7 @@ const MailGateway: Component = () => {
</td>
<td class="px-2 py-1.5 text-[11px] text-gray-500 dark:text-gray-400">Spam quarantined</td>
</tr>
<tr class="animate-enter">
<tr>
<td class="px-2 py-1.5">
<div class="text-xs font-medium text-red-600 dark:text-red-400">{formatNum(qVirus)}</div>
<div class="text-[11px] text-gray-500 dark:text-gray-400">
@ -241,7 +241,7 @@ const MailGateway: Component = () => {
</td>
<td class="px-2 py-1.5 text-[11px] text-gray-500 dark:text-gray-400">Virus quarantined</td>
</tr>
<tr class="animate-enter">
<tr>
<td class="px-2 py-1.5">
<div class="text-xs font-medium text-yellow-600 dark:text-yellow-400">{formatNum(qAttachment)}</div>
<div class="text-[11px] text-gray-500 dark:text-gray-400">
@ -250,7 +250,7 @@ const MailGateway: Component = () => {
</td>
<td class="px-2 py-1.5 text-[11px] text-gray-500 dark:text-gray-400">Attachments blocked</td>
</tr>
<tr class="animate-enter">
<tr>
<td class="px-2 py-1.5">
<div class="text-xs text-gray-900 dark:text-gray-100">{formatNum(qBlacklist)}</div>
<div class="text-[11px] text-gray-500 dark:text-gray-400">
@ -259,7 +259,7 @@ const MailGateway: Component = () => {
</td>
<td class="px-2 py-1.5 text-[11px] text-gray-500 dark:text-gray-400">Blacklisted</td>
</tr>
<tr class="animate-enter">
<tr>
<td class="px-2 py-1.5">
<div class="text-xs font-semibold text-gray-900 dark:text-gray-100">{formatNum(qTotal)}</div>
<div class="text-[11px] text-gray-500 dark:text-gray-400">
@ -315,7 +315,7 @@ const MailGateway: Component = () => {
</div>
<table class="w-full text-xs">
<tbody class="divide-y divide-gray-200 dark:divide-gray-700">
<tr class="animate-enter">
<tr>
<td class="px-2 py-1.5">
<div class="text-xs text-gray-900 dark:text-gray-100">{formatNum(bouncesIn)}</div>
<div class="text-[11px] text-gray-500 dark:text-gray-400">
@ -324,7 +324,7 @@ const MailGateway: Component = () => {
</td>
<td class="px-2 py-1.5 text-[11px] text-gray-500 dark:text-gray-400">Bounces inbound</td>
</tr>
<tr class="animate-enter">
<tr>
<td class="px-2 py-1.5">
<div class="text-xs text-gray-900 dark:text-gray-100">{formatNum(bouncesOut)}</div>
<div class="text-[11px] text-gray-500 dark:text-gray-400">
@ -333,7 +333,7 @@ const MailGateway: Component = () => {
</td>
<td class="px-2 py-1.5 text-[11px] text-gray-500 dark:text-gray-400">Bounces outbound</td>
</tr>
<tr class="animate-enter">
<tr>
<td class="px-2 py-1.5">
<div class="text-xs text-gray-900 dark:text-gray-100">{formatNum(rbl)}</div>
<div class="text-[11px] text-gray-500 dark:text-gray-400">
@ -342,7 +342,7 @@ const MailGateway: Component = () => {
</td>
<td class="px-2 py-1.5 text-[11px] text-gray-500 dark:text-gray-400">RBL rejects</td>
</tr>
<tr class="animate-enter">
<tr>
<td class="px-2 py-1.5">
<div class="text-xs text-gray-900 dark:text-gray-100">{formatNum(pregreet)}</div>
<div class="text-[11px] text-gray-500 dark:text-gray-400">
@ -351,7 +351,7 @@ const MailGateway: Component = () => {
</td>
<td class="px-2 py-1.5 text-[11px] text-gray-500 dark:text-gray-400">Pregreet rejects</td>
</tr>
<tr class="animate-enter">
<tr>
<td class="px-2 py-1.5">
<div class="text-xs text-gray-900 dark:text-gray-100">{formatNum(greylist)}</div>
<div class="text-[11px] text-gray-500 dark:text-gray-400">
@ -360,7 +360,7 @@ const MailGateway: Component = () => {
</td>
<td class="px-2 py-1.5 text-[11px] text-gray-500 dark:text-gray-400">Greylisted</td>
</tr>
<tr class="animate-enter">
<tr>
<td class="px-2 py-1.5">
<div class="text-xs text-gray-900 dark:text-gray-100">{formatNum(junk)}</div>
<div class="text-[11px] text-gray-500 dark:text-gray-400">
@ -369,7 +369,7 @@ const MailGateway: Component = () => {
</td>
<td class="px-2 py-1.5 text-[11px] text-gray-500 dark:text-gray-400">Junk mail</td>
</tr>
<tr class="animate-enter">
<tr>
<td class="px-2 py-1.5">
<div class="text-xs text-gray-900 dark:text-gray-100">
{pmg.mailStats?.averageProcessTimeMs ? formatDec(pmg.mailStats.averageProcessTimeMs / 1000, 2) : '—'} s
@ -421,7 +421,7 @@ const MailGateway: Component = () => {
const oldestClass = oldestAge > 1800 ? 'text-amber-600 dark:text-amber-400' : 'text-gray-700 dark:text-gray-300';
return (
<tr class="animate-enter">
<tr class="">
<td class="px-2 py-1.5 text-xs font-medium text-gray-900 dark:text-gray-100 truncate max-w-[150px]">{node.name}</td>
<td class="px-2 py-1.5 text-xs text-gray-700 dark:text-gray-300 capitalize">{node.role || '—'}</td>
<td class="px-2 py-1.5">

View file

@ -121,7 +121,7 @@ const Replication: Component = () => {
{(job) => {
const badge = getStatusBadge(job);
return (
<tr class="hover:bg-gray-50/80 dark:hover:bg-gray-900/40 transition-colors animate-enter">
<tr class="hover:bg-gray-50/80 dark:hover:bg-gray-900/40 transition-colors">
<td class="px-4 py-3">
<div class="font-medium text-gray-900 dark:text-gray-100 truncate max-w-[200px]">
{job.guestName || `VM ${job.guestId ?? ''}`}

View file

@ -422,30 +422,30 @@ export const APITokenManager: Component<APITokenManagerProps> = (props) => {
</div>
<div
class={`rounded-lg border p-4 text-sm shadow-sm ${hasWildcardTokens()
? 'border-amber-300/80 bg-amber-50/80 text-amber-900 dark:border-amber-700/70 dark:bg-amber-900/20 dark:text-amber-100'
: 'border-gray-200/70 bg-white/70 text-gray-700 dark:border-gray-700/70 dark:bg-gray-900/40 dark:text-gray-300'
? 'border-amber-300/80 bg-amber-50/80 text-amber-900 dark:border-amber-700/70 dark:bg-amber-900/20 dark:text-amber-100'
: 'border-gray-200/70 bg-white/70 text-gray-700 dark:border-gray-700/70 dark:bg-gray-900/40 dark:text-gray-300'
}`}
>
<div
class={`text-[0.7rem] font-semibold uppercase tracking-wide ${hasWildcardTokens()
? 'text-amber-700 dark:text-amber-300'
: 'text-gray-500 dark:text-gray-400'
? 'text-amber-700 dark:text-amber-300'
: 'text-gray-500 dark:text-gray-400'
}`}
>
Full access tokens
</div>
<div
class={`mt-1 text-2xl font-semibold ${hasWildcardTokens()
? 'text-amber-800 dark:text-amber-100'
: 'text-gray-900 dark:text-gray-100'
? 'text-amber-800 dark:text-amber-100'
: 'text-gray-900 dark:text-gray-100'
}`}
>
{wildcardCount()}
</div>
<p
class={`mt-1 text-xs ${hasWildcardTokens()
? 'text-amber-700 dark:text-amber-200'
: 'text-gray-500 dark:text-gray-400'
? 'text-amber-700 dark:text-amber-200'
: 'text-gray-500 dark:text-gray-400'
}`}
>
{hasWildcardTokens()
@ -582,9 +582,9 @@ export const APITokenManager: Component<APITokenManagerProps> = (props) => {
return (
<tr
class={`transition-colors animate-enter ${rowIsWildcard
? 'bg-amber-50/50 dark:bg-amber-900/10'
: 'bg-white dark:bg-gray-900/10'
class={`transition-colors ${rowIsWildcard
? 'bg-amber-50/50 dark:bg-amber-900/10'
: 'bg-white dark:bg-gray-900/10'
} hover:bg-blue-50/40 dark:hover:bg-gray-800/50`}
>
<td class="whitespace-nowrap px-5 py-3 font-medium text-gray-900 dark:text-gray-100">
@ -601,8 +601,8 @@ export const APITokenManager: Component<APITokenManagerProps> = (props) => {
return (
<span
class={`inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-medium ${isWildcard
? 'bg-amber-100 text-amber-800 dark:bg-amber-900/40 dark:text-amber-200'
: 'bg-gray-100 text-gray-700 dark:bg-gray-800 dark:text-gray-300'
? 'bg-amber-100 text-amber-800 dark:bg-amber-900/40 dark:text-amber-200'
: 'bg-gray-100 text-gray-700 dark:bg-gray-800 dark:text-gray-300'
}`}
title={scope.value}
>
@ -717,8 +717,8 @@ export const APITokenManager: Component<APITokenManagerProps> = (props) => {
<button
type="button"
class={`inline-flex items-center rounded-full border px-3 py-1 text-xs font-semibold transition ${isFullAccessSelected()
? 'border-blue-500 bg-blue-600 text-white shadow-sm'
: 'border-gray-300 bg-white text-gray-700 hover:border-blue-400 hover:text-blue-600 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-300 dark:hover:border-blue-400 dark:hover:text-blue-200'
? 'border-blue-500 bg-blue-600 text-white shadow-sm'
: 'border-gray-300 bg-white text-gray-700 hover:border-blue-400 hover:text-blue-600 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-300 dark:hover:border-blue-400 dark:hover:text-blue-200'
}`}
onClick={clearScopes}
title="Legacy wildcard all permissions"
@ -731,8 +731,8 @@ export const APITokenManager: Component<APITokenManagerProps> = (props) => {
<button
type="button"
class={`inline-flex items-center rounded-full border px-3 py-1 text-xs font-semibold transition ${presetMatchesSelection(preset.scopes)
? 'border-blue-500 bg-blue-600 text-white shadow-sm'
: 'border-gray-300 bg-white text-gray-700 hover:border-blue-400 hover:text-blue-600 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-300 dark:hover:border-blue-400 dark:hover:text-blue-200'
? 'border-blue-500 bg-blue-600 text-white shadow-sm'
: 'border-gray-300 bg-white text-gray-700 hover:border-blue-400 hover:text-blue-600 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-300 dark:hover:border-blue-400 dark:hover:text-blue-200'
}`}
onClick={() => applyScopePreset(preset.scopes)}
title={preset.description}
@ -763,8 +763,8 @@ export const APITokenManager: Component<APITokenManagerProps> = (props) => {
<button
type="button"
class={`rounded-full border px-3 py-1 text-xs font-semibold transition ${isActive()
? 'border-blue-500 bg-blue-600 text-white shadow-sm'
: 'border-gray-300 bg-white text-gray-700 hover:border-blue-400 hover:text-blue-600 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-300 dark:hover:border-blue-400 dark:hover:text-blue-200'
? 'border-blue-500 bg-blue-600 text-white shadow-sm'
: 'border-gray-300 bg-white text-gray-700 hover:border-blue-400 hover:text-blue-600 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-300 dark:hover:border-blue-400 dark:hover:text-blue-200'
}`}
onClick={() => {
setSelectedScopes((prev) => {

View file

@ -311,7 +311,7 @@ export const PveNodesTable: Component<PveNodesTableProps> = (props) => {
),
);
return (
<tr class="even:bg-gray-50/60 dark:even:bg-gray-800/30 hover:bg-blue-50/40 dark:hover:bg-blue-900/20 transition-colors animate-enter">
<tr class="even:bg-gray-50/60 dark:even:bg-gray-800/30 hover:bg-blue-50/40 dark:hover:bg-blue-900/20 transition-colors">
<td class="align-top py-3 pl-4 pr-3">
<div class="min-w-0 space-y-1">
<div class="flex items-start gap-3">
@ -568,7 +568,7 @@ export const PbsNodesTable: Component<PbsNodesTableProps> = (props) => {
{(node) => {
const statusMeta = createMemo(() => resolvePbsStatusMeta(node, props.statePbs));
return (
<tr class="even:bg-gray-50/60 dark:even:bg-gray-800/30 hover:bg-blue-50/40 dark:hover:bg-blue-900/20 transition-colors animate-enter">
<tr class="even:bg-gray-50/60 dark:even:bg-gray-800/30 hover:bg-blue-50/40 dark:hover:bg-blue-900/20 transition-colors">
<td class="align-top py-3 pl-4 pr-3">
<div class="min-w-0 space-y-1">
<div class="flex items-start gap-3">
@ -751,7 +751,7 @@ export const PmgNodesTable: Component<PmgNodesTableProps> = (props) => {
{(node) => {
const statusMeta = createMemo(() => resolvePmgStatusMeta(node, props.statePmg));
return (
<tr class="even:bg-gray-50/60 dark:even:bg-gray-800/30 hover:bg-blue-50/40 dark:hover:bg-blue-900/20 transition-colors animate-enter">
<tr class="even:bg-gray-50/60 dark:even:bg-gray-800/30 hover:bg-blue-50/40 dark:hover:bg-blue-900/20 transition-colors">
<td class="align-top py-3 pl-4 pr-3">
<div class="min-w-0 space-y-1">
<div class="flex items-start gap-3">

View file

@ -656,7 +656,7 @@ export const UnifiedAgents: Component = () => {
</tr>
}>
{(agent) => (
<tr class="animate-enter">
<tr>
<td class="whitespace-nowrap px-4 py-3 text-sm font-medium text-gray-900 dark:text-gray-100">
{agent.displayName || agent.hostname}
<Show when={agent.displayName && agent.displayName !== agent.hostname}>

View file

@ -385,8 +385,12 @@ describe('UnifiedAgents platform commands', () => {
expect(screen.getByText('Enable Docker monitoring')).toBeInTheDocument();
});
// Docker monitoring is enabled by default
// Docker monitoring is disabled by default
const checkbox = screen.getByRole('checkbox');
expect(checkbox).not.toBeChecked();
// Enable it
fireEvent.click(checkbox);
expect(checkbox).toBeChecked();
// Find a copy button and check that the command includes the docker flag

View file

@ -191,7 +191,7 @@ export const DiskList: Component<DiskListProps> = (props) => {
return (
<>
<tr class="hover:bg-gray-50 dark:hover:bg-gray-700/30 transition-colors animate-enter">
<tr class="hover:bg-gray-50 dark:hover:bg-gray-700/30 transition-colors">
<td class="px-2 py-1.5 text-xs">
<span class="font-medium text-gray-900 dark:text-gray-100">
{disk.node}

View file

@ -1043,7 +1043,7 @@ const Storage: Component = () => {
return (
<>
<tr
class={`${rowClass()} transition-colors animate-enter`}
class={`${rowClass()} transition-colors`}
style={rowStyle()}
onClick={toggleDrawer}
aria-expanded={canExpand() && isExpanded() ? 'true' : 'false'}

View file

@ -120,6 +120,12 @@ export const NodeSummaryTable: Component<NodeSummaryTableProps> = (props) => {
return globalEnabled;
};
type TableItem = Node | PBSInstance;
const isPVE = (item: TableItem): item is Node => {
return (item as Node).pveVersion !== undefined;
};
type CountSortKey = 'vmCount' | 'containerCount' | 'storageCount' | 'diskCount' | 'backupCount';
type SortKey =
| 'default'
@ -131,11 +137,6 @@ export const NodeSummaryTable: Component<NodeSummaryTableProps> = (props) => {
| 'temperature'
| CountSortKey;
interface SortableItem {
type: 'pve' | 'pbs';
data: Node | PBSInstance;
}
interface CountColumn {
header: string;
key: CountSortKey;
@ -240,9 +241,9 @@ export const NodeSummaryTable: Component<NodeSummaryTableProps> = (props) => {
return counts;
});
const getCpuTemperatureValue = (item: SortableItem) => {
if (item.type !== 'pve') return null;
const node = item.data as Node;
const getCpuTemperatureValue = (item: TableItem) => {
if (!isPVE(item)) return null;
const node = item;
const value = getCpuTemperature(node.temperature);
return value !== null ? Math.round(value) : null;
};
@ -270,12 +271,12 @@ export const NodeSummaryTable: Component<NodeSummaryTableProps> = (props) => {
}
};
const isItemOnline = (item: SortableItem) => {
if (item.type === 'pve') {
const node = item.data as Node;
const isItemOnline = (item: TableItem) => {
if (isPVE(item)) {
const node = item;
return node.status === 'online' && (node.uptime || 0) > 0;
}
const pbs = item.data as PBSInstance;
const pbs = item;
return pbs.status === 'healthy' || pbs.status === 'online';
};
@ -290,63 +291,63 @@ export const NodeSummaryTable: Component<NodeSummaryTableProps> = (props) => {
);
};
const getCpuPercent = (item: SortableItem) => {
if (item.type === 'pve') {
const node = item.data as Node;
const getCpuPercent = (item: TableItem) => {
if (isPVE(item)) {
const node = item;
return Math.round((node.cpu || 0) * 100);
}
const pbs = item.data as PBSInstance;
const pbs = item;
return Math.round(pbs.cpu || 0);
};
const getMemoryPercent = (item: SortableItem) => {
if (item.type === 'pve') {
const node = item.data as Node;
const getMemoryPercent = (item: TableItem) => {
if (isPVE(item)) {
const node = item;
return Math.round(node.memory?.usage || 0);
}
const pbs = item.data as PBSInstance;
const pbs = item;
if (!pbs.memoryTotal) return 0;
return Math.round((pbs.memoryUsed / pbs.memoryTotal) * 100);
};
const getDiskPercent = (item: SortableItem) => {
if (item.type === 'pve') {
const node = item.data as Node;
const getDiskPercent = (item: TableItem) => {
if (isPVE(item)) {
const node = item;
if (!node.disk || node.disk.total === 0) return 0;
return Math.round((node.disk.used / node.disk.total) * 100);
}
const pbs = item.data as PBSInstance;
const pbs = item;
const totals = getPbsTotals(pbs);
if (totals.total === 0) return 0;
return Math.round((totals.used / totals.total) * 100);
};
const getDiskSublabel = (item: SortableItem) => {
if (item.type === 'pve') {
const node = item.data as Node;
const getDiskSublabel = (item: TableItem) => {
if (isPVE(item)) {
const node = item;
if (!node.disk) return undefined;
return `${formatBytes(node.disk.used, 0)}/${formatBytes(node.disk.total, 0)}`;
}
const pbs = item.data as PBSInstance;
const pbs = item;
if (!pbs.datastores || pbs.datastores.length === 0) return undefined;
const totals = getPbsTotals(pbs);
return `${formatBytes(totals.used, 0)}/${formatBytes(totals.total, 0)}`;
};
const getTemperatureValue = (item: SortableItem) => {
const getTemperatureValue = (item: TableItem) => {
return getCpuTemperatureValue(item);
};
const getCountValue = (item: SortableItem, key: CountSortKey): number | null => {
if (item.type === 'pbs') {
const pbs = item.data as PBSInstance;
const getCountValue = (item: TableItem, key: CountSortKey): number | null => {
if (!isPVE(item)) {
const pbs = item;
if (key === 'backupCount') {
return props.backupCounts?.[pbs.name] ?? 0;
}
return null;
}
const node = item.data as Node;
const node = item;
const keyId = nodeKey(node.instance, node.name);
switch (key) {
@ -365,16 +366,16 @@ export const NodeSummaryTable: Component<NodeSummaryTableProps> = (props) => {
}
};
const getSortValue = (item: SortableItem, key: SortKey): number | string | null => {
const getSortValue = (item: TableItem, key: SortKey): number | string | null => {
switch (key) {
case 'name':
return item.type === 'pve'
? getNodeDisplayName(item.data as Node)
: (item.data as PBSInstance).name;
return isPVE(item)
? getNodeDisplayName(item)
: item.name;
case 'uptime':
return item.type === 'pve'
? (item.data as Node).uptime ?? 0
: (item.data as PBSInstance).uptime ?? 0;
return isPVE(item)
? item.uptime ?? 0
: item.uptime ?? 0;
case 'cpu':
return getCpuPercent(item);
case 'memory':
@ -394,19 +395,21 @@ export const NodeSummaryTable: Component<NodeSummaryTableProps> = (props) => {
}
};
const defaultComparison = (a: SortableItem, b: SortableItem) => {
if (a.type !== b.type) return a.type === 'pve' ? -1 : 1;
const defaultComparison = (a: TableItem, b: TableItem) => {
const aIsPVE = isPVE(a);
const bIsPVE = isPVE(b);
if (aIsPVE !== bIsPVE) return aIsPVE ? -1 : 1;
const aOnline = isItemOnline(a);
const bOnline = isItemOnline(b);
if (aOnline !== bOnline) return aOnline ? -1 : 1;
const aName = a.type === 'pve'
? getNodeDisplayName(a.data as Node)
: (a.data as PBSInstance).name;
const bName = b.type === 'pve'
? getNodeDisplayName(b.data as Node)
: (b.data as PBSInstance).name;
const aName = aIsPVE
? getNodeDisplayName(a)
: a.name;
const bName = bIsPVE
? getNodeDisplayName(b)
: b.name;
return aName.localeCompare(bName);
};
@ -434,10 +437,10 @@ export const NodeSummaryTable: Component<NodeSummaryTableProps> = (props) => {
};
const sortedItems = createMemo(() => {
const items: SortableItem[] = [];
const items: TableItem[] = [];
props.nodes?.forEach((node) => items.push({ type: 'pve', data: node }));
props.pbsInstances?.forEach((pbs) => items.push({ type: 'pbs', data: pbs }));
if (props.nodes) items.push(...props.nodes);
if (props.pbsInstances) items.push(...props.pbsInstances);
const key = sortKey();
const direction = sortDirection();
@ -516,30 +519,30 @@ export const NodeSummaryTable: Component<NodeSummaryTableProps> = (props) => {
<div class="divide-y divide-gray-200 dark:divide-gray-700">
<For each={sortedItems()}>
{(item) => {
const isPVE = item.type === 'pve';
const isPBS = item.type === 'pbs';
const node = isPVE ? (item.data as Node) : null;
const pbs = isPBS ? (item.data as PBSInstance) : null;
const isPVEItem = isPVE(item);
const isPBSItem = !isPVEItem;
const node = isPVEItem ? (item as Node) : null;
const pbs = isPBSItem ? (item as PBSInstance) : null;
const online = isItemOnline(item);
const statusIndicator = createMemo(() =>
isPVE ? getNodeStatusIndicator(node as Node) : getPBSStatusIndicator(pbs as PBSInstance),
isPVEItem ? getNodeStatusIndicator(node as Node) : getPBSStatusIndicator(pbs as PBSInstance),
);
const cpuPercentValue = getCpuPercent(item);
const memoryPercentValue = getMemoryPercent(item);
const diskPercentValue = getDiskPercent(item);
const diskSublabel = getDiskSublabel(item);
const cpuTemperatureValue = getCpuTemperatureValue(item);
const uptimeValue = isPVE ? node?.uptime ?? 0 : isPBS ? pbs?.uptime ?? 0 : 0;
const uptimeValue = isPVEItem ? node?.uptime ?? 0 : isPBSItem ? pbs?.uptime ?? 0 : 0;
const displayName = () => {
if (isPVE) return getNodeDisplayName(node as Node);
if (isPVEItem) return getNodeDisplayName(node as Node);
return (pbs as PBSInstance).name;
};
const showActualName = () => isPVE && hasAlternateDisplayName(node as Node);
const showActualName = () => isPVEItem && hasAlternateDisplayName(node as Node);
const nodeId = isPVE ? node!.id : pbs!.name;
const nodeId = isPVEItem ? node!.id : pbs!.name;
const isSelected = () => props.selectedNode === nodeId;
const resourceId = isPVE ? node!.id || node!.name : pbs!.id || pbs!.name;
const resourceId = isPVEItem ? node!.id || node!.name : pbs!.id || pbs!.name;
const metricsKey = buildMetricKey('node', resourceId);
const alertStyles = createMemo(() =>
getAlertStyles(resourceId, activeAlerts, alertsEnabled()),
@ -570,7 +573,7 @@ export const NodeSummaryTable: Component<NodeSummaryTableProps> = (props) => {
});
const rowClass = createMemo(() => {
const baseHover = 'cursor-pointer transition-all duration-200 relative hover:shadow-sm group animate-enter';
const baseHover = 'cursor-pointer transition-all duration-200 relative hover:shadow-sm group';
if (isSelected()) {
return `cursor-pointer transition-all duration-200 relative hover:shadow-sm z-10 group`;
@ -621,7 +624,7 @@ export const NodeSummaryTable: Component<NodeSummaryTableProps> = (props) => {
/>
<a
href={
isPVE
isPVEItem
? node!.guestURL || node!.host || `https://${node!.name}:8006`
: pbs!.host || `https://${pbs!.name}:8007`
}
@ -638,17 +641,17 @@ export const NodeSummaryTable: Component<NodeSummaryTableProps> = (props) => {
</span>
</Show>
<div class="hidden xl:flex items-center gap-1.5 ml-1">
<Show when={isPVE}>
<Show when={isPVEItem}>
<span class="text-[9px] px-1 py-0 rounded text-[8px] font-medium bg-orange-100 text-orange-700 dark:bg-orange-900/30 dark:text-orange-400">
PVE
</span>
</Show>
<Show when={isPVE && node!.pveVersion}>
<Show when={isPVEItem && node!.pveVersion}>
<span class="text-[9px] text-gray-500 dark:text-gray-400">
v{node!.pveVersion.split('/')[1] || node!.pveVersion}
</span>
</Show>
<Show when={isPVE && node!.isClusterMember !== undefined}>
<Show when={isPVEItem && node!.isClusterMember !== undefined}>
<span
class={`text-[9px] px-1 py-0 rounded text-[8px] font-medium whitespace-nowrap ${node!.isClusterMember
? 'bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400'
@ -658,12 +661,12 @@ export const NodeSummaryTable: Component<NodeSummaryTableProps> = (props) => {
{node!.isClusterMember ? node!.clusterName : 'Standalone'}
</span>
</Show>
<Show when={isPBS}>
<Show when={isPBSItem}>
<span class="text-[9px] px-1 py-0 rounded text-[8px] font-medium bg-purple-100 text-purple-700 dark:bg-purple-900/30 dark:text-purple-400">
PBS
</span>
</Show>
<Show when={isPBS && pbs!.version}>
<Show when={isPBSItem && pbs!.version}>
<span class="text-[9px] text-gray-500 dark:text-gray-400">
v{pbs!.version}
</span>
@ -677,7 +680,7 @@ export const NodeSummaryTable: Component<NodeSummaryTableProps> = (props) => {
return (
<div class={`${baseCellClass} ${alignClass} whitespace-nowrap`}>
<span
class={`text-xs ${isPVE && (node?.uptime ?? 0) < 3600
class={`text-xs ${isPVEItem && (node?.uptime ?? 0) < 3600
? 'text-orange-500'
: 'text-gray-600 dark:text-gray-400'
}`}
@ -698,7 +701,7 @@ export const NodeSummaryTable: Component<NodeSummaryTableProps> = (props) => {
value={cpuPercentValue}
type="cpu"
resourceId={metricsKey}
sublabel={isPVE && node!.cpuInfo?.cores ? `${node!.cpuInfo.cores} cores` : undefined}
sublabel={isPVEItem && node!.cpuInfo?.cores ? `${node!.cpuInfo.cores} cores` : undefined}
isRunning={online}
showMobile={isMobile()}
class="w-full"
@ -714,9 +717,9 @@ export const NodeSummaryTable: Component<NodeSummaryTableProps> = (props) => {
type="memory"
resourceId={metricsKey}
sublabel={
isPVE && node!.memory
isPVEItem && node!.memory
? `${formatBytes(node!.memory.used, 0)}/${formatBytes(node!.memory.total, 0)}`
: isPBS && pbs!.memoryTotal
: isPBSItem && pbs!.memoryTotal
? `${formatBytes(pbs!.memoryUsed, 0)}/${formatBytes(pbs!.memoryTotal, 0)}`
: undefined
}
@ -748,7 +751,7 @@ export const NodeSummaryTable: Component<NodeSummaryTableProps> = (props) => {
<Show
when={
online &&
isPVE &&
isPVEItem &&
cpuTemperatureValue !== null &&
(node!.temperature?.hasCPU ?? node!.temperature?.hasGPU ?? node!.temperature?.available) &&
isTemperatureMonitoringEnabled(node!)
@ -841,7 +844,7 @@ export const NodeSummaryTable: Component<NodeSummaryTableProps> = (props) => {
<div
class={`${rowClass()} grid items-center`}
style={{ ...rowStyle(), 'grid-template-columns': gridTemplate() }}
onClick={() => props.onNodeClick(nodeId, item.type)}
onClick={() => props.onNodeClick(nodeId, isPVEItem ? 'pve' : 'pbs')}
>
<For each={visibleColumns()}>
{(column) => renderCell(column)}

View file

@ -1,5 +1,5 @@
import { createSignal, onCleanup } from 'solid-js';
import { createStore, produce } from 'solid-js/store';
import { createStore, produce, reconcile } from 'solid-js/store';
import type {
State,
WSMessage,
@ -336,7 +336,7 @@ export function createWebSocketStore(url: string) {
logger.debug('[WebSocket] Updating nodes', {
count: message.data.nodes?.length || 0,
});
setState('nodes', message.data.nodes);
setState('nodes', reconcile(message.data.nodes, { key: 'id' }));
// Lifecycle cleanup: remove metrics for nodes that disappeared
const currentIds = new Set(message.data.nodes?.map((n: any) => n.id).filter(Boolean) || []);
@ -369,7 +369,7 @@ export function createWebSocketStore(url: string) {
tags: transformedTags,
};
});
setState('vms', transformedVMs);
setState('vms', reconcile(transformedVMs, { key: 'id' }));
// Lifecycle cleanup: remove metrics for VMs that disappeared
const vmIds = new Set(transformedVMs.map((vm: VM) => vm.id).filter(Boolean));
@ -402,7 +402,7 @@ export function createWebSocketStore(url: string) {
tags: transformedTags,
};
});
setState('containers', transformedContainers);
setState('containers', reconcile(transformedContainers, { key: 'id' }));
// Lifecycle cleanup: remove metrics for containers that disappeared
const containerIds = new Set(transformedContainers.map((c: Container) => c.id).filter(Boolean));
@ -425,7 +425,7 @@ export function createWebSocketStore(url: string) {
count: incomingHosts.length,
});
const merged = mergeDockerHostRevocations(incomingHosts);
setState('dockerHosts', merged);
setState('dockerHosts', reconcile(merged, { key: 'id' }));
// Lifecycle cleanup for Docker hosts and containers
const hostIds = new Set(merged.map((h: DockerHost) => h.id).filter(Boolean));
@ -449,7 +449,7 @@ export function createWebSocketStore(url: string) {
count: incomingHosts.length,
});
const merged = mergeDockerHostRevocations(incomingHosts);
setState('dockerHosts', merged);
setState('dockerHosts', reconcile(merged, { key: 'id' }));
// Lifecycle cleanup: prune metrics for removed hosts/containers
const hostIds = new Set(merged.map((h: DockerHost) => h.id).filter(Boolean));
@ -472,18 +472,18 @@ export function createWebSocketStore(url: string) {
}
if (message.data.hosts !== undefined && message.data.hosts !== null) {
if (Array.isArray(message.data.hosts)) {
setState('hosts', mergeHostRevocations(message.data.hosts));
setState('hosts', reconcile(mergeHostRevocations(message.data.hosts), { key: 'id' }));
} else {
setState('hosts', message.data.hosts);
setState('hosts', reconcile(message.data.hosts, { key: 'id' }));
}
}
if (message.data.storage !== undefined) setState('storage', message.data.storage);
if (message.data.storage !== undefined) setState('storage', reconcile(message.data.storage, { key: 'id' }));
if (message.data.cephClusters !== undefined)
setState('cephClusters', message.data.cephClusters);
if (message.data.pbs !== undefined) setState('pbs', message.data.pbs);
if (message.data.pmg !== undefined) setState('pmg', message.data.pmg);
setState('cephClusters', reconcile(message.data.cephClusters, { key: 'id' }));
if (message.data.pbs !== undefined) setState('pbs', reconcile(message.data.pbs, { key: 'id' }));
if (message.data.pmg !== undefined) setState('pmg', reconcile(message.data.pmg, { key: 'id' }));
if (message.data.replicationJobs !== undefined)
setState('replicationJobs', message.data.replicationJobs);
setState('replicationJobs', reconcile(message.data.replicationJobs, { key: 'id' }));
if (message.data.backups !== undefined) {
setState('backups', message.data.backups);
if (message.data.backups.pve !== undefined)
@ -506,7 +506,7 @@ export function createWebSocketStore(url: string) {
setState('connectionHealth', message.data.connectionHealth);
if (message.data.stats !== undefined) setState('stats', message.data.stats);
if (message.data.physicalDisks !== undefined)
setState('physicalDisks', message.data.physicalDisks);
setState('physicalDisks', reconcile(message.data.physicalDisks, { key: 'id' }));
// Sync active alerts from state
if (message.data.activeAlerts !== undefined) {
const newAlerts: Record<string, Alert> = {};