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:
parent
cdd2249e00
commit
55f10ce36c
3 changed files with 513 additions and 0 deletions
315
frontend-modern/src/hooks/useResources.ts
Normal file
315
frontend-modern/src/hooks/useResources.ts
Normal 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 };
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
194
frontend-modern/src/types/resource.ts
Normal file
194
frontend-modern/src/types/resource.ts
Normal 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;
|
||||
}
|
||||
Loading…
Reference in a new issue