feat: adaptive node table layout, guest row fixes, and legacy agent detection
- Implemented adaptive layout for NodeSummaryTable with responsive columns and sticky name column. - Fixed GuestRow background display issues. - Added IsLegacy field to Host and DockerHost models to flag legacy agents (version < 1.0.0). - Updated monitor to populate IsLegacy based on agent version.
This commit is contained in:
parent
b68bfa8696
commit
ebcafa1dac
14 changed files with 1140 additions and 950 deletions
|
|
@ -6,7 +6,6 @@ import { useWebSocket } from '@/App';
|
||||||
import { getAlertStyles } from '@/utils/alerts';
|
import { getAlertStyles } from '@/utils/alerts';
|
||||||
import { useAlertsActivation } from '@/stores/alertsActivation';
|
import { useAlertsActivation } from '@/stores/alertsActivation';
|
||||||
import { ComponentErrorBoundary } from '@/components/ErrorBoundary';
|
import { ComponentErrorBoundary } from '@/components/ErrorBoundary';
|
||||||
import { ScrollableTable } from '@/components/shared/ScrollableTable';
|
|
||||||
import { parseFilterStack, evaluateFilterStack } from '@/utils/searchQuery';
|
import { parseFilterStack, evaluateFilterStack } from '@/utils/searchQuery';
|
||||||
import { UnifiedNodeSelector } from '@/components/shared/UnifiedNodeSelector';
|
import { UnifiedNodeSelector } from '@/components/shared/UnifiedNodeSelector';
|
||||||
import { DashboardFilter } from './DashboardFilter';
|
import { DashboardFilter } from './DashboardFilter';
|
||||||
|
|
@ -926,163 +925,157 @@ export function Dashboard(props: DashboardProps) {
|
||||||
{/* Table View */}
|
{/* Table View */}
|
||||||
<Show when={connected() && initialDataReceived() && filteredGuests().length > 0}>
|
<Show when={connected() && initialDataReceived() && filteredGuests().length > 0}>
|
||||||
<ComponentErrorBoundary name="Guest Table">
|
<ComponentErrorBoundary name="Guest Table">
|
||||||
<Card padding="none" class="mb-4 overflow-hidden">
|
<Card padding="none" class="mb-4 overflow-hidden bg-white dark:bg-gray-800">
|
||||||
<ScrollableTable minWidth="600px">
|
{/* Desktop Header - Hidden on mobile/tablet, visible on xl+ */}
|
||||||
<table class="w-full min-w-[600px] table-fixed border-collapse">
|
<div class="flex md:grid md:grid-cols-[minmax(150px,1fr)_60px_60px_80px_minmax(100px,1fr)_minmax(100px,1fr)_minmax(100px,1fr)] xl:grid-cols-[minmax(200px,1fr)_80px_80px_100px_minmax(150px,1.5fr)_minmax(150px,1.5fr)_minmax(150px,1.5fr)_minmax(100px,1fr)_minmax(100px,1fr)_minmax(100px,1fr)_minmax(100px,1fr)] border-b border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-700/50 text-gray-600 dark:text-gray-300 text-[11px] sm:text-xs font-medium uppercase tracking-wider sticky top-0 z-20">
|
||||||
<thead>
|
{/* Name Header - Sticky on mobile */}
|
||||||
<tr class="bg-gray-50 dark:bg-gray-700/50 text-gray-600 dark:text-gray-300 border-b border-gray-200 dark:border-gray-600">
|
<div
|
||||||
<th
|
class="pl-4 pr-2 py-1 cursor-pointer hover:bg-gray-200 dark:hover:bg-gray-600 flex items-center sticky left-0 z-30 bg-gray-50 dark:bg-gray-700/50 w-[160px] sm:w-[200px] md:w-full flex-shrink-0 border-r md:border-r-0 border-gray-200 dark:border-gray-700"
|
||||||
class="pl-4 pr-2 py-1 text-left text-[11px] sm:text-xs font-medium uppercase tracking-wider cursor-pointer hover:bg-gray-200 dark:hover:bg-gray-600 focus:outline-none focus:ring-2 focus:ring-inset focus:ring-blue-500 w-auto"
|
onClick={() => handleSort('name')}
|
||||||
onClick={() => handleSort('name')}
|
>
|
||||||
onKeyDown={(e) => e.key === 'Enter' && handleSort('name')}
|
Name
|
||||||
tabindex="0"
|
{sortKey() === 'name' && (sortDirection() === 'asc' ? '▲' : '▼')}
|
||||||
role="button"
|
</div>
|
||||||
aria-label={`Sort by name ${sortKey() === 'name' ? (sortDirection() === 'asc' ? 'ascending' : 'descending') : ''}`}
|
|
||||||
>
|
|
||||||
Name {sortKey() === 'name' && (sortDirection() === 'asc' ? '▲' : '▼')}
|
|
||||||
</th>
|
|
||||||
<th
|
|
||||||
class="hidden lg:table-cell px-2 py-1 text-left text-[11px] sm:text-xs font-medium uppercase tracking-wider w-[5%] cursor-pointer hover:bg-gray-200 dark:hover:bg-gray-600"
|
|
||||||
onClick={() => handleSort('type')}
|
|
||||||
title="Type"
|
|
||||||
>
|
|
||||||
<span class="hidden xl:inline">Type</span>
|
|
||||||
<span class="xl:hidden" aria-label="Type">Typ</span>{' '}
|
|
||||||
{sortKey() === 'type' && (sortDirection() === 'asc' ? '▲' : '▼')}
|
|
||||||
</th>
|
|
||||||
<th
|
|
||||||
class="hidden lg:table-cell px-1.5 py-1 text-left text-[11px] sm:text-xs font-medium uppercase tracking-wide w-[5%] cursor-pointer hover:bg-gray-200 dark:hover:bg-gray-600"
|
|
||||||
onClick={() => handleSort('vmid')}
|
|
||||||
title="VM/Container ID"
|
|
||||||
>
|
|
||||||
<span class="hidden xl:inline">VMID</span>
|
|
||||||
<span class="xl:hidden" aria-label="VM ID">ID</span>{' '}
|
|
||||||
{sortKey() === 'vmid' && (sortDirection() === 'asc' ? '▲' : '▼')}
|
|
||||||
</th>
|
|
||||||
<th
|
|
||||||
class="hidden sm:table-cell px-1.5 py-1 text-left text-[11px] sm:text-xs font-medium uppercase tracking-wide w-[8%] cursor-pointer hover:bg-gray-200 dark:hover:bg-gray-600"
|
|
||||||
onClick={() => handleSort('uptime')}
|
|
||||||
title="Uptime"
|
|
||||||
>
|
|
||||||
<span class="hidden lg:inline">Uptime</span>
|
|
||||||
<span class="lg:hidden" aria-label="Uptime">Up</span>{' '}
|
|
||||||
{sortKey() === 'uptime' && (sortDirection() === 'asc' ? '▲' : '▼')}
|
|
||||||
</th>
|
|
||||||
<th
|
|
||||||
class="px-2 py-1 text-left text-[11px] sm:text-xs font-medium uppercase tracking-wider w-[10%] cursor-pointer hover:bg-gray-200 dark:hover:bg-gray-600"
|
|
||||||
onClick={() => handleSort('cpu')}
|
|
||||||
>
|
|
||||||
CPU {sortKey() === 'cpu' && (sortDirection() === 'asc' ? '▲' : '▼')}
|
|
||||||
</th>
|
|
||||||
<th
|
|
||||||
class="px-2 py-1 text-left text-[11px] sm:text-xs font-medium uppercase tracking-wider w-[10%] cursor-pointer hover:bg-gray-200 dark:hover:bg-gray-600"
|
|
||||||
onClick={() => handleSort('memory')}
|
|
||||||
title="Memory"
|
|
||||||
>
|
|
||||||
<span class="hidden lg:inline">Memory</span>
|
|
||||||
<span class="lg:hidden" aria-label="Memory">Mem</span>{' '}
|
|
||||||
{sortKey() === 'memory' && (sortDirection() === 'asc' ? '▲' : '▼')}
|
|
||||||
</th>
|
|
||||||
<th
|
|
||||||
class="hidden sm:table-cell px-2 py-1 text-left text-[11px] sm:text-xs font-medium uppercase tracking-wider w-[10%] cursor-pointer hover:bg-gray-200 dark:hover:bg-gray-600"
|
|
||||||
onClick={() => handleSort('disk')}
|
|
||||||
>
|
|
||||||
Disk {sortKey() === 'disk' && (sortDirection() === 'asc' ? '▲' : '▼')}
|
|
||||||
</th>
|
|
||||||
<th
|
|
||||||
class="hidden lg:table-cell px-2 py-1 text-left text-[11px] sm:text-xs font-medium uppercase tracking-wider w-[6%] cursor-pointer hover:bg-gray-200 dark:hover:bg-gray-600"
|
|
||||||
onClick={() => handleSort('diskRead')}
|
|
||||||
title="Disk Read"
|
|
||||||
>
|
|
||||||
<span class="hidden lg:inline">Disk Read</span>
|
|
||||||
<span class="lg:hidden" aria-label="Disk Read">D Rd</span>{' '}
|
|
||||||
{sortKey() === 'diskRead' && (sortDirection() === 'asc' ? '▲' : '▼')}
|
|
||||||
</th>
|
|
||||||
<th
|
|
||||||
class="hidden lg:table-cell px-2 py-1 text-left text-[11px] sm:text-xs font-medium uppercase tracking-wider w-[6%] cursor-pointer hover:bg-gray-200 dark:hover:bg-gray-600"
|
|
||||||
onClick={() => handleSort('diskWrite')}
|
|
||||||
title="Disk Write"
|
|
||||||
>
|
|
||||||
<span class="hidden lg:inline">Disk Write</span>
|
|
||||||
<span class="lg:hidden" aria-label="Disk Write">D Wr</span>{' '}
|
|
||||||
{sortKey() === 'diskWrite' && (sortDirection() === 'asc' ? '▲' : '▼')}
|
|
||||||
</th>
|
|
||||||
<th
|
|
||||||
class="hidden lg:table-cell px-2 py-1 text-left text-[11px] sm:text-xs font-medium uppercase tracking-wider w-[6%] cursor-pointer hover:bg-gray-200 dark:hover:bg-gray-600"
|
|
||||||
onClick={() => handleSort('networkIn')}
|
|
||||||
title="Network In"
|
|
||||||
>
|
|
||||||
<span class="hidden lg:inline">Net In</span>
|
|
||||||
<span class="lg:hidden" aria-label="Network In">N In</span>{' '}
|
|
||||||
{sortKey() === 'networkIn' && (sortDirection() === 'asc' ? '▲' : '▼')}
|
|
||||||
</th>
|
|
||||||
<th
|
|
||||||
class="hidden lg:table-cell px-2 py-1 text-left text-[11px] sm:text-xs font-medium uppercase tracking-wider w-[6%] cursor-pointer hover:bg-gray-200 dark:hover:bg-gray-600"
|
|
||||||
onClick={() => handleSort('networkOut')}
|
|
||||||
title="Network Out"
|
|
||||||
>
|
|
||||||
<span class="hidden lg:inline">Net Out</span>
|
|
||||||
<span class="lg:hidden" aria-label="Network Out">N Out</span>{' '}
|
|
||||||
{sortKey() === 'networkOut' && (sortDirection() === 'asc' ? '▲' : '▼')}
|
|
||||||
</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody class="divide-y divide-gray-200 dark:divide-gray-700">
|
|
||||||
<For
|
|
||||||
each={Object.entries(groupedGuests()).sort(([instanceIdA], [instanceIdB]) => {
|
|
||||||
// Sort by friendly node name first, falling back to instance ID for stability
|
|
||||||
const nodeA = nodeByInstance()[instanceIdA];
|
|
||||||
const nodeB = nodeByInstance()[instanceIdB];
|
|
||||||
const labelA = nodeA ? getNodeDisplayName(nodeA) : instanceIdA;
|
|
||||||
const labelB = nodeB ? getNodeDisplayName(nodeB) : instanceIdB;
|
|
||||||
const nameCompare = labelA.localeCompare(labelB);
|
|
||||||
if (nameCompare !== 0) return nameCompare;
|
|
||||||
// If labels match (unlikely), fall back to the instance IDs
|
|
||||||
return instanceIdA.localeCompare(instanceIdB);
|
|
||||||
})}
|
|
||||||
fallback={<></>}
|
|
||||||
>
|
|
||||||
{([instanceId, guests]) => {
|
|
||||||
const node = nodeByInstance()[instanceId];
|
|
||||||
|
|
||||||
return (
|
{/* Metrics Headers - Scrollable on mobile */}
|
||||||
<>
|
<div class="flex-1 overflow-hidden md:contents">
|
||||||
<Show when={node && groupingMode() === 'grouped'}>
|
<div class="flex md:contents min-w-full md:min-w-0">
|
||||||
<NodeGroupHeader node={node!} colspan={11} />
|
<div
|
||||||
</Show>
|
class="flex-1 px-0.5 py-1 cursor-pointer hover:bg-gray-200 dark:hover:bg-gray-600 flex items-center justify-center md:justify-start w-auto min-w-[30px] md:w-full"
|
||||||
<For each={guests} fallback={<></>}>
|
onClick={() => handleSort('type')}
|
||||||
{(guest) => {
|
>
|
||||||
// Match backend ID generation logic: stable format is "instance-vmid"
|
<span class="md:hidden" title="Type">
|
||||||
const guestId =
|
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10" /></svg>
|
||||||
guest.id || `${guest.instance}-${guest.vmid}`;
|
</span>
|
||||||
const metadata =
|
<span class="hidden md:inline">Type</span>
|
||||||
guestMetadata()[guestId] ||
|
{sortKey() === 'type' && (sortDirection() === 'asc' ? '▲' : '▼')}
|
||||||
guestMetadata()[`${guest.node}-${guest.vmid}`];
|
</div>
|
||||||
const parentNode = node ?? resolveParentNode(guest);
|
<div
|
||||||
const parentNodeOnline = parentNode ? isNodeOnline(parentNode) : true;
|
class="flex-1 px-0.5 py-1 cursor-pointer hover:bg-gray-200 dark:hover:bg-gray-600 flex items-center justify-center md:justify-start w-auto min-w-[30px] md:w-full"
|
||||||
return (
|
onClick={() => handleSort('vmid')}
|
||||||
<ComponentErrorBoundary name="GuestRow">
|
>
|
||||||
<GuestRow
|
<span class="md:hidden" title="ID">
|
||||||
guest={guest}
|
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 20l4-16m2 16l4-16M6 9h14M4 15h14" /></svg>
|
||||||
alertStyles={getAlertStyles(guestId, activeAlerts, alertsEnabled())}
|
</span>
|
||||||
customUrl={metadata?.customUrl}
|
<span class="hidden md:inline">VMID</span>
|
||||||
onTagClick={handleTagClick}
|
{sortKey() === 'vmid' && (sortDirection() === 'asc' ? '▲' : '▼')}
|
||||||
activeSearch={search()}
|
</div>
|
||||||
parentNodeOnline={parentNodeOnline}
|
<div
|
||||||
onCustomUrlUpdate={handleCustomUrlUpdate}
|
class="flex-1 px-0.5 py-1 cursor-pointer hover:bg-gray-200 dark:hover:bg-gray-600 flex items-center justify-center md:justify-start w-auto min-w-[40px] md:w-full"
|
||||||
isGroupedView={groupingMode() === 'grouped'}
|
onClick={() => handleSort('uptime')}
|
||||||
/>
|
>
|
||||||
</ComponentErrorBoundary>
|
<span class="md:hidden" title="Uptime">
|
||||||
);
|
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" /></svg>
|
||||||
}}
|
</span>
|
||||||
</For>
|
<span class="hidden md:inline">Uptime</span>
|
||||||
</>
|
{sortKey() === 'uptime' && (sortDirection() === 'asc' ? '▲' : '▼')}
|
||||||
);
|
</div>
|
||||||
}}
|
<div
|
||||||
</For>
|
class="flex-1 px-0.5 py-2 cursor-pointer hover:bg-gray-200 dark:hover:bg-gray-600 flex items-center justify-center md:justify-start w-auto min-w-[35px] md:w-full"
|
||||||
</tbody>
|
onClick={() => handleSort('cpu')}
|
||||||
</table>
|
>
|
||||||
</ScrollableTable>
|
<span class="md:hidden" title="CPU">
|
||||||
|
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 3v2m6-2v2M9 19v2m6-2v2M5 9H3m2 6H3m18-6h-2m2 6h-2M7 19h10a2 2 0 002-2V7a2 2 0 00-2-2H7a2 2 0 00-2 2v10a2 2 0 002 2zM9 9h6v6H9V9z" /></svg>
|
||||||
|
</span>
|
||||||
|
<span class="hidden md:inline">CPU</span>
|
||||||
|
{sortKey() === 'cpu' && (sortDirection() === 'asc' ? '▲' : '▼')}
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
class="flex-1 px-0.5 py-1 cursor-pointer hover:bg-gray-200 dark:hover:bg-gray-600 flex items-center justify-center md:justify-start w-auto min-w-[35px] md:w-full"
|
||||||
|
onClick={() => handleSort('memory')}
|
||||||
|
>
|
||||||
|
<span class="md:hidden" title="Memory">
|
||||||
|
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 17v-2m3 2v-4m3 4v-6m2 10H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" /></svg>
|
||||||
|
</span>
|
||||||
|
<span class="hidden md:inline">Memory</span>
|
||||||
|
{sortKey() === 'memory' && (sortDirection() === 'asc' ? '▲' : '▼')}
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
class="flex-1 px-0.5 py-1 cursor-pointer hover:bg-gray-200 dark:hover:bg-gray-600 flex items-center justify-center md:justify-start w-auto min-w-[35px] md:w-full"
|
||||||
|
onClick={() => handleSort('disk')}
|
||||||
|
>
|
||||||
|
<span class="md:hidden" title="Disk">
|
||||||
|
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 7v10c0 2.21 3.582 4 8 4s8-1.79 8-4V7M4 7c0 2.21 3.582 4 8 4s8-1.79 8-4M4 7c0-2.21 3.582-4 8-4s8 1.79 8 4m0 5c0 2.21-3.582 4-8 4s-8-1.79-8-4" /></svg>
|
||||||
|
</span>
|
||||||
|
<span class="hidden md:inline">Disk</span>
|
||||||
|
{sortKey() === 'disk' && (sortDirection() === 'asc' ? '▲' : '▼')}
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
class="hidden xl:flex px-2 py-1 cursor-pointer hover:bg-gray-200 dark:hover:bg-gray-600 items-center w-full"
|
||||||
|
onClick={() => handleSort('diskRead')}
|
||||||
|
>
|
||||||
|
D Read {sortKey() === 'diskRead' && (sortDirection() === 'asc' ? '▲' : '▼')}
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
class="hidden xl:flex px-2 py-1 cursor-pointer hover:bg-gray-200 dark:hover:bg-gray-600 items-center w-full"
|
||||||
|
onClick={() => handleSort('diskWrite')}
|
||||||
|
>
|
||||||
|
D Write {sortKey() === 'diskWrite' && (sortDirection() === 'asc' ? '▲' : '▼')}
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
class="hidden xl:flex px-2 py-1 cursor-pointer hover:bg-gray-200 dark:hover:bg-gray-600 items-center w-full"
|
||||||
|
onClick={() => handleSort('networkIn')}
|
||||||
|
>
|
||||||
|
Net In {sortKey() === 'networkIn' && (sortDirection() === 'asc' ? '▲' : '▼')}
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
class="hidden xl:flex px-2 py-1 cursor-pointer hover:bg-gray-200 dark:hover:bg-gray-600 items-center w-full"
|
||||||
|
onClick={() => handleSort('networkOut')}
|
||||||
|
>
|
||||||
|
Net Out {sortKey() === 'networkOut' && (sortDirection() === 'asc' ? '▲' : '▼')}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Guest List */}
|
||||||
|
<div class="divide-y divide-gray-200 dark:divide-gray-700">
|
||||||
|
<For
|
||||||
|
each={Object.entries(groupedGuests()).sort(([instanceIdA], [instanceIdB]) => {
|
||||||
|
const nodeA = nodeByInstance()[instanceIdA];
|
||||||
|
const nodeB = nodeByInstance()[instanceIdB];
|
||||||
|
const labelA = nodeA ? getNodeDisplayName(nodeA) : instanceIdA;
|
||||||
|
const labelB = nodeB ? getNodeDisplayName(nodeB) : instanceIdB;
|
||||||
|
return labelA.localeCompare(labelB) || instanceIdA.localeCompare(instanceIdB);
|
||||||
|
})}
|
||||||
|
fallback={<></>}
|
||||||
|
>
|
||||||
|
{([instanceId, guests]) => {
|
||||||
|
const node = nodeByInstance()[instanceId];
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Show when={node && groupingMode() === 'grouped'}>
|
||||||
|
<NodeGroupHeader node={node!} />
|
||||||
|
</Show>
|
||||||
|
<For each={guests} fallback={<></>}>
|
||||||
|
{(guest) => {
|
||||||
|
const guestId = guest.id || `${guest.instance}-${guest.vmid}`;
|
||||||
|
const metadata =
|
||||||
|
guestMetadata()[guestId] ||
|
||||||
|
guestMetadata()[`${guest.node}-${guest.vmid}`];
|
||||||
|
const parentNode = node ?? resolveParentNode(guest);
|
||||||
|
const parentNodeOnline = parentNode ? isNodeOnline(parentNode) : true;
|
||||||
|
return (
|
||||||
|
<ComponentErrorBoundary name="GuestRow">
|
||||||
|
<GuestRow
|
||||||
|
guest={guest}
|
||||||
|
alertStyles={getAlertStyles(guestId, activeAlerts, alertsEnabled())}
|
||||||
|
customUrl={metadata?.customUrl}
|
||||||
|
onTagClick={handleTagClick}
|
||||||
|
activeSearch={search()}
|
||||||
|
parentNodeOnline={parentNodeOnline}
|
||||||
|
onCustomUrlUpdate={handleCustomUrlUpdate}
|
||||||
|
isGroupedView={groupingMode() === 'grouped'}
|
||||||
|
/>
|
||||||
|
</ComponentErrorBoundary>
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
</For>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
</For>
|
||||||
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
</ComponentErrorBoundary>
|
</ComponentErrorBoundary>
|
||||||
</Show>
|
</Show>
|
||||||
|
|
|
||||||
200
frontend-modern/src/components/Dashboard/GuestDrawer.tsx
Normal file
200
frontend-modern/src/components/Dashboard/GuestDrawer.tsx
Normal file
|
|
@ -0,0 +1,200 @@
|
||||||
|
import { Component, Show, For } from 'solid-js';
|
||||||
|
import { VM, Container } from '@/types/api';
|
||||||
|
import { formatBytes } from '@/utils/format';
|
||||||
|
import { DiskList } from './DiskList';
|
||||||
|
import { IOMetric } from './IOMetric';
|
||||||
|
|
||||||
|
type Guest = VM | Container;
|
||||||
|
|
||||||
|
interface GuestDrawerProps {
|
||||||
|
guest: Guest;
|
||||||
|
metricsKey: string;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const GuestDrawer: Component<GuestDrawerProps> = (props) => {
|
||||||
|
const isVM = (guest: Guest): guest is VM => {
|
||||||
|
return guest.type === 'qemu';
|
||||||
|
};
|
||||||
|
|
||||||
|
const hasOsInfo = () => {
|
||||||
|
if (!isVM(props.guest)) return false;
|
||||||
|
return (props.guest.osInfo?.name?.length ?? 0) > 0 || (props.guest.osInfo?.version?.length ?? 0) > 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
const osName = () => {
|
||||||
|
if (!isVM(props.guest)) return '';
|
||||||
|
return props.guest.osInfo?.name || '';
|
||||||
|
};
|
||||||
|
|
||||||
|
const osVersion = () => {
|
||||||
|
if (!isVM(props.guest)) return '';
|
||||||
|
return props.guest.osInfo?.version || '';
|
||||||
|
};
|
||||||
|
|
||||||
|
const hasAgentInfo = () => {
|
||||||
|
if (!isVM(props.guest)) return false;
|
||||||
|
return !!props.guest.agentVersion;
|
||||||
|
};
|
||||||
|
|
||||||
|
const agentVersion = () => {
|
||||||
|
if (!isVM(props.guest)) return '';
|
||||||
|
return props.guest.agentVersion || '';
|
||||||
|
};
|
||||||
|
|
||||||
|
const ipAddresses = () => {
|
||||||
|
if (!isVM(props.guest)) return [];
|
||||||
|
return props.guest.ipAddresses || [];
|
||||||
|
};
|
||||||
|
|
||||||
|
const memoryExtraLines = () => {
|
||||||
|
if (!props.guest.memory) return undefined;
|
||||||
|
const lines: string[] = [];
|
||||||
|
const total = props.guest.memory.total ?? 0;
|
||||||
|
if (
|
||||||
|
props.guest.memory.balloon &&
|
||||||
|
props.guest.memory.balloon > 0 &&
|
||||||
|
props.guest.memory.balloon !== total
|
||||||
|
) {
|
||||||
|
lines.push(`Balloon: ${formatBytes(props.guest.memory.balloon, 0)}`);
|
||||||
|
}
|
||||||
|
if (props.guest.memory.swapTotal && props.guest.memory.swapTotal > 0) {
|
||||||
|
const swapUsed = props.guest.memory.swapUsed ?? 0;
|
||||||
|
lines.push(`Swap: ${formatBytes(swapUsed, 0)} / ${formatBytes(props.guest.memory.swapTotal, 0)}`);
|
||||||
|
}
|
||||||
|
return lines.length > 0 ? lines : undefined;
|
||||||
|
};
|
||||||
|
|
||||||
|
const hasFilesystemDetails = () => {
|
||||||
|
if (!props.guest.disks) return false;
|
||||||
|
return props.guest.disks.length > 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
const networkInterfaces = () => {
|
||||||
|
if (!isVM(props.guest)) return [];
|
||||||
|
return props.guest.networkInterfaces || [];
|
||||||
|
};
|
||||||
|
|
||||||
|
const hasNetworkInterfaces = () => {
|
||||||
|
return networkInterfaces().length > 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div class="flex items-start gap-4">
|
||||||
|
{/* Left Column: Guest Overview */}
|
||||||
|
<div class="min-w-[220px] flex-1 rounded border border-gray-200 bg-white/70 p-2 shadow-sm dark:border-gray-600/70 dark:bg-gray-900/30">
|
||||||
|
<div class="text-[11px] font-medium text-gray-700 dark:text-gray-200">Guest Overview</div>
|
||||||
|
<div class="mt-1 space-y-1">
|
||||||
|
<Show when={hasOsInfo()}>
|
||||||
|
<div class="flex flex-wrap items-center gap-1 text-gray-600 dark:text-gray-300">
|
||||||
|
<Show when={osName().length > 0}>
|
||||||
|
<span class="font-medium" title={osName()}>{osName()}</span>
|
||||||
|
</Show>
|
||||||
|
<Show when={osName().length > 0 && osVersion().length > 0}>
|
||||||
|
<span class="text-gray-400 dark:text-gray-500">•</span>
|
||||||
|
</Show>
|
||||||
|
<Show when={osVersion().length > 0}>
|
||||||
|
<span title={osVersion()}>{osVersion()}</span>
|
||||||
|
</Show>
|
||||||
|
</div>
|
||||||
|
</Show>
|
||||||
|
<Show when={hasAgentInfo()}>
|
||||||
|
<div class="flex flex-wrap items-center gap-1 text-[11px] text-gray-500 dark:text-gray-400">
|
||||||
|
<span class="uppercase tracking-wide text-[10px] text-gray-400 dark:text-gray-500">
|
||||||
|
Agent
|
||||||
|
</span>
|
||||||
|
<span title={`QEMU guest agent ${agentVersion()}`}>
|
||||||
|
QEMU guest agent {agentVersion()}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</Show>
|
||||||
|
<Show when={ipAddresses().length > 0}>
|
||||||
|
<div class="flex flex-wrap gap-1">
|
||||||
|
<For each={ipAddresses()}>
|
||||||
|
{(ip) => (
|
||||||
|
<span
|
||||||
|
class="max-w-full truncate rounded bg-blue-100 px-1.5 py-0.5 text-blue-700 dark:bg-blue-900/40 dark:text-blue-200"
|
||||||
|
title={ip}
|
||||||
|
>
|
||||||
|
{ip}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</For>
|
||||||
|
</div>
|
||||||
|
</Show>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Middle Column: Memory Details */}
|
||||||
|
<Show when={memoryExtraLines() && memoryExtraLines()!.length > 0}>
|
||||||
|
<div class="min-w-[220px] flex-1 rounded border border-gray-200 bg-white/70 p-2 shadow-sm dark:border-gray-600/70 dark:bg-gray-900/30">
|
||||||
|
<div class="text-[11px] font-medium text-gray-700 dark:text-gray-200">Memory</div>
|
||||||
|
<div class="mt-1 space-y-1 text-gray-600 dark:text-gray-300">
|
||||||
|
<For each={memoryExtraLines()!}>{(line) => <div>{line}</div>}</For>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Show>
|
||||||
|
|
||||||
|
{/* Right Column: Filesystems */}
|
||||||
|
<Show when={hasFilesystemDetails() && props.guest.disks && props.guest.disks.length > 0}>
|
||||||
|
<div class="min-w-[220px] flex-1 rounded border border-gray-200 bg-white/70 p-2 shadow-sm dark:border-gray-600/70 dark:bg-gray-900/30">
|
||||||
|
<div class="text-[11px] font-medium text-gray-700 dark:text-gray-200">Filesystems</div>
|
||||||
|
<div class="mt-1 text-gray-600 dark:text-gray-300">
|
||||||
|
<DiskList
|
||||||
|
disks={props.guest.disks || []}
|
||||||
|
diskStatusReason={isVM(props.guest) ? props.guest.diskStatusReason : undefined}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Show>
|
||||||
|
|
||||||
|
{/* Far Right Column: Network Interfaces */}
|
||||||
|
<Show when={hasNetworkInterfaces()}>
|
||||||
|
<div class="min-w-[220px] flex-1 rounded border border-gray-200 bg-white/70 p-2 shadow-sm dark:border-gray-600/70 dark:bg-gray-900/30">
|
||||||
|
<div class="text-[11px] font-medium text-gray-700 dark:text-gray-200">Network Interfaces</div>
|
||||||
|
<div class="mt-1 text-[10px] text-gray-400 dark:text-gray-500">Row charts show current rate; totals below are cumulative since boot.</div>
|
||||||
|
<div class="mt-1 space-y-1 text-gray-600 dark:text-gray-300">
|
||||||
|
<For each={networkInterfaces()}>
|
||||||
|
{(iface) => {
|
||||||
|
const addresses = iface.addresses ?? [];
|
||||||
|
const hasTraffic = (iface.rxBytes ?? 0) > 0 || (iface.txBytes ?? 0) > 0;
|
||||||
|
return (
|
||||||
|
<div class="space-y-1 rounded border border-dashed border-gray-200 p-2 last:mb-0 dark:border-gray-700">
|
||||||
|
<div class="flex items-center gap-2 font-medium text-gray-700 dark:text-gray-200">
|
||||||
|
<span class="truncate" title={iface.name}>{iface.name || 'interface'}</span>
|
||||||
|
<Show when={iface.mac}>
|
||||||
|
<span class="text-[10px] text-gray-400 dark:text-gray-500" title={iface.mac}>
|
||||||
|
{iface.mac}
|
||||||
|
</span>
|
||||||
|
</Show>
|
||||||
|
</div>
|
||||||
|
<Show when={addresses.length > 0}>
|
||||||
|
<div class="flex flex-wrap gap-1">
|
||||||
|
<For each={addresses}>
|
||||||
|
{(ip) => (
|
||||||
|
<span
|
||||||
|
class="max-w-full truncate rounded bg-blue-100 px-1.5 py-0.5 text-blue-700 dark:bg-blue-900/40 dark:text-blue-200"
|
||||||
|
title={ip}
|
||||||
|
>
|
||||||
|
{ip}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</For>
|
||||||
|
</div>
|
||||||
|
</Show>
|
||||||
|
<Show when={hasTraffic}>
|
||||||
|
<div class="flex items-center gap-3 text-[10px] text-gray-500 dark:text-gray-400">
|
||||||
|
<span>Total RX {formatBytes(iface.rxBytes ?? 0)}</span>
|
||||||
|
<span>Total TX {formatBytes(iface.txBytes ?? 0)}</span>
|
||||||
|
</div>
|
||||||
|
</Show>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
</For>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Show>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
@ -1,10 +1,11 @@
|
||||||
import { createMemo, createSignal, createEffect, Show, For } from 'solid-js';
|
import { GuestDrawer } from './GuestDrawer';
|
||||||
|
import { createMemo, createSignal, createEffect, Show } from 'solid-js';
|
||||||
import type { VM, Container } from '@/types/api';
|
import type { VM, Container } from '@/types/api';
|
||||||
import { formatBytes, formatPercent, formatUptime } from '@/utils/format';
|
import { formatBytes, formatPercent, formatUptime } from '@/utils/format';
|
||||||
import { MetricBar } from './MetricBar';
|
import { MetricBar } from './MetricBar';
|
||||||
import { IOMetric } from './IOMetric';
|
import { IOMetric } from './IOMetric';
|
||||||
import { TagBadges } from './TagBadges';
|
import { TagBadges } from './TagBadges';
|
||||||
import { DiskList } from './DiskList';
|
|
||||||
import { StatusDot } from '@/components/shared/StatusDot';
|
import { StatusDot } from '@/components/shared/StatusDot';
|
||||||
import { getGuestPowerIndicator, isGuestRunning } from '@/utils/status';
|
import { getGuestPowerIndicator, isGuestRunning } from '@/utils/status';
|
||||||
import { GuestMetadataAPI } from '@/api/guestMetadata';
|
import { GuestMetadataAPI } from '@/api/guestMetadata';
|
||||||
|
|
@ -381,7 +382,7 @@ export function GuestRow(props: GuestRowProps) {
|
||||||
return '#9ca3af';
|
return '#9ca3af';
|
||||||
});
|
});
|
||||||
|
|
||||||
const drawerDisabled = createMemo(() => !isRunning());
|
|
||||||
|
|
||||||
// Get row styling - include alert styles if present
|
// Get row styling - include alert styles if present
|
||||||
const rowClass = createMemo(() => {
|
const rowClass = createMemo(() => {
|
||||||
|
|
@ -391,7 +392,7 @@ export function GuestRow(props: GuestRowProps) {
|
||||||
? props.alertStyles?.severity === 'critical'
|
? props.alertStyles?.severity === 'critical'
|
||||||
? 'bg-red-50 dark:bg-red-950/30'
|
? 'bg-red-50 dark:bg-red-950/30'
|
||||||
: 'bg-yellow-50 dark:bg-yellow-950/20'
|
: 'bg-yellow-50 dark:bg-yellow-950/20'
|
||||||
: '';
|
: 'bg-white dark:bg-gray-800';
|
||||||
const defaultHover = hasUnacknowledgedAlert()
|
const defaultHover = hasUnacknowledgedAlert()
|
||||||
? ''
|
? ''
|
||||||
: 'hover:bg-gray-50 dark:hover:bg-gray-700/30';
|
: 'hover:bg-gray-50 dark:hover:bg-gray-700/30';
|
||||||
|
|
@ -403,13 +404,7 @@ export function GuestRow(props: GuestRowProps) {
|
||||||
return `${base} ${hover} ${defaultHover} ${alertBg} ${stoppedDimming} ${clickable} ${expanded}`;
|
return `${base} ${hover} ${defaultHover} ${alertBg} ${stoppedDimming} ${clickable} ${expanded}`;
|
||||||
});
|
});
|
||||||
|
|
||||||
// Get first cell styling
|
|
||||||
const firstCellClass = createMemo(() => {
|
|
||||||
const base =
|
|
||||||
'py-0.5 pr-2 whitespace-nowrap relative w-auto';
|
|
||||||
const indent = props.isGroupedView ? GROUPED_FIRST_CELL_INDENT : DEFAULT_FIRST_CELL_INDENT;
|
|
||||||
return `${base} ${indent}`;
|
|
||||||
});
|
|
||||||
|
|
||||||
// Get row styles including box-shadow for alert border
|
// Get row styles including box-shadow for alert border
|
||||||
const rowStyle = createMemo(() => {
|
const rowStyle = createMemo(() => {
|
||||||
|
|
@ -423,10 +418,15 @@ export function GuestRow(props: GuestRowProps) {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<tr class={rowClass()} style={rowStyle()} onClick={toggleDrawer} aria-expanded={drawerOpen()}>
|
<div
|
||||||
{/* Name - Sticky column */}
|
class={`${rowClass()} flex md:grid md:grid-cols-[minmax(150px,1fr)_60px_60px_80px_minmax(100px,1fr)_minmax(100px,1fr)_minmax(100px,1fr)] xl:grid-cols-[minmax(200px,1fr)_80px_80px_100px_minmax(150px,1.5fr)_minmax(150px,1.5fr)_minmax(150px,1.5fr)_minmax(100px,1fr)_minmax(100px,1fr)_minmax(100px,1fr)_minmax(100px,1fr)] items-center`}
|
||||||
<td class={firstCellClass()}>
|
style={rowStyle()}
|
||||||
<div class="flex items-center gap-2">
|
onClick={toggleDrawer}
|
||||||
|
aria-expanded={drawerOpen()}
|
||||||
|
>
|
||||||
|
{/* Name Section - Sticky on mobile */}
|
||||||
|
<div class={`p-2 md:py-0 md:pl-4 md:pr-2 flex items-center sticky left-0 z-10 bg-inherit w-[160px] sm:w-[200px] md:w-full flex-shrink-0 border-r md:border-r-0 border-gray-100 dark:border-gray-700 ${props.isGroupedView ? GROUPED_FIRST_CELL_INDENT : DEFAULT_FIRST_CELL_INDENT}`}>
|
||||||
|
<div class="flex items-center gap-2 min-w-0 w-full">
|
||||||
<div class="flex items-center gap-1.5 flex-1 min-w-0">
|
<div class="flex items-center gap-1.5 flex-1 min-w-0">
|
||||||
<StatusDot
|
<StatusDot
|
||||||
variant={guestStatus().variant}
|
variant={guestStatus().variant}
|
||||||
|
|
@ -435,13 +435,12 @@ export function GuestRow(props: GuestRowProps) {
|
||||||
size="xs"
|
size="xs"
|
||||||
/>
|
/>
|
||||||
<div class="flex-1 min-w-0">
|
<div class="flex-1 min-w-0">
|
||||||
{/* Name - show input when editing, otherwise show name with optional link */}
|
|
||||||
<Show
|
<Show
|
||||||
when={isEditingUrl()}
|
when={isEditingUrl()}
|
||||||
fallback={
|
fallback={
|
||||||
<div class="flex items-center gap-1.5 min-w-0">
|
<div class="flex items-center gap-1.5 min-w-0">
|
||||||
<span
|
<span
|
||||||
class="text-sm font-medium text-gray-900 dark:text-gray-100 cursor-text select-none overflow-hidden text-ellipsis"
|
class="text-xs font-medium text-gray-900 dark:text-gray-100 cursor-text select-none overflow-hidden text-ellipsis whitespace-nowrap"
|
||||||
style="cursor: text;"
|
style="cursor: text;"
|
||||||
title={`${props.guest.name}${customUrl() ? ' - Click to edit URL' : ' - Click to add URL'}`}
|
title={`${props.guest.name}${customUrl() ? ' - Click to edit URL' : ' - Click to add URL'}`}
|
||||||
onClick={startEditingUrl}
|
onClick={startEditingUrl}
|
||||||
|
|
@ -528,9 +527,8 @@ export function GuestRow(props: GuestRowProps) {
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Tag badges - hide when editing URL to save space */}
|
|
||||||
<Show when={!isEditingUrl()}>
|
<Show when={!isEditingUrl()}>
|
||||||
<div class="flex" data-prevent-toggle onClick={(event) => event.stopPropagation()}>
|
<div class="hidden md:flex" data-prevent-toggle onClick={(event) => event.stopPropagation()}>
|
||||||
<TagBadges
|
<TagBadges
|
||||||
tags={Array.isArray(props.guest.tags) ? props.guest.tags : []}
|
tags={Array.isArray(props.guest.tags) ? props.guest.tags : []}
|
||||||
maxVisible={3}
|
maxVisible={3}
|
||||||
|
|
@ -549,276 +547,147 @@ export function GuestRow(props: GuestRowProps) {
|
||||||
</span>
|
</span>
|
||||||
</Show>
|
</Show>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</div>
|
||||||
|
|
||||||
{/* Type */}
|
{/* Metrics Section - Scrollable on mobile */}
|
||||||
<td class="hidden lg:table-cell py-0.5 px-2 whitespace-nowrap w-[5%]">
|
<div class="flex-1 overflow-x-auto md:contents no-scrollbar">
|
||||||
<div class="flex h-[24px] items-center">
|
<div class="flex md:contents min-w-full md:min-w-0 divide-x md:divide-none divide-gray-100 dark:divide-gray-800">
|
||||||
<span
|
{/* Type */}
|
||||||
class={`inline-block px-1.5 py-0.5 text-xs font-medium rounded ${props.guest.type === 'qemu'
|
<div class="flex-1 px-0.5 py-1 md:px-2 md:py-0 w-auto min-w-[30px] md:w-full flex justify-center md:justify-start items-center">
|
||||||
? 'bg-blue-100 text-blue-700 dark:bg-blue-900/50 dark:text-blue-300'
|
<span
|
||||||
: 'bg-green-100 text-green-700 dark:bg-green-900/50 dark:text-green-300'
|
class={`inline-block px-1.5 py-0.5 text-xs font-medium rounded ${props.guest.type === 'qemu'
|
||||||
}`}
|
? 'bg-blue-100 text-blue-700 dark:bg-blue-900/50 dark:text-blue-300'
|
||||||
>
|
: 'bg-green-100 text-green-700 dark:bg-green-900/50 dark:text-green-300'
|
||||||
{isVM(props.guest) ? 'VM' : 'LXC'}
|
}`}
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
|
|
||||||
{/* VMID */}
|
|
||||||
<td class="hidden lg:table-cell py-0.5 px-1.5 whitespace-nowrap w-[5%] text-sm text-gray-600 dark:text-gray-400 align-middle">
|
|
||||||
{props.guest.vmid}
|
|
||||||
</td>
|
|
||||||
|
|
||||||
{/* Uptime */}
|
|
||||||
<td
|
|
||||||
class={`hidden sm:table-cell py-0.5 px-1.5 w-[8%] text-sm whitespace-nowrap align-middle ${props.guest.uptime < 3600 ? 'text-orange-500' : 'text-gray-600 dark:text-gray-400'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<Show when={isRunning()} fallback="-">
|
|
||||||
{formatUptime(props.guest.uptime)}
|
|
||||||
</Show>
|
|
||||||
</td>
|
|
||||||
|
|
||||||
{/* CPU */}
|
|
||||||
<td class="py-0.5 px-2 w-[10%]">
|
|
||||||
<Show when={isRunning()} fallback={<span class="text-sm text-gray-400">-</span>}>
|
|
||||||
<MetricBar
|
|
||||||
value={cpuPercent()}
|
|
||||||
label={formatPercent(cpuPercent())}
|
|
||||||
sublabel={
|
|
||||||
props.guest.cpus
|
|
||||||
? `${props.guest.cpus} ${props.guest.cpus === 1 ? 'core' : 'cores'}`
|
|
||||||
: undefined
|
|
||||||
}
|
|
||||||
type="cpu"
|
|
||||||
resourceId={metricsKey()}
|
|
||||||
/>
|
|
||||||
</Show>
|
|
||||||
</td>
|
|
||||||
|
|
||||||
{/* Memory */}
|
|
||||||
<td class="py-0.5 px-2 w-[10%]">
|
|
||||||
<div title={memoryTooltip() ?? undefined}>
|
|
||||||
<Show when={isRunning()} fallback={<span class="text-sm text-gray-400">-</span>}>
|
|
||||||
<MetricBar
|
|
||||||
value={memPercent()}
|
|
||||||
label={formatPercent(memPercent())}
|
|
||||||
sublabel={memoryUsageLabel()}
|
|
||||||
type="memory"
|
|
||||||
resourceId={metricsKey()}
|
|
||||||
/>
|
|
||||||
</Show>
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
|
|
||||||
{/* Disk – surface usage even if guest is currently stopped so users can see last reported values */}
|
|
||||||
<td class="hidden sm:table-cell py-0.5 px-2 w-[10%]">
|
|
||||||
<Show
|
|
||||||
when={hasDiskUsage()}
|
|
||||||
fallback={
|
|
||||||
<span class="text-gray-400 text-sm cursor-help" title={getDiskStatusTooltip()}>
|
|
||||||
-
|
|
||||||
</span>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<MetricBar
|
|
||||||
value={diskPercent()}
|
|
||||||
label={formatPercent(diskPercent())}
|
|
||||||
sublabel={
|
|
||||||
props.guest.disk
|
|
||||||
? `${formatBytes(props.guest.disk.used, 0)}/${formatBytes(props.guest.disk.total, 0)}`
|
|
||||||
: undefined
|
|
||||||
}
|
|
||||||
type="disk"
|
|
||||||
resourceId={metricsKey()}
|
|
||||||
/>
|
|
||||||
</Show>
|
|
||||||
</td>
|
|
||||||
|
|
||||||
{/* Disk I/O */}
|
|
||||||
<td class="hidden lg:table-cell py-0.5 px-2 w-[6%]">
|
|
||||||
<div class="flex h-[24px] items-center">
|
|
||||||
<IOMetric value={props.guest.diskRead} disabled={!isRunning()} />
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
<td class="hidden lg:table-cell py-0.5 px-2 w-[6%]">
|
|
||||||
<div class="flex h-[24px] items-center">
|
|
||||||
<IOMetric value={props.guest.diskWrite} disabled={!isRunning()} />
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
|
|
||||||
{/* Network I/O */}
|
|
||||||
<td class="hidden lg:table-cell py-0.5 px-2 w-[6%]">
|
|
||||||
<div class="flex h-[24px] items-center">
|
|
||||||
<IOMetric value={props.guest.networkIn} disabled={!isRunning()} />
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
<td class="hidden lg:table-cell py-0.5 px-2 w-[6%]">
|
|
||||||
<div class="flex h-[24px] items-center">
|
|
||||||
<IOMetric value={props.guest.networkOut} disabled={!isRunning()} />
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
<Show when={drawerOpen() && canShowDrawer()}>
|
|
||||||
<tr
|
|
||||||
class={`text-[11px] ${isRunning() && props.parentNodeOnline !== false
|
|
||||||
? 'bg-gray-50/60 text-gray-600 dark:bg-gray-800/40 dark:text-gray-300'
|
|
||||||
: 'bg-gray-100/70 text-gray-400 dark:bg-gray-900/30 dark:text-gray-500'
|
|
||||||
}`}
|
|
||||||
aria-hidden={!isRunning() || props.parentNodeOnline === false}
|
|
||||||
>
|
|
||||||
<td class="px-4 py-2" colSpan={11}>
|
|
||||||
<div
|
|
||||||
class={`flex flex-wrap gap-3 justify-start ${drawerDisabled() ? 'opacity-50 saturate-75 pointer-events-none' : ''
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<Show
|
|
||||||
when={hasDrawerContent()}
|
|
||||||
fallback={
|
|
||||||
<div class="min-w-[220px] flex-1 rounded border border-gray-200 bg-white/70 p-2 shadow-sm dark:border-gray-600/70 dark:bg-gray-900/30">
|
|
||||||
<div class="text-[11px] font-medium text-gray-700 dark:text-gray-200">
|
|
||||||
Guest details unavailable
|
|
||||||
</div>
|
|
||||||
<div class="mt-1 space-y-1 text-gray-600 dark:text-gray-300">
|
|
||||||
<Show
|
|
||||||
when={isVM(props.guest)}
|
|
||||||
fallback={
|
|
||||||
<p>
|
|
||||||
Start this container and ensure the Pulse user has sufficient Proxmox
|
|
||||||
permissions to collect guest metrics.
|
|
||||||
</p>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<p>{getDiskStatusTooltip()}</p>
|
|
||||||
<p>
|
|
||||||
Install and run the qemu-guest-agent inside this VM so Pulse can surface
|
|
||||||
OS, network, and filesystem details.
|
|
||||||
</p>
|
|
||||||
</Show>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
>
|
>
|
||||||
<>
|
{isVM(props.guest) ? 'VM' : 'LXC'}
|
||||||
<Show when={hasOsInfo() || hasAgentInfo() || ipAddresses().length > 0}>
|
</span>
|
||||||
<div class="min-w-[220px] flex-1 rounded border border-gray-200 bg-white/70 p-2 shadow-sm dark:border-gray-600/70 dark:bg-gray-900/30">
|
</div>
|
||||||
<div class="text-[11px] font-medium text-gray-700 dark:text-gray-200">Guest Overview</div>
|
|
||||||
<div class="mt-1 space-y-1">
|
|
||||||
<Show when={hasOsInfo()}>
|
|
||||||
<div class="flex flex-wrap items-center gap-1 text-gray-600 dark:text-gray-300">
|
|
||||||
<Show when={osName().length > 0}>
|
|
||||||
<span class="font-medium" title={osName()}>{osName()}</span>
|
|
||||||
</Show>
|
|
||||||
<Show when={osName().length > 0 && osVersion().length > 0}>
|
|
||||||
<span class="text-gray-400 dark:text-gray-500">•</span>
|
|
||||||
</Show>
|
|
||||||
<Show when={osVersion().length > 0}>
|
|
||||||
<span title={osVersion()}>{osVersion()}</span>
|
|
||||||
</Show>
|
|
||||||
</div>
|
|
||||||
</Show>
|
|
||||||
<Show when={hasAgentInfo()}>
|
|
||||||
<div class="flex flex-wrap items-center gap-1 text-[11px] text-gray-500 dark:text-gray-400">
|
|
||||||
<span class="uppercase tracking-wide text-[10px] text-gray-400 dark:text-gray-500">
|
|
||||||
Agent
|
|
||||||
</span>
|
|
||||||
<span title={`QEMU guest agent ${agentVersion()}`}>
|
|
||||||
QEMU guest agent {agentVersion()}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</Show>
|
|
||||||
<Show when={ipAddresses().length > 0}>
|
|
||||||
<div class="flex flex-wrap gap-1">
|
|
||||||
<For each={ipAddresses()}>
|
|
||||||
{(ip) => (
|
|
||||||
<span
|
|
||||||
class="max-w-full truncate rounded bg-blue-100 px-1.5 py-0.5 text-blue-700 dark:bg-blue-900/40 dark:text-blue-200"
|
|
||||||
title={ip}
|
|
||||||
>
|
|
||||||
{ip}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</For>
|
|
||||||
</div>
|
|
||||||
</Show>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Show>
|
|
||||||
|
|
||||||
<Show when={memoryExtraLines() && memoryExtraLines()!.length > 0}>
|
{/* VMID */}
|
||||||
<div class="min-w-[220px] flex-1 rounded border border-gray-200 bg-white/70 p-2 shadow-sm dark:border-gray-600/70 dark:bg-gray-900/30">
|
<div class="flex-1 px-0.5 py-1 md:px-1.5 md:py-0 w-auto min-w-[30px] md:w-full flex justify-center md:justify-start items-center text-xs text-gray-600 dark:text-gray-400">
|
||||||
<div class="text-[11px] font-medium text-gray-700 dark:text-gray-200">Memory</div>
|
{props.guest.vmid}
|
||||||
<div class="mt-1 space-y-1 text-gray-600 dark:text-gray-300">
|
</div>
|
||||||
<For each={memoryExtraLines()!}>{(line) => <div>{line}</div>}</For>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Show>
|
|
||||||
|
|
||||||
<Show when={hasFilesystemDetails() && props.guest.disks && props.guest.disks.length > 0}>
|
{/* Uptime */}
|
||||||
<div class="min-w-[220px] flex-1 rounded border border-gray-200 bg-white/70 p-2 shadow-sm dark:border-gray-600/70 dark:bg-gray-900/30">
|
<div class="flex-1 px-0.5 py-1 md:px-1.5 md:py-0 w-auto min-w-[40px] md:w-full flex justify-center md:justify-start items-center">
|
||||||
<div class="text-[11px] font-medium text-gray-700 dark:text-gray-200">Filesystems</div>
|
<div class={`text-xs whitespace-nowrap ${props.guest.uptime < 3600 ? 'text-orange-500' : 'text-gray-600 dark:text-gray-400'}`}>
|
||||||
<div class="mt-1 text-gray-600 dark:text-gray-300">
|
<Show when={isRunning()} fallback="-">
|
||||||
<DiskList
|
<span class="md:hidden">{formatUptime(props.guest.uptime, true)}</span>
|
||||||
disks={props.guest.disks || []}
|
<span class="hidden md:inline">{formatUptime(props.guest.uptime)}</span>
|
||||||
diskStatusReason={isVM(props.guest) ? props.guest.diskStatusReason : undefined}
|
</Show>
|
||||||
/>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
</Show>
|
|
||||||
|
|
||||||
<Show when={hasNetworkInterfaces()}>
|
{/* CPU */}
|
||||||
<div class="min-w-[220px] flex-1 rounded border border-gray-200 bg-white/70 p-2 shadow-sm dark:border-gray-600/70 dark:bg-gray-900/30">
|
<div class="flex-1 px-0.5 py-1 md:px-2 md:py-0 w-auto min-w-[35px] md:w-full flex justify-center items-center">
|
||||||
<div class="text-[11px] font-medium text-gray-700 dark:text-gray-200">Network Interfaces</div>
|
<Show when={isRunning()} fallback={<span class="text-xs text-gray-400">-</span>}>
|
||||||
<div class="mt-1 text-[10px] text-gray-400 dark:text-gray-500">Row charts show current rate; totals below are cumulative since boot.</div>
|
<div class={`md:hidden text-xs ${cpuPercent() >= 90 ? 'text-red-600 dark:text-red-400 font-bold' : cpuPercent() >= 80 ? 'text-orange-600 dark:text-orange-400 font-medium' : 'text-gray-600 dark:text-gray-400'}`}>
|
||||||
<div class="mt-1 space-y-1 text-gray-600 dark:text-gray-300">
|
{formatPercent(cpuPercent())}
|
||||||
<For each={networkInterfaces()}>
|
</div>
|
||||||
{(iface) => {
|
<div class="hidden md:block w-full">
|
||||||
const addresses = iface.addresses ?? [];
|
<MetricBar
|
||||||
const hasTraffic = (iface.rxBytes ?? 0) > 0 || (iface.txBytes ?? 0) > 0;
|
value={cpuPercent()}
|
||||||
return (
|
label={formatPercent(cpuPercent())}
|
||||||
<div class="space-y-1 rounded border border-dashed border-gray-200 p-2 last:mb-0 dark:border-gray-700">
|
sublabel={
|
||||||
<div class="flex items-center gap-2 font-medium text-gray-700 dark:text-gray-200">
|
props.guest.cpus
|
||||||
<span class="truncate" title={iface.name}>{iface.name || 'interface'}</span>
|
? `${props.guest.cpus} ${props.guest.cpus === 1 ? 'core' : 'cores'}`
|
||||||
<Show when={iface.mac}>
|
: undefined
|
||||||
<span class="text-[10px] text-gray-400 dark:text-gray-500" title={iface.mac}>
|
}
|
||||||
{iface.mac}
|
type="cpu"
|
||||||
</span>
|
resourceId={metricsKey()}
|
||||||
</Show>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<Show when={addresses.length > 0}>
|
|
||||||
<div class="flex flex-wrap gap-1">
|
|
||||||
<For each={addresses}>
|
|
||||||
{(ip) => (
|
|
||||||
<span
|
|
||||||
class="max-w-full truncate rounded bg-blue-100 px-1.5 py-0.5 text-blue-700 dark:bg-blue-900/40 dark:text-blue-200"
|
|
||||||
title={ip}
|
|
||||||
>
|
|
||||||
{ip}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</For>
|
|
||||||
</div>
|
|
||||||
</Show>
|
|
||||||
<Show when={hasTraffic}>
|
|
||||||
<div class="flex items-center gap-3 text-[10px] text-gray-500 dark:text-gray-400">
|
|
||||||
<span>Total RX {formatBytes(iface.rxBytes ?? 0)}</span>
|
|
||||||
<span>Total TX {formatBytes(iface.txBytes ?? 0)}</span>
|
|
||||||
</div>
|
|
||||||
</Show>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}}
|
|
||||||
</For>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Show>
|
|
||||||
</>
|
|
||||||
</Show>
|
</Show>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
|
||||||
</tr>
|
{/* Memory */}
|
||||||
|
<div class="flex-1 px-0.5 py-1 md:px-2 md:py-0 w-auto min-w-[35px] md:w-full flex justify-center items-center">
|
||||||
|
<div title={memoryTooltip() ?? undefined} class="w-full text-center xl:text-left">
|
||||||
|
<Show when={isRunning()} fallback={<span class="text-xs text-gray-400">-</span>}>
|
||||||
|
<div class={`md:hidden text-xs ${memPercent() >= 85 ? 'text-red-600 dark:text-red-400 font-bold' : memPercent() >= 75 ? 'text-orange-600 dark:text-orange-400 font-medium' : 'text-gray-600 dark:text-gray-400'}`}>
|
||||||
|
{formatPercent(memPercent())}
|
||||||
|
</div>
|
||||||
|
<div class="hidden md:block w-full">
|
||||||
|
<MetricBar
|
||||||
|
value={memPercent()}
|
||||||
|
label={formatPercent(memPercent())}
|
||||||
|
sublabel={memoryUsageLabel()}
|
||||||
|
type="memory"
|
||||||
|
resourceId={metricsKey()}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</Show>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Disk */}
|
||||||
|
<div class="flex-1 px-0.5 py-1 md:px-2 md:py-0 w-auto min-w-[35px] md:w-full flex justify-center items-center">
|
||||||
|
<Show
|
||||||
|
when={hasDiskUsage()}
|
||||||
|
fallback={
|
||||||
|
<span class="text-xs text-gray-400 cursor-help" title={getDiskStatusTooltip()}>
|
||||||
|
-
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<div class={`md:hidden text-xs ${diskPercent() >= 90 ? 'text-red-600 dark:text-red-400 font-bold' : diskPercent() >= 80 ? 'text-orange-600 dark:text-orange-400 font-medium' : 'text-gray-600 dark:text-gray-400'}`}>
|
||||||
|
{formatPercent(diskPercent())}
|
||||||
|
</div>
|
||||||
|
<div class="hidden md:block w-full">
|
||||||
|
<MetricBar
|
||||||
|
value={diskPercent()}
|
||||||
|
label={formatPercent(diskPercent())}
|
||||||
|
sublabel={
|
||||||
|
props.guest.disk
|
||||||
|
? `${formatBytes(props.guest.disk.used, 0)}/${formatBytes(props.guest.disk.total, 0)}`
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
type="disk"
|
||||||
|
resourceId={metricsKey()}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</Show>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Disk I/O */}
|
||||||
|
<div class="hidden xl:flex px-2 py-1 xl:px-2 xl:py-0 w-auto min-w-[60px] xl:w-full justify-start items-center">
|
||||||
|
<div class="flex h-[24px] items-center w-full justify-start">
|
||||||
|
<IOMetric value={props.guest.diskRead} disabled={!isRunning()} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="hidden xl:flex px-2 py-1 xl:px-2 xl:py-0 w-auto min-w-[60px] xl:w-full justify-start items-center">
|
||||||
|
<div class="flex h-[24px] items-center w-full justify-start">
|
||||||
|
<IOMetric value={props.guest.diskWrite} disabled={!isRunning()} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Network I/O */}
|
||||||
|
<div class="hidden xl:flex px-2 py-1 xl:px-2 xl:py-0 w-auto min-w-[60px] xl:w-full justify-start items-center">
|
||||||
|
<div class="flex h-[24px] items-center w-full justify-start">
|
||||||
|
<IOMetric value={props.guest.networkIn} disabled={!isRunning()} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="hidden xl:flex px-2 py-1 xl:px-2 xl:py-0 w-auto min-w-[60px] xl:w-full justify-start items-center">
|
||||||
|
<div class="flex h-[24px] items-center w-full justify-start">
|
||||||
|
<IOMetric value={props.guest.networkOut} disabled={!isRunning()} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Show when={drawerOpen() && canShowDrawer()}>
|
||||||
|
<div class="bg-gray-50 dark:bg-gray-900/50 border-b border-gray-200 dark:border-gray-700">
|
||||||
|
<div class="p-4">
|
||||||
|
<GuestDrawer
|
||||||
|
guest={props.guest}
|
||||||
|
metricsKey={metricsKey()}
|
||||||
|
onClose={() => setCurrentlyExpandedGuestId(null)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</Show>
|
</Show>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
};
|
||||||
|
|
|
||||||
|
|
@ -38,9 +38,9 @@ export function IOMetric(props: IOMetricProps) {
|
||||||
return (
|
return (
|
||||||
<Show
|
<Show
|
||||||
when={!props.disabled}
|
when={!props.disabled}
|
||||||
fallback={<div class="min-h-[24px] flex items-center text-sm text-gray-400">-</div>}
|
fallback={<div class="min-h-[14px] flex items-center text-xs text-gray-400">-</div>}
|
||||||
>
|
>
|
||||||
<div class={`min-h-[24px] text-sm font-mono ${colorClass()} flex items-center`}>
|
<div class={`min-h-[14px] text-xs font-mono ${colorClass()} flex items-center`}>
|
||||||
{formatSpeed(currentValue(), 0)}
|
{formatSpeed(currentValue(), 0)}
|
||||||
</div>
|
</div>
|
||||||
</Show>
|
</Show>
|
||||||
|
|
|
||||||
|
|
@ -67,28 +67,15 @@ export function MetricBar(props: MetricBarProps) {
|
||||||
when={viewMode() === 'sparklines' && props.resourceId}
|
when={viewMode() === 'sparklines' && props.resourceId}
|
||||||
fallback={
|
fallback={
|
||||||
// Original progress bar mode
|
// Original progress bar mode
|
||||||
<div class="metric-text w-full h-6 flex items-center">
|
<div class="metric-text w-full h-4 flex items-center">
|
||||||
{/* On very small screens (< lg), show compact percentage with color indicator */}
|
<div class="relative w-full h-full overflow-hidden bg-gray-200 dark:bg-gray-600 rounded">
|
||||||
<div class="lg:hidden relative w-full h-3.5 flex items-center justify-center rounded overflow-hidden bg-gray-100 dark:bg-gray-700">
|
|
||||||
{/* Slim color indicator bar */}
|
|
||||||
<div class={`absolute bottom-0 left-0 h-1 w-full ${progressColorClass()}`} />
|
|
||||||
<span class={`text-[10px] font-medium z-10 ${
|
|
||||||
getColor() === 'red' ? 'text-red-700 dark:text-red-300' :
|
|
||||||
getColor() === 'yellow' ? 'text-yellow-700 dark:text-yellow-300' :
|
|
||||||
'text-gray-800 dark:text-gray-100'
|
|
||||||
}`}>
|
|
||||||
{props.label}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
{/* On larger screens (>= lg), show full progress bar */}
|
|
||||||
<div class="hidden lg:block relative w-full h-3.5 rounded overflow-hidden bg-gray-200 dark:bg-gray-600">
|
|
||||||
<div class={`absolute top-0 left-0 h-full ${progressColorClass()}`} style={{ width: `${width()}%` }} />
|
<div class={`absolute top-0 left-0 h-full ${progressColorClass()}`} style={{ width: `${width()}%` }} />
|
||||||
<span class="absolute inset-0 flex items-center justify-center text-[10px] font-medium text-gray-800 dark:text-gray-100 leading-none">
|
<span class="absolute inset-0 flex items-center justify-center text-[10px] font-medium text-gray-800 dark:text-gray-100 leading-none">
|
||||||
<span class="flex items-center gap-1 whitespace-nowrap px-0.5">
|
<span class="flex items-center gap-1 whitespace-nowrap px-0.5">
|
||||||
<span>{props.label}</span>
|
<span>{props.label}</span>
|
||||||
<Show when={props.sublabel}>
|
<Show when={props.sublabel}>
|
||||||
{(sublabel) => (
|
{(sublabel) => (
|
||||||
<span class="metric-sublabel hidden xl:inline font-normal">
|
<span class="metric-sublabel font-normal">
|
||||||
({sublabel()})
|
({sublabel()})
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
|
|
@ -141,12 +141,12 @@ export const DockerHostSummaryTable: Component<DockerHostSummaryTableProps> = (p
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card padding="none" class="mb-4 overflow-hidden">
|
<Card padding="none" class="mb-4 overflow-hidden">
|
||||||
<ScrollableTable minWidth="720px" persistKey="docker-host-summary">
|
<ScrollableTable minWidth="300px" persistKey="docker-host-summary">
|
||||||
<table class="w-full table-fixed border-collapse sm:whitespace-nowrap">
|
<table class="w-full table-fixed border-collapse sm:whitespace-nowrap">
|
||||||
<thead>
|
<thead>
|
||||||
<tr class="bg-gray-50 dark:bg-gray-700/50 text-gray-600 dark:text-gray-300 border-b border-gray-200 dark:border-gray-600">
|
<tr class="bg-gray-50 dark:bg-gray-700/50 text-gray-600 dark:text-gray-300 border-b border-gray-200 dark:border-gray-600">
|
||||||
<th
|
<th
|
||||||
class="pl-3 pr-2 py-1.5 text-left text-[11px] sm:text-xs font-medium uppercase tracking-wider w-[18%] cursor-pointer hover:bg-gray-200 dark:hover:bg-gray-600 focus:outline-none focus:ring-2 focus:ring-inset focus:ring-blue-500 whitespace-nowrap"
|
class="pl-3 pr-2 py-1 text-left text-[11px] sm:text-xs font-medium uppercase tracking-wider w-[35%] md:w-[18%] cursor-pointer hover:bg-gray-200 dark:hover:bg-gray-600 focus:outline-none focus:ring-2 focus:ring-inset focus:ring-blue-500 whitespace-nowrap"
|
||||||
onClick={() => handleSort('name')}
|
onClick={() => handleSort('name')}
|
||||||
onKeyDown={(e) => e.key === 'Enter' && handleSort('name')}
|
onKeyDown={(e) => e.key === 'Enter' && handleSort('name')}
|
||||||
tabIndex={0}
|
tabIndex={0}
|
||||||
|
|
@ -155,47 +155,47 @@ export const DockerHostSummaryTable: Component<DockerHostSummaryTableProps> = (p
|
||||||
>
|
>
|
||||||
Host {renderSortIndicator('name')}
|
Host {renderSortIndicator('name')}
|
||||||
</th>
|
</th>
|
||||||
<th class="px-2 py-1.5 text-center text-[11px] sm:text-xs font-medium uppercase tracking-wider w-[7%] whitespace-nowrap">
|
<th class="px-0.5 md:px-2 py-1 text-center text-[11px] sm:text-xs font-medium uppercase tracking-wider w-[10%] md:w-[6%] whitespace-nowrap">
|
||||||
Status
|
Status
|
||||||
</th>
|
</th>
|
||||||
<th
|
<th
|
||||||
class="px-2 py-1.5 text-center text-[11px] sm:text-xs font-medium uppercase tracking-wider w-[14%] cursor-pointer hover:bg-gray-200 dark:hover:bg-gray-600 whitespace-nowrap"
|
class="px-0.5 md:px-2 py-1 text-center text-[11px] sm:text-xs font-medium uppercase tracking-wider w-[13%] md:w-[12%] min-w-[35px] cursor-pointer hover:bg-gray-200 dark:hover:bg-gray-600 whitespace-nowrap"
|
||||||
onClick={() => handleSort('cpu')}
|
onClick={() => handleSort('cpu')}
|
||||||
>
|
>
|
||||||
CPU {renderSortIndicator('cpu')}
|
CPU {renderSortIndicator('cpu')}
|
||||||
</th>
|
</th>
|
||||||
<th
|
<th
|
||||||
class="px-2 py-1.5 text-center text-[11px] sm:text-xs font-medium uppercase tracking-wider w-[14%] cursor-pointer hover:bg-gray-200 dark:hover:bg-gray-600 whitespace-nowrap"
|
class="px-0.5 md:px-2 py-1 text-center text-[11px] sm:text-xs font-medium uppercase tracking-wider w-[13%] md:w-[12%] min-w-[35px] cursor-pointer hover:bg-gray-200 dark:hover:bg-gray-600 whitespace-nowrap"
|
||||||
onClick={() => handleSort('memory')}
|
onClick={() => handleSort('memory')}
|
||||||
>
|
>
|
||||||
Memory {renderSortIndicator('memory')}
|
Memory {renderSortIndicator('memory')}
|
||||||
</th>
|
</th>
|
||||||
<th
|
<th
|
||||||
class="px-2 py-1.5 text-center text-[11px] sm:text-xs font-medium uppercase tracking-wider w-[14%] cursor-pointer hover:bg-gray-200 dark:hover:bg-gray-600 whitespace-nowrap"
|
class="px-0.5 md:px-2 py-1 text-center text-[11px] sm:text-xs font-medium uppercase tracking-wider w-[13%] md:w-[12%] min-w-[35px] cursor-pointer hover:bg-gray-200 dark:hover:bg-gray-600 whitespace-nowrap"
|
||||||
onClick={() => handleSort('disk')}
|
onClick={() => handleSort('disk')}
|
||||||
>
|
>
|
||||||
Disk {renderSortIndicator('disk')}
|
Disk {renderSortIndicator('disk')}
|
||||||
</th>
|
</th>
|
||||||
<th
|
<th
|
||||||
class="px-2 py-1.5 text-center text-[11px] sm:text-xs font-medium uppercase tracking-wider w-[11%] cursor-pointer hover:bg-gray-200 dark:hover:bg-gray-600 whitespace-nowrap"
|
class="px-0.5 md:px-2 py-1 text-center text-[11px] sm:text-xs font-medium uppercase tracking-wider w-[16%] md:w-[10%] cursor-pointer hover:bg-gray-200 dark:hover:bg-gray-600 whitespace-nowrap"
|
||||||
onClick={() => handleSort('running')}
|
onClick={() => handleSort('running')}
|
||||||
>
|
>
|
||||||
Containers {renderSortIndicator('running')}
|
Containers {renderSortIndicator('running')}
|
||||||
</th>
|
</th>
|
||||||
<th
|
<th
|
||||||
class="hidden px-2 py-1.5 text-center text-[11px] sm:text-xs font-medium uppercase tracking-wider w-[9%] cursor-pointer hover:bg-gray-200 dark:hover:bg-gray-600 whitespace-nowrap sm:table-cell"
|
class="hidden px-2 py-1 text-center text-[11px] sm:text-xs font-medium uppercase tracking-wider md:w-[10%] cursor-pointer hover:bg-gray-200 dark:hover:bg-gray-600 whitespace-nowrap sm:table-cell"
|
||||||
onClick={() => handleSort('uptime')}
|
onClick={() => handleSort('uptime')}
|
||||||
>
|
>
|
||||||
Uptime {renderSortIndicator('uptime')}
|
Uptime {renderSortIndicator('uptime')}
|
||||||
</th>
|
</th>
|
||||||
<th
|
<th
|
||||||
class="hidden px-2 py-1.5 text-center text-[11px] sm:text-xs font-medium uppercase tracking-wider w-[12%] cursor-pointer hover:bg-gray-200 dark:hover:bg-gray-600 whitespace-nowrap sm:table-cell"
|
class="hidden px-2 py-1 text-center text-[11px] sm:text-xs font-medium uppercase tracking-wider md:w-[12%] cursor-pointer hover:bg-gray-200 dark:hover:bg-gray-600 whitespace-nowrap sm:table-cell"
|
||||||
onClick={() => handleSort('lastSeen')}
|
onClick={() => handleSort('lastSeen')}
|
||||||
>
|
>
|
||||||
Last Update {renderSortIndicator('lastSeen')}
|
Last Update {renderSortIndicator('lastSeen')}
|
||||||
</th>
|
</th>
|
||||||
<th
|
<th
|
||||||
class="hidden px-2 py-1.5 text-center text-[11px] sm:text-xs font-medium uppercase tracking-wider w-[9%] cursor-pointer hover:bg-gray-200 dark:hover:bg-gray-600 whitespace-nowrap sm:table-cell"
|
class="hidden px-2 py-1 text-center text-[11px] sm:text-xs font-medium uppercase tracking-wider md:w-[8%] cursor-pointer hover:bg-gray-200 dark:hover:bg-gray-600 whitespace-nowrap sm:table-cell"
|
||||||
onClick={() => handleSort('agent')}
|
onClick={() => handleSort('agent')}
|
||||||
>
|
>
|
||||||
Agent {renderSortIndicator('agent')}
|
Agent {renderSortIndicator('agent')}
|
||||||
|
|
@ -322,44 +322,59 @@ export const DockerHostSummaryTable: Component<DockerHostSummaryTableProps> = (p
|
||||||
</Show>
|
</Show>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td class="px-2 py-1 align-middle">
|
<td class="px-0.5 md:px-2 py-1 align-middle">
|
||||||
<div class="flex justify-center items-center h-full w-full max-w-[180px] whitespace-nowrap">
|
<div class="flex justify-center items-center h-full w-full max-w-[180px] whitespace-nowrap">
|
||||||
{renderDockerStatusBadge(summary.host.status)}
|
{renderDockerStatusBadge(summary.host.status)}
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td class="px-2 py-1 align-middle">
|
<td class="px-0.5 md:px-2 py-1 align-middle">
|
||||||
<Show when={online} fallback={<span class="text-xs text-gray-400 dark:text-gray-500">—</span>}>
|
<Show when={online} fallback={<span class="text-xs text-gray-400 dark:text-gray-500">—</span>}>
|
||||||
<MetricBar
|
<div class={`md:hidden text-xs text-center ${summary.cpuPercent >= 90 ? 'text-red-600 dark:text-red-400 font-bold' : summary.cpuPercent >= 80 ? 'text-orange-600 dark:text-orange-400 font-medium' : 'text-gray-600 dark:text-gray-400'}`}>
|
||||||
value={summary.cpuPercent}
|
{formatPercent(summary.cpuPercent)}
|
||||||
label={formatPercent(summary.cpuPercent)}
|
</div>
|
||||||
type="cpu"
|
<div class="hidden md:block">
|
||||||
resourceId={metricsKey}
|
<MetricBar
|
||||||
/>
|
value={summary.cpuPercent}
|
||||||
|
label={formatPercent(summary.cpuPercent)}
|
||||||
|
type="cpu"
|
||||||
|
resourceId={metricsKey}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</Show>
|
</Show>
|
||||||
</td>
|
</td>
|
||||||
<td class="px-2 py-1 align-middle">
|
<td class="px-0.5 md:px-2 py-1 align-middle">
|
||||||
<Show when={online} fallback={<span class="text-xs text-gray-400 dark:text-gray-500">—</span>}>
|
<Show when={online} fallback={<span class="text-xs text-gray-400 dark:text-gray-500">—</span>}>
|
||||||
<MetricBar
|
<div class={`md:hidden text-xs text-center ${summary.memoryPercent >= 85 ? 'text-red-600 dark:text-red-400 font-bold' : summary.memoryPercent >= 75 ? 'text-orange-600 dark:text-orange-400 font-medium' : 'text-gray-600 dark:text-gray-400'}`}>
|
||||||
value={summary.memoryPercent}
|
{formatPercent(summary.memoryPercent)}
|
||||||
label={formatPercent(summary.memoryPercent)}
|
</div>
|
||||||
sublabel={summary.memoryLabel}
|
<div class="hidden md:block">
|
||||||
type="memory"
|
<MetricBar
|
||||||
resourceId={metricsKey}
|
value={summary.memoryPercent}
|
||||||
/>
|
label={formatPercent(summary.memoryPercent)}
|
||||||
|
sublabel={summary.memoryLabel}
|
||||||
|
type="memory"
|
||||||
|
resourceId={metricsKey}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</Show>
|
</Show>
|
||||||
</td>
|
</td>
|
||||||
<td class="px-2 py-1 align-middle">
|
<td class="px-0.5 md:px-2 py-1 align-middle">
|
||||||
<Show when={summary.diskLabel} fallback={<span class="text-xs text-gray-400 dark:text-gray-500">—</span>}>
|
<Show when={summary.diskLabel} fallback={<span class="text-xs text-gray-400 dark:text-gray-500">—</span>}>
|
||||||
<MetricBar
|
<div class={`md:hidden text-xs text-center ${summary.diskPercent >= 90 ? 'text-red-600 dark:text-red-400 font-bold' : summary.diskPercent >= 80 ? 'text-orange-600 dark:text-orange-400 font-medium' : 'text-gray-600 dark:text-gray-400'}`}>
|
||||||
value={summary.diskPercent}
|
{formatPercent(summary.diskPercent)}
|
||||||
label={formatPercent(summary.diskPercent)}
|
</div>
|
||||||
sublabel={summary.diskLabel}
|
<div class="hidden md:block">
|
||||||
type="disk"
|
<MetricBar
|
||||||
resourceId={metricsKey}
|
value={summary.diskPercent}
|
||||||
/>
|
label={formatPercent(summary.diskPercent)}
|
||||||
|
sublabel={summary.diskLabel}
|
||||||
|
type="disk"
|
||||||
|
resourceId={metricsKey}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</Show>
|
</Show>
|
||||||
</td>
|
</td>
|
||||||
<td class="px-2 py-1 align-middle">
|
<td class="px-0.5 md:px-2 py-1 align-middle">
|
||||||
<div class="flex justify-center items-center h-full whitespace-nowrap">
|
<div class="flex justify-center items-center h-full whitespace-nowrap">
|
||||||
<Show
|
<Show
|
||||||
when={summary.totalCount > 0}
|
when={summary.totalCount > 0}
|
||||||
|
|
@ -411,11 +426,10 @@ export const DockerHostSummaryTable: Component<DockerHostSummaryTableProps> = (p
|
||||||
? 'px-1.5 py-0.5 rounded bg-yellow-100 dark:bg-yellow-900/30 text-yellow-700 dark:text-yellow-400 font-medium'
|
? 'px-1.5 py-0.5 rounded bg-yellow-100 dark:bg-yellow-900/30 text-yellow-700 dark:text-yellow-400 font-medium'
|
||||||
: 'px-1.5 py-0.5 rounded bg-green-100 dark:bg-green-900/30 text-green-700 dark:text-green-400 font-medium'
|
: 'px-1.5 py-0.5 rounded bg-green-100 dark:bg-green-900/30 text-green-700 dark:text-green-400 font-medium'
|
||||||
}
|
}
|
||||||
title={`${
|
title={`${agentOutdated
|
||||||
agentOutdated
|
? 'Agent is outdated on this host'
|
||||||
? 'Agent is outdated on this host'
|
: 'Agent is up to date'
|
||||||
: 'Agent is up to date'
|
}${summary.host.intervalSeconds ? `\nReporting interval: ${summary.host.intervalSeconds}s` : ''}`}
|
||||||
}${summary.host.intervalSeconds ? `\nReporting interval: ${summary.host.intervalSeconds}s` : ''}`}
|
|
||||||
>
|
>
|
||||||
v{version()}
|
v{version()}
|
||||||
</span>
|
</span>
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,7 @@ import { MonitoringAPI } from '@/api/monitoring';
|
||||||
import { SecurityAPI } from '@/api/security';
|
import { SecurityAPI } from '@/api/security';
|
||||||
import { notificationStore } from '@/stores/notifications';
|
import { notificationStore } from '@/stores/notifications';
|
||||||
import type { SecurityStatus } from '@/types/config';
|
import type { SecurityStatus } from '@/types/config';
|
||||||
import type { Host, HostLookupResponse, DockerHost } from '@/types/api';
|
import type { HostLookupResponse } from '@/types/api';
|
||||||
import type { APITokenRecord } from '@/api/security';
|
import type { APITokenRecord } from '@/api/security';
|
||||||
import { HOST_AGENT_SCOPE, DOCKER_REPORT_SCOPE } from '@/constants/apiScopes';
|
import { HOST_AGENT_SCOPE, DOCKER_REPORT_SCOPE } from '@/constants/apiScopes';
|
||||||
import { copyToClipboard } from '@/utils/clipboard';
|
import { copyToClipboard } from '@/utils/clipboard';
|
||||||
|
|
@ -252,8 +252,8 @@ export const UnifiedAgents: Component = () => {
|
||||||
types: ('host' | 'docker')[];
|
types: ('host' | 'docker')[];
|
||||||
status: string;
|
status: string;
|
||||||
version?: string;
|
version?: string;
|
||||||
lastSeen?: number | string;
|
lastSeen?: number;
|
||||||
ip?: string;
|
isLegacy?: boolean;
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
// Process Host Agents
|
// Process Host Agents
|
||||||
|
|
@ -267,7 +267,7 @@ export const UnifiedAgents: Component = () => {
|
||||||
status: h.status || 'unknown',
|
status: h.status || 'unknown',
|
||||||
version: h.agentVersion,
|
version: h.agentVersion,
|
||||||
lastSeen: h.lastSeen,
|
lastSeen: h.lastSeen,
|
||||||
ip: h.ip
|
isLegacy: h.isLegacy
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -280,7 +280,8 @@ export const UnifiedAgents: Component = () => {
|
||||||
existing.types.push('docker');
|
existing.types.push('docker');
|
||||||
}
|
}
|
||||||
// Update version/status if newer
|
// Update version/status if newer
|
||||||
if (!existing.version && d.version) existing.version = d.version;
|
if (!existing.version && d.agentVersion) existing.version = d.agentVersion;
|
||||||
|
if (d.isLegacy) existing.isLegacy = true;
|
||||||
} else {
|
} else {
|
||||||
unified.set(key, {
|
unified.set(key, {
|
||||||
id: d.id,
|
id: d.id,
|
||||||
|
|
@ -288,8 +289,9 @@ export const UnifiedAgents: Component = () => {
|
||||||
displayName: d.displayName,
|
displayName: d.displayName,
|
||||||
types: ['docker'],
|
types: ['docker'],
|
||||||
status: d.status || 'unknown',
|
status: d.status || 'unknown',
|
||||||
version: d.version || d.dockerVersion,
|
version: d.agentVersion || d.dockerVersion,
|
||||||
lastSeen: d.lastSeen,
|
lastSeen: d.lastSeen,
|
||||||
|
isLegacy: d.isLegacy,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
@ -620,8 +622,8 @@ export const UnifiedAgents: Component = () => {
|
||||||
<For each={agent.types}>
|
<For each={agent.types}>
|
||||||
{(type) => (
|
{(type) => (
|
||||||
<span class={`inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium ${type === 'host'
|
<span class={`inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium ${type === 'host'
|
||||||
? 'bg-purple-100 text-purple-800 dark:bg-purple-900/30 dark:text-purple-300'
|
? 'bg-purple-100 text-purple-800 dark:bg-purple-900/30 dark:text-purple-300'
|
||||||
: 'bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-300'
|
: 'bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-300'
|
||||||
}`}>
|
}`}>
|
||||||
{type === 'host' ? 'Host' : 'Docker'}
|
{type === 'host' ? 'Host' : 'Docker'}
|
||||||
</span>
|
</span>
|
||||||
|
|
@ -631,17 +633,22 @@ export const UnifiedAgents: Component = () => {
|
||||||
</td>
|
</td>
|
||||||
<td class="whitespace-nowrap px-4 py-3 text-sm">
|
<td class="whitespace-nowrap px-4 py-3 text-sm">
|
||||||
<span class={`inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium ${connectedFromStatus(agent.status)
|
<span class={`inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium ${connectedFromStatus(agent.status)
|
||||||
? 'bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-300'
|
? 'bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-300'
|
||||||
: 'bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-300'
|
: 'bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-300'
|
||||||
}`}>
|
}`}>
|
||||||
{agent.status}
|
{agent.status}
|
||||||
</span>
|
</span>
|
||||||
</td>
|
</td>
|
||||||
<td class="whitespace-nowrap px-4 py-3 text-sm text-gray-500 dark:text-gray-400">
|
<td class="whitespace-nowrap px-4 py-3 text-sm text-gray-500 dark:text-gray-400">
|
||||||
{agent.version || '—'}
|
{agent.version || '—'}
|
||||||
|
<Show when={agent.isLegacy}>
|
||||||
|
<span class="ml-2 inline-flex items-center rounded-full bg-amber-100 px-2 py-0.5 text-xs font-medium text-amber-800 dark:bg-amber-900/30 dark:text-amber-200" title="This agent is using an old version. Please update to the unified agent.">
|
||||||
|
Legacy
|
||||||
|
</span>
|
||||||
|
</Show>
|
||||||
</td>
|
</td>
|
||||||
<td class="whitespace-nowrap px-4 py-3 text-sm text-gray-500 dark:text-gray-400">
|
<td class="whitespace-nowrap px-4 py-3 text-sm text-gray-500 dark:text-gray-400">
|
||||||
{formatRelativeTime(agent.lastSeen)}
|
{agent.lastSeen ? formatRelativeTime(agent.lastSeen) : '—'}
|
||||||
</td>
|
</td>
|
||||||
<td class="whitespace-nowrap px-4 py-3 text-right text-sm font-medium">
|
<td class="whitespace-nowrap px-4 py-3 text-right text-sm font-medium">
|
||||||
<button
|
<button
|
||||||
|
|
|
||||||
|
|
@ -800,7 +800,7 @@ const Storage: Component = () => {
|
||||||
<>
|
<>
|
||||||
{/* Group Header */}
|
{/* Group Header */}
|
||||||
<Show when={viewMode() === 'node' && node}>
|
<Show when={viewMode() === 'node' && node}>
|
||||||
{(validNode) => <NodeGroupHeader node={validNode()} colspan={9} />}
|
{(validNode) => <NodeGroupHeader node={validNode()} colspan={9} renderAs="tr" />}
|
||||||
</Show>
|
</Show>
|
||||||
|
|
||||||
{/* Storage Rows */}
|
{/* Storage Rows */}
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,8 @@ import { getNodeStatusIndicator } from '@/utils/status';
|
||||||
|
|
||||||
interface NodeGroupHeaderProps {
|
interface NodeGroupHeaderProps {
|
||||||
node: Node;
|
node: Node;
|
||||||
colspan: number;
|
colspan?: number;
|
||||||
|
renderAs?: 'tr' | 'div';
|
||||||
}
|
}
|
||||||
|
|
||||||
export const NodeGroupHeader: Component<NodeGroupHeaderProps> = (props) => {
|
export const NodeGroupHeader: Component<NodeGroupHeaderProps> = (props) => {
|
||||||
|
|
@ -16,48 +17,62 @@ export const NodeGroupHeader: Component<NodeGroupHeaderProps> = (props) => {
|
||||||
const displayName = () => getNodeDisplayName(props.node);
|
const displayName = () => getNodeDisplayName(props.node);
|
||||||
const showActualName = () => hasAlternateDisplayName(props.node);
|
const showActualName = () => hasAlternateDisplayName(props.node);
|
||||||
|
|
||||||
return (
|
const InnerContent = () => (
|
||||||
<tr class="bg-gray-50 dark:bg-gray-900/40">
|
<div
|
||||||
<td
|
class={`flex flex-wrap items-center gap-3 ${isOnline() ? '' : 'opacity-60'}`}
|
||||||
colspan={props.colspan}
|
title={nodeStatus().label}
|
||||||
class="py-1 pr-2 pl-4 text-[12px] sm:text-sm font-semibold text-slate-700 dark:text-slate-100"
|
>
|
||||||
|
<StatusDot
|
||||||
|
variant={nodeStatus().variant}
|
||||||
|
title={nodeStatus().label}
|
||||||
|
ariaLabel={nodeStatus().label}
|
||||||
|
size="xs"
|
||||||
|
/>
|
||||||
|
<a
|
||||||
|
href={nodeUrl()}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
class="transition-colors duration-150 hover:text-sky-600 dark:hover:text-sky-400"
|
||||||
|
title={`Open ${props.node.name} web interface`}
|
||||||
>
|
>
|
||||||
<div
|
{displayName()}
|
||||||
class={`flex flex-wrap items-center gap-3 ${isOnline() ? '' : 'opacity-60'}`}
|
</a>
|
||||||
title={nodeStatus().label}
|
<Show when={showActualName()}>
|
||||||
>
|
<span class="text-[10px] text-slate-500 dark:text-slate-400">({props.node.name})</span>
|
||||||
<StatusDot
|
</Show>
|
||||||
variant={nodeStatus().variant}
|
|
||||||
title={nodeStatus().label}
|
|
||||||
ariaLabel={nodeStatus().label}
|
|
||||||
size="xs"
|
|
||||||
/>
|
|
||||||
<a
|
|
||||||
href={nodeUrl()}
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
class="transition-colors duration-150 hover:text-sky-600 dark:hover:text-sky-400"
|
|
||||||
title={`Open ${props.node.name} web interface`}
|
|
||||||
>
|
|
||||||
{displayName()}
|
|
||||||
</a>
|
|
||||||
<Show when={showActualName()}>
|
|
||||||
<span class="text-[10px] text-slate-500 dark:text-slate-400">({props.node.name})</span>
|
|
||||||
</Show>
|
|
||||||
|
|
||||||
<Show when={props.node.isClusterMember !== undefined}>
|
<Show when={props.node.isClusterMember !== undefined}>
|
||||||
<span
|
<span
|
||||||
class={`rounded px-2 py-0.5 text-[10px] font-medium whitespace-nowrap ${
|
class={`rounded px-2 py-0.5 text-[10px] font-medium whitespace-nowrap ${props.node.isClusterMember
|
||||||
props.node.isClusterMember
|
? 'bg-blue-100 text-blue-700 dark:bg-blue-900/40 dark:text-blue-300'
|
||||||
? 'bg-blue-100 text-blue-700 dark:bg-blue-900/40 dark:text-blue-300'
|
: 'bg-slate-200 text-slate-600 dark:bg-slate-700/60 dark:text-slate-300'
|
||||||
: 'bg-slate-200 text-slate-600 dark:bg-slate-700/60 dark:text-slate-300'
|
}`}
|
||||||
}`}
|
>
|
||||||
>
|
{props.node.isClusterMember ? props.node.clusterName : 'Standalone'}
|
||||||
{props.node.isClusterMember ? props.node.clusterName : 'Standalone'}
|
</span>
|
||||||
</span>
|
</Show>
|
||||||
</Show>
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Show
|
||||||
|
when={props.renderAs === 'tr'}
|
||||||
|
fallback={
|
||||||
|
<div class="bg-gray-50 dark:bg-gray-900/40 w-full">
|
||||||
|
<div class="py-1 pr-2 pl-4 text-[12px] sm:text-sm font-semibold text-slate-700 dark:text-slate-100">
|
||||||
|
<InnerContent />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
}
|
||||||
</tr>
|
>
|
||||||
|
<tr class="bg-gray-50 dark:bg-gray-900/40">
|
||||||
|
<td
|
||||||
|
colspan={props.colspan}
|
||||||
|
class="py-1 pr-2 pl-4 text-[12px] sm:text-sm font-semibold text-slate-700 dark:text-slate-100"
|
||||||
|
>
|
||||||
|
<InnerContent />
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</Show>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -349,6 +349,24 @@ export const NodeSummaryTable: Component<NodeSummaryTableProps> = (props) => {
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const gridTemplate = createMemo(() => {
|
||||||
|
const parts = ['minmax(90px, 1.5fr)']; // Name
|
||||||
|
parts.push('minmax(45px, 80px)'); // Uptime
|
||||||
|
parts.push('minmax(55px, 1.5fr)'); // CPU
|
||||||
|
parts.push('minmax(55px, 1.5fr)'); // Memory
|
||||||
|
parts.push('minmax(55px, 1.5fr)'); // Disk
|
||||||
|
|
||||||
|
if (hasAnyTemperatureData()) {
|
||||||
|
parts.push('minmax(45px, 65px)'); // Temp
|
||||||
|
}
|
||||||
|
|
||||||
|
countColumns().forEach(() => {
|
||||||
|
parts.push('minmax(40px, 85px)'); // Counts
|
||||||
|
});
|
||||||
|
|
||||||
|
return parts.join(' ');
|
||||||
|
});
|
||||||
|
|
||||||
// Don't return null - let the table render even if empty
|
// Don't return null - let the table render even if empty
|
||||||
// This prevents the table from disappearing on refresh while data loads
|
// This prevents the table from disappearing on refresh while data loads
|
||||||
|
|
||||||
|
|
@ -356,186 +374,234 @@ export const NodeSummaryTable: Component<NodeSummaryTableProps> = (props) => {
|
||||||
<>
|
<>
|
||||||
<Card padding="none" class="mb-4 overflow-hidden">
|
<Card padding="none" class="mb-4 overflow-hidden">
|
||||||
<div class="overflow-x-auto">
|
<div class="overflow-x-auto">
|
||||||
<table class="w-full table-fixed border-collapse">
|
{/* Header */}
|
||||||
<thead>
|
<div
|
||||||
<tr class="bg-gray-50 dark:bg-gray-700/50 text-gray-600 dark:text-gray-300 border-b border-gray-200 dark:border-gray-600">
|
class="grid items-center bg-gray-50 dark:bg-gray-700/50 text-gray-600 dark:text-gray-300 border-b border-gray-200 dark:border-gray-600 text-[11px] sm:text-xs font-medium uppercase tracking-wider sticky top-0 z-20 min-w-full"
|
||||||
<th
|
style={{ 'grid-template-columns': gridTemplate() }}
|
||||||
class="pl-3 pr-2 py-1.5 text-left text-[11px] sm:text-xs font-medium uppercase tracking-wider w-auto cursor-pointer hover:bg-gray-200 dark:hover:bg-gray-600 focus:outline-none focus:ring-2 focus:ring-inset focus:ring-blue-500 whitespace-nowrap"
|
>
|
||||||
onClick={() => handleSort('name')}
|
<div
|
||||||
onKeyDown={(e) => e.key === 'Enter' && handleSort('name')}
|
class="sticky left-0 z-30 bg-gray-50 dark:bg-gray-700/50 pl-3 pr-2 py-1 text-left border-r md:border-r-0 border-gray-200 dark:border-gray-600 cursor-pointer hover:bg-gray-200 dark:hover:bg-gray-600 flex items-center h-full"
|
||||||
tabindex="0"
|
onClick={() => handleSort('name')}
|
||||||
role="button"
|
onKeyDown={(e) => e.key === 'Enter' && handleSort('name')}
|
||||||
aria-label={`Sort by name ${sortKey() === 'name' ? (sortDirection() === 'asc' ? 'ascending' : 'descending') : ''
|
tabindex="0"
|
||||||
}`}
|
role="button"
|
||||||
|
aria-label={`Sort by name ${sortKey() === 'name' ? (sortDirection() === 'asc' ? 'ascending' : 'descending') : ''
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{props.currentTab === 'backups' ? 'Node / PBS' : 'Node'}{' '}
|
||||||
|
{sortKey() === 'name' && (sortDirection() === 'asc' ? '▲' : '▼')}
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
class="px-0.5 md:px-2 py-1 text-center md:text-left cursor-pointer hover:bg-gray-200 dark:hover:bg-gray-600 flex items-center justify-center md:justify-start h-full"
|
||||||
|
onClick={() => handleSort('uptime')}
|
||||||
|
>
|
||||||
|
<span class="md:hidden" title="Uptime">
|
||||||
|
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" /></svg>
|
||||||
|
</span>
|
||||||
|
<span class="hidden md:inline">Uptime</span>
|
||||||
|
{sortKey() === 'uptime' && (sortDirection() === 'asc' ? '▲' : '▼')}
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
class="px-0.5 md:px-2 py-1 text-center md:text-left cursor-pointer hover:bg-gray-200 dark:hover:bg-gray-600 flex items-center justify-center md:justify-start h-full"
|
||||||
|
onClick={() => handleSort('cpu')}
|
||||||
|
>
|
||||||
|
<span class="md:hidden" title="CPU">
|
||||||
|
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 3v2m6-2v2M9 19v2m6-2v2M5 9H3m2 6H3m18-6h-2m2 6h-2M7 19h10a2 2 0 002-2V7a2 2 0 00-2-2H7a2 2 0 00-2 2v10a2 2 0 002 2zM9 9h6v6H9V9z" /></svg>
|
||||||
|
</span>
|
||||||
|
<span class="hidden md:inline">CPU</span>
|
||||||
|
{sortKey() === 'cpu' && (sortDirection() === 'asc' ? '▲' : '▼')}
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
class="px-0.5 md:px-2 py-1 text-center md:text-left cursor-pointer hover:bg-gray-200 dark:hover:bg-gray-600 flex items-center justify-center md:justify-start h-full"
|
||||||
|
onClick={() => handleSort('memory')}
|
||||||
|
>
|
||||||
|
<span class="md:hidden" title="Memory">
|
||||||
|
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 17v-2m3 2v-4m3 4v-6m2 10H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" /></svg>
|
||||||
|
</span>
|
||||||
|
<span class="hidden md:inline">Memory</span>
|
||||||
|
{sortKey() === 'memory' && (sortDirection() === 'asc' ? '▲' : '▼')}
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
class="px-0.5 md:px-2 py-1 text-center md:text-left cursor-pointer hover:bg-gray-200 dark:hover:bg-gray-600 flex items-center justify-center md:justify-start h-full"
|
||||||
|
onClick={() => handleSort('disk')}
|
||||||
|
>
|
||||||
|
<span class="md:hidden" title={props.currentTab === 'backups' && props.pbsInstances ? 'Storage / Disk' : 'Disk'}>
|
||||||
|
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 7v10c0 2.21 3.582 4 8 4s8-1.79 8-4V7M4 7c0 2.21 3.582 4 8 4s8-1.79 8-4M4 7c0-2.21 3.582-4 8-4s8 1.79 8 4m0 5c0 2.21-3.582 4-8 4s-8-1.79-8-4" /></svg>
|
||||||
|
</span>
|
||||||
|
<span class="hidden md:inline">
|
||||||
|
{props.currentTab === 'backups' && props.pbsInstances ? 'Storage / Disk' : 'Disk'}
|
||||||
|
</span>
|
||||||
|
{sortKey() === 'disk' && (sortDirection() === 'asc' ? '▲' : '▼')}
|
||||||
|
</div>
|
||||||
|
<Show when={hasAnyTemperatureData()}>
|
||||||
|
<div
|
||||||
|
class="px-0.5 md:px-2 py-1 text-center md:text-left cursor-pointer hover:bg-gray-200 dark:hover:bg-gray-600 flex items-center justify-center md:justify-start h-full"
|
||||||
|
onClick={() => handleSort('temperature')}
|
||||||
|
>
|
||||||
|
<span class="md:hidden" title="Temperature">
|
||||||
|
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z" /></svg>
|
||||||
|
</span>
|
||||||
|
<span class="hidden md:inline">Temp</span>
|
||||||
|
{sortKey() === 'temperature' && (sortDirection() === 'asc' ? '▲' : '▼')}
|
||||||
|
</div>
|
||||||
|
</Show>
|
||||||
|
<For each={countColumns()}>
|
||||||
|
{(column) => (
|
||||||
|
<div
|
||||||
|
class="px-0.5 md:px-2 py-1 text-center cursor-pointer hover:bg-gray-200 dark:hover:bg-gray-600 flex items-center justify-center h-full"
|
||||||
|
onClick={() => handleSort(column.key)}
|
||||||
>
|
>
|
||||||
{props.currentTab === 'backups' ? 'Node / PBS' : 'Node'}{' '}
|
<span class="md:hidden" title={column.header}>
|
||||||
{sortKey() === 'name' && (sortDirection() === 'asc' ? '▲' : '▼')}
|
<Show when={column.key === 'vmCount'}>
|
||||||
</th>
|
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9.75 17L9 20l-1 1h8l-1-1-.75-3M3 13h18M5 17h14a2 2 0 002-2V5a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z" /></svg>
|
||||||
<th
|
</Show>
|
||||||
class="hidden sm:table-cell px-2 py-1.5 text-left text-[11px] sm:text-xs font-medium uppercase tracking-wider w-[12%] cursor-pointer hover:bg-gray-200 dark:hover:bg-gray-600 whitespace-nowrap"
|
<Show when={column.key === 'containerCount'}>
|
||||||
onClick={() => handleSort('uptime')}
|
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M20 7l-8-4-8 4m16 0l-8 4m8-4v10l-8 4m0-10L4 7m8 4v10M4 7v10l8 4" /></svg>
|
||||||
>
|
</Show>
|
||||||
Uptime {sortKey() === 'uptime' && (sortDirection() === 'asc' ? '▲' : '▼')}
|
<Show when={column.key === 'storageCount' || column.key === 'diskCount'}>
|
||||||
</th>
|
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 7v10c0 2.21 3.582 4 8 4s8-1.79 8-4V7M4 7c0 2.21 3.582 4 8 4s8-1.79 8-4M4 7c0-2.21 3.582-4 8-4s8 1.79 8 4m0 5c0 2.21-3.582 4-8 4s-8-1.79-8-4" /></svg>
|
||||||
<th
|
</Show>
|
||||||
class="px-2 py-1.5 text-left text-[11px] sm:text-xs font-medium uppercase tracking-wider w-[25%] sm:w-[15%] cursor-pointer hover:bg-gray-200 dark:hover:bg-gray-600 whitespace-nowrap"
|
<Show when={column.key === 'backupCount'}>
|
||||||
onClick={() => handleSort('cpu')}
|
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 7H5a2 2 0 00-2 2v9a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-3m-1 4l-3 3m0 0l-3-3m3 3V4" /></svg>
|
||||||
>
|
</Show>
|
||||||
CPU {sortKey() === 'cpu' && (sortDirection() === 'asc' ? '▲' : '▼')}
|
</span>
|
||||||
</th>
|
<span class="hidden md:inline">{column.header}</span>
|
||||||
<th
|
{sortKey() === column.key && (sortDirection() === 'asc' ? '▲' : '▼')}
|
||||||
class="px-2 py-1.5 text-left text-[11px] sm:text-xs font-medium uppercase tracking-wider w-[25%] sm:w-[15%] cursor-pointer hover:bg-gray-200 dark:hover:bg-gray-600 whitespace-nowrap"
|
</div>
|
||||||
onClick={() => handleSort('memory')}
|
)}
|
||||||
>
|
</For>
|
||||||
Memory {sortKey() === 'memory' && (sortDirection() === 'asc' ? '▲' : '▼')}
|
</div>
|
||||||
</th>
|
|
||||||
<th
|
<div class="divide-y divide-gray-200 dark:divide-gray-700 min-w-full">
|
||||||
class="hidden md:table-cell px-2 py-1.5 text-left text-[11px] sm:text-xs font-medium uppercase tracking-wider w-[15%] cursor-pointer hover:bg-gray-200 dark:hover:bg-gray-600 whitespace-nowrap"
|
<For each={sortedItems()}>
|
||||||
onClick={() => handleSort('disk')}
|
{(item) => {
|
||||||
>
|
const isPVE = item.type === 'pve';
|
||||||
{props.currentTab === 'backups' && props.pbsInstances ? 'Storage / Disk' : 'Disk'}{' '}
|
const isPBS = item.type === 'pbs';
|
||||||
{sortKey() === 'disk' && (sortDirection() === 'asc' ? '▲' : '▼')}
|
const node = isPVE ? (item.data as Node) : null;
|
||||||
</th>
|
const pbs = isPBS ? (item.data as PBSInstance) : null;
|
||||||
<Show when={hasAnyTemperatureData()}>
|
|
||||||
<th
|
const online = isItemOnline(item);
|
||||||
class="hidden lg:table-cell px-2 py-1.5 text-left text-[11px] sm:text-xs font-medium uppercase tracking-wider w-[8%] cursor-pointer hover:bg-gray-200 dark:hover:bg-gray-600 whitespace-nowrap"
|
const statusIndicator = createMemo(() =>
|
||||||
onClick={() => handleSort('temperature')}
|
isPVE ? 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 displayName = () => {
|
||||||
|
if (isPVE) return getNodeDisplayName(node as Node);
|
||||||
|
return (pbs as PBSInstance).name;
|
||||||
|
};
|
||||||
|
const showActualName = () => isPVE && hasAlternateDisplayName(node as Node);
|
||||||
|
|
||||||
|
// Use unique node ID (not hostname) to handle duplicate node names
|
||||||
|
const nodeId = isPVE ? node!.id : pbs!.name;
|
||||||
|
const isSelected = () => props.selectedNode === nodeId;
|
||||||
|
// Use the full resource ID for alert matching
|
||||||
|
const resourceId = isPVE ? node!.id || node!.name : pbs!.id || pbs!.name;
|
||||||
|
// Use namespaced metric key for sparklines
|
||||||
|
const metricsKey = buildMetricKey('node', resourceId);
|
||||||
|
const alertStyles = createMemo(() =>
|
||||||
|
getAlertStyles(resourceId, activeAlerts, alertsEnabled()),
|
||||||
|
);
|
||||||
|
const showAlertHighlight = createMemo(
|
||||||
|
() => alertStyles().hasUnacknowledgedAlert && online,
|
||||||
|
);
|
||||||
|
|
||||||
|
const rowStyle = createMemo(() => {
|
||||||
|
const styles: Record<string, string> = {};
|
||||||
|
const shadows: string[] = [];
|
||||||
|
|
||||||
|
if (showAlertHighlight()) {
|
||||||
|
const color = alertStyles().severity === 'critical' ? '#ef4444' : '#eab308';
|
||||||
|
shadows.push(`inset 4px 0 0 0 ${color}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isSelected()) {
|
||||||
|
shadows.push('0 0 0 1px rgba(59, 130, 246, 0.5)');
|
||||||
|
shadows.push('0 2px 4px -1px rgba(0, 0, 0, 0.1)');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (shadows.length > 0) {
|
||||||
|
styles['box-shadow'] = shadows.join(', ');
|
||||||
|
}
|
||||||
|
|
||||||
|
return styles;
|
||||||
|
});
|
||||||
|
|
||||||
|
const rowClass = createMemo(() => {
|
||||||
|
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`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (showAlertHighlight()) {
|
||||||
|
return alertStyles().severity === 'critical'
|
||||||
|
? 'cursor-pointer transition-all duration-200 relative hover:shadow-sm group'
|
||||||
|
: 'cursor-pointer transition-all duration-200 relative hover:shadow-sm group';
|
||||||
|
}
|
||||||
|
|
||||||
|
let className = baseHover;
|
||||||
|
|
||||||
|
if (props.selectedNode && props.selectedNode !== nodeId) {
|
||||||
|
className += ' opacity-50 hover:opacity-80';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!online) {
|
||||||
|
className += ' opacity-60';
|
||||||
|
}
|
||||||
|
|
||||||
|
return className;
|
||||||
|
});
|
||||||
|
|
||||||
|
const cellBgClass = createMemo(() => {
|
||||||
|
if (isSelected()) return 'bg-blue-50 dark:bg-blue-900/20 group-hover:bg-blue-100 dark:group-hover:bg-blue-900/30';
|
||||||
|
if (showAlertHighlight()) {
|
||||||
|
return alertStyles().severity === 'critical'
|
||||||
|
? 'bg-red-50 dark:bg-red-950/30 group-hover:bg-red-100 dark:group-hover:bg-red-950/40'
|
||||||
|
: 'bg-yellow-50 dark:bg-yellow-950/20 group-hover:bg-yellow-100 dark:group-hover:bg-yellow-950/30';
|
||||||
|
}
|
||||||
|
return 'bg-white dark:bg-gray-800 group-hover:bg-gray-50 dark:group-hover:bg-gray-700/50';
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
class={`${rowClass()} grid items-center`}
|
||||||
|
style={{ ...rowStyle(), 'grid-template-columns': gridTemplate() }}
|
||||||
|
onClick={() => props.onNodeClick(nodeId, item.type)}
|
||||||
>
|
>
|
||||||
Temp{' '}
|
<div
|
||||||
{sortKey() === 'temperature' && (sortDirection() === 'asc' ? '▲' : '▼')}
|
class={`sticky left-0 z-10 ${cellBgClass()} pr-2 py-1 whitespace-nowrap border-r md:border-r-0 border-gray-100 dark:border-gray-700 flex items-center h-full ${showAlertHighlight() ? 'pl-4' : 'pl-3'}`}
|
||||||
</th>
|
|
||||||
</Show>
|
|
||||||
<For each={countColumns()}>
|
|
||||||
{(column) => (
|
|
||||||
<th
|
|
||||||
class="hidden lg:table-cell px-2 py-1.5 text-center text-[11px] sm:text-xs font-medium uppercase tracking-wider w-[8%] cursor-pointer hover:bg-gray-200 dark:hover:bg-gray-600 whitespace-nowrap"
|
|
||||||
onClick={() => handleSort(column.key)}
|
|
||||||
>
|
>
|
||||||
{column.header}{' '}
|
<div class="flex items-center gap-1.5">
|
||||||
{sortKey() === column.key && (sortDirection() === 'asc' ? '▲' : '▼')}
|
<StatusDot
|
||||||
</th>
|
variant={statusIndicator().variant}
|
||||||
)}
|
title={statusIndicator().label}
|
||||||
</For>
|
ariaLabel={statusIndicator().label}
|
||||||
</tr>
|
size="xs"
|
||||||
</thead>
|
/>
|
||||||
<tbody class="divide-y divide-gray-200 dark:divide-gray-700">
|
<a
|
||||||
<For each={sortedItems()}>
|
href={
|
||||||
{(item) => {
|
isPVE
|
||||||
const isPVE = item.type === 'pve';
|
? node!.guestURL || node!.host || `https://${node!.name}:8006`
|
||||||
const isPBS = item.type === 'pbs';
|
: pbs!.host || `https://${pbs!.name}:8007`
|
||||||
const node = isPVE ? (item.data as Node) : null;
|
}
|
||||||
const pbs = isPBS ? (item.data as PBSInstance) : null;
|
target="_blank"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
const online = isItemOnline(item);
|
class="font-medium text-[11px] text-gray-900 dark:text-gray-100 hover:text-blue-600 dark:hover:text-blue-400 truncate"
|
||||||
const statusIndicator = createMemo(() =>
|
title={displayName()}
|
||||||
isPVE ? getNodeStatusIndicator(node as Node) : getPBSStatusIndicator(pbs as PBSInstance),
|
>
|
||||||
);
|
{displayName()}
|
||||||
const cpuPercentValue = getCpuPercent(item);
|
</a>
|
||||||
const memoryPercentValue = getMemoryPercent(item);
|
<Show when={showActualName()}>
|
||||||
const diskPercentValue = getDiskPercent(item);
|
<span class="text-[9px] text-gray-500 dark:text-gray-400 truncate">
|
||||||
const diskSublabel = getDiskSublabel(item);
|
({(node as Node).name})
|
||||||
const cpuTemperatureValue = getCpuTemperatureValue(item);
|
</span>
|
||||||
const uptimeValue = isPVE ? node?.uptime ?? 0 : isPBS ? pbs?.uptime ?? 0 : 0;
|
</Show>
|
||||||
const displayName = () => {
|
<div class="hidden xl:flex items-center gap-1.5 ml-1">
|
||||||
if (isPVE) return getNodeDisplayName(node as Node);
|
|
||||||
return (pbs as PBSInstance).name;
|
|
||||||
};
|
|
||||||
const showActualName = () => isPVE && hasAlternateDisplayName(node as Node);
|
|
||||||
|
|
||||||
// Use unique node ID (not hostname) to handle duplicate node names
|
|
||||||
const nodeId = isPVE ? node!.id : pbs!.name;
|
|
||||||
const isSelected = () => props.selectedNode === nodeId;
|
|
||||||
// Use the full resource ID for alert matching
|
|
||||||
const resourceId = isPVE ? node!.id || node!.name : pbs!.id || pbs!.name;
|
|
||||||
// Use namespaced metric key for sparklines
|
|
||||||
const metricsKey = buildMetricKey('node', resourceId);
|
|
||||||
const alertStyles = createMemo(() =>
|
|
||||||
getAlertStyles(resourceId, activeAlerts, alertsEnabled()),
|
|
||||||
);
|
|
||||||
const showAlertHighlight = createMemo(
|
|
||||||
() => alertStyles().hasUnacknowledgedAlert && online,
|
|
||||||
);
|
|
||||||
|
|
||||||
const rowStyle = createMemo(() => {
|
|
||||||
const styles: Record<string, string> = {};
|
|
||||||
const shadows: string[] = [];
|
|
||||||
|
|
||||||
if (showAlertHighlight()) {
|
|
||||||
const color = alertStyles().severity === 'critical' ? '#ef4444' : '#eab308';
|
|
||||||
shadows.push(`inset 4px 0 0 0 ${color}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isSelected()) {
|
|
||||||
shadows.push('0 0 0 1px rgba(59, 130, 246, 0.5)');
|
|
||||||
shadows.push('0 2px 4px -1px rgba(0, 0, 0, 0.1)');
|
|
||||||
}
|
|
||||||
|
|
||||||
if (shadows.length > 0) {
|
|
||||||
styles['box-shadow'] = shadows.join(', ');
|
|
||||||
}
|
|
||||||
|
|
||||||
return styles;
|
|
||||||
});
|
|
||||||
|
|
||||||
const rowClass = createMemo(() => {
|
|
||||||
const baseHover = 'cursor-pointer transition-all duration-200 relative hover:bg-gray-50 dark:hover:bg-gray-700/50 hover:shadow-sm';
|
|
||||||
|
|
||||||
if (isSelected()) {
|
|
||||||
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`;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (showAlertHighlight()) {
|
|
||||||
return alertStyles().severity === 'critical'
|
|
||||||
? 'cursor-pointer transition-all duration-200 relative bg-red-50 dark:bg-red-950/30 hover:bg-red-100 dark:hover:bg-red-950/40 hover:shadow-sm'
|
|
||||||
: 'cursor-pointer transition-all duration-200 relative bg-yellow-50 dark:bg-yellow-950/20 hover:bg-yellow-100 dark:hover:bg-yellow-950/30 hover:shadow-sm';
|
|
||||||
}
|
|
||||||
|
|
||||||
let className = baseHover;
|
|
||||||
|
|
||||||
if (props.selectedNode && props.selectedNode !== nodeId) {
|
|
||||||
className += ' opacity-50 hover:opacity-80';
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!online) {
|
|
||||||
className += ' opacity-60';
|
|
||||||
}
|
|
||||||
|
|
||||||
return className;
|
|
||||||
});
|
|
||||||
|
|
||||||
return (
|
|
||||||
<tr
|
|
||||||
class={rowClass()}
|
|
||||||
style={rowStyle()}
|
|
||||||
onClick={() => props.onNodeClick(nodeId, item.type)}
|
|
||||||
>
|
|
||||||
<td
|
|
||||||
class={`pr-2 py-0.5 whitespace-nowrap ${showAlertHighlight() ? 'pl-4' : 'pl-3'}`}
|
|
||||||
>
|
|
||||||
<div class="flex items-center gap-1.5">
|
|
||||||
<StatusDot
|
|
||||||
variant={statusIndicator().variant}
|
|
||||||
title={statusIndicator().label}
|
|
||||||
ariaLabel={statusIndicator().label}
|
|
||||||
size="xs"
|
|
||||||
/>
|
|
||||||
<a
|
|
||||||
href={
|
|
||||||
isPVE
|
|
||||||
? node!.guestURL || node!.host || `https://${node!.name}:8006`
|
|
||||||
: pbs!.host || `https://${pbs!.name}:8007`
|
|
||||||
}
|
|
||||||
target="_blank"
|
|
||||||
onClick={(e) => e.stopPropagation()}
|
|
||||||
class="font-medium text-[11px] text-gray-900 dark:text-gray-100 hover:text-blue-600 dark:hover:text-blue-400"
|
|
||||||
>
|
|
||||||
{displayName()}
|
|
||||||
</a>
|
|
||||||
<Show when={showActualName()}>
|
|
||||||
<span class="text-[9px] text-gray-500 dark:text-gray-400">
|
|
||||||
({(node as Node).name})
|
|
||||||
</span>
|
|
||||||
</Show>
|
|
||||||
<Show when={isPVE}>
|
<Show when={isPVE}>
|
||||||
<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
|
||||||
|
|
@ -549,8 +615,8 @@ export const NodeSummaryTable: Component<NodeSummaryTableProps> = (props) => {
|
||||||
<Show when={isPVE && node!.isClusterMember !== undefined}>
|
<Show when={isPVE && 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'
|
||||||
: 'bg-gray-100 text-gray-600 dark:bg-gray-700/50 dark:text-gray-400'
|
: 'bg-gray-100 text-gray-600 dark:bg-gray-700/50 dark:text-gray-400'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{node!.isClusterMember ? node!.clusterName : 'Standalone'}
|
{node!.isClusterMember ? node!.clusterName : 'Standalone'}
|
||||||
|
|
@ -567,27 +633,33 @@ export const NodeSummaryTable: Component<NodeSummaryTableProps> = (props) => {
|
||||||
</span>
|
</span>
|
||||||
</Show>
|
</Show>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</div>
|
||||||
<td class="hidden sm:table-cell px-2 py-0.5 whitespace-nowrap">
|
</div>
|
||||||
<span
|
<div class={`${cellBgClass()} px-0.5 md:px-2 py-1 whitespace-nowrap text-center md:text-left flex items-center justify-center md:justify-start h-full`}>
|
||||||
class={`text-xs ${isPVE && (node?.uptime ?? 0) < 3600
|
<span
|
||||||
? 'text-orange-500'
|
class={`text-xs ${isPVE && (node?.uptime ?? 0) < 3600
|
||||||
: 'text-gray-600 dark:text-gray-400'
|
? 'text-orange-500'
|
||||||
}`}
|
: 'text-gray-600 dark:text-gray-400'
|
||||||
>
|
}`}
|
||||||
<Show
|
>
|
||||||
when={online && uptimeValue}
|
|
||||||
fallback="-"
|
|
||||||
>
|
|
||||||
{formatUptime(uptimeValue)}
|
|
||||||
</Show>
|
|
||||||
</span>
|
|
||||||
</td>
|
|
||||||
<td class="px-2 py-0.5">
|
|
||||||
<Show
|
<Show
|
||||||
when={online && cpuPercentValue !== null}
|
when={online && uptimeValue}
|
||||||
fallback={<span class="text-xs text-gray-500 dark:text-gray-400">-</span>}
|
fallback="-"
|
||||||
>
|
>
|
||||||
|
<span class="md:hidden">{formatUptime(uptimeValue, true)}</span>
|
||||||
|
<span class="hidden md:inline">{formatUptime(uptimeValue)}</span>
|
||||||
|
</Show>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class={`${cellBgClass()} px-0.5 md:px-2 py-1 flex items-center justify-center h-full`}>
|
||||||
|
<Show
|
||||||
|
when={online && cpuPercentValue !== null}
|
||||||
|
fallback={<span class="text-xs text-gray-500 dark:text-gray-400">-</span>}
|
||||||
|
>
|
||||||
|
<div class={`md:hidden text-xs text-center ${cpuPercentValue! >= 90 ? 'text-red-600 dark:text-red-400 font-bold' : cpuPercentValue! >= 80 ? 'text-orange-600 dark:text-orange-400 font-medium' : 'text-gray-600 dark:text-gray-400'}`}>
|
||||||
|
{formatPercent(cpuPercentValue ?? 0)}
|
||||||
|
</div>
|
||||||
|
<div class="hidden md:block w-full">
|
||||||
<MetricBar
|
<MetricBar
|
||||||
value={cpuPercentValue ?? 0}
|
value={cpuPercentValue ?? 0}
|
||||||
label={formatPercent(cpuPercentValue ?? 0)}
|
label={formatPercent(cpuPercentValue ?? 0)}
|
||||||
|
|
@ -599,13 +671,18 @@ export const NodeSummaryTable: Component<NodeSummaryTableProps> = (props) => {
|
||||||
type="cpu"
|
type="cpu"
|
||||||
resourceId={metricsKey}
|
resourceId={metricsKey}
|
||||||
/>
|
/>
|
||||||
</Show>
|
</div>
|
||||||
</td>
|
</Show>
|
||||||
<td class="px-2 py-0.5">
|
</div>
|
||||||
<Show
|
<div class={`${cellBgClass()} px-0.5 md:px-2 py-1 flex items-center justify-center h-full`}>
|
||||||
when={online && memoryPercentValue !== null}
|
<Show
|
||||||
fallback={<span class="text-xs text-gray-500 dark:text-gray-400">-</span>}
|
when={online && memoryPercentValue !== null}
|
||||||
>
|
fallback={<span class="text-xs text-gray-500 dark:text-gray-400">-</span>}
|
||||||
|
>
|
||||||
|
<div class={`md:hidden text-xs text-center ${memoryPercentValue! >= 85 ? 'text-red-600 dark:text-red-400 font-bold' : memoryPercentValue! >= 75 ? 'text-orange-600 dark:text-orange-400 font-medium' : 'text-gray-600 dark:text-gray-400'}`}>
|
||||||
|
{formatPercent(memoryPercentValue ?? 0)}
|
||||||
|
</div>
|
||||||
|
<div class="hidden md:block w-full">
|
||||||
<MetricBar
|
<MetricBar
|
||||||
value={memoryPercentValue ?? 0}
|
value={memoryPercentValue ?? 0}
|
||||||
label={formatPercent(memoryPercentValue ?? 0)}
|
label={formatPercent(memoryPercentValue ?? 0)}
|
||||||
|
|
@ -619,13 +696,18 @@ export const NodeSummaryTable: Component<NodeSummaryTableProps> = (props) => {
|
||||||
type="memory"
|
type="memory"
|
||||||
resourceId={metricsKey}
|
resourceId={metricsKey}
|
||||||
/>
|
/>
|
||||||
</Show>
|
</div>
|
||||||
</td>
|
</Show>
|
||||||
<td class="hidden md:table-cell px-2 py-0.5">
|
</div>
|
||||||
<Show
|
<div class={`${cellBgClass()} px-0.5 md:px-2 py-1 flex items-center justify-center h-full`}>
|
||||||
when={online && diskPercentValue !== null}
|
<Show
|
||||||
fallback={<span class="text-xs text-gray-500 dark:text-gray-400">-</span>}
|
when={online && diskPercentValue !== null}
|
||||||
>
|
fallback={<span class="text-xs text-gray-500 dark:text-gray-400">-</span>}
|
||||||
|
>
|
||||||
|
<div class={`md:hidden text-xs text-center ${diskPercentValue! >= 90 ? 'text-red-600 dark:text-red-400 font-bold' : diskPercentValue! >= 80 ? 'text-orange-600 dark:text-orange-400 font-medium' : 'text-gray-600 dark:text-gray-400'}`}>
|
||||||
|
{formatPercent(diskPercentValue ?? 0)}
|
||||||
|
</div>
|
||||||
|
<div class="hidden md:block w-full">
|
||||||
<MetricBar
|
<MetricBar
|
||||||
value={diskPercentValue ?? 0}
|
value={diskPercentValue ?? 0}
|
||||||
label={formatPercent(diskPercentValue ?? 0)}
|
label={formatPercent(diskPercentValue ?? 0)}
|
||||||
|
|
@ -633,105 +715,105 @@ export const NodeSummaryTable: Component<NodeSummaryTableProps> = (props) => {
|
||||||
type="disk"
|
type="disk"
|
||||||
resourceId={metricsKey}
|
resourceId={metricsKey}
|
||||||
/>
|
/>
|
||||||
</Show>
|
</div>
|
||||||
</td>
|
|
||||||
<Show when={hasAnyTemperatureData()}>
|
|
||||||
<td class="hidden lg:table-cell px-2 py-0.5 whitespace-nowrap text-center">
|
|
||||||
<Show
|
|
||||||
when={
|
|
||||||
online &&
|
|
||||||
isPVE &&
|
|
||||||
cpuTemperatureValue !== null &&
|
|
||||||
(node!.temperature?.hasCPU ?? node!.temperature?.hasGPU ?? node!.temperature?.available) &&
|
|
||||||
isTemperatureMonitoringEnabled(node!)
|
|
||||||
}
|
|
||||||
fallback={
|
|
||||||
<span class="text-xs text-gray-400 dark:text-gray-500">-</span>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{(() => {
|
|
||||||
const value = cpuTemperatureValue as number;
|
|
||||||
const severityClass =
|
|
||||||
value >= 80
|
|
||||||
? 'text-red-600 dark:text-red-400'
|
|
||||||
: value >= 70
|
|
||||||
? 'text-yellow-600 dark:text-yellow-400'
|
|
||||||
: 'text-green-600 dark:text-green-400';
|
|
||||||
|
|
||||||
const temp = node!.temperature;
|
|
||||||
const cpuMinValue =
|
|
||||||
typeof temp?.cpuMin === 'number' && temp.cpuMin > 0 ? temp.cpuMin : null;
|
|
||||||
const cpuMaxValue =
|
|
||||||
typeof temp?.cpuMaxRecord === 'number' && temp.cpuMaxRecord > 0
|
|
||||||
? temp.cpuMaxRecord
|
|
||||||
: null;
|
|
||||||
const hasMinMax = cpuMinValue !== null && cpuMaxValue !== null;
|
|
||||||
|
|
||||||
const gpus = temp?.gpu ?? [];
|
|
||||||
const hasGPU = gpus.length > 0;
|
|
||||||
|
|
||||||
if (hasMinMax || hasGPU) {
|
|
||||||
const min = Math.round(cpuMinValue!);
|
|
||||||
const max = Math.round(cpuMaxValue!);
|
|
||||||
|
|
||||||
const getTooltipColor = (temp: number) => {
|
|
||||||
if (temp >= 80) return 'text-red-400';
|
|
||||||
if (temp >= 70) return 'text-yellow-400';
|
|
||||||
return 'text-green-400';
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<span class="relative inline-block group">
|
|
||||||
<span class={`text-xs font-medium ${severityClass} cursor-help`}>
|
|
||||||
{value}°C
|
|
||||||
</span>
|
|
||||||
<span class="invisible group-hover:visible absolute bottom-full left-1/2 -translate-x-1/2 mb-1 px-2 py-1 text-xs whitespace-nowrap bg-gray-900 dark:bg-gray-700 text-white rounded shadow-lg z-50 pointer-events-none">
|
|
||||||
{hasMinMax && (
|
|
||||||
<div>
|
|
||||||
<span class="text-gray-300">CPU:</span> <span class={getTooltipColor(min)}>{min}</span>-<span class={getTooltipColor(max)}>{max}</span>°C
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{hasGPU && gpus.map((gpu) => {
|
|
||||||
const gpuTemp = gpu.edge ?? gpu.junction ?? gpu.mem ?? 0;
|
|
||||||
return (
|
|
||||||
<div>
|
|
||||||
<span class="text-gray-300">GPU:</span> <span class={getTooltipColor(gpuTemp)}>{Math.round(gpuTemp)}</span>°C
|
|
||||||
{gpu.edge && ` E:${Math.round(gpu.edge)}`}
|
|
||||||
{gpu.junction && ` J:${Math.round(gpu.junction)}`}
|
|
||||||
{gpu.mem && ` M:${Math.round(gpu.mem)}`}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</span>
|
|
||||||
</span>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return <span class={`text-xs font-medium ${severityClass}`}>{value}°C</span>;
|
|
||||||
})()}
|
|
||||||
</Show>
|
|
||||||
</td>
|
|
||||||
</Show>
|
</Show>
|
||||||
<For each={countColumns()}>
|
</div>
|
||||||
{(column) => {
|
<Show when={hasAnyTemperatureData()}>
|
||||||
const value = getCountValue(item, column.key);
|
<div class={`${cellBgClass()} px-0.5 md:px-2 py-1 whitespace-nowrap text-center md:text-left flex items-center justify-center md:justify-start h-full`}>
|
||||||
const display = online ? value ?? '-' : '-';
|
<Show
|
||||||
const textClass = online
|
when={
|
||||||
? 'text-xs text-gray-700 dark:text-gray-300'
|
online &&
|
||||||
: 'text-xs text-gray-400 dark:text-gray-500';
|
isPVE &&
|
||||||
return (
|
cpuTemperatureValue !== null &&
|
||||||
<td class="hidden lg:table-cell px-2 py-0.5 whitespace-nowrap text-center">
|
(node!.temperature?.hasCPU ?? node!.temperature?.hasGPU ?? node!.temperature?.available) &&
|
||||||
<span class={textClass}>{display}</span>
|
isTemperatureMonitoringEnabled(node!)
|
||||||
</td>
|
}
|
||||||
);
|
fallback={
|
||||||
}}
|
<span class="text-xs text-gray-400 dark:text-gray-500">-</span>
|
||||||
</For>
|
}
|
||||||
</tr>
|
>
|
||||||
);
|
{(() => {
|
||||||
}}
|
const value = cpuTemperatureValue as number;
|
||||||
</For>
|
const severityClass =
|
||||||
</tbody>
|
value >= 80
|
||||||
</table>
|
? 'text-red-600 dark:text-red-400'
|
||||||
|
: value >= 70
|
||||||
|
? 'text-yellow-600 dark:text-yellow-400'
|
||||||
|
: 'text-green-600 dark:text-green-400';
|
||||||
|
|
||||||
|
const temp = node!.temperature;
|
||||||
|
const cpuMinValue =
|
||||||
|
typeof temp?.cpuMin === 'number' && temp.cpuMin > 0 ? temp.cpuMin : null;
|
||||||
|
const cpuMaxValue =
|
||||||
|
typeof temp?.cpuMaxRecord === 'number' && temp.cpuMaxRecord > 0
|
||||||
|
? temp.cpuMaxRecord
|
||||||
|
: null;
|
||||||
|
const hasMinMax = cpuMinValue !== null && cpuMaxValue !== null;
|
||||||
|
|
||||||
|
const gpus = temp?.gpu ?? [];
|
||||||
|
const hasGPU = gpus.length > 0;
|
||||||
|
|
||||||
|
if (hasMinMax || hasGPU) {
|
||||||
|
const min = Math.round(cpuMinValue!);
|
||||||
|
const max = Math.round(cpuMaxValue!);
|
||||||
|
|
||||||
|
const getTooltipColor = (temp: number) => {
|
||||||
|
if (temp >= 80) return 'text-red-400';
|
||||||
|
if (temp >= 70) return 'text-yellow-400';
|
||||||
|
return 'text-green-400';
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<span class="relative inline-block group">
|
||||||
|
<span class={`text-xs font-medium ${severityClass} cursor-help`}>
|
||||||
|
{value}°C
|
||||||
|
</span>
|
||||||
|
<span class="invisible group-hover:visible absolute bottom-full left-1/2 -translate-x-1/2 mb-1 px-2 py-1 text-xs whitespace-nowrap bg-gray-900 dark:bg-gray-700 text-white rounded shadow-lg z-50 pointer-events-none">
|
||||||
|
{hasMinMax && (
|
||||||
|
<div>
|
||||||
|
<span class="text-gray-300">CPU:</span> <span class={getTooltipColor(min)}>{min}</span>-<span class={getTooltipColor(max)}>{max}</span>°C
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{hasGPU && gpus.map((gpu) => {
|
||||||
|
const gpuTemp = gpu.edge ?? gpu.junction ?? gpu.mem ?? 0;
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<span class="text-gray-300">GPU:</span> <span class={getTooltipColor(gpuTemp)}>{Math.round(gpuTemp)}</span>°C
|
||||||
|
{gpu.edge && ` E:${Math.round(gpu.edge)}`}
|
||||||
|
{gpu.junction && ` J:${Math.round(gpu.junction)}`}
|
||||||
|
{gpu.mem && ` M:${Math.round(gpu.mem)}`}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return <span class={`text-xs font-medium ${severityClass}`}>{value}°C</span>;
|
||||||
|
})()}
|
||||||
|
</Show>
|
||||||
|
</div>
|
||||||
|
</Show>
|
||||||
|
<For each={countColumns()}>
|
||||||
|
{(column) => {
|
||||||
|
const value = getCountValue(item, column.key);
|
||||||
|
const display = online ? value ?? '-' : '-';
|
||||||
|
const textClass = online
|
||||||
|
? 'text-xs text-gray-700 dark:text-gray-300'
|
||||||
|
: 'text-xs text-gray-400 dark:text-gray-500';
|
||||||
|
return (
|
||||||
|
<div class={`${cellBgClass()} px-0.5 md:px-2 py-1 whitespace-nowrap text-center flex items-center justify-center h-full`}>
|
||||||
|
<span class={textClass}>{display}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
</For>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
</For>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
</>
|
</>
|
||||||
|
|
|
||||||
|
|
@ -166,6 +166,7 @@ export interface DockerHost {
|
||||||
hidden?: boolean;
|
hidden?: boolean;
|
||||||
pendingUninstall?: boolean;
|
pendingUninstall?: boolean;
|
||||||
command?: DockerHostCommand;
|
command?: DockerHostCommand;
|
||||||
|
isLegacy?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface DockerHostCommand {
|
export interface DockerHostCommand {
|
||||||
|
|
@ -343,6 +344,7 @@ export interface Host {
|
||||||
revokedTokenId?: string;
|
revokedTokenId?: string;
|
||||||
tokenRevokedAt?: number;
|
tokenRevokedAt?: number;
|
||||||
tags?: string[];
|
tags?: string[];
|
||||||
|
isLegacy?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface HostNetworkInterface {
|
export interface HostNetworkInterface {
|
||||||
|
|
@ -914,60 +916,60 @@ export type WSMessage =
|
||||||
| { type: 'alertResolved'; data: { alertId: string } }
|
| { type: 'alertResolved'; data: { alertId: string } }
|
||||||
| { type: 'settingsUpdate'; data: { theme?: string } }
|
| { type: 'settingsUpdate'; data: { theme?: string } }
|
||||||
| {
|
| {
|
||||||
type: 'update:progress';
|
type: 'update:progress';
|
||||||
data: {
|
data: {
|
||||||
phase: string;
|
phase: string;
|
||||||
progress: number;
|
progress: number;
|
||||||
message: string;
|
message: string;
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
| {
|
| {
|
||||||
type: 'node_auto_registered';
|
type: 'node_auto_registered';
|
||||||
data: {
|
data: {
|
||||||
type: string;
|
type: string;
|
||||||
host: string;
|
host: string;
|
||||||
name: string;
|
name: string;
|
||||||
tokenId: string;
|
tokenId: string;
|
||||||
hasToken: boolean;
|
hasToken: boolean;
|
||||||
verifySSL?: boolean;
|
verifySSL?: boolean;
|
||||||
status?: string;
|
status?: string;
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
| { type: 'node_deleted'; data: { nodeType: string } }
|
| { type: 'node_deleted'; data: { nodeType: string } }
|
||||||
| { type: 'nodes_changed'; data?: unknown }
|
| { type: 'nodes_changed'; data?: unknown }
|
||||||
| {
|
| {
|
||||||
type: 'discovery_update';
|
type: 'discovery_update';
|
||||||
data: {
|
data: {
|
||||||
servers: Array<{
|
servers: Array<{
|
||||||
ip: string;
|
ip: string;
|
||||||
port: number;
|
port: number;
|
||||||
type: string;
|
type: string;
|
||||||
version: string;
|
version: string;
|
||||||
hostname?: string;
|
hostname?: string;
|
||||||
release?: string;
|
release?: string;
|
||||||
}>;
|
}>;
|
||||||
errors?: string[];
|
errors?: string[];
|
||||||
timestamp?: number;
|
timestamp?: number;
|
||||||
immediate?: boolean;
|
immediate?: boolean;
|
||||||
scanning?: boolean;
|
scanning?: boolean;
|
||||||
cached?: boolean;
|
cached?: boolean;
|
||||||
};
|
|
||||||
}
|
|
||||||
| {
|
|
||||||
type: 'discovery_started';
|
|
||||||
data?: {
|
|
||||||
subnet?: string;
|
|
||||||
timestamp?: number;
|
|
||||||
scanning?: boolean;
|
|
||||||
};
|
|
||||||
}
|
|
||||||
| {
|
|
||||||
type: 'discovery_complete';
|
|
||||||
data?: {
|
|
||||||
timestamp?: number;
|
|
||||||
scanning?: boolean;
|
|
||||||
};
|
|
||||||
};
|
};
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
type: 'discovery_started';
|
||||||
|
data?: {
|
||||||
|
subnet?: string;
|
||||||
|
timestamp?: number;
|
||||||
|
scanning?: boolean;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
type: 'discovery_complete';
|
||||||
|
data?: {
|
||||||
|
timestamp?: number;
|
||||||
|
scanning?: boolean;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
// Utility types
|
// Utility types
|
||||||
export type Status = 'running' | 'stopped' | 'paused' | 'unknown';
|
export type Status = 'running' | 'stopped' | 'paused' | 'unknown';
|
||||||
|
|
|
||||||
|
|
@ -25,7 +25,7 @@ export function formatPercent(value: number): string {
|
||||||
return `${Math.round(value)}%`;
|
return `${Math.round(value)}%`;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function formatUptime(seconds: number): string {
|
export function formatUptime(seconds: number, condensed = false): string {
|
||||||
if (!seconds || seconds < 0) return '0s';
|
if (!seconds || seconds < 0) return '0s';
|
||||||
|
|
||||||
const days = Math.floor(seconds / 86400);
|
const days = Math.floor(seconds / 86400);
|
||||||
|
|
@ -33,9 +33,9 @@ export function formatUptime(seconds: number): string {
|
||||||
const minutes = Math.floor((seconds % 3600) / 60);
|
const minutes = Math.floor((seconds % 3600) / 60);
|
||||||
|
|
||||||
if (days > 0) {
|
if (days > 0) {
|
||||||
return `${days}d ${hours}h`;
|
return condensed ? `${days}d` : `${days}d ${hours}h`;
|
||||||
} else if (hours > 0) {
|
} else if (hours > 0) {
|
||||||
return `${hours}h ${minutes}m`;
|
return condensed ? `${hours}h` : `${hours}h ${minutes}m`;
|
||||||
} else {
|
} else {
|
||||||
return `${minutes}m`;
|
return `${minutes}m`;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -9,26 +9,26 @@ import (
|
||||||
|
|
||||||
// State represents the current state of all monitored resources
|
// State represents the current state of all monitored resources
|
||||||
type State struct {
|
type State struct {
|
||||||
mu sync.RWMutex
|
mu sync.RWMutex
|
||||||
Nodes []Node `json:"nodes"`
|
Nodes []Node `json:"nodes"`
|
||||||
VMs []VM `json:"vms"`
|
VMs []VM `json:"vms"`
|
||||||
Containers []Container `json:"containers"`
|
Containers []Container `json:"containers"`
|
||||||
DockerHosts []DockerHost `json:"dockerHosts"`
|
DockerHosts []DockerHost `json:"dockerHosts"`
|
||||||
RemovedDockerHosts []RemovedDockerHost `json:"removedDockerHosts"`
|
RemovedDockerHosts []RemovedDockerHost `json:"removedDockerHosts"`
|
||||||
Hosts []Host `json:"hosts"`
|
Hosts []Host `json:"hosts"`
|
||||||
Storage []Storage `json:"storage"`
|
Storage []Storage `json:"storage"`
|
||||||
CephClusters []CephCluster `json:"cephClusters"`
|
CephClusters []CephCluster `json:"cephClusters"`
|
||||||
PhysicalDisks []PhysicalDisk `json:"physicalDisks"`
|
PhysicalDisks []PhysicalDisk `json:"physicalDisks"`
|
||||||
PBSInstances []PBSInstance `json:"pbs"`
|
PBSInstances []PBSInstance `json:"pbs"`
|
||||||
PMGInstances []PMGInstance `json:"pmg"`
|
PMGInstances []PMGInstance `json:"pmg"`
|
||||||
PBSBackups []PBSBackup `json:"pbsBackups"`
|
PBSBackups []PBSBackup `json:"pbsBackups"`
|
||||||
PMGBackups []PMGBackup `json:"pmgBackups"`
|
PMGBackups []PMGBackup `json:"pmgBackups"`
|
||||||
Backups Backups `json:"backups"`
|
Backups Backups `json:"backups"`
|
||||||
ReplicationJobs []ReplicationJob `json:"replicationJobs"`
|
ReplicationJobs []ReplicationJob `json:"replicationJobs"`
|
||||||
Metrics []Metric `json:"metrics"`
|
Metrics []Metric `json:"metrics"`
|
||||||
PVEBackups PVEBackups `json:"pveBackups"`
|
PVEBackups PVEBackups `json:"pveBackups"`
|
||||||
Performance Performance `json:"performance"`
|
Performance Performance `json:"performance"`
|
||||||
ConnectionHealth map[string]bool `json:"connectionHealth"`
|
ConnectionHealth map[string]bool `json:"connectionHealth"`
|
||||||
Stats Stats `json:"stats"`
|
Stats Stats `json:"stats"`
|
||||||
ActiveAlerts []Alert `json:"activeAlerts"`
|
ActiveAlerts []Alert `json:"activeAlerts"`
|
||||||
RecentlyResolved []ResolvedAlert `json:"recentlyResolved"`
|
RecentlyResolved []ResolvedAlert `json:"recentlyResolved"`
|
||||||
|
|
@ -62,28 +62,28 @@ type ResolvedAlert struct {
|
||||||
|
|
||||||
// Node represents a Proxmox VE node
|
// Node represents a Proxmox VE node
|
||||||
type Node struct {
|
type Node struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
DisplayName string `json:"displayName,omitempty"`
|
DisplayName string `json:"displayName,omitempty"`
|
||||||
Instance string `json:"instance"`
|
Instance string `json:"instance"`
|
||||||
Host string `json:"host"` // Full host URL from config
|
Host string `json:"host"` // Full host URL from config
|
||||||
GuestURL string `json:"guestURL"` // Optional guest-accessible URL (for navigation)
|
GuestURL string `json:"guestURL"` // Optional guest-accessible URL (for navigation)
|
||||||
Status string `json:"status"`
|
Status string `json:"status"`
|
||||||
Type string `json:"type"`
|
Type string `json:"type"`
|
||||||
CPU float64 `json:"cpu"`
|
CPU float64 `json:"cpu"`
|
||||||
Memory Memory `json:"memory"`
|
Memory Memory `json:"memory"`
|
||||||
Disk Disk `json:"disk"`
|
Disk Disk `json:"disk"`
|
||||||
Uptime int64 `json:"uptime"`
|
Uptime int64 `json:"uptime"`
|
||||||
LoadAverage []float64 `json:"loadAverage"`
|
LoadAverage []float64 `json:"loadAverage"`
|
||||||
KernelVersion string `json:"kernelVersion"`
|
KernelVersion string `json:"kernelVersion"`
|
||||||
PVEVersion string `json:"pveVersion"`
|
PVEVersion string `json:"pveVersion"`
|
||||||
CPUInfo CPUInfo `json:"cpuInfo"`
|
CPUInfo CPUInfo `json:"cpuInfo"`
|
||||||
Temperature *Temperature `json:"temperature,omitempty"` // CPU/NVMe temperatures
|
Temperature *Temperature `json:"temperature,omitempty"` // CPU/NVMe temperatures
|
||||||
TemperatureMonitoringEnabled *bool `json:"temperatureMonitoringEnabled,omitempty"` // Per-node temperature monitoring override
|
TemperatureMonitoringEnabled *bool `json:"temperatureMonitoringEnabled,omitempty"` // Per-node temperature monitoring override
|
||||||
LastSeen time.Time `json:"lastSeen"`
|
LastSeen time.Time `json:"lastSeen"`
|
||||||
ConnectionHealth string `json:"connectionHealth"`
|
ConnectionHealth string `json:"connectionHealth"`
|
||||||
IsClusterMember bool `json:"isClusterMember"` // True if part of a cluster
|
IsClusterMember bool `json:"isClusterMember"` // True if part of a cluster
|
||||||
ClusterName string `json:"clusterName"` // Name of cluster (empty if standalone)
|
ClusterName string `json:"clusterName"` // Name of cluster (empty if standalone)
|
||||||
}
|
}
|
||||||
|
|
||||||
// VM represents a virtual machine
|
// VM represents a virtual machine
|
||||||
|
|
@ -174,6 +174,7 @@ type Host struct {
|
||||||
TokenHint string `json:"tokenHint,omitempty"`
|
TokenHint string `json:"tokenHint,omitempty"`
|
||||||
TokenLastUsedAt *time.Time `json:"tokenLastUsedAt,omitempty"`
|
TokenLastUsedAt *time.Time `json:"tokenLastUsedAt,omitempty"`
|
||||||
Tags []string `json:"tags,omitempty"`
|
Tags []string `json:"tags,omitempty"`
|
||||||
|
IsLegacy bool `json:"isLegacy,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// HostNetworkInterface describes a host network adapter summary.
|
// HostNetworkInterface describes a host network adapter summary.
|
||||||
|
|
@ -254,6 +255,7 @@ type DockerHost struct {
|
||||||
Hidden bool `json:"hidden"`
|
Hidden bool `json:"hidden"`
|
||||||
PendingUninstall bool `json:"pendingUninstall"`
|
PendingUninstall bool `json:"pendingUninstall"`
|
||||||
Command *DockerHostCommandStatus `json:"command,omitempty"`
|
Command *DockerHostCommandStatus `json:"command,omitempty"`
|
||||||
|
IsLegacy bool `json:"isLegacy,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// RemovedDockerHost tracks a docker host that was deliberately removed and blocked from reporting.
|
// RemovedDockerHost tracks a docker host that was deliberately removed and blocked from reporting.
|
||||||
|
|
|
||||||
|
|
@ -1877,6 +1877,7 @@ func (m *Monitor) ApplyDockerReport(report agentsdocker.Report, tokenRecord *con
|
||||||
Services: services,
|
Services: services,
|
||||||
Tasks: tasks,
|
Tasks: tasks,
|
||||||
Swarm: swarmInfo,
|
Swarm: swarmInfo,
|
||||||
|
IsLegacy: isLegacyDockerAgent(report.Agent.Version),
|
||||||
}
|
}
|
||||||
|
|
||||||
if tokenRecord != nil {
|
if tokenRecord != nil {
|
||||||
|
|
@ -2159,6 +2160,7 @@ func (m *Monitor) ApplyHostReport(report agentshost.Report, tokenRecord *config.
|
||||||
LastSeen: timestamp,
|
LastSeen: timestamp,
|
||||||
AgentVersion: strings.TrimSpace(report.Agent.Version),
|
AgentVersion: strings.TrimSpace(report.Agent.Version),
|
||||||
Tags: append([]string(nil), report.Tags...),
|
Tags: append([]string(nil), report.Tags...),
|
||||||
|
IsLegacy: isLegacyHostAgent(report.Agent.Version),
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(host.LoadAverage) == 0 {
|
if len(host.LoadAverage) == 0 {
|
||||||
|
|
@ -9560,3 +9562,20 @@ func (m *Monitor) checkMockAlerts() {
|
||||||
// mock state without needing to grab the alert manager lock again.
|
// mock state without needing to grab the alert manager lock again.
|
||||||
mock.UpdateAlertSnapshots(m.alertManager.GetActiveAlerts(), m.alertManager.GetRecentlyResolved())
|
mock.UpdateAlertSnapshots(m.alertManager.GetActiveAlerts(), m.alertManager.GetRecentlyResolved())
|
||||||
}
|
}
|
||||||
|
func isLegacyHostAgent(version string) bool {
|
||||||
|
// New unified agent is 1.0.0+
|
||||||
|
// Legacy agents are 0.x.x or empty
|
||||||
|
if version == "" {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return strings.HasPrefix(version, "0.")
|
||||||
|
}
|
||||||
|
|
||||||
|
func isLegacyDockerAgent(version string) bool {
|
||||||
|
// New unified agent is 1.0.0+
|
||||||
|
// Legacy agents are 0.x.x or empty
|
||||||
|
if version == "" {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return strings.HasPrefix(version, "0.")
|
||||||
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue