phase 2: frontend types and useResources hook

- Created resource.ts with TypeScript types for unified Resource model
  - ResourceType, PlatformType, SourceType, ResourceStatus enums
  - Resource interface matching backend ResourceFrontend
  - Helper functions: isInfrastructure, isWorkload, getDisplayName, etc.
  - ResourceFilter interface for complex filtering

- Updated api.ts State interface to include optional resources array

- Created useResources hook for accessing unified resources
  - Reactive access via getGlobalWebSocketStore
  - Pre-computed memos for infra, workloads, statusCounts
  - Filtering methods: byType, byPlatform, filtered
  - Query helpers: get, children, topByCpu, topByMemory

- Created useResourcesAsLegacy helper for migration
  - Converts resources to legacy VM/Container formats
  - Enables gradual component migration

This provides the foundation for migrating frontend pages to use
the unified resource model.
This commit is contained in:
rcourtman 2025-12-07 23:07:45 +00:00
parent cdd2249e00
commit 55f10ce36c
3 changed files with 513 additions and 0 deletions

View file

@ -0,0 +1,315 @@
/**
* useResources Hook
*
* Provides reactive access to unified resources from the WebSocket state.
* This is the primary way for frontend components to access resource data
* as we migrate away from legacy arrays (nodes, vms, containers, etc.).
*
* Example usage:
* ```tsx
* const { resources, infra, workloads, filtered } = useResources();
*
* // Get all resources
* <For each={resources()}>{r => <div>{r.name}</div>}</For>
*
* // Get only infrastructure (nodes, hosts)
* <For each={infra()}>{r => <div>{r.name}</div>}</For>
*
* // Get filtered workloads
* const vms = filtered({ types: ['vm'], statuses: ['running'] });
* <For each={vms}>{r => <div>{r.name}</div>}</For>
* ```
*/
import { createMemo, type Accessor } from 'solid-js';
import { getGlobalWebSocketStore } from '@/stores/websocket-global';
import type {
Resource,
ResourceType,
PlatformType,
ResourceStatus,
ResourceFilter,
} from '@/types/resource';
import {
isInfrastructure,
isWorkload,
getDisplayName,
getCpuPercent,
getMemoryPercent,
getDiskPercent,
} from '@/types/resource';
export interface UseResourcesReturn {
/** All unified resources */
resources: Accessor<Resource[]>;
/** Infrastructure resources only (nodes, hosts, docker-hosts) */
infra: Accessor<Resource[]>;
/** Workload resources only (vms, containers, docker-containers) */
workloads: Accessor<Resource[]>;
/** Resources by type */
byType: (type: ResourceType) => Resource[];
/** Resources by platform */
byPlatform: (platform: PlatformType) => Resource[];
/** Filter resources with multiple criteria */
filtered: (filter: ResourceFilter) => Resource[];
/** Get a single resource by ID */
get: (id: string) => Resource | undefined;
/** Get children of a resource (e.g., VMs of a node) */
children: (parentId: string) => Resource[];
/** Count of resources by status */
statusCounts: Accessor<Record<ResourceStatus, number>>;
/** Top consumers by CPU */
topByCpu: (limit?: number) => Resource[];
/** Top consumers by memory */
topByMemory: (limit?: number) => Resource[];
/** Whether resources are available */
hasResources: Accessor<boolean>;
}
/**
* Hook for accessing unified resources with reactive filtering.
*/
export function useResources(): UseResourcesReturn {
// Get the WebSocket store instance
const wsStore = getGlobalWebSocketStore();
// All resources from WebSocket state
const resources = createMemo<Resource[]>(() => {
return wsStore.state.resources ?? [];
});
// Pre-filtered memos for common use cases
const infra = createMemo<Resource[]>(() => {
return resources().filter(isInfrastructure);
});
const workloads = createMemo<Resource[]>(() => {
return resources().filter(isWorkload);
});
const hasResources = createMemo<boolean>(() => {
return resources().length > 0;
});
// Status counts
const statusCounts = createMemo<Record<ResourceStatus, number>>(() => {
const counts: Record<ResourceStatus, number> = {
online: 0,
offline: 0,
running: 0,
stopped: 0,
degraded: 0,
paused: 0,
unknown: 0,
};
for (const r of resources()) {
if (r.status in counts) {
counts[r.status as ResourceStatus]++;
}
}
return counts;
});
// Filter by type
const byType = (type: ResourceType): Resource[] => {
return resources().filter(r => r.type === type);
};
// Filter by platform
const byPlatform = (platform: PlatformType): Resource[] => {
return resources().filter(r => r.platformType === platform);
};
// Complex filtering
const filtered = (filter: ResourceFilter): Resource[] => {
let result = resources();
// Filter by types
if (filter.types && filter.types.length > 0) {
result = result.filter(r => filter.types!.includes(r.type));
}
// Filter by platforms
if (filter.platforms && filter.platforms.length > 0) {
result = result.filter(r => filter.platforms!.includes(r.platformType));
}
// Filter by statuses
if (filter.statuses && filter.statuses.length > 0) {
result = result.filter(r => filter.statuses!.includes(r.status));
}
// Filter by parent
if (filter.parentId) {
result = result.filter(r => r.parentId === filter.parentId);
}
// Filter by cluster
if (filter.clusterId) {
result = result.filter(r => r.clusterId === filter.clusterId);
}
// Filter by alerts
if (filter.hasAlerts) {
result = result.filter(r => r.alerts && r.alerts.length > 0);
}
// Search filter (name, displayName)
if (filter.search && filter.search.trim()) {
const term = filter.search.toLowerCase().trim();
result = result.filter(r => {
const name = getDisplayName(r).toLowerCase();
const id = r.id.toLowerCase();
return name.includes(term) || id.includes(term);
});
}
return result;
};
// Get single resource by ID
const get = (id: string): Resource | undefined => {
return resources().find(r => r.id === id);
};
// Get children of a parent
const children = (parentId: string): Resource[] => {
return resources().filter(r => r.parentId === parentId);
};
// Top by CPU
const topByCpu = (limit: number = 10): Resource[] => {
return [...resources()]
.filter(r => r.cpu && r.cpu.current > 0)
.sort((a, b) => getCpuPercent(b) - getCpuPercent(a))
.slice(0, limit);
};
// Top by memory
const topByMemory = (limit: number = 10): Resource[] => {
return [...resources()]
.filter(r => r.memory)
.sort((a, b) => getMemoryPercent(b) - getMemoryPercent(a))
.slice(0, limit);
};
return {
resources,
infra,
workloads,
byType,
byPlatform,
filtered,
get,
children,
statusCounts,
topByCpu,
topByMemory,
hasResources,
};
}
/**
* Helper hook for resource-to-legacy-type conversion.
* This helps during migration when components still expect legacy types.
*/
export function useResourcesAsLegacy() {
const { resources, byType } = useResources();
// Convert resources to legacy VM format
const asVMs = createMemo(() => {
return byType('vm').map(r => ({
id: r.id,
vmid: parseInt(r.id.split('-').pop() ?? '0', 10),
name: r.name,
node: r.parentId ?? '',
instance: r.platformId,
status: r.status === 'running' ? 'running' : 'stopped',
type: 'qemu',
cpu: r.cpu?.current ?? 0,
cpus: 1, // Not available in unified model
memory: r.memory ? {
total: r.memory.total ?? 0,
used: r.memory.used ?? 0,
free: r.memory.free ?? 0,
usage: r.memory.current,
} : { total: 0, used: 0, free: 0, usage: 0 },
disk: r.disk ? {
total: r.disk.total ?? 0,
used: r.disk.used ?? 0,
free: r.disk.free ?? 0,
usage: r.disk.current,
} : { total: 0, used: 0, free: 0, usage: 0 },
networkIn: r.network?.rxBytes ?? 0,
networkOut: r.network?.txBytes ?? 0,
diskRead: 0,
diskWrite: 0,
uptime: r.uptime ?? 0,
template: false,
lastBackup: 0,
tags: r.tags ?? [],
lock: '',
lastSeen: new Date(r.lastSeen).toISOString(),
}));
});
// Convert resources to legacy Container format
const asContainers = createMemo(() => {
return byType('container').map(r => ({
id: r.id,
vmid: parseInt(r.id.split('-').pop() ?? '0', 10),
name: r.name,
node: r.parentId ?? '',
instance: r.platformId,
status: r.status === 'running' ? 'running' : 'stopped',
type: 'lxc',
cpu: r.cpu?.current ?? 0,
cpus: 1,
memory: r.memory ? {
total: r.memory.total ?? 0,
used: r.memory.used ?? 0,
free: r.memory.free ?? 0,
usage: r.memory.current,
} : { total: 0, used: 0, free: 0, usage: 0 },
disk: r.disk ? {
total: r.disk.total ?? 0,
used: r.disk.used ?? 0,
free: r.disk.free ?? 0,
usage: r.disk.current,
} : { total: 0, used: 0, free: 0, usage: 0 },
networkIn: r.network?.rxBytes ?? 0,
networkOut: r.network?.txBytes ?? 0,
diskRead: 0,
diskWrite: 0,
uptime: r.uptime ?? 0,
template: false,
lastBackup: 0,
tags: r.tags ?? [],
lock: '',
lastSeen: new Date(r.lastSeen).toISOString(),
}));
});
return {
resources,
asVMs,
asContainers,
};
}
// Re-export types and helpers
export type { Resource, ResourceType, PlatformType, ResourceStatus, ResourceFilter };
export { isInfrastructure, isWorkload, getDisplayName, getCpuPercent, getMemoryPercent, getDiskPercent };

