From 5c831a327c174196c14ab2fc034a2af0234188d2 Mon Sep 17 00:00:00 2001 From: LordMathis Date: Thu, 24 Jul 2025 22:57:32 +0200 Subject: [PATCH] Implement working health check --- ui/src/components/HealthBadge.tsx | 73 ++++++++++++------ ui/src/components/InstanceCard.tsx | 11 +-- ui/src/components/InstanceList.tsx | 7 +- ui/src/contexts/InstancesContext.tsx | 106 +++++++++++++++------------ ui/src/hooks/useInstanceHealth.ts | 25 +++++++ ui/src/lib/healthService.ts | 95 +++++++++++++++--------- ui/src/types/instance.ts | 3 +- 7 files changed, 208 insertions(+), 112 deletions(-) create mode 100644 ui/src/hooks/useInstanceHealth.ts diff --git a/ui/src/components/HealthBadge.tsx b/ui/src/components/HealthBadge.tsx index 299c60c..2c5a2a0 100644 --- a/ui/src/components/HealthBadge.tsx +++ b/ui/src/components/HealthBadge.tsx @@ -1,49 +1,74 @@ // ui/src/components/HealthBadge.tsx -import React from 'react' -import { Badge } from '@/components/ui/badge' -import { HealthStatus } from '@/types/instance' -import { CheckCircle, Loader2, XCircle } from 'lucide-react' +import React from "react"; +import { Badge } from "@/components/ui/badge"; +import { HealthStatus } from "@/types/instance"; +import { CheckCircle, Loader2, XCircle } from "lucide-react"; interface HealthBadgeProps { - health?: HealthStatus + health?: HealthStatus; } const HealthBadge: React.FC = ({ health }) => { - if (!health) return null + if (!health) { + health = { + status: "unknown", // Default to unknown if not provided + lastChecked: new Date(), // Default to current date + message: undefined, // No message by default + }; + } const getIcon = () => { switch (health.status) { - case 'ok': return - case 'loading': return - case 'error': return + case "ok": + return ; + case "loading": + return ; + case "error": + return ; + case "unknown": + return ; } - } + }; const getVariant = () => { switch (health.status) { - case 'ok': return 'default' - case 'loading': return 'outline' - case 'error': return 'destructive' + case "ok": + return "default"; + case "loading": + return "outline"; + case "error": + return "destructive"; + case "unknown": + return "secondary"; } - } + }; const getText = () => { switch (health.status) { - case 'ok': return 'Ready' - case 'loading': return 'Loading' - case 'error': return 'Error' + case "ok": + return "Ready"; + case "loading": + return "Loading"; + case "error": + return "Error"; + case "unknown": + return "Unknown"; } - } + }; return ( - {getIcon()} {getText()} - ) -} + ); +}; -export default HealthBadge \ No newline at end of file +export default HealthBadge; diff --git a/ui/src/components/InstanceCard.tsx b/ui/src/components/InstanceCard.tsx index b33df0f..7a693a0 100644 --- a/ui/src/components/InstanceCard.tsx +++ b/ui/src/components/InstanceCard.tsx @@ -1,11 +1,12 @@ +// ui/src/components/InstanceCard.tsx import { Button } from "@/components/ui/button"; -import { Badge } from "@/components/ui/badge"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Instance } from "@/types/instance"; import { Edit, FileText, Play, Square, Trash2 } from "lucide-react"; import LogsModal from "@/components/LogModal"; -import { useState } from "react"; import HealthBadge from "@/components/HealthBadge"; +import { useState } from "react"; +import { useInstanceHealth } from "@/hooks/useInstanceHealth"; interface InstanceCardProps { instance: Instance; @@ -22,8 +23,8 @@ function InstanceCard({ deleteInstance, editInstance, }: InstanceCardProps) { - const [isLogsOpen, setIsLogsOpen] = useState(false); + const health = useInstanceHealth(instance.name, instance.running); const handleStart = () => { startInstance(instance.name); @@ -55,7 +56,7 @@ function InstanceCard({
{instance.name} - {instance.running ? : Stopped} + {instance.running && }
@@ -122,4 +123,4 @@ function InstanceCard({ ); } -export default InstanceCard; +export default InstanceCard; \ No newline at end of file diff --git a/ui/src/components/InstanceList.tsx b/ui/src/components/InstanceList.tsx index 1a5e0e5..1e2571b 100644 --- a/ui/src/components/InstanceList.tsx +++ b/ui/src/components/InstanceList.tsx @@ -1,11 +1,16 @@ +// ui/src/components/InstanceList.tsx import { useInstances } from '@/contexts/InstancesContext' import InstanceCard from '@/components/InstanceCard' import { Instance } from '@/types/instance' +import { memo } from 'react' interface InstanceListProps { editInstance: (instance: Instance) => void } +// Memoize InstanceCard to prevent re-renders when other instances change +const MemoizedInstanceCard = memo(InstanceCard) + function InstanceList({ editInstance }: InstanceListProps) { const { instances, loading, error, startInstance, stopInstance, deleteInstance } = useInstances() @@ -48,7 +53,7 @@ function InstanceList({ editInstance }: InstanceListProps) {
{instances.map((instance) => ( - { - const [instances, setInstances] = useState([]) + const [instancesMap, setInstancesMap] = useState>(new Map()) const [loading, setLoading] = useState(true) const [error, setError] = useState(null) + // Convert map to array for consumers + const instances = Array.from(instancesMap.values()) + const clearError = useCallback(() => { setError(null) }, []) - const updateInstanceHealth = useCallback((instanceName: string, health: HealthStatus) => { - setInstances(prev => prev.map(instance => - instance.name === instanceName - ? { ...instance, health } - : instance - )) - }, []) - - useEffect(() => { - instances.forEach(instance => { - if (instance.running) { - healthService.startHealthCheck( - instance.name, - (health) => updateInstanceHealth(instance.name, health) - ) - } else { - healthService.stopHealthCheck(instance.name) - } - }) - }, [instances, updateInstanceHealth]) - - useEffect(() => { - return () => healthService.stopAll() - }, []) - const fetchInstances = useCallback(async () => { try { setLoading(true) setError(null) const data = await instancesApi.list() - setInstances(data) + + // Convert array to map + const newMap = new Map() + data.forEach(instance => { + newMap.set(instance.name, instance) + }) + setInstancesMap(newMap) } catch (err) { setError(err instanceof Error ? err.message : 'Failed to fetch instances') } finally { @@ -75,67 +58,100 @@ export const InstancesProvider = ({ children }: InstancesProviderProps) => { } }, []) + const updateInstanceInMap = useCallback((name: string, updates: Partial) => { + setInstancesMap(prev => { + const newMap = new Map(prev) + const existing = newMap.get(name) + if (existing) { + newMap.set(name, { ...existing, ...updates }) + } + return newMap + }) + }, []) + const createInstance = useCallback(async (name: string, options: CreateInstanceOptions) => { try { setError(null) - await instancesApi.create(name, options) - await fetchInstances() + const newInstance = await instancesApi.create(name, options) + + // Add to map directly + setInstancesMap(prev => { + const newMap = new Map(prev) + newMap.set(name, newInstance) + return newMap + }) } catch (err) { setError(err instanceof Error ? err.message : 'Failed to create instance') } - }, [fetchInstances]) + }, []) const updateInstance = useCallback(async (name: string, options: CreateInstanceOptions) => { try { setError(null) - await instancesApi.update(name, options) - await fetchInstances() + const updatedInstance = await instancesApi.update(name, options) + + // Update in map directly + setInstancesMap(prev => { + const newMap = new Map(prev) + newMap.set(name, updatedInstance) + return newMap + }) } catch (err) { setError(err instanceof Error ? err.message : 'Failed to update instance') } - }, [fetchInstances]) + }, []) const startInstance = useCallback(async (name: string) => { try { setError(null) await instancesApi.start(name) - await fetchInstances() + + // Update only this instance's running status + updateInstanceInMap(name, { running: true }) } catch (err) { setError(err instanceof Error ? err.message : 'Failed to start instance') } - }, [fetchInstances]) + }, [updateInstanceInMap]) const stopInstance = useCallback(async (name: string) => { try { setError(null) - healthService.stopHealthCheck(name) await instancesApi.stop(name) - await fetchInstances() + + // Update only this instance's running status + updateInstanceInMap(name, { running: false }) } catch (err) { setError(err instanceof Error ? err.message : 'Failed to stop instance') } - }, [fetchInstances]) + }, [updateInstanceInMap]) const restartInstance = useCallback(async (name: string) => { try { setError(null) await instancesApi.restart(name) - await fetchInstances() + + // Update only this instance's running status + updateInstanceInMap(name, { running: true }) } catch (err) { setError(err instanceof Error ? err.message : 'Failed to restart instance') } - }, [fetchInstances]) + }, [updateInstanceInMap]) const deleteInstance = useCallback(async (name: string) => { try { setError(null) - healthService.stopHealthCheck(name) await instancesApi.delete(name) - await fetchInstances() + + // Remove from map directly + setInstancesMap(prev => { + const newMap = new Map(prev) + newMap.delete(name) + return newMap + }) } catch (err) { setError(err instanceof Error ? err.message : 'Failed to delete instance') } - }, [fetchInstances]) + }, []) useEffect(() => { fetchInstances() diff --git a/ui/src/hooks/useInstanceHealth.ts b/ui/src/hooks/useInstanceHealth.ts new file mode 100644 index 0000000..78282a7 --- /dev/null +++ b/ui/src/hooks/useInstanceHealth.ts @@ -0,0 +1,25 @@ +// ui/src/hooks/useInstanceHealth.ts +import { useState, useEffect } from 'react' +import { HealthStatus } from '@/types/instance' +import { healthService } from '@/lib/healthService' + +export function useInstanceHealth(instanceName: string, isRunning: boolean): HealthStatus | undefined { + const [health, setHealth] = useState() + + useEffect(() => { + if (!isRunning) { + setHealth(undefined) + return + } + + // Subscribe to health updates for this instance + const unsubscribe = healthService.subscribe(instanceName, (healthStatus) => { + setHealth(healthStatus) + }) + + // Cleanup subscription on unmount or when running changes + return unsubscribe + }, [instanceName, isRunning]) + + return health +} \ No newline at end of file diff --git a/ui/src/lib/healthService.ts b/ui/src/lib/healthService.ts index e03bb75..5daa475 100644 --- a/ui/src/lib/healthService.ts +++ b/ui/src/lib/healthService.ts @@ -1,9 +1,10 @@ -// ui/src/lib/healthService.ts import { HealthStatus } from '@/types/instance' +type HealthCallback = (health: HealthStatus) => void + class HealthService { private intervals: Map = new Map() - private startupTimeouts: Map = new Map() + private callbacks: Map> = new Map() async checkHealth(instanceName: string): Promise { try { @@ -37,37 +38,54 @@ class HealthService { } } - startHealthCheck(instanceName: string, onUpdate: (health: HealthStatus) => void): void { - // Don't start if already checking - if (this.isChecking(instanceName)) { - return + subscribe(instanceName: string, callback: HealthCallback): () => void { + if (!this.callbacks.has(instanceName)) { + this.callbacks.set(instanceName, new Set()) } - const startupTimeout = setTimeout(() => { - this.startupTimeouts.delete(instanceName) - - const check = async () => { - const health = await this.checkHealth(instanceName) - onUpdate(health) + this.callbacks.get(instanceName)!.add(callback) + + // Start health checking if this is the first subscriber + if (this.callbacks.get(instanceName)!.size === 1) { + this.startHealthCheck(instanceName) + } + + // Return unsubscribe function + return () => { + const callbacks = this.callbacks.get(instanceName) + if (callbacks) { + callbacks.delete(callback) + + // Stop health checking if no more subscribers + if (callbacks.size === 0) { + this.stopHealthCheck(instanceName) + this.callbacks.delete(instanceName) + } } - - check() - const interval = setInterval(check, 60000) - this.intervals.set(instanceName, interval) - }, 2000) - - this.startupTimeouts.set(instanceName, startupTimeout) + } } - stopHealthCheck(instanceName: string): void { - // Clear startup timeout if exists - const startupTimeout = this.startupTimeouts.get(instanceName) - if (startupTimeout) { - clearTimeout(startupTimeout) - this.startupTimeouts.delete(instanceName) + private startHealthCheck(instanceName: string): void { + if (this.intervals.has(instanceName)) { + return // Already checking } - - // Clear interval if exists + + // Initial check with delay + setTimeout(async () => { + const health = await this.checkHealth(instanceName) + this.notifyCallbacks(instanceName, health) + + // Start periodic checks + const interval = setInterval(async () => { + const health = await this.checkHealth(instanceName) + this.notifyCallbacks(instanceName, health) + }, 60000) + + this.intervals.set(instanceName, interval) + }, 2000) + } + + private stopHealthCheck(instanceName: string): void { const interval = this.intervals.get(instanceName) if (interval) { clearInterval(interval) @@ -75,16 +93,23 @@ class HealthService { } } - stopAll(): void { - this.startupTimeouts.forEach(timeout => clearTimeout(timeout)) - this.startupTimeouts.clear() - this.intervals.forEach(interval => clearInterval(interval)) - this.intervals.clear() + private notifyCallbacks(instanceName: string, health: HealthStatus): void { + const callbacks = this.callbacks.get(instanceName) + if (callbacks) { + callbacks.forEach(callback => callback(health)) + } } - isChecking(instanceName: string): boolean { - return this.intervals.has(instanceName) || this.startupTimeouts.has(instanceName) + stopAll(): void { + this.intervals.forEach(interval => clearInterval(interval)) + this.intervals.clear() + this.callbacks.clear() } } -export const healthService = new HealthService() \ No newline at end of file +export const healthService = new HealthService() + +// Export the individual checkHealth function as well +export async function checkHealth(instanceName: string): Promise { + return healthService.checkHealth(instanceName) +} \ No newline at end of file diff --git a/ui/src/types/instance.ts b/ui/src/types/instance.ts index 09e1b08..fe3935c 100644 --- a/ui/src/types/instance.ts +++ b/ui/src/types/instance.ts @@ -3,7 +3,7 @@ import { CreateInstanceOptions } from '@/schemas/instanceOptions' export { type CreateInstanceOptions } from '@/schemas/instanceOptions' export interface HealthStatus { - status: 'ok' | 'loading' | 'error' + status: 'ok' | 'loading' | 'error' | 'unknown' message?: string lastChecked: Date } @@ -12,5 +12,4 @@ export interface Instance { name: string; running: boolean; options?: CreateInstanceOptions; - health?: HealthStatus; } \ No newline at end of file