Add workloads page and unified workload utilities

This commit is contained in:
rcourtman
2026-02-05 13:41:22 +00:00
parent 0c21f16a29
commit 7b72ad58f9
8 changed files with 534 additions and 82 deletions

View File

@@ -946,6 +946,7 @@ function App() {
<Route path="/" component={() => <Navigate href="/proxmox/overview" />} />
<Route path="/proxmox" component={() => <Navigate href="/proxmox/overview" />} />
<Route path="/proxmox/overview" component={DashboardView} />
<Route path="/workloads" component={DashboardView} />
<Route path="/proxmox/storage" component={StorageComponent} />
<Route path="/proxmox/ceph" component={CephPage} />
<Route path="/proxmox/replication" component={Replication} />

View File

@@ -1,6 +1,8 @@
import { createSignal, createMemo, createEffect, For, Show, onMount } from 'solid-js';
import { useNavigate } from '@solidjs/router';
import type { VM, Container, Node } from '@/types/api';
import type { Resource } from '@/types/resource';
import type { WorkloadGuest, WorkloadType } from '@/types/workloads';
import { GuestRow, GUEST_COLUMNS, type GuestColumnDef } from './GuestRow';
import { GuestDrawer } from './GuestDrawer';
import { useWebSocket } from '@/App';
@@ -26,6 +28,8 @@ import { useBreakpoint } from '@/hooks/useBreakpoint';
import { STORAGE_KEYS } from '@/utils/localStorage';
import { aiChatStore } from '@/stores/aiChat';
import { isKioskMode, subscribeToKioskMode } from '@/utils/url';
import { apiFetchJSON } from '@/utils/apiClient';
import { getWorkloadMetricsKind, resolveWorkloadType } from '@/utils/workloads';
type GuestMetadataRecord = Record<string, GuestMetadata>;
type IdleCallbackHandle = number;
@@ -200,7 +204,7 @@ interface DashboardProps {
nodes: Node[];
}
type ViewMode = 'all' | 'vm' | 'lxc';
type ViewMode = 'all' | 'vm' | 'lxc' | 'docker' | 'k8s';
type StatusMode = 'all' | 'running' | 'degraded' | 'stopped';
type GroupingMode = 'grouped' | 'flat';
export function Dashboard(props: DashboardProps) {
@@ -267,12 +271,172 @@ export function Dashboard(props: DashboardProps) {
return next;
});
// Combine VMs and containers into a single list for filtering
const allGuests = createMemo<(VM | Container)[]>(() => [...props.vms, ...props.containers]);
const [workloadGuests, setWorkloadGuests] = createSignal<WorkloadGuest[]>([]);
const [workloadsLoaded, setWorkloadsLoaded] = createSignal(false);
const [workloadsLoading, setWorkloadsLoading] = createSignal(false);
const normalizeWorkloadStatus = (status?: string | null): string => {
const normalized = (status || '').trim().toLowerCase();
if (!normalized) return 'unknown';
if (normalized === 'online' || normalized === 'healthy') return 'running';
if (normalized === 'offline') return 'stopped';
return normalized;
};
const buildMetric = (metric?: Resource['memory']) => {
const total = metric?.total ?? 0;
const used = metric?.used ?? 0;
const free = metric?.free ?? (total > 0 ? Math.max(0, total - used) : 0);
const usage = metric?.current ?? (total > 0 ? (used / total) * 100 : 0);
return { total, used, free, usage };
};
const resolveWorkloadsPayload = (payload: unknown): Resource[] => {
if (Array.isArray(payload)) return payload as Resource[];
if (!payload || typeof payload !== 'object') return [];
const record = payload as Record<string, unknown>;
const candidates = ['resources', 'workloads', 'data'];
for (const key of candidates) {
const value = record[key];
if (Array.isArray(value)) return value as Resource[];
}
return [];
};
const mapResourceToWorkload = (resource: Resource): WorkloadGuest | null => {
const type = resource.type;
const workloadType: WorkloadType | null =
type === 'vm'
? 'vm'
: type === 'container' || type === 'oci-container'
? 'lxc'
: type === 'docker-container'
? 'docker'
: type === 'pod'
? 'k8s'
: null;
if (!workloadType) return null;
const platformData = (resource.platformData ?? {}) as Record<string, unknown>;
const name = (resource.displayName || resource.name || resource.id || '').toString().trim();
const node =
(platformData.node as string) ??
(platformData.nodeName as string) ??
(platformData.host as string) ??
(platformData.hostName as string) ??
'';
const instance =
(platformData.instance as string) ??
(platformData.clusterId as string) ??
resource.platformId ??
'';
const vmid =
typeof platformData.vmid === 'number'
? platformData.vmid
: parseInt(resource.id.split('-').pop() ?? '0', 10);
const rawDisplayId =
(platformData.shortId as string) ??
(platformData.uid as string) ??
resource.id;
const displayId =
workloadType === 'vm' || workloadType === 'lxc'
? vmid > 0
? String(vmid)
: undefined
: rawDisplayId
? rawDisplayId.length > 12
? rawDisplayId.slice(0, 12)
: rawDisplayId
: undefined;
const isOci =
type === 'oci-container' ||
platformData.isOci === true ||
platformData.type === 'oci';
const legacyType =
workloadType === 'vm'
? 'qemu'
: workloadType === 'docker'
? 'docker'
: workloadType === 'k8s'
? 'k8s'
: isOci
? 'oci'
: 'lxc';
const ipAddresses =
(platformData.ipAddresses as string[] | undefined) ??
(resource.identity?.ips as string[] | undefined);
return {
id: resource.id,
vmid: Number.isFinite(vmid) ? vmid : 0,
name: name || resource.id,
node,
instance,
status: normalizeWorkloadStatus(resource.status),
type: legacyType,
cpu: (resource.cpu?.current ?? 0) / 100,
cpus: (platformData.cpus as number) ?? (platformData.cpuCount as number) ?? 1,
memory: buildMetric(resource.memory),
disk: buildMetric(resource.disk),
disks: platformData.disks as any[] | undefined,
diskStatusReason: platformData.diskStatusReason as string | undefined,
ipAddresses,
osName: platformData.osName as string | undefined,
osVersion: platformData.osVersion as string | undefined,
agentVersion: platformData.agentVersion as string | undefined,
networkInterfaces: platformData.networkInterfaces as any[] | undefined,
networkIn: resource.network?.rxBytes ?? 0,
networkOut: resource.network?.txBytes ?? 0,
diskRead: (platformData.diskRead as number) ?? 0,
diskWrite: (platformData.diskWrite as number) ?? 0,
uptime: resource.uptime ?? 0,
template: (platformData.template as boolean) ?? false,
lastBackup: (platformData.lastBackup as number) ?? 0,
tags: resource.tags ?? [],
lock: (platformData.lock as string) ?? '',
lastSeen: new Date(resource.lastSeen).toISOString(),
isOci,
osTemplate: platformData.osTemplate as string | undefined,
workloadType,
displayId,
image:
workloadType === 'docker'
? ((platformData.image as string) ??
(platformData.imageName as string) ??
(platformData.imageRef as string))
: undefined,
namespace:
workloadType === 'k8s'
? ((platformData.namespace as string) ?? (platformData.ns as string))
: undefined,
contextLabel:
(platformData.clusterName as string) ??
(platformData.cluster as string) ??
(platformData.context as string) ??
(platformData.host as string) ??
undefined,
platformType: resource.platformType,
};
};
const legacyGuests = createMemo<WorkloadGuest[]>(() => [
...props.vms.map((vm) => ({ ...vm, workloadType: 'vm', displayId: String(vm.vmid) })),
...props.containers.map((ct) => ({ ...ct, workloadType: 'lxc', displayId: String(ct.vmid) })),
]);
// Combine workloads into a single list for filtering, preferring /api/v2/resources
const allGuests = createMemo<WorkloadGuest[]>(() =>
workloadsLoaded() ? workloadGuests() : legacyGuests(),
);
// Initialize from localStorage with proper type checking
const [viewMode, setViewMode] = usePersistentSignal<ViewMode>('dashboardViewMode', 'all', {
deserialize: (raw) => (raw === 'all' || raw === 'vm' || raw === 'lxc' ? raw : 'all'),
deserialize: (raw) =>
raw === 'all' || raw === 'vm' || raw === 'lxc' || raw === 'docker' || raw === 'k8s'
? raw
: 'all',
});
const [statusMode, setStatusMode] = usePersistentSignal<StatusMode>('dashboardStatusMode', 'all', {
@@ -301,7 +465,7 @@ export function Dashboard(props: DashboardProps) {
);
// Sorting state - default to VMID ascending (matches Proxmox order)
const [sortKey, setSortKey] = createSignal<keyof (VM | Container) | null>('vmid');
const [sortKey, setSortKey] = createSignal<keyof WorkloadGuest | null>('vmid');
const [sortDirection, setSortDirection] = createSignal<'asc' | 'desc'>('asc');
// Column visibility management
@@ -328,8 +492,35 @@ export function Dashboard(props: DashboardProps) {
}
};
const refreshWorkloads = async () => {
if (workloadsLoading()) return;
setWorkloadsLoading(true);
const hadWorkloads = workloadsLoaded();
try {
const response = await apiFetchJSON('/api/v2/resources');
const resources = resolveWorkloadsPayload(response);
const mapped = resources
.map((resource) => mapResourceToWorkload(resource))
.filter((resource): resource is WorkloadGuest => !!resource);
setWorkloadGuests(mapped);
setWorkloadsLoaded(true);
logger.debug('[Dashboard] Loaded workloads', {
total: mapped.length,
types: [...new Set(mapped.map((w) => w.workloadType))],
});
} catch (err) {
logger.debug('[Dashboard] Failed to load workloads', err);
if (!hadWorkloads) {
setWorkloadsLoaded(false);
}
} finally {
setWorkloadsLoading(false);
}
};
// Load all guest metadata on mount (single API call for all guests)
onMount(async () => {
await refreshWorkloads();
await refreshGuestMetadata();
// Listen for metadata changes from AI or other sources
@@ -373,6 +564,12 @@ export function Dashboard(props: DashboardProps) {
// In practice, Dashboard is always mounted so this is fine
});
createEffect(() => {
if (connected()) {
void refreshWorkloads();
}
});
// Callback to update a guest's custom URL in metadata
const handleCustomUrlUpdate = (guestId: string, url: string) => {
const trimmedUrl = url.trim();
@@ -469,7 +666,7 @@ export function Dashboard(props: DashboardProps) {
});
// Sort handler
const handleSort = (key: keyof (VM | Container)) => {
const handleSort = (key: keyof WorkloadGuest) => {
if (sortKey() === key) {
// Toggle direction for the same column
setSortDirection(sortDirection() === 'asc' ? 'desc' : 'asc');
@@ -494,7 +691,7 @@ export function Dashboard(props: DashboardProps) {
}
};
const getDiskUsagePercent = (guest: VM | Container): number | null => {
const getDiskUsagePercent = (guest: WorkloadGuest): number | null => {
const disk = guest?.disk;
if (!disk) return null;
@@ -529,7 +726,7 @@ export function Dashboard(props: DashboardProps) {
return null;
}
return (a: VM | Container, b: VM | Container): number => {
return (a: WorkloadGuest, b: WorkloadGuest): number => {
let aVal: string | number | boolean | null | undefined = a[key] as
| string
| number
@@ -654,18 +851,19 @@ export function Dashboard(props: DashboardProps) {
// Find the node to get both instance and name for precise matching
const node = props.nodes.find((n) => n.id === selectedNodeId);
if (node) {
guests = guests.filter(
(g) => g.instance === node.instance && g.node === node.name,
);
guests = guests.filter((g) => {
const type = resolveWorkloadType(g);
if (type !== 'vm' && type !== 'lxc') {
return true;
}
return g.instance === node.instance && g.node === node.name;
});
}
}
// Filter by type
if (viewMode() === 'vm') {
guests = guests.filter((g) => g.type === 'qemu');
} else if (viewMode() === 'lxc') {
// Include both traditional LXC and OCI containers (Proxmox 9.1+)
guests = guests.filter((g) => g.type === 'lxc' || g.type === 'oci');
if (viewMode() !== 'all') {
guests = guests.filter((g) => resolveWorkloadType(g) === viewMode());
}
// Filter by status
@@ -733,13 +931,34 @@ export function Dashboard(props: DashboardProps) {
return guests;
});
const getGroupKey = (guest: WorkloadGuest): string => {
const type = resolveWorkloadType(guest);
if (type === 'vm' || type === 'lxc') {
return `${guest.instance}-${guest.node}`;
}
const context = guest.contextLabel || guest.node || guest.instance || guest.namespace || guest.id;
return `${type}:${context}`;
};
const getGroupLabel = (groupKey: string, guests: WorkloadGuest[]): string => {
const node = nodeByInstance()[groupKey];
if (node) return getNodeDisplayName(node);
const [prefix, ...rest] = groupKey.split(':');
const context = rest.length > 0 ? rest.join(':') : groupKey;
if (prefix === 'docker') return `Docker • ${context}`;
if (prefix === 'k8s') return `K8s • ${context}`;
if (prefix === 'vm') return `VMs • ${context}`;
if (prefix === 'lxc') return `LXCs • ${context}`;
return guests[0]?.contextLabel || context;
};
// Group by node or return flat list based on grouping mode
const groupedGuests = createMemo(() => {
const guests = filteredGuests();
// If flat mode, return all guests in a single group
if (groupingMode() === 'flat') {
const groups: Record<string, (VM | Container)[]> = { '': guests };
const groups: Record<string, WorkloadGuest[]> = { '': guests };
// PERFORMANCE: Use memoized sort comparator (eliminates ~50 lines of duplicate code)
const comparator = guestSortComparator();
if (comparator) {
@@ -749,10 +968,9 @@ export function Dashboard(props: DashboardProps) {
}
// Group by node ID (instance + node name) to match Node.ID format
const groups: Record<string, (VM | Container)[]> = {};
const groups: Record<string, WorkloadGuest[]> = {};
guests.forEach((guest) => {
// Node.ID is formatted as "instance-nodename", so we need to match that
const nodeId = `${guest.instance}-${guest.node}`;
const nodeId = getGroupKey(guest);
if (!groups[nodeId]) {
groups[nodeId] = [];
@@ -783,8 +1001,10 @@ export function Dashboard(props: DashboardProps) {
);
}).length;
const stopped = guests.length - running - degraded;
const vms = guests.filter((g) => g.type === 'qemu').length;
const containers = guests.filter((g) => g.type === 'lxc' || g.type === 'oci').length;
const vms = guests.filter((g) => resolveWorkloadType(g) === 'vm').length;
const containers = guests.filter((g) => resolveWorkloadType(g) === 'lxc').length;
const docker = guests.filter((g) => resolveWorkloadType(g) === 'docker').length;
const k8s = guests.filter((g) => resolveWorkloadType(g) === 'k8s').length;
return {
total: guests.length,
running,
@@ -792,6 +1012,8 @@ export function Dashboard(props: DashboardProps) {
stopped,
vms,
containers,
docker,
k8s,
};
});
@@ -872,8 +1094,8 @@ export function Dashboard(props: DashboardProps) {
globalTemperatureMonitoringEnabled={ws.state.temperatureMonitoringEnabled}
onNodeSelect={handleNodeSelect}
nodes={props.nodes}
filteredVms={filteredGuests().filter((g) => g.type === 'qemu')}
filteredContainers={filteredGuests().filter((g) => g.type === 'lxc' || g.type === 'oci')}
filteredVms={filteredGuests().filter((g) => resolveWorkloadType(g) === 'vm') as VM[]}
filteredContainers={filteredGuests().filter((g) => resolveWorkloadType(g) === 'lxc') as Container[]}
searchTerm={search()}
/>
@@ -940,8 +1162,7 @@ export function Dashboard(props: DashboardProps) {
connected() &&
initialDataReceived() &&
props.nodes.length === 0 &&
props.vms.length === 0 &&
props.containers.length === 0
allGuests().length === 0
}
>
<Card padding="lg">
@@ -1027,7 +1248,7 @@ export function Dashboard(props: DashboardProps) {
<For each={visibleColumns()}>
{(col) => {
const isFirst = () => col.id === visibleColumns()[0]?.id;
const sortKeyForCol = col.sortKey as keyof (VM | Container) | undefined;
const sortKeyForCol = col.sortKey as keyof WorkloadGuest | undefined;
const isSortable = !!sortKeyForCol;
const isSorted = () => sortKeyForCol && sortKey() === sortKeyForCol;
@@ -1061,21 +1282,37 @@ export function Dashboard(props: DashboardProps) {
</thead>
<tbody 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);
})}
each={Object.entries(groupedGuests()).sort(
([instanceIdA, guestsA], [instanceIdB, guestsB]) => {
const nodeA = nodeByInstance()[instanceIdA];
const nodeB = nodeByInstance()[instanceIdB];
const labelA = nodeA ? getNodeDisplayName(nodeA) : getGroupLabel(instanceIdA, guestsA);
const labelB = nodeB ? getNodeDisplayName(nodeB) : getGroupLabel(instanceIdB, guestsB);
return labelA.localeCompare(labelB) || instanceIdA.localeCompare(instanceIdB);
},
)}
fallback={<></>}
>
{([instanceId, guests]) => {
const node = nodeByInstance()[instanceId];
return (
<>
<Show when={node && groupingMode() === 'grouped'}>
<NodeGroupHeader node={node!} renderAs="tr" colspan={totalColumns()} />
<Show when={groupingMode() === 'grouped'}>
<Show
when={node}
fallback={
<tr class="bg-gray-50 dark:bg-gray-900/40">
<td
colspan={totalColumns()}
class="py-1 pr-2 pl-4 text-[12px] sm:text-sm font-semibold text-slate-700 dark:text-slate-100"
>
{getGroupLabel(instanceId, guests)}
</td>
</tr>
}
>
<NodeGroupHeader node={node!} renderAs="tr" colspan={totalColumns()} />
</Show>
</Show>
<For each={guests} fallback={<></>}>
{(guest) => {
@@ -1111,7 +1348,7 @@ export function Dashboard(props: DashboardProps) {
<div class="p-4" onClick={(e) => e.stopPropagation()}>
<GuestDrawer
guest={guest}
metricsKey={buildMetricKey(guest.type === 'qemu' ? 'vm' : 'container', guestId)}
metricsKey={buildMetricKey(getWorkloadMetricsKind(guest), guestId)}
onClose={() => setSelectedGuestId(null)}
customUrl={getMetadata()?.customUrl}
onCustomUrlChange={handleCustomUrlUpdate}
@@ -1140,7 +1377,7 @@ export function Dashboard(props: DashboardProps) {
connected() &&
initialDataReceived() &&
filteredGuests().length === 0 &&
(props.vms.length > 0 || props.containers.length > 0)
allGuests().length > 0
}
>
<Card padding="lg" class="mb-4">

View File

@@ -11,8 +11,8 @@ interface DashboardFilterProps {
search: () => string;
setSearch: (value: string) => void;
isSearchLocked: () => boolean;
viewMode: () => 'all' | 'vm' | 'lxc';
setViewMode: (value: 'all' | 'vm' | 'lxc') => void;
viewMode: () => 'all' | 'vm' | 'lxc' | 'docker' | 'k8s';
setViewMode: (value: 'all' | 'vm' | 'lxc' | 'docker' | 'k8s') => void;
statusMode: () => 'all' | 'running' | 'degraded' | 'stopped';
setStatusMode: (value: 'all' | 'running' | 'degraded' | 'stopped') => void;
groupingMode: () => 'grouped' | 'flat';
@@ -335,6 +335,34 @@ export const DashboardFilter: Component<DashboardFilterProps> = (props) => {
</svg>
LXCs
</button>
<button
type="button"
onClick={() => props.setViewMode(props.viewMode() === 'docker' ? 'all' : 'docker')}
class={`inline-flex items-center gap-1.5 px-2.5 py-1 text-xs font-medium rounded-md transition-all duration-150 active:scale-95 ${props.viewMode() === 'docker'
? 'bg-white dark:bg-gray-800 text-sky-600 dark:text-sky-400 shadow-sm ring-1 ring-sky-200 dark:ring-sky-800'
: 'text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-100 hover:bg-gray-50 dark:hover:bg-gray-600/50'
}`}
>
<svg class="w-3 h-3" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<rect x="3" y="6" width="18" height="12" rx="2" />
<path d="M3 10h18M7 6v12M13 6v12" />
</svg>
Docker
</button>
<button
type="button"
onClick={() => props.setViewMode(props.viewMode() === 'k8s' ? 'all' : 'k8s')}
class={`inline-flex items-center gap-1.5 px-2.5 py-1 text-xs font-medium rounded-md transition-all duration-150 active:scale-95 ${props.viewMode() === 'k8s'
? 'bg-white dark:bg-gray-800 text-amber-600 dark:text-amber-400 shadow-sm ring-1 ring-amber-200 dark:ring-amber-800'
: 'text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-100 hover:bg-gray-50 dark:hover:bg-gray-600/50'
}`}
>
<svg class="w-3 h-3" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M12 2l7 4v8l-7 4-7-4V6l7-4z" />
<path d="M12 6v12" />
</svg>
K8s
</button>
</div>
<div class="h-5 w-px bg-gray-200 dark:bg-gray-600 hidden sm:block"></div>

View File

@@ -1,5 +1,5 @@
import { Component, Show, For, Suspense, createSignal } from 'solid-js';
import { VM, Container } from '@/types/api';
import type { WorkloadGuest } from '@/types/workloads';
import { formatBytes, formatUptime } from '@/utils/format';
import { DiskList } from './DiskList';
import { HistoryChart } from '../shared/HistoryChart';
@@ -7,8 +7,9 @@ import { ResourceType, HistoryTimeRange } from '@/api/charts';
import { hasFeature } from '@/stores/license';
import { DiscoveryTab } from '../Discovery/DiscoveryTab';
import type { ResourceType as DiscoveryResourceType } from '@/types/discovery';
import { resolveWorkloadType } from '@/utils/workloads';
type Guest = VM | Container;
type Guest = WorkloadGuest;
interface GuestDrawerProps {
guest: Guest;
@@ -24,8 +25,8 @@ export const GuestDrawer: Component<GuestDrawerProps> = (props) => {
return `${props.guest.instance}:${props.guest.node}:${props.guest.vmid}`;
};
const isVM = (guest: Guest): guest is VM => {
return guest.type === 'qemu';
const isVM = (guest: Guest): boolean => {
return resolveWorkloadType(guest) === 'vm';
};
const hasOsInfo = () => {
@@ -92,7 +93,13 @@ export const GuestDrawer: Component<GuestDrawerProps> = (props) => {
const metricsResource = (): { type: ResourceType; id: string } => {
const key = props.metricsKey || '';
const separatorIndex = key.indexOf(':');
const fallbackType: ResourceType = isVM(props.guest) ? 'vm' : 'container';
const fallbackType: ResourceType = (() => {
const type = resolveWorkloadType(props.guest);
if (type === 'vm') return 'vm';
if (type === 'docker') return 'docker';
if (type === 'k8s') return 'k8s';
return 'container';
})();
if (separatorIndex === -1) {
return { type: fallbackType, id: fallbackGuestId() };
@@ -100,7 +107,14 @@ export const GuestDrawer: Component<GuestDrawerProps> = (props) => {
const kind = key.slice(0, separatorIndex);
const id = key.slice(separatorIndex + 1) || fallbackGuestId();
const type: ResourceType = kind === 'vm' || kind === 'container' ? kind : fallbackType;
const type: ResourceType =
kind === 'vm' || kind === 'container'
? (kind as ResourceType)
: kind === 'dockerContainer'
? 'docker'
: kind === 'k8s'
? 'k8s'
: fallbackType;
return { type, id };
};
@@ -120,7 +134,11 @@ export const GuestDrawer: Component<GuestDrawerProps> = (props) => {
// Get discovery resource type for the guest
const discoveryResourceType = (): DiscoveryResourceType => {
return isVM(props.guest) ? 'vm' : 'lxc';
const type = resolveWorkloadType(props.guest);
if (type === 'vm') return 'vm';
if (type === 'docker') return 'docker';
if (type === 'k8s') return 'k8s';
return 'lxc';
};
return (
<div class="space-y-3">

View File

@@ -2,7 +2,8 @@ import { createMemo, createSignal, createEffect, Show, For } from 'solid-js';
import type { JSX } from 'solid-js';
import { Portal } from 'solid-js/web';
import type { VM, Container, GuestNetworkInterface } from '@/types/api';
import { formatBytes, formatUptime, formatSpeed, getBackupInfo, type BackupStatus } from '@/utils/format';
import type { WorkloadGuest } from '@/types/workloads';
import { formatBytes, formatUptime, formatSpeed, getBackupInfo, getShortImageName, type BackupStatus } from '@/utils/format';
import { TagBadges } from './TagBadges';
import { StackedDiskBar } from './StackedDiskBar';
import { StackedMemoryBar } from './StackedMemoryBar';
@@ -10,6 +11,7 @@ import { StackedMemoryBar } from './StackedMemoryBar';
import { StatusDot } from '@/components/shared/StatusDot';
import { getGuestPowerIndicator, isGuestRunning } from '@/utils/status';
import { buildMetricKey } from '@/utils/metricsKeys';
import { getWorkloadMetricsKind, resolveWorkloadType } from '@/utils/workloads';
import { type ColumnPriority } from '@/hooks/useBreakpoint';
import { ResponsiveMetricCell } from '@/components/shared/responsive';
import { EnhancedCPUBar } from '@/components/Dashboard/EnhancedCPUBar';
@@ -20,7 +22,7 @@ import { useAlertsActivation } from '@/stores/alertsActivation';
import { useAnomalyForMetric } from '@/hooks/useAnomalies';
type Guest = VM | Container;
type Guest = WorkloadGuest;
/**
* Get color class for I/O values based on throughput (bytes/sec)
@@ -47,7 +49,7 @@ const buildGuestId = (guest: Guest) => {
// Type guard for VM vs Container
const isVM = (guest: Guest): guest is VM => {
return guest.type === 'qemu';
return resolveWorkloadType(guest) === 'vm';
};
// Backup status indicator colors and icons
@@ -426,7 +428,7 @@ export const GUEST_COLUMNS: GuestColumnDef[] = [
{ id: 'name', label: 'Name', priority: 'essential', width: '200px', sortKey: 'name' },
// Secondary - visible on md+ (Now essential for mobile scroll)
{ id: 'type', label: 'Type', priority: 'essential', width: '40px', sortKey: 'type' },
{ id: 'type', label: 'Type', priority: 'essential', width: '60px', sortKey: 'type' },
{ id: 'vmid', label: 'ID', priority: 'essential', width: '45px', sortKey: 'vmid' },
// Core metrics - fixed minimum widths to prevent content overlap
@@ -437,7 +439,10 @@ export const GUEST_COLUMNS: GuestColumnDef[] = [
// Secondary - visible on md+ (Now essential), user toggleable - use icons
{ id: 'ip', label: 'IP', icon: <svg class="w-3.5 h-3.5 block" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 12a9 9 0 01-9 9m9-9a9 9 0 00-9-9m9 9H3m9 9a9 9 0 01-9-9m9 9c1.657 0 3-4.03 3-9s-1.343-9-3-9m0 18c-1.657 0-3-4.03-3-9s1.343-9 3-9m-9 9a9 9 0 019-9" /></svg>, priority: 'essential', width: '45px', toggleable: true },
{ id: 'uptime', label: 'Uptime', icon: <svg class="w-3.5 h-3.5 block" 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>, priority: 'essential', width: '60px', toggleable: true, sortKey: 'uptime' },
{ id: 'node', label: 'Node', icon: <svg class="w-3.5 h-3.5 block" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 12h14M5 12a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v4a2 2 0 01-2 2M5 12a2 2 0 00-2 2v4a2 2 0 002 2h14a2 2 0 002-2v-4a2 2 0 00-2-2m-2-4h.01M17 16h.01" /></svg>, priority: 'essential', width: '55px', toggleable: true, sortKey: 'node' },
{ id: 'node', label: 'Node', icon: <svg class="w-3.5 h-3.5 block" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 12h14M5 12a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v4a2 2 0 01-2 2M5 12a2 2 0 00-2 2v4a2 2 0 002 2h14a2 2 0 002-2v-4a2 2 0 00-2-2m-2-4h.01M17 16h.01" /></svg>, priority: 'essential', width: '70px', toggleable: true, sortKey: 'node' },
{ id: 'image', label: 'Image', icon: <svg class="w-3.5 h-3.5 block" fill="none" stroke="currentColor" viewBox="0 0 24 24"><rect x="3" y="6" width="18" height="12" rx="2" /><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 10h18M7 6v12M13 6v12" /></svg>, priority: 'secondary', width: '140px', minWidth: '120px', toggleable: true, sortKey: 'image' },
{ id: 'namespace', label: 'Namespace', icon: <svg class="w-3.5 h-3.5 block" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 2l7 4v8l-7 4-7-4V6l7-4z" /><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 6v12" /></svg>, priority: 'secondary', width: '110px', minWidth: '90px', toggleable: true, sortKey: 'namespace' },
// Supplementary - visible on lg+ (Now essential), user toggleable
{ id: 'backup', label: 'Backup', icon: <svg class="w-3.5 h-3.5 block" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z" /></svg>, priority: 'essential', width: '50px', toggleable: true },
@@ -506,11 +511,10 @@ export function GuestRow(props: GuestRowProps) {
return set.has(colId);
};
const workloadType = createMemo(() => resolveWorkloadType(props.guest));
// Create namespaced metrics key for sparklines
const metricsKey = createMemo(() => {
const kind = props.guest.type === 'qemu' ? 'vm' : 'container';
return buildMetricKey(kind, guestId());
});
const metricsKey = createMemo(() => buildMetricKey(getWorkloadMetricsKind(props.guest), guestId()));
// Get anomalies for this guest's metrics (deterministic, no LLM)
const cpuAnomaly = useAnomalyForMetric(() => props.guest.id, () => 'cpu');
@@ -519,6 +523,15 @@ export function GuestRow(props: GuestRowProps) {
const [customUrl, setCustomUrl] = createSignal<string | undefined>(props.customUrl);
const displayId = createMemo(() => {
const provided = props.guest.displayId?.trim();
if (provided) return provided;
if (typeof props.guest.vmid === 'number' && props.guest.vmid > 0) {
return String(props.guest.vmid);
}
return '';
});
const ipAddresses = createMemo(() => props.guest.ipAddresses ?? []);
const networkInterfaces = createMemo(() => props.guest.networkInterfaces ?? []);
const hasNetworkInterfaces = createMemo(() => networkInterfaces().length > 0);
@@ -526,9 +539,15 @@ export function GuestRow(props: GuestRowProps) {
const osVersion = createMemo(() => props.guest.osVersion?.trim() ?? '');
const agentVersion = createMemo(() => props.guest.agentVersion?.trim() ?? '');
const hasOsInfo = createMemo(() => osName().length > 0 || osVersion().length > 0);
const dockerImage = createMemo(() => props.guest.image?.trim() ?? '');
const namespace = createMemo(() => props.guest.namespace?.trim() ?? '');
const supportsBackup = createMemo(() => {
const type = workloadType();
return type === 'vm' || type === 'lxc';
});
const isOCIContainer = createMemo(() => {
if (isVM(props.guest)) return false;
if (workloadType() !== 'lxc') return false;
const container = props.guest as Container;
return props.guest.type === 'oci' || container.isOci === true;
});
@@ -545,6 +564,71 @@ export function GuestRow(props: GuestRowProps) {
return image;
});
const typeInfo = createMemo(() => {
const type = workloadType();
if (type === 'vm') {
return {
label: 'VM',
title: 'Virtual Machine',
className: 'bg-blue-100 text-blue-700 dark:bg-blue-900/50 dark:text-blue-300',
icon: (
<svg class="w-3.5 h-3.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<rect x="3" y="4" width="18" height="12" rx="2" />
<path d="M8 20h8M12 16v4" />
</svg>
),
};
}
if (type === 'docker') {
return {
label: 'Docker',
title: 'Docker Container',
className: 'bg-sky-100 text-sky-700 dark:bg-sky-900/50 dark:text-sky-300',
icon: (
<svg class="w-3.5 h-3.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<rect x="3" y="6" width="18" height="12" rx="2" />
<path d="M3 10h18M7 6v12M13 6v12" />
</svg>
),
};
}
if (type === 'k8s') {
return {
label: 'K8s',
title: 'Kubernetes Pod',
className: 'bg-amber-100 text-amber-700 dark:bg-amber-900/50 dark:text-amber-300',
icon: (
<svg class="w-3.5 h-3.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M12 2l7 4v8l-7 4-7-4V6l7-4z" />
<path d="M12 6v12" />
</svg>
),
};
}
if (isOCIContainer()) {
return {
label: 'OCI',
title: `OCI Container${ociImage() ? `${ociImage()}` : ''}`,
className: 'bg-purple-100 text-purple-700 dark:bg-purple-900/50 dark:text-purple-300',
icon: (
<svg class="w-3.5 h-3.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z" />
</svg>
),
};
}
return {
label: 'LXC',
title: 'LXC Container',
className: 'bg-green-100 text-green-700 dark:bg-green-900/50 dark:text-green-300',
icon: (
<svg class="w-3.5 h-3.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z" />
</svg>
),
};
});
// Update custom URL when prop changes
createEffect(() => {
setCustomUrl(props.customUrl);
@@ -713,7 +797,7 @@ export function GuestRow(props: GuestRowProps) {
{props.guest.name}
</span>
{/* Show backup indicator in name cell only if backup column is hidden */}
<Show when={!isColVisible('backup')}>
<Show when={!isColVisible('backup') && supportsBackup()}>
<BackupIndicator lastBackup={props.guest.lastBackup} isTemplate={props.guest.template} />
</Show>
@@ -738,21 +822,11 @@ export function GuestRow(props: GuestRowProps) {
<td class="px-2 py-1 align-middle">
<div class="flex justify-center">
<span
class={`inline-block px-1 py-0.5 text-[10px] font-medium rounded whitespace-nowrap ${props.guest.type === 'qemu'
? 'bg-blue-100 text-blue-700 dark:bg-blue-900/50 dark:text-blue-300'
: isOCIContainer()
? 'bg-purple-100 text-purple-700 dark:bg-purple-900/50 dark:text-purple-300'
: 'bg-green-100 text-green-700 dark:bg-green-900/50 dark:text-green-300'
}`}
title={
isVM(props.guest)
? 'Virtual Machine'
: isOCIContainer()
? `OCI Container${ociImage() ? `${ociImage()}` : ''}`
: 'LXC Container'
}
class={`inline-flex items-center gap-1 px-1 py-0.5 text-[10px] font-medium rounded whitespace-nowrap ${typeInfo().className}`}
title={typeInfo().title}
>
{isVM(props.guest) ? 'VM' : isOCIContainer() ? 'OCI' : 'LXC'}
{typeInfo().icon}
<span>{typeInfo().label}</span>
</span>
</div>
</td>
@@ -762,7 +836,9 @@ export function GuestRow(props: GuestRowProps) {
<Show when={isColVisible('vmid')}>
<td class="px-2 py-1 align-middle">
<div class="flex justify-center text-xs text-gray-600 dark:text-gray-400 whitespace-nowrap">
{props.guest.vmid}
<Show when={displayId()} fallback={<span class="text-gray-400">-</span>}>
{displayId()}
</Show>
</div>
</td>
</Show>
@@ -880,9 +956,49 @@ export function GuestRow(props: GuestRowProps) {
<Show when={isColVisible('node')}>
<td class="px-2 py-1 align-middle">
<div class="flex justify-center">
<span class="text-xs text-gray-600 dark:text-gray-400 truncate max-w-[80px]" title={props.guest.node}>
{props.guest.node}
</span>
<Show when={props.guest.node} fallback={<span class="text-xs text-gray-400">-</span>}>
<span class="text-xs text-gray-600 dark:text-gray-400 truncate max-w-[80px]" title={props.guest.node}>
{props.guest.node}
</span>
</Show>
</div>
</td>
</Show>
{/* Image */}
<Show when={isColVisible('image')}>
<td class="px-2 py-1 align-middle">
<div class="flex justify-center">
<Show
when={workloadType() === 'docker' && dockerImage()}
fallback={<span class="text-xs text-gray-400">-</span>}
>
<span
class="text-xs text-gray-600 dark:text-gray-400 truncate max-w-[140px]"
title={dockerImage()}
>
{getShortImageName(dockerImage())}
</span>
</Show>
</div>
</td>
</Show>
{/* Namespace */}
<Show when={isColVisible('namespace')}>
<td class="px-2 py-1 align-middle">
<div class="flex justify-center">
<Show
when={workloadType() === 'k8s' && namespace()}
fallback={<span class="text-xs text-gray-400">-</span>}
>
<span
class="text-xs text-gray-600 dark:text-gray-400 truncate max-w-[120px]"
title={namespace()}
>
{namespace()}
</span>
</Show>
</div>
</td>
</Show>
@@ -891,11 +1007,13 @@ export function GuestRow(props: GuestRowProps) {
<Show when={isColVisible('backup')}>
<td class="px-2 py-1 align-middle">
<div class="flex justify-center">
<Show when={!props.guest.template}>
<BackupStatusCell lastBackup={props.guest.lastBackup} />
</Show>
<Show when={props.guest.template}>
<span class="text-xs text-gray-400">-</span>
<Show when={supportsBackup()} fallback={<span class="text-xs text-gray-400">-</span>}>
<Show when={!props.guest.template}>
<BackupStatusCell lastBackup={props.guest.lastBackup} />
</Show>
<Show when={props.guest.template}>
<span class="text-xs text-gray-400">-</span>
</Show>
</Show>
</div>
</td>

View File

@@ -0,0 +1,12 @@
import type { VM, Container } from './api';
export type WorkloadType = 'vm' | 'lxc' | 'docker' | 'k8s';
export type WorkloadGuest = (VM | Container) & {
workloadType?: WorkloadType;
displayId?: string;
image?: string;
namespace?: string;
contextLabel?: string;
platformType?: string;
};

View File

@@ -5,7 +5,14 @@
* across different resource types.
*/
export type MetricResourceKind = 'node' | 'vm' | 'container' | 'host' | 'dockerHost' | 'dockerContainer';
export type MetricResourceKind =
| 'node'
| 'vm'
| 'container'
| 'host'
| 'dockerHost'
| 'dockerContainer'
| 'k8s';
/**
* Build a namespaced metric key for a resource

View File

@@ -0,0 +1,31 @@
import type { WorkloadGuest, WorkloadType } from '@/types/workloads';
import type { MetricResourceKind } from '@/utils/metricsKeys';
export const resolveWorkloadType = (
guest: Pick<WorkloadGuest, 'workloadType' | 'type'>,
): WorkloadType => {
if (guest.workloadType) return guest.workloadType;
const rawType = (guest.type || '').toLowerCase();
if (rawType === 'qemu' || rawType === 'vm') return 'vm';
if (rawType === 'lxc' || rawType === 'oci' || rawType === 'container') return 'lxc';
if (rawType === 'docker' || rawType === 'docker-container') return 'docker';
if (rawType === 'k8s' || rawType === 'pod' || rawType === 'kubernetes') return 'k8s';
return 'lxc';
};
export const getWorkloadMetricsKind = (
guest: Pick<WorkloadGuest, 'workloadType' | 'type'>,
): MetricResourceKind => {
const type = resolveWorkloadType(guest);
switch (type) {
case 'vm':
return 'vm';
case 'docker':
return 'dockerContainer';
case 'k8s':
return 'k8s';
case 'lxc':
default:
return 'container';
}
};