View file

@ -1,5 +1,7 @@
// Properly typed TypeScript interfaces for Pulse API
import type { Resource } from './resource';
export interface State {
nodes: Node[];
vms: VM[];
@ -25,6 +27,8 @@ export interface State {
recentlyResolved: ResolvedAlert[];
lastUpdate: string;
temperatureMonitoringEnabled?: boolean;
// Unified resources (new data model - eventually replaces legacy arrays above)
resources?: Resource[];
}
export interface RemovedDockerHost {

View file

@ -0,0 +1,194 @@
/**
* Unified Resource Types
*
* These types define the unified resource model that normalizes all monitored
* entities (VMs, containers, hosts, etc.) into a common structure.
*
* The frontend receives these via WebSocket state.resources[].
*/
// Resource types - what kind of entity is being monitored
export type ResourceType =
| 'node' // Proxmox VE node
| 'host' // Standalone host (via host-agent)
| 'docker-host' // Docker/Podman host
| 'k8s-node' // Kubernetes node
| 'truenas' // TrueNAS system
| 'vm' // Proxmox VM
| 'container' // LXC container
| 'docker-container' // Docker container
| 'pod' // Kubernetes pod
| 'jail' // BSD jail
| 'docker-service' // Docker Swarm service
| 'k8s-deployment' // Kubernetes deployment
| 'k8s-service' // Kubernetes service
| 'storage' // Storage resource
| 'datastore' // PBS datastore
| 'pool' // ZFS/Ceph pool
| 'dataset' // ZFS dataset
| 'pbs' // Proxmox Backup Server
| 'pmg'; // Proxmox Mail Gateway
// Platform types - which system the resource comes from
export type PlatformType =
| 'proxmox-pve'
| 'proxmox-pbs'
| 'proxmox-pmg'
| 'docker'
| 'kubernetes'
| 'truenas'
| 'host-agent';
// Source types - how data is collected
export type SourceType =
| 'api' // Data from polling an API
| 'agent' // Data pushed from agent
| 'hybrid'; // Both sources, agent preferred
// Resource status - operational state
export type ResourceStatus =
| 'online'
| 'offline'
| 'running'
| 'stopped'
| 'degraded'
| 'paused'
| 'unknown';
// Metric value with optional limits
export interface ResourceMetric {
current: number; // Current value (percentage or bytes)
total?: number; // Total capacity (bytes) - null for percentages
used?: number; // Used amount (bytes)
free?: number; // Free amount (bytes)
}
// Network I/O metrics
export interface ResourceNetwork {
rxBytes: number; // Total bytes received
txBytes: number; // Total bytes transmitted
}
// Alert associated with a resource
export interface ResourceAlert {
id: string;
type: string; // cpu, memory, disk, temperature, etc.
level: string; // warning, critical
message: string;
value: number;
threshold: number;
startTime: number; // Unix milliseconds
}
// Identity information for deduplication
export interface ResourceIdentity {
hostname?: string;
machineId?: string;
ips?: string[];
}
/**
* The core unified Resource type.
* This is what the frontend receives from WebSocket state.resources[].
*/
export interface Resource {
// Identity
id: string;
type: ResourceType;
name: string;
displayName: string;
// Platform/Source
platformId: string;
platformType: PlatformType;
sourceType: SourceType;
// Hierarchy
parentId?: string; // Parent resource (e.g., VM -> Node)
clusterId?: string; // Cluster membership
// Universal Metrics
status: ResourceStatus;
cpu?: ResourceMetric;
memory?: ResourceMetric;
disk?: ResourceMetric;
network?: ResourceNetwork;
temperature?: number;
uptime?: number; // Seconds
// Metadata
tags?: string[];
labels?: Record<string, string>;
lastSeen: number; // Unix milliseconds
alerts?: ResourceAlert[];
// Identity for deduplication
identity?: ResourceIdentity;
// Platform-specific data (varies by type)
platformData?: Record<string, unknown>;
}
/**
* Helper type guards
*/
export function isInfrastructure(r: Resource): boolean {
return ['node', 'host', 'docker-host', 'k8s-node', 'truenas'].includes(r.type);
}
export function isWorkload(r: Resource): boolean {
return ['vm', 'container', 'docker-container', 'pod', 'jail'].includes(r.type);
}
export function isStorage(r: Resource): boolean {
return ['storage', 'datastore', 'pool', 'dataset'].includes(r.type);
}
/**
* Resource filtering options
*/
export interface ResourceFilter {
types?: ResourceType[];
platforms?: PlatformType[];
statuses?: ResourceStatus[];
parentId?: string;
clusterId?: string;
hasAlerts?: boolean;
search?: string;
}
/**
* Helper to get effective display name
*/
export function getDisplayName(r: Resource): string {
return r.displayName || r.name;
}
/**
* Helper to get CPU percentage
*/
export function getCpuPercent(r: Resource): number {
return r.cpu?.current ?? 0;
}
/**
* Helper to get memory percentage
*/
export function getMemoryPercent(r: Resource): number {
if (!r.memory) return 0;
if (r.memory.total && r.memory.used) {
return (r.memory.used / r.memory.total) * 100;
}
return r.memory.current;
}
/**
* Helper to get disk percentage
*/
export function getDiskPercent(r: Resource): number {
if (!r.disk) return 0;
if (r.disk.total && r.disk.used) {
return (r.disk.used / r.disk.total) * 100;
}
return r.disk.current;
}