diff --git a/frontend-modern/src/App.tsx b/frontend-modern/src/App.tsx index f0ee1a78b..deee3e440 100644 --- a/frontend-modern/src/App.tsx +++ b/frontend-modern/src/App.tsx @@ -946,6 +946,7 @@ function App() { } /> } /> + diff --git a/frontend-modern/src/components/Dashboard/Dashboard.tsx b/frontend-modern/src/components/Dashboard/Dashboard.tsx index 3cb66ca7b..a03cf7669 100644 --- a/frontend-modern/src/components/Dashboard/Dashboard.tsx +++ b/frontend-modern/src/components/Dashboard/Dashboard.tsx @@ -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; 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([]); + 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; + 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; + 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(() => [ + ...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(() => + workloadsLoaded() ? workloadGuests() : legacyGuests(), + ); // Initialize from localStorage with proper type checking const [viewMode, setViewMode] = usePersistentSignal('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('dashboardStatusMode', 'all', { @@ -301,7 +465,7 @@ export function Dashboard(props: DashboardProps) { ); // Sorting state - default to VMID ascending (matches Proxmox order) - const [sortKey, setSortKey] = createSignal('vmid'); + const [sortKey, setSortKey] = createSignal('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 = { '': guests }; + const groups: Record = { '': 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 = {}; + const groups: Record = {}; 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 } > @@ -1027,7 +1248,7 @@ export function Dashboard(props: DashboardProps) { {(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) { { - 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 ( <> - - + + + + {getGroupLabel(instanceId, guests)} + + + } + > + + }> {(guest) => { @@ -1111,7 +1348,7 @@ export function Dashboard(props: DashboardProps) {
e.stopPropagation()}> 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 } > diff --git a/frontend-modern/src/components/Dashboard/DashboardFilter.tsx b/frontend-modern/src/components/Dashboard/DashboardFilter.tsx index c0f7647d1..ec0c97350 100644 --- a/frontend-modern/src/components/Dashboard/DashboardFilter.tsx +++ b/frontend-modern/src/components/Dashboard/DashboardFilter.tsx @@ -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 = (props) => { LXCs + +
diff --git a/frontend-modern/src/components/Dashboard/GuestDrawer.tsx b/frontend-modern/src/components/Dashboard/GuestDrawer.tsx index 3e82bcceb..e34123784 100644 --- a/frontend-modern/src/components/Dashboard/GuestDrawer.tsx +++ b/frontend-modern/src/components/Dashboard/GuestDrawer.tsx @@ -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 = (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 = (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 = (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 = (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 (
diff --git a/frontend-modern/src/components/Dashboard/GuestRow.tsx b/frontend-modern/src/components/Dashboard/GuestRow.tsx index 25df511bd..80f6e3e80 100644 --- a/frontend-modern/src/components/Dashboard/GuestRow.tsx +++ b/frontend-modern/src/components/Dashboard/GuestRow.tsx @@ -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: , priority: 'essential', width: '45px', toggleable: true }, { id: 'uptime', label: 'Uptime', icon: , priority: 'essential', width: '60px', toggleable: true, sortKey: 'uptime' }, - { id: 'node', label: 'Node', icon: , priority: 'essential', width: '55px', toggleable: true, sortKey: 'node' }, + { id: 'node', label: 'Node', icon: , priority: 'essential', width: '70px', toggleable: true, sortKey: 'node' }, + + { id: 'image', label: 'Image', icon: , priority: 'secondary', width: '140px', minWidth: '120px', toggleable: true, sortKey: 'image' }, + { id: 'namespace', label: 'Namespace', icon: , priority: 'secondary', width: '110px', minWidth: '90px', toggleable: true, sortKey: 'namespace' }, // Supplementary - visible on lg+ (Now essential), user toggleable { id: 'backup', label: 'Backup', icon: , 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(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: ( + + + + + ), + }; + } + 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: ( + + + + + ), + }; + } + 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: ( + + + + + ), + }; + } + 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: ( + + + + ), + }; + } + return { + label: 'LXC', + title: 'LXC Container', + className: 'bg-green-100 text-green-700 dark:bg-green-900/50 dark:text-green-300', + icon: ( + + + + ), + }; + }); + // Update custom URL when prop changes createEffect(() => { setCustomUrl(props.customUrl); @@ -713,7 +797,7 @@ export function GuestRow(props: GuestRowProps) { {props.guest.name} {/* Show backup indicator in name cell only if backup column is hidden */} - + @@ -738,21 +822,11 @@ export function GuestRow(props: GuestRowProps) {
- {isVM(props.guest) ? 'VM' : isOCIContainer() ? 'OCI' : 'LXC'} + {typeInfo().icon} + {typeInfo().label}
@@ -762,7 +836,9 @@ export function GuestRow(props: GuestRowProps) {
- {props.guest.vmid} + -}> + {displayId()} +
@@ -880,9 +956,49 @@ export function GuestRow(props: GuestRowProps) {
- - {props.guest.node} - + -}> + + {props.guest.node} + + +
+ +
+ + {/* Image */} + + +
+ -} + > + + {getShortImageName(dockerImage())} + + +
+ +
+ + {/* Namespace */} + + +
+ -} + > + + {namespace()} + +
@@ -891,11 +1007,13 @@ export function GuestRow(props: GuestRowProps) {
- - - - - - + -}> + + + + + - +
diff --git a/frontend-modern/src/types/workloads.ts b/frontend-modern/src/types/workloads.ts new file mode 100644 index 000000000..5243d2416 --- /dev/null +++ b/frontend-modern/src/types/workloads.ts @@ -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; +}; diff --git a/frontend-modern/src/utils/metricsKeys.ts b/frontend-modern/src/utils/metricsKeys.ts index 50dd44d1d..7706ae5a6 100644 --- a/frontend-modern/src/utils/metricsKeys.ts +++ b/frontend-modern/src/utils/metricsKeys.ts @@ -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 diff --git a/frontend-modern/src/utils/workloads.ts b/frontend-modern/src/utils/workloads.ts new file mode 100644 index 000000000..7432c6819 --- /dev/null +++ b/frontend-modern/src/utils/workloads.ts @@ -0,0 +1,31 @@ +import type { WorkloadGuest, WorkloadType } from '@/types/workloads'; +import type { MetricResourceKind } from '@/utils/metricsKeys'; + +export const resolveWorkloadType = ( + guest: Pick, +): 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, +): MetricResourceKind => { + const type = resolveWorkloadType(guest); + switch (type) { + case 'vm': + return 'vm'; + case 'docker': + return 'dockerContainer'; + case 'k8s': + return 'k8s'; + case 'lxc': + default: + return 'container'; + } +};