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 ( return (
<tr <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 */} {/* Alert toggle column */}
<td class="p-1 px-2 text-center align-middle"> <td class="p-1 px-2 text-center align-middle">
@ -1380,7 +1380,7 @@ export function ResourceTable(props: ResourceTableProps) {
return ( return (
<tr <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 */} {/* Alert toggle column */}
<td class="p-1 px-2 text-center align-middle"> <td class="p-1 px-2 text-center align-middle">

View file

@ -2137,7 +2137,7 @@ const UnifiedBackups: Component = () => {
</tr> </tr>
<For each={group.items}> <For each={group.items}>
{(item) => ( {(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 pl-5 pr-1.5 text-sm align-middle">{item.vmid}</td>
<td class="p-0.5 px-1.5 align-middle"> <td class="p-0.5 px-1.5 align-middle">
<span <span

View file

@ -456,7 +456,7 @@ export function GuestRow(props: GuestRowProps) {
}); });
const rowClass = createMemo(() => { 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 hover = 'hover:shadow-sm';
const alertBg = hasUnacknowledgedAlert() const alertBg = hasUnacknowledgedAlert()
? props.alertStyles?.severity === 'critical' ? props.alertStyles?.severity === 'critical'

View file

@ -227,7 +227,7 @@ export const DockerHostSummaryTable: Component<DockerHostSummaryTableProps> = (p
}; };
const rowClass = () => { 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) { 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'; 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 type { Component } from 'solid-js';
import { Show, createMemo, createSignal, createEffect, onMount, onCleanup } 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 { useNavigate } from '@solidjs/router';
import type { DockerHost } from '@/types/api'; import type { DockerHost } from '@/types/api';
import { Card } from '@/components/shared/Card'; import { Card } from '@/components/shared/Card';
@ -73,8 +74,13 @@ export const DockerHosts: Component<DockerHostsProps> = (props) => {
return value; return value;
}; };
// Cache for stable summary objects to prevent re-animations
const summaryCache = new Map<string, [DockerHostSummary, any]>();
const hostSummaries = createMemo<DockerHostSummary[]>(() => { 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 totalContainers = host.containers?.length ?? 0;
const runningContainers = const runningContainers =
host.containers?.filter((container) => container.state?.toLowerCase() === 'running').length ?? 0; 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 lastSeenRelative = host.lastSeen ? formatRelativeTime(host.lastSeen) : '—';
const lastSeenAbsolute = host.lastSeen ? new Date(host.lastSeen).toLocaleString() : ''; const lastSeenAbsolute = host.lastSeen ? new Date(host.lastSeen).toLocaleString() : '';
return { const newSummary: DockerHostSummary = {
host, host,
cpuPercent, cpuPercent,
memoryPercent, memoryPercent,
@ -127,7 +133,29 @@ export const DockerHosts: Component<DockerHostsProps> = (props) => {
lastSeenRelative, lastSeenRelative,
lastSeenAbsolute, 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; let searchInputRef: HTMLInputElement | undefined;
@ -168,7 +196,7 @@ export const DockerHosts: Component<DockerHostsProps> = (props) => {
} }
}) })
.catch((err) => { .catch((err) => {
logger.debug('Failed to load docker metadata', err); logger.debug('Failed to load docker metadata', err);
}); });
}); });
onCleanup(() => document.removeEventListener('keydown', handleKeyDown)); onCleanup(() => document.removeEventListener('keydown', handleKeyDown));

View file

@ -1253,7 +1253,7 @@ const DockerContainerRow: Component<{
return ( return (
<> <>
<div <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() }} style={{ 'grid-template-columns': props.gridTemplate() }}
onClick={toggle} onClick={toggle}
aria-expanded={expanded()} aria-expanded={expanded()}
@ -1950,7 +1950,7 @@ const DockerServiceRow: Component<{
return ( return (
<> <>
<div <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() }} style={{ 'grid-template-columns': props.gridTemplate() }}
onClick={toggle} onClick={toggle}
aria-expanded={expanded()} 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) => { const DockerUnifiedTable: Component<DockerUnifiedTableProps> = (props) => {
// Use the responsive grid template hook for dynamic column visibility // Use the responsive grid template hook for dynamic column visibility
const { gridTemplate, visibleColumns, isMobile } = useGridTemplate({ columns: DOCKER_COLUMNS }); 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 tokens = createMemo(() => parseSearchTerm(props.searchTerm));
const [sortKey, setSortKey] = usePersistentSignal<SortKey>('dockerUnifiedSortKey', 'host', { const [sortKey, setSortKey] = usePersistentSignal<SortKey>('dockerUnifiedSortKey', 'host', {
deserialize: (value) => (SORT_KEYS.includes(value as SortKey) ? (value as SortKey) : '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 filter = props.statsFilter ?? null;
const searchTokens = tokens(); const searchTokens = tokens();
const selectedHostId = props.selectedHostId ? props.selectedHostId() : null; const selectedHostId = props.selectedHostId ? props.selectedHostId() : null;
const usedCacheKeys = new Set<string>();
const usedTaskCacheKeys = new Set<string>();
sortedHosts().forEach((host) => { sortedHosts().forEach((host) => {
if (!hostMatchesFilter(filter, host)) { if (!hostMatchesFilter(filter, host)) {
@ -2177,12 +2191,22 @@ const DockerUnifiedTable: Component<DockerUnifiedTableProps> = (props) => {
const matchesSearch = searchTokens.every((token) => containerMatchesToken(token, host, container)); const matchesSearch = searchTokens.every((token) => containerMatchesToken(token, host, container));
if (!matchesSearch) return; if (!matchesSearch) return;
containerRows.push({ const rowId = container.id || `${host.id}-container-${container.name}`;
kind: 'container', const cacheKey = `c:${host.id}:${rowId}`;
id: container.id || `${host.id}-container-${container.name}`, usedCacheKeys.add(cacheKey);
host,
container, 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) => { services.forEach((service) => {
@ -2190,7 +2214,7 @@ const DockerUnifiedTable: Component<DockerUnifiedTableProps> = (props) => {
const matchesSearch = searchTokens.every((token) => serviceMatchesToken(token, host, service)); const matchesSearch = searchTokens.every((token) => serviceMatchesToken(token, host, service));
if (!matchesSearch) return; if (!matchesSearch) return;
const associatedTasks = tasks.filter((task) => { let associatedTasks = tasks.filter((task) => {
if (service.id && task.serviceId) { if (service.id && task.serviceId) {
return task.serviceId === service.id; return task.serviceId === service.id;
} }
@ -2200,18 +2224,39 @@ const DockerUnifiedTable: Component<DockerUnifiedTableProps> = (props) => {
return false; 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) => { associatedTasks.forEach((task) => {
if (task.containerId) serviceOwnedContainers.add(task.containerId.toLowerCase()); if (task.containerId) serviceOwnedContainers.add(task.containerId.toLowerCase());
if (task.containerName) serviceOwnedContainers.add(task.containerName.toLowerCase()); if (task.containerName) serviceOwnedContainers.add(task.containerName.toLowerCase());
}); });
serviceRows.push({ const rowId = service.id || `${host.id}-service-${service.name}`;
kind: 'service', const cacheKey = `s:${host.id}:${rowId}`;
id: service.id || `${host.id}-service-${service.name}`, usedCacheKeys.add(cacheKey);
host,
service, let row = rowCache.get(cacheKey);
tasks: associatedTasks, // 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) { 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; return groups;
}); });

View file

@ -243,7 +243,7 @@ export const HostsOverview: Component<HostsOverviewProps> = (props) => {
}); });
const rowClass = () => { 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 hover = 'hover:bg-gray-50 dark:hover:bg-gray-800/50';
const clickable = hasDrawerContent() ? 'cursor-pointer' : ''; const clickable = hasDrawerContent() ? 'cursor-pointer' : '';
const expanded = drawerOpen() ? 'bg-gray-50 dark:bg-gray-800/40' : ''; const expanded = drawerOpen() ? 'bg-gray-50 dark:bg-gray-800/40' : '';

View file

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

View file

@ -121,7 +121,7 @@ const Replication: Component = () => {
{(job) => { {(job) => {
const badge = getStatusBadge(job); const badge = getStatusBadge(job);
return ( 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"> <td class="px-4 py-3">
<div class="font-medium text-gray-900 dark:text-gray-100 truncate max-w-[200px]"> <div class="font-medium text-gray-900 dark:text-gray-100 truncate max-w-[200px]">
{job.guestName || `VM ${job.guestId ?? ''}`} {job.guestName || `VM ${job.guestId ?? ''}`}

View file

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

View file

@ -311,7 +311,7 @@ export const PveNodesTable: Component<PveNodesTableProps> = (props) => {
), ),
); );
return ( 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"> <td class="align-top py-3 pl-4 pr-3">
<div class="min-w-0 space-y-1"> <div class="min-w-0 space-y-1">
<div class="flex items-start gap-3"> <div class="flex items-start gap-3">
@ -568,7 +568,7 @@ export const PbsNodesTable: Component<PbsNodesTableProps> = (props) => {
{(node) => { {(node) => {
const statusMeta = createMemo(() => resolvePbsStatusMeta(node, props.statePbs)); const statusMeta = createMemo(() => resolvePbsStatusMeta(node, props.statePbs));
return ( 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"> <td class="align-top py-3 pl-4 pr-3">
<div class="min-w-0 space-y-1"> <div class="min-w-0 space-y-1">
<div class="flex items-start gap-3"> <div class="flex items-start gap-3">
@ -751,7 +751,7 @@ export const PmgNodesTable: Component<PmgNodesTableProps> = (props) => {
{(node) => { {(node) => {
const statusMeta = createMemo(() => resolvePmgStatusMeta(node, props.statePmg)); const statusMeta = createMemo(() => resolvePmgStatusMeta(node, props.statePmg));
return ( 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"> <td class="align-top py-3 pl-4 pr-3">
<div class="min-w-0 space-y-1"> <div class="min-w-0 space-y-1">
<div class="flex items-start gap-3"> <div class="flex items-start gap-3">

View file

@ -656,7 +656,7 @@ export const UnifiedAgents: Component = () => {
</tr> </tr>
}> }>
{(agent) => ( {(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"> <td class="whitespace-nowrap px-4 py-3 text-sm font-medium text-gray-900 dark:text-gray-100">
{agent.displayName || agent.hostname} {agent.displayName || agent.hostname}
<Show when={agent.displayName && 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(); expect(screen.getByText('Enable Docker monitoring')).toBeInTheDocument();
}); });
// Docker monitoring is enabled by default // Docker monitoring is disabled by default
const checkbox = screen.getByRole('checkbox'); const checkbox = screen.getByRole('checkbox');
expect(checkbox).not.toBeChecked();
// Enable it
fireEvent.click(checkbox);
expect(checkbox).toBeChecked(); expect(checkbox).toBeChecked();
// Find a copy button and check that the command includes the docker flag // 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 ( 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"> <td class="px-2 py-1.5 text-xs">
<span class="font-medium text-gray-900 dark:text-gray-100"> <span class="font-medium text-gray-900 dark:text-gray-100">
{disk.node} {disk.node}

View file

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

View file

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

View file

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