mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-02-18 00:17:39 +01:00
Add workloads page and unified workload utilities
This commit is contained in:
@@ -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} />
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
12
frontend-modern/src/types/workloads.ts
Normal file
12
frontend-modern/src/types/workloads.ts
Normal 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;
|
||||
};
|
||||
@@ -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
|
||||
|
||||
31
frontend-modern/src/utils/workloads.ts
Normal file
31
frontend-modern/src/utils/workloads.ts
Normal 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';
|
||||
}
|
||||
};
|
||||
Reference in New Issue
Block a user