From 1ffc39c75dcd8068a4e137f473ff7d63d7faa3f7 Mon Sep 17 00:00:00 2001 From: LordMathis Date: Thu, 24 Jul 2025 21:39:44 +0200 Subject: [PATCH] Initial healthcheck implementation --- ui/src/components/HealthBadge.tsx | 49 ++++++++++++++ ui/src/components/InstanceCard.tsx | 7 +- ui/src/components/InstanceModal.tsx | 1 - ui/src/components/{ui => }/LogModal.tsx | 2 +- ui/src/components/SystemInfoModal.tsx | 1 - ui/src/contexts/InstancesContext.tsx | 35 ++++++++-- ui/src/lib/healthService.ts | 90 +++++++++++++++++++++++++ ui/src/types/instance.ts | 7 ++ 8 files changed, 180 insertions(+), 12 deletions(-) create mode 100644 ui/src/components/HealthBadge.tsx rename ui/src/components/{ui => }/LogModal.tsx (99%) create mode 100644 ui/src/lib/healthService.ts diff --git a/ui/src/components/HealthBadge.tsx b/ui/src/components/HealthBadge.tsx new file mode 100644 index 0000000..299c60c --- /dev/null +++ b/ui/src/components/HealthBadge.tsx @@ -0,0 +1,49 @@ +// 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' + +interface HealthBadgeProps { + health?: HealthStatus +} + +const HealthBadge: React.FC = ({ health }) => { + if (!health) return null + + const getIcon = () => { + switch (health.status) { + case 'ok': return + case 'loading': return + case 'error': return + } + } + + const getVariant = () => { + switch (health.status) { + case 'ok': return 'default' + case 'loading': return 'outline' + case 'error': return 'destructive' + } + } + + const getText = () => { + switch (health.status) { + case 'ok': return 'Ready' + case 'loading': return 'Loading' + case 'error': return 'Error' + } + } + + return ( + + {getIcon()} + {getText()} + + ) +} + +export default HealthBadge \ No newline at end of file diff --git a/ui/src/components/InstanceCard.tsx b/ui/src/components/InstanceCard.tsx index 3214eec..b33df0f 100644 --- a/ui/src/components/InstanceCard.tsx +++ b/ui/src/components/InstanceCard.tsx @@ -3,8 +3,9 @@ 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/ui/LogModal"; +import LogsModal from "@/components/LogModal"; import { useState } from "react"; +import HealthBadge from "@/components/HealthBadge"; interface InstanceCardProps { instance: Instance; @@ -54,9 +55,7 @@ function InstanceCard({
{instance.name} - - {instance.running ? "Running" : "Stopped"} - + {instance.running ? : Stopped}
diff --git a/ui/src/components/InstanceModal.tsx b/ui/src/components/InstanceModal.tsx index 9390d99..f213ddc 100644 --- a/ui/src/components/InstanceModal.tsx +++ b/ui/src/components/InstanceModal.tsx @@ -1,4 +1,3 @@ -// ui/src/components/InstanceModal.tsx import React, { useState, useEffect } from 'react' import { Button } from '@/components/ui/button' import { Input } from '@/components/ui/input' diff --git a/ui/src/components/ui/LogModal.tsx b/ui/src/components/LogModal.tsx similarity index 99% rename from ui/src/components/ui/LogModal.tsx rename to ui/src/components/LogModal.tsx index 9d16f68..5533e93 100644 --- a/ui/src/components/ui/LogModal.tsx +++ b/ui/src/components/LogModal.tsx @@ -171,7 +171,7 @@ const LogsModal: React.FC = ({ return ( - +
diff --git a/ui/src/components/SystemInfoModal.tsx b/ui/src/components/SystemInfoModal.tsx index 3386677..add3c7c 100644 --- a/ui/src/components/SystemInfoModal.tsx +++ b/ui/src/components/SystemInfoModal.tsx @@ -1,4 +1,3 @@ -// ui/src/components/SystemInfoModal.tsx import React, { useState, useEffect } from 'react' import { Button } from '@/components/ui/button' import { diff --git a/ui/src/contexts/InstancesContext.tsx b/ui/src/contexts/InstancesContext.tsx index 2a9d214..4b5cc4f 100644 --- a/ui/src/contexts/InstancesContext.tsx +++ b/ui/src/contexts/InstancesContext.tsx @@ -1,6 +1,7 @@ import { createContext, useContext, useState, useEffect, useCallback, ReactNode } from 'react' -import { CreateInstanceOptions, Instance } from '@/types/instance' +import { CreateInstanceOptions, Instance, HealthStatus } from '@/types/instance' import { instancesApi } from '@/lib/api' +import { healthService } from '@/lib/healthService' interface InstancesContextState { instances: Instance[] @@ -36,6 +37,31 @@ export const InstancesProvider = ({ children }: InstancesProviderProps) => { 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) @@ -82,6 +108,7 @@ export const InstancesProvider = ({ children }: InstancesProviderProps) => { const stopInstance = useCallback(async (name: string) => { try { setError(null) + healthService.stopHealthCheck(name) await instancesApi.stop(name) await fetchInstances() } catch (err) { @@ -102,6 +129,7 @@ export const InstancesProvider = ({ children }: InstancesProviderProps) => { const deleteInstance = useCallback(async (name: string) => { try { setError(null) + healthService.stopHealthCheck(name) await instancesApi.delete(name) await fetchInstances() } catch (err) { @@ -109,17 +137,14 @@ export const InstancesProvider = ({ children }: InstancesProviderProps) => { } }, [fetchInstances]) - // Fetch instances on mount useEffect(() => { fetchInstances() }, [fetchInstances]) const value: InstancesContextType = { - // State instances, loading, error, - // Actions fetchInstances, createInstance, updateInstance, @@ -143,4 +168,4 @@ export const useInstances = (): InstancesContextType => { throw new Error('useInstances must be used within an InstancesProvider') } return context -} +} \ No newline at end of file diff --git a/ui/src/lib/healthService.ts b/ui/src/lib/healthService.ts new file mode 100644 index 0000000..e03bb75 --- /dev/null +++ b/ui/src/lib/healthService.ts @@ -0,0 +1,90 @@ +// ui/src/lib/healthService.ts +import { HealthStatus } from '@/types/instance' + +class HealthService { + private intervals: Map = new Map() + private startupTimeouts: Map = new Map() + + async checkHealth(instanceName: string): Promise { + try { + const response = await fetch(`/api/v1/instances/${instanceName}/proxy/health`) + + if (response.status === 200) { + return { + status: 'ok', + lastChecked: new Date() + } + } else if (response.status === 503) { + const data = await response.json() + return { + status: 'loading', + message: data.error.message, + lastChecked: new Date() + } + } else { + return { + status: 'error', + message: `HTTP ${response.status}`, + lastChecked: new Date() + } + } + } catch (error) { + return { + status: 'error', + message: 'Network error', + lastChecked: new Date() + } + } + } + + startHealthCheck(instanceName: string, onUpdate: (health: HealthStatus) => void): void { + // Don't start if already checking + if (this.isChecking(instanceName)) { + return + } + + const startupTimeout = setTimeout(() => { + this.startupTimeouts.delete(instanceName) + + const check = async () => { + const health = await this.checkHealth(instanceName) + onUpdate(health) + } + + 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) + } + + // Clear interval if exists + const interval = this.intervals.get(instanceName) + if (interval) { + clearInterval(interval) + this.intervals.delete(instanceName) + } + } + + stopAll(): void { + this.startupTimeouts.forEach(timeout => clearTimeout(timeout)) + this.startupTimeouts.clear() + this.intervals.forEach(interval => clearInterval(interval)) + this.intervals.clear() + } + + isChecking(instanceName: string): boolean { + return this.intervals.has(instanceName) || this.startupTimeouts.has(instanceName) + } +} + +export const healthService = new HealthService() \ No newline at end of file diff --git a/ui/src/types/instance.ts b/ui/src/types/instance.ts index 4c4dccc..09e1b08 100644 --- a/ui/src/types/instance.ts +++ b/ui/src/types/instance.ts @@ -2,8 +2,15 @@ import { CreateInstanceOptions } from '@/schemas/instanceOptions' export { type CreateInstanceOptions } from '@/schemas/instanceOptions' +export interface HealthStatus { + status: 'ok' | 'loading' | 'error' + message?: string + lastChecked: Date +} + export interface Instance { name: string; running: boolean; options?: CreateInstanceOptions; + health?: HealthStatus; } \ No newline at end of file