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:
parent
cf62ebae27
commit
b0ff539fcc
17 changed files with 252 additions and 160 deletions
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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' : '';
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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 ?? ''}`}
|
||||
|
|
|
|||
|
|
@ -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) => {
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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}>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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'}
|
||||
|
|
|
|||
|
|
@ -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)}
|
||||
|
|
|
|||
|
|
@ -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> = {};
|
||||
|
|
|
|||
Loading…
Reference in a new issue