Initial healthcheck implementation

This commit is contained in:
2025-07-24 21:39:44 +02:00
parent cf8d581275
commit 1ffc39c75d
8 changed files with 180 additions and 12 deletions

View File

@@ -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<HealthBadgeProps> = ({ health }) => {
if (!health) return null
const getIcon = () => {
switch (health.status) {
case 'ok': return <CheckCircle 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" />
}
}
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 (
<Badge
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' : ''}`}
>
{getIcon()}
<span className="text-xs">{getText()}</span>
</Badge>
)
}
export default HealthBadge

View File

@@ -3,8 +3,9 @@ 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/ui/LogModal"; import LogsModal from "@/components/LogModal";
import { useState } from "react"; import { useState } from "react";
import HealthBadge from "@/components/HealthBadge";
interface InstanceCardProps { interface InstanceCardProps {
instance: Instance; instance: Instance;
@@ -54,9 +55,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>
<Badge variant={instance.running ? "default" : "secondary"}> {instance.running ? <HealthBadge health={instance.health} /> : <Badge variant="secondary">Stopped</Badge>}
{instance.running ? "Running" : "Stopped"}
</Badge>
</div> </div>
</CardHeader> </CardHeader>

View File

@@ -1,4 +1,3 @@
// ui/src/components/InstanceModal.tsx
import React, { useState, useEffect } from 'react' import React, { useState, useEffect } from 'react'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input' import { Input } from '@/components/ui/input'

View File

@@ -171,7 +171,7 @@ const LogsModal: React.FC<LogsModalProps> = ({
return ( return (
<Dialog open={open} onOpenChange={onOpenChange}> <Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-4xl max-h-[80vh] flex flex-col"> <DialogContent className="sm:max-w-4xl max-w-[calc(100%-2rem)] max-h-[80vh] flex flex-col">
<DialogHeader> <DialogHeader>
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div> <div>

View File

@@ -1,4 +1,3 @@
// ui/src/components/SystemInfoModal.tsx
import React, { useState, useEffect } from 'react' import React, { useState, useEffect } from 'react'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { import {

View File

@@ -1,6 +1,7 @@
import { createContext, useContext, useState, useEffect, useCallback, ReactNode } from 'react' 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 { instancesApi } from '@/lib/api'
import { healthService } from '@/lib/healthService'
interface InstancesContextState { interface InstancesContextState {
instances: Instance[] instances: Instance[]
@@ -36,6 +37,31 @@ export const InstancesProvider = ({ children }: InstancesProviderProps) => {
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)
@@ -82,6 +108,7 @@ export const InstancesProvider = ({ children }: InstancesProviderProps) => {
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() await fetchInstances()
} catch (err) { } catch (err) {
@@ -102,6 +129,7 @@ export const InstancesProvider = ({ children }: InstancesProviderProps) => {
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() await fetchInstances()
} catch (err) { } catch (err) {
@@ -109,17 +137,14 @@ export const InstancesProvider = ({ children }: InstancesProviderProps) => {
} }
}, [fetchInstances]) }, [fetchInstances])
// Fetch instances on mount
useEffect(() => { useEffect(() => {
fetchInstances() fetchInstances()
}, [fetchInstances]) }, [fetchInstances])
const value: InstancesContextType = { const value: InstancesContextType = {
// State
instances, instances,
loading, loading,
error, error,
// Actions
fetchInstances, fetchInstances,
createInstance, createInstance,
updateInstance, updateInstance,
@@ -143,4 +168,4 @@ export const useInstances = (): InstancesContextType => {
throw new Error('useInstances must be used within an InstancesProvider') throw new Error('useInstances must be used within an InstancesProvider')
} }
return context return context
} }

View File

@@ -0,0 +1,90 @@
// ui/src/lib/healthService.ts
import { HealthStatus } from '@/types/instance'
class HealthService {
private intervals: Map<string, NodeJS.Timeout> = new Map()
private startupTimeouts: Map<string, NodeJS.Timeout> = new Map()
async checkHealth(instanceName: string): Promise<HealthStatus> {
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()

View File

@@ -2,8 +2,15 @@ import { CreateInstanceOptions } from '@/schemas/instanceOptions'
export { type CreateInstanceOptions } from '@/schemas/instanceOptions' export { type CreateInstanceOptions } from '@/schemas/instanceOptions'
export interface HealthStatus {
status: 'ok' | 'loading' | 'error'
message?: string
lastChecked: Date
}
export interface Instance { export interface Instance {
name: string; name: string;
running: boolean; running: boolean;
options?: CreateInstanceOptions; options?: CreateInstanceOptions;
health?: HealthStatus;
} }