Implement working health check

This commit is contained in:
2025-07-24 22:57:32 +02:00
parent 1ffc39c75d
commit 5c831a327c
7 changed files with 208 additions and 112 deletions

View File

@@ -1,49 +1,74 @@
// ui/src/components/HealthBadge.tsx // ui/src/components/HealthBadge.tsx
import React from 'react' import React from "react";
import { Badge } from '@/components/ui/badge' import { Badge } from "@/components/ui/badge";
import { HealthStatus } from '@/types/instance' import { HealthStatus } from "@/types/instance";
import { CheckCircle, Loader2, XCircle } from 'lucide-react' import { CheckCircle, Loader2, XCircle } from "lucide-react";
interface HealthBadgeProps { interface HealthBadgeProps {
health?: HealthStatus health?: HealthStatus;
} }
const HealthBadge: React.FC<HealthBadgeProps> = ({ health }) => { const HealthBadge: React.FC<HealthBadgeProps> = ({ 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 = () => { const getIcon = () => {
switch (health.status) { switch (health.status) {
case 'ok': return <CheckCircle className="h-3 w-3" /> case "ok":
case 'loading': return <Loader2 className="h-3 w-3 animate-spin" /> return <CheckCircle className="h-3 w-3" />;
case 'error': return <XCircle className="h-3 w-3" /> case "loading":
} return <Loader2 className="h-3 w-3 animate-spin" />;
case "error":
return <XCircle className="h-3 w-3" />;
case "unknown":
return <Loader2 className="h-3 w-3 animate-spin" />;
} }
};
const getVariant = () => { const getVariant = () => {
switch (health.status) { switch (health.status) {
case 'ok': return 'default' case "ok":
case 'loading': return 'outline' return "default";
case 'error': return 'destructive' case "loading":
} return "outline";
case "error":
return "destructive";
case "unknown":
return "secondary";
} }
};
const getText = () => { const getText = () => {
switch (health.status) { switch (health.status) {
case 'ok': return 'Ready' case "ok":
case 'loading': return 'Loading' return "Ready";
case 'error': return 'Error' case "loading":
} return "Loading";
case "error":
return "Error";
case "unknown":
return "Unknown";
} }
};
return ( return (
<Badge <Badge
variant={getVariant()} variant={getVariant()}
className={`flex items-center gap-1.5 ${health.status === 'ok' ? 'bg-green-100 text-green-800 border-green-200 dark:bg-green-900 dark:text-green-200 dark:border-green-800' : ''}`} className={`flex items-center gap-1.5 ${
health.status === "ok"
? "bg-green-100 text-green-800 border-green-200 dark:bg-green-900 dark:text-green-200 dark:border-green-800"
: ""
}`}
> >
{getIcon()} {getIcon()}
<span className="text-xs">{getText()}</span> <span className="text-xs">{getText()}</span>
</Badge> </Badge>
) );
} };
export default HealthBadge export default HealthBadge;

View File

@@ -1,11 +1,12 @@
// ui/src/components/InstanceCard.tsx
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Instance } from "@/types/instance"; import { Instance } from "@/types/instance";
import { Edit, FileText, Play, Square, Trash2 } from "lucide-react"; import { Edit, FileText, Play, Square, Trash2 } from "lucide-react";
import LogsModal from "@/components/LogModal"; import LogsModal from "@/components/LogModal";
import { useState } from "react";
import HealthBadge from "@/components/HealthBadge"; import HealthBadge from "@/components/HealthBadge";
import { useState } from "react";
import { useInstanceHealth } from "@/hooks/useInstanceHealth";
interface InstanceCardProps { interface InstanceCardProps {
instance: Instance; instance: Instance;
@@ -22,8 +23,8 @@ function InstanceCard({
deleteInstance, deleteInstance,
editInstance, editInstance,
}: InstanceCardProps) { }: InstanceCardProps) {
const [isLogsOpen, setIsLogsOpen] = useState(false); const [isLogsOpen, setIsLogsOpen] = useState(false);
const health = useInstanceHealth(instance.name, instance.running);
const handleStart = () => { const handleStart = () => {
startInstance(instance.name); startInstance(instance.name);
@@ -55,7 +56,7 @@ function InstanceCard({
<CardHeader className="pb-3"> <CardHeader className="pb-3">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<CardTitle className="text-lg">{instance.name}</CardTitle> <CardTitle className="text-lg">{instance.name}</CardTitle>
{instance.running ? <HealthBadge health={instance.health} /> : <Badge variant="secondary">Stopped</Badge>} {instance.running && <HealthBadge health={health} />}
</div> </div>
</CardHeader> </CardHeader>

View File

@@ -1,11 +1,16 @@
// ui/src/components/InstanceList.tsx
import { useInstances } from '@/contexts/InstancesContext' import { useInstances } from '@/contexts/InstancesContext'
import InstanceCard from '@/components/InstanceCard' import InstanceCard from '@/components/InstanceCard'
import { Instance } from '@/types/instance' import { Instance } from '@/types/instance'
import { memo } from 'react'
interface InstanceListProps { interface InstanceListProps {
editInstance: (instance: Instance) => void editInstance: (instance: Instance) => void
} }
// Memoize InstanceCard to prevent re-renders when other instances change
const MemoizedInstanceCard = memo(InstanceCard)
function InstanceList({ editInstance }: InstanceListProps) { function InstanceList({ editInstance }: InstanceListProps) {
const { instances, loading, error, startInstance, stopInstance, deleteInstance } = useInstances() const { instances, loading, error, startInstance, stopInstance, deleteInstance } = useInstances()
@@ -48,7 +53,7 @@ function InstanceList({ editInstance }: InstanceListProps) {
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3"> <div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
{instances.map((instance) => ( {instances.map((instance) => (
<InstanceCard <MemoizedInstanceCard
key={instance.name} key={instance.name}
instance={instance} instance={instance}
startInstance={startInstance} startInstance={startInstance}

View File

@@ -1,7 +1,6 @@
import { createContext, useContext, useState, useEffect, useCallback, ReactNode } from 'react' import { createContext, useContext, useState, useEffect, useCallback, ReactNode } from 'react'
import { CreateInstanceOptions, Instance, HealthStatus } from '@/types/instance' import { CreateInstanceOptions, Instance } from '@/types/instance'
import { instancesApi } from '@/lib/api' import { instancesApi } from '@/lib/api'
import { healthService } from '@/lib/healthService'
interface InstancesContextState { interface InstancesContextState {
instances: Instance[] instances: Instance[]
@@ -29,45 +28,29 @@ interface InstancesProviderProps {
} }
export const InstancesProvider = ({ children }: InstancesProviderProps) => { export const InstancesProvider = ({ children }: InstancesProviderProps) => {
const [instances, setInstances] = useState<Instance[]>([]) const [instancesMap, setInstancesMap] = useState<Map<string, Instance>>(new Map())
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null) const [error, setError] = useState<string | null>(null)
// Convert map to array for consumers
const instances = Array.from(instancesMap.values())
const clearError = useCallback(() => { const clearError = useCallback(() => {
setError(null) 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 () => { const fetchInstances = useCallback(async () => {
try { try {
setLoading(true) setLoading(true)
setError(null) setError(null)
const data = await instancesApi.list() const data = await instancesApi.list()
setInstances(data)
// Convert array to map
const newMap = new Map<string, Instance>()
data.forEach(instance => {
newMap.set(instance.name, instance)
})
setInstancesMap(newMap)
} catch (err) { } catch (err) {
setError(err instanceof Error ? err.message : 'Failed to fetch instances') setError(err instanceof Error ? err.message : 'Failed to fetch instances')
} finally { } finally {
@@ -75,67 +58,100 @@ export const InstancesProvider = ({ children }: InstancesProviderProps) => {
} }
}, []) }, [])
const updateInstanceInMap = useCallback((name: string, updates: Partial<Instance>) => {
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) => { const createInstance = useCallback(async (name: string, options: CreateInstanceOptions) => {
try { try {
setError(null) setError(null)
await instancesApi.create(name, options) const newInstance = await instancesApi.create(name, options)
await fetchInstances()
// Add to map directly
setInstancesMap(prev => {
const newMap = new Map(prev)
newMap.set(name, newInstance)
return newMap
})
} catch (err) { } catch (err) {
setError(err instanceof Error ? err.message : 'Failed to create instance') setError(err instanceof Error ? err.message : 'Failed to create instance')
} }
}, [fetchInstances]) }, [])
const updateInstance = useCallback(async (name: string, options: CreateInstanceOptions) => { const updateInstance = useCallback(async (name: string, options: CreateInstanceOptions) => {
try { try {
setError(null) setError(null)
await instancesApi.update(name, options) const updatedInstance = await instancesApi.update(name, options)
await fetchInstances()
// Update in map directly
setInstancesMap(prev => {
const newMap = new Map(prev)
newMap.set(name, updatedInstance)
return newMap
})
} catch (err) { } catch (err) {
setError(err instanceof Error ? err.message : 'Failed to update instance') setError(err instanceof Error ? err.message : 'Failed to update instance')
} }
}, [fetchInstances]) }, [])
const startInstance = useCallback(async (name: string) => { const startInstance = useCallback(async (name: string) => {
try { try {
setError(null) setError(null)
await instancesApi.start(name) await instancesApi.start(name)
await fetchInstances()
// Update only this instance's running status
updateInstanceInMap(name, { running: true })
} catch (err) { } catch (err) {
setError(err instanceof Error ? err.message : 'Failed to start instance') setError(err instanceof Error ? err.message : 'Failed to start instance')
} }
}, [fetchInstances]) }, [updateInstanceInMap])
const stopInstance = useCallback(async (name: string) => { const stopInstance = useCallback(async (name: string) => {
try { try {
setError(null) setError(null)
healthService.stopHealthCheck(name)
await instancesApi.stop(name) await instancesApi.stop(name)
await fetchInstances()
// Update only this instance's running status
updateInstanceInMap(name, { running: false })
} catch (err) { } catch (err) {
setError(err instanceof Error ? err.message : 'Failed to stop instance') setError(err instanceof Error ? err.message : 'Failed to stop instance')
} }
}, [fetchInstances]) }, [updateInstanceInMap])
const restartInstance = useCallback(async (name: string) => { const restartInstance = useCallback(async (name: string) => {
try { try {
setError(null) setError(null)
await instancesApi.restart(name) await instancesApi.restart(name)
await fetchInstances()
// Update only this instance's running status
updateInstanceInMap(name, { running: true })
} catch (err) { } catch (err) {
setError(err instanceof Error ? err.message : 'Failed to restart instance') setError(err instanceof Error ? err.message : 'Failed to restart instance')
} }
}, [fetchInstances]) }, [updateInstanceInMap])
const deleteInstance = useCallback(async (name: string) => { const deleteInstance = useCallback(async (name: string) => {
try { try {
setError(null) setError(null)
healthService.stopHealthCheck(name)
await instancesApi.delete(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) { } catch (err) {
setError(err instanceof Error ? err.message : 'Failed to delete instance') setError(err instanceof Error ? err.message : 'Failed to delete instance')
} }
}, [fetchInstances]) }, [])
useEffect(() => { useEffect(() => {
fetchInstances() fetchInstances()

View File

@@ -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<HealthStatus | undefined>()
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
}

View File

@@ -1,9 +1,10 @@
// ui/src/lib/healthService.ts
import { HealthStatus } from '@/types/instance' import { HealthStatus } from '@/types/instance'
type HealthCallback = (health: HealthStatus) => void
class HealthService { class HealthService {
private intervals: Map<string, NodeJS.Timeout> = new Map() private intervals: Map<string, NodeJS.Timeout> = new Map()
private startupTimeouts: Map<string, NodeJS.Timeout> = new Map() private callbacks: Map<string, Set<HealthCallback>> = new Map()
async checkHealth(instanceName: string): Promise<HealthStatus> { async checkHealth(instanceName: string): Promise<HealthStatus> {
try { try {
@@ -37,37 +38,54 @@ class HealthService {
} }
} }
startHealthCheck(instanceName: string, onUpdate: (health: HealthStatus) => void): void { subscribe(instanceName: string, callback: HealthCallback): () => void {
// Don't start if already checking if (!this.callbacks.has(instanceName)) {
if (this.isChecking(instanceName)) { this.callbacks.set(instanceName, new Set())
return
} }
const startupTimeout = setTimeout(() => { this.callbacks.get(instanceName)!.add(callback)
this.startupTimeouts.delete(instanceName)
const check = async () => { // 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)
}
}
}
}
private startHealthCheck(instanceName: string): void {
if (this.intervals.has(instanceName)) {
return // Already checking
}
// Initial check with delay
setTimeout(async () => {
const health = await this.checkHealth(instanceName) const health = await this.checkHealth(instanceName)
onUpdate(health) this.notifyCallbacks(instanceName, health)
}
// Start periodic checks
const interval = setInterval(async () => {
const health = await this.checkHealth(instanceName)
this.notifyCallbacks(instanceName, health)
}, 60000)
check()
const interval = setInterval(check, 60000)
this.intervals.set(instanceName, interval) this.intervals.set(instanceName, interval)
}, 2000) }, 2000)
this.startupTimeouts.set(instanceName, startupTimeout)
} }
stopHealthCheck(instanceName: string): void { private 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) const interval = this.intervals.get(instanceName)
if (interval) { if (interval) {
clearInterval(interval) clearInterval(interval)
@@ -75,16 +93,23 @@ class HealthService {
} }
} }
stopAll(): void { private notifyCallbacks(instanceName: string, health: HealthStatus): void {
this.startupTimeouts.forEach(timeout => clearTimeout(timeout)) const callbacks = this.callbacks.get(instanceName)
this.startupTimeouts.clear() if (callbacks) {
this.intervals.forEach(interval => clearInterval(interval)) callbacks.forEach(callback => callback(health))
this.intervals.clear() }
} }
isChecking(instanceName: string): boolean { stopAll(): void {
return this.intervals.has(instanceName) || this.startupTimeouts.has(instanceName) this.intervals.forEach(interval => clearInterval(interval))
this.intervals.clear()
this.callbacks.clear()
} }
} }
export const healthService = new HealthService() export const healthService = new HealthService()
// Export the individual checkHealth function as well
export async function checkHealth(instanceName: string): Promise<HealthStatus> {
return healthService.checkHealth(instanceName)
}

View File

@@ -3,7 +3,7 @@ import { CreateInstanceOptions } from '@/schemas/instanceOptions'
export { type CreateInstanceOptions } from '@/schemas/instanceOptions' export { type CreateInstanceOptions } from '@/schemas/instanceOptions'
export interface HealthStatus { export interface HealthStatus {
status: 'ok' | 'loading' | 'error' status: 'ok' | 'loading' | 'error' | 'unknown'
message?: string message?: string
lastChecked: Date lastChecked: Date
} }
@@ -12,5 +12,4 @@ export interface Instance {
name: string; name: string;
running: boolean; running: boolean;
options?: CreateInstanceOptions; options?: CreateInstanceOptions;
health?: HealthStatus;
} }