From f94d05dad2f9d392814d92b7008e47b666e7ea25 Mon Sep 17 00:00:00 2001 From: LordMathis Date: Sun, 26 Oct 2025 18:55:05 +0100 Subject: [PATCH 1/5] Add Restarting state --- pkg/instance/process.go | 4 ++++ pkg/instance/status.go | 15 +++++++++------ 2 files changed, 13 insertions(+), 6 deletions(-) diff --git a/pkg/instance/process.go b/pkg/instance/process.go index 3429e61..94e057f 100644 --- a/pkg/instance/process.go +++ b/pkg/instance/process.go @@ -350,6 +350,10 @@ func (p *process) handleAutoRestart(err error) { maxRestarts := *opts.MaxRestarts p.restarts++ + + // Set status to Restarting instead of leaving as Stopped + p.instance.SetStatus(Restarting) + log.Printf("Auto-restarting instance %s (attempt %d/%d) in %v", p.instance.Name, p.restarts, maxRestarts, time.Duration(restartDelay)*time.Second) diff --git a/pkg/instance/status.go b/pkg/instance/status.go index 92e8669..c05d460 100644 --- a/pkg/instance/status.go +++ b/pkg/instance/status.go @@ -13,18 +13,21 @@ const ( Stopped Status = iota Running Failed + Restarting ) var nameToStatus = map[string]Status{ - "stopped": Stopped, - "running": Running, - "failed": Failed, + "stopped": Stopped, + "running": Running, + "failed": Failed, + "restarting": Restarting, } var statusToName = map[Status]string{ - Stopped: "stopped", - Running: "running", - Failed: "failed", + Stopped: "stopped", + Running: "running", + Failed: "failed", + Restarting: "restarting", } // Status enum JSON marshaling methods From 2a1bebeb24d01378e3af8f8f2c221911a23f6dc9 Mon Sep 17 00:00:00 2001 From: LordMathis Date: Sun, 26 Oct 2025 19:05:03 +0100 Subject: [PATCH 2/5] Improve health checks for instances --- webui/src/components/HealthBadge.tsx | 49 ++-- webui/src/contexts/InstancesContext.tsx | 10 + webui/src/hooks/useInstanceHealth.ts | 23 +- webui/src/lib/healthService.ts | 312 ++++++++++++++++++++---- webui/src/types/instance.ts | 10 +- 5 files changed, 324 insertions(+), 80 deletions(-) diff --git a/webui/src/components/HealthBadge.tsx b/webui/src/components/HealthBadge.tsx index 45c1960..e2b04e7 100644 --- a/webui/src/components/HealthBadge.tsx +++ b/webui/src/components/HealthBadge.tsx @@ -2,7 +2,7 @@ import React from "react"; import { Badge } from "@/components/ui/badge"; import type { HealthStatus } from "@/types/instance"; -import { CheckCircle, Loader2, XCircle } from "lucide-react"; +import { CheckCircle, Loader2, XCircle, Clock, AlertCircle } from "lucide-react"; interface HealthBadgeProps { health?: HealthStatus; @@ -10,53 +10,61 @@ interface HealthBadgeProps { const HealthBadge: React.FC = ({ health }) => { if (!health) { - health = { - status: "unknown", // Default to unknown if not provided - lastChecked: new Date(), // Default to current date - message: undefined, // No message by default - }; + return null; } const getIcon = () => { - switch (health.status) { - case "ok": + switch (health.state) { + case "ready": return ; case "loading": return ; - case "error": - return ; - case "unknown": + case "starting": return ; + case "restarting": + return ; + case "stopped": + return ; + case "error": + return ; case "failed": return ; } }; const getVariant = () => { - switch (health.status) { - case "ok": + switch (health.state) { + case "ready": return "default"; case "loading": return "outline"; + case "starting": + return "outline"; + case "restarting": + return "outline"; + case "stopped": + return "secondary"; case "error": return "destructive"; - case "unknown": - return "secondary"; case "failed": return "destructive"; } }; const getText = () => { - switch (health.status) { - case "ok": + switch (health.state) { + case "ready": return "Ready"; case "loading": return "Loading"; + case "starting": + return "Starting"; + case "restarting": + return "Restarting"; + case "stopped": + return "Stopped"; case "error": return "Error"; - case "unknown": - return "Unknown"; case "failed": return "Failed"; } @@ -66,10 +74,11 @@ const HealthBadge: React.FC = ({ health }) => { {getIcon()} {getText()} diff --git a/webui/src/contexts/InstancesContext.tsx b/webui/src/contexts/InstancesContext.tsx index 1aa1bd7..8a03083 100644 --- a/webui/src/contexts/InstancesContext.tsx +++ b/webui/src/contexts/InstancesContext.tsx @@ -2,6 +2,7 @@ import { type ReactNode, createContext, useContext, useState, useEffect, useCall import type { CreateInstanceOptions, Instance } from '@/types/instance' import { instancesApi } from '@/lib/api' import { useAuth } from '@/contexts/AuthContext' +import { healthService } from '@/lib/healthService' interface InstancesContextState { instances: Instance[] @@ -115,6 +116,9 @@ export const InstancesProvider = ({ children }: InstancesProviderProps) => { // Update only this instance's status updateInstanceInMap(name, { status: "running" }) + + // Trigger health check after starting + healthService.checkHealthAfterOperation(name, 'start') } catch (err) { setError(err instanceof Error ? err.message : 'Failed to start instance') } @@ -127,6 +131,9 @@ export const InstancesProvider = ({ children }: InstancesProviderProps) => { // Update only this instance's status updateInstanceInMap(name, { status: "stopped" }) + + // Trigger health check after stopping + healthService.checkHealthAfterOperation(name, 'stop') } catch (err) { setError(err instanceof Error ? err.message : 'Failed to stop instance') } @@ -139,6 +146,9 @@ export const InstancesProvider = ({ children }: InstancesProviderProps) => { // Update only this instance's status updateInstanceInMap(name, { status: "running" }) + + // Trigger health check after restarting + healthService.checkHealthAfterOperation(name, 'restart') } catch (err) { setError(err instanceof Error ? err.message : 'Failed to restart instance') } diff --git a/webui/src/hooks/useInstanceHealth.ts b/webui/src/hooks/useInstanceHealth.ts index eaa1597..87d818c 100644 --- a/webui/src/hooks/useInstanceHealth.ts +++ b/webui/src/hooks/useInstanceHealth.ts @@ -7,24 +7,23 @@ export function useInstanceHealth(instanceName: string, instanceStatus: Instance const [health, setHealth] = useState() useEffect(() => { - if (instanceStatus === "stopped") { - setHealth({ status: "unknown", lastChecked: new Date() }) - return - } - - if (instanceStatus === "failed") { - setHealth({ status: instanceStatus, lastChecked: new Date() }) - return - } - // Subscribe to health updates for this instance const unsubscribe = healthService.subscribe(instanceName, (healthStatus) => { setHealth(healthStatus) }) - // Cleanup subscription on unmount or when instanceStatus changes + // Cleanup subscription on unmount or when instance changes return unsubscribe + }, [instanceName]) + + // Trigger health check when instance status changes to active states + useEffect(() => { + if (instanceStatus === 'running' || instanceStatus === 'restarting') { + healthService.refreshHealth(instanceName).catch(error => { + console.error(`Failed to refresh health for ${instanceName}:`, error) + }) + } }, [instanceName, instanceStatus]) return health -} \ No newline at end of file +} diff --git a/webui/src/lib/healthService.ts b/webui/src/lib/healthService.ts index 025d29e..5830d53 100644 --- a/webui/src/lib/healthService.ts +++ b/webui/src/lib/healthService.ts @@ -1,51 +1,226 @@ -import { type HealthStatus } from '@/types/instance' +import { type HealthStatus, type InstanceStatus, type HealthState } from '@/types/instance' import { instancesApi } from '@/lib/api' type HealthCallback = (health: HealthStatus) => void +// Polling intervals based on health state (in milliseconds) +const POLLING_INTERVALS: Record = { + 'starting': 5000, // 5 seconds - frequent during startup + 'loading': 5000, // 5 seconds - model loading + 'restarting': 5000, // 5 seconds - restart in progress + 'ready': 60000, // 60 seconds - stable state + 'stopped': 0, // No polling + 'failed': 0, // No polling + 'error': 10000, // 10 seconds - retry on error +} + class HealthService { private intervals: Map = new Map() private callbacks: Map> = new Map() + private lastHealthState: Map = new Map() + private healthCache: Map = new Map() + private readonly CACHE_TTL = 2000 // 2 seconds cache + + /** + * Performs a two-tier health check: + * 1. Get instance status from backend (authoritative) + * 2. If running, perform HTTP health check + */ + async performHealthCheck(instanceName: string): Promise { + // Check cache first + const cached = this.healthCache.get(instanceName) + if (cached && Date.now() - cached.timestamp < this.CACHE_TTL) { + return cached.health + } - async checkHealth(instanceName: string): Promise { try { - await instancesApi.getHealth(instanceName) - - return { - status: 'ok', - lastChecked: new Date() - } - } catch (error) { - if (error instanceof Error) { - // Check if it's a 503 (service unavailable - loading) - if (error.message.includes('503')) { - return { - status: 'loading', - message: 'Instance is starting up', - lastChecked: new Date() + // Step 1: Get instance details (includes status) + const instance = await instancesApi.get(instanceName) + + // Step 2: If running, attempt HTTP health check + if (instance.status === 'running') { + try { + await instancesApi.getHealth(instanceName) + + // HTTP health check succeeded + const health: HealthStatus = { + state: 'ready', + instanceStatus: 'running', + lastChecked: new Date(), + source: 'http' + } + + this.updateCache(instanceName, health) + return health + + } catch (httpError) { + // HTTP health check failed while instance is running + // Re-verify instance is still running + try { + const verifyInstance = await instancesApi.get(instanceName) + + if (verifyInstance.status !== 'running') { + // Instance stopped/failed since our first check + const health: HealthStatus = { + state: this.mapStatusToHealthState(verifyInstance.status), + instanceStatus: verifyInstance.status, + lastChecked: new Date(), + source: 'backend' + } + + this.updateCache(instanceName, health) + return health + } + + // Instance still running but HTTP failed - classify error + const health = this.classifyHttpError(httpError as Error, 'running') + this.updateCache(instanceName, health) + return health + + } catch (verifyError) { + // Failed to verify - return error state + const health: HealthStatus = { + state: 'error', + instanceStatus: 'running', + lastChecked: new Date(), + error: 'Failed to verify instance status', + source: 'error' + } + + this.updateCache(instanceName, health) + return health } } - - return { - status: 'error', - message: error.message, - lastChecked: new Date() + } else { + // Instance not running - return backend status + const health: HealthStatus = { + state: this.mapStatusToHealthState(instance.status), + instanceStatus: instance.status, + lastChecked: new Date(), + source: 'backend' } + + this.updateCache(instanceName, health) + return health } - - return { - status: 'error', - message: 'Unknown error', - lastChecked: new Date() + + } catch (error) { + // Failed to get instance + const health: HealthStatus = { + state: 'error', + instanceStatus: 'unknown', + lastChecked: new Date(), + error: error instanceof Error ? error.message : 'Unknown error', + source: 'error' } + + this.updateCache(instanceName, health) + return health } } + /** + * Classifies HTTP errors into appropriate health states + */ + private classifyHttpError(error: Error, instanceStatus: InstanceStatus): HealthStatus { + const errorMessage = error.message.toLowerCase() + + // Parse HTTP status code from error message if available + if (errorMessage.includes('503')) { + return { + state: 'loading', + instanceStatus, + lastChecked: new Date(), + error: 'Service loading', + source: 'http' + } + } + + if (errorMessage.includes('connection refused') || + errorMessage.includes('econnrefused') || + errorMessage.includes('network error')) { + return { + state: 'starting', + instanceStatus, + lastChecked: new Date(), + error: 'Connection refused', + source: 'http' + } + } + + // Other HTTP errors + return { + state: 'error', + instanceStatus, + lastChecked: new Date(), + error: error.message, + source: 'http' + } + } + + /** + * Maps backend instance status to health state + */ + private mapStatusToHealthState(status: InstanceStatus): HealthState { + switch (status) { + case 'stopped': return 'stopped' + case 'running': return 'starting' // Unknown without HTTP check + case 'failed': return 'failed' + case 'restarting': return 'restarting' + default: return 'error' + } + } + + /** + * Updates health cache + */ + private updateCache(instanceName: string, health: HealthStatus): void { + this.healthCache.set(instanceName, { + health, + timestamp: Date.now() + }) + } + + /** + * Manually refresh health for an instance + */ + async refreshHealth(instanceName: string): Promise { + // Invalidate cache + this.healthCache.delete(instanceName) + + const health = await this.performHealthCheck(instanceName) + this.notifyCallbacks(instanceName, health) + + // Update last state and adjust polling interval if needed + const previousState = this.lastHealthState.get(instanceName) + this.lastHealthState.set(instanceName, health.state) + + if (previousState !== health.state) { + this.adjustPollingInterval(instanceName, health.state) + } + } + + /** + * Trigger health check after instance operation + */ + checkHealthAfterOperation(instanceName: string, operation: 'start' | 'stop' | 'restart'): void { + // Invalidate cache immediately + this.healthCache.delete(instanceName) + + // Perform immediate health check + this.refreshHealth(instanceName).catch(error => { + console.error(`Failed to check health after ${operation}:`, error) + }) + } + + /** + * Subscribe to health updates for an instance + */ subscribe(instanceName: string, callback: HealthCallback): () => void { if (!this.callbacks.has(instanceName)) { this.callbacks.set(instanceName, new Set()) } - + this.callbacks.get(instanceName)!.add(callback) // Start health checking if this is the first subscriber @@ -58,36 +233,75 @@ class HealthService { 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) + this.lastHealthState.delete(instanceName) + this.healthCache.delete(instanceName) } } } } + /** + * Start health checking for an instance + */ 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) - 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) - }, 5000) + // Initial check immediately + this.refreshHealth(instanceName).then(() => { + const currentState = this.lastHealthState.get(instanceName) + if (currentState) { + this.adjustPollingInterval(instanceName, currentState) + } + }).catch(error => { + console.error(`Failed to start health check for ${instanceName}:`, error) + }) } + /** + * Adjust polling interval based on current health state + */ + private adjustPollingInterval(instanceName: string, state: HealthState): void { + // Clear existing interval + this.stopHealthCheck(instanceName) + + const pollInterval = POLLING_INTERVALS[state] + + // Don't poll for stable states (stopped, failed, ready has long interval) + if (pollInterval === 0) { + return + } + + // Start new interval with appropriate timing + const interval = setInterval(async () => { + try { + const health = await this.performHealthCheck(instanceName) + this.notifyCallbacks(instanceName, health) + + // Check if state changed and adjust interval + const previousState = this.lastHealthState.get(instanceName) + this.lastHealthState.set(instanceName, health.state) + + if (previousState !== health.state) { + this.adjustPollingInterval(instanceName, health.state) + } + } catch (error) { + console.error(`Health check failed for ${instanceName}:`, error) + } + }, pollInterval) + + this.intervals.set(instanceName, interval) + } + + /** + * Stop health checking for an instance + */ private stopHealthCheck(instanceName: string): void { const interval = this.intervals.get(instanceName) if (interval) { @@ -96,6 +310,9 @@ class HealthService { } } + /** + * Notify all callbacks with health update + */ private notifyCallbacks(instanceName: string, health: HealthStatus): void { const callbacks = this.callbacks.get(instanceName) if (callbacks) { @@ -103,16 +320,21 @@ class HealthService { } } - stopAll(): void { + /** + * Stop all health checking and cleanup + */ + destroy(): void { this.intervals.forEach(interval => clearInterval(interval)) this.intervals.clear() this.callbacks.clear() + this.lastHealthState.clear() + this.healthCache.clear() } } export const healthService = new HealthService() -// Export the individual checkHealth function as well +// Export the individual performHealthCheck function as well export async function checkHealth(instanceName: string): Promise { - return healthService.checkHealth(instanceName) -} \ No newline at end of file + return healthService.performHealthCheck(instanceName) +} diff --git a/webui/src/types/instance.ts b/webui/src/types/instance.ts index 074e2f2..97d3cac 100644 --- a/webui/src/types/instance.ts +++ b/webui/src/types/instance.ts @@ -11,12 +11,16 @@ export const BackendType = { export type BackendTypeValue = typeof BackendType[keyof typeof BackendType] -export type InstanceStatus = 'running' | 'stopped' | 'failed' +export type InstanceStatus = 'running' | 'stopped' | 'failed' | 'restarting' + +export type HealthState = 'stopped' | 'starting' | 'loading' | 'ready' | 'error' | 'failed' | 'restarting' export interface HealthStatus { - status: 'ok' | 'loading' | 'error' | 'unknown' | 'failed' - message?: string + state: HealthState + instanceStatus: InstanceStatus | 'unknown' lastChecked: Date + error?: string + source: 'backend' | 'http' | 'error' } export interface Instance { From 75e7b628cab5a97eea027f0607405498b7913d1f Mon Sep 17 00:00:00 2001 From: LordMathis Date: Sun, 26 Oct 2025 19:12:35 +0100 Subject: [PATCH 3/5] Remove 'loading' and 'error' states --- webui/src/components/HealthBadge.tsx | 14 +-- webui/src/lib/healthService.ts | 132 +++++++-------------------- webui/src/types/instance.ts | 6 +- 3 files changed, 37 insertions(+), 115 deletions(-) diff --git a/webui/src/components/HealthBadge.tsx b/webui/src/components/HealthBadge.tsx index e2b04e7..13eda9f 100644 --- a/webui/src/components/HealthBadge.tsx +++ b/webui/src/components/HealthBadge.tsx @@ -2,7 +2,7 @@ import React from "react"; import { Badge } from "@/components/ui/badge"; import type { HealthStatus } from "@/types/instance"; -import { CheckCircle, Loader2, XCircle, Clock, AlertCircle } from "lucide-react"; +import { CheckCircle, Loader2, XCircle, Clock } from "lucide-react"; interface HealthBadgeProps { health?: HealthStatus; @@ -17,16 +17,12 @@ const HealthBadge: React.FC = ({ health }) => { switch (health.state) { case "ready": return ; - case "loading": - return ; case "starting": return ; case "restarting": return ; case "stopped": return ; - case "error": - return ; case "failed": return ; } @@ -36,16 +32,12 @@ const HealthBadge: React.FC = ({ health }) => { switch (health.state) { case "ready": return "default"; - case "loading": - return "outline"; case "starting": return "outline"; case "restarting": return "outline"; case "stopped": return "secondary"; - case "error": - return "destructive"; case "failed": return "destructive"; } @@ -55,16 +47,12 @@ const HealthBadge: React.FC = ({ health }) => { switch (health.state) { case "ready": return "Ready"; - case "loading": - return "Loading"; case "starting": return "Starting"; case "restarting": return "Restarting"; case "stopped": return "Stopped"; - case "error": - return "Error"; case "failed": return "Failed"; } diff --git a/webui/src/lib/healthService.ts b/webui/src/lib/healthService.ts index 5830d53..aabea0c 100644 --- a/webui/src/lib/healthService.ts +++ b/webui/src/lib/healthService.ts @@ -6,12 +6,10 @@ type HealthCallback = (health: HealthStatus) => void // Polling intervals based on health state (in milliseconds) const POLLING_INTERVALS: Record = { 'starting': 5000, // 5 seconds - frequent during startup - 'loading': 5000, // 5 seconds - model loading 'restarting': 5000, // 5 seconds - restart in progress 'ready': 60000, // 60 seconds - stable state 'stopped': 0, // No polling 'failed': 0, // No polling - 'error': 10000, // 10 seconds - retry on error } class HealthService { @@ -42,7 +40,7 @@ class HealthService { try { await instancesApi.getHealth(instanceName) - // HTTP health check succeeded + // HTTP health check succeeded - instance is ready const health: HealthStatus = { state: 'ready', instanceStatus: 'running', @@ -54,45 +52,21 @@ class HealthService { return health } catch (httpError) { - // HTTP health check failed while instance is running - // Re-verify instance is still running - try { - const verifyInstance = await instancesApi.get(instanceName) - - if (verifyInstance.status !== 'running') { - // Instance stopped/failed since our first check - const health: HealthStatus = { - state: this.mapStatusToHealthState(verifyInstance.status), - instanceStatus: verifyInstance.status, - lastChecked: new Date(), - source: 'backend' - } - - this.updateCache(instanceName, health) - return health - } - - // Instance still running but HTTP failed - classify error - const health = this.classifyHttpError(httpError as Error, 'running') - this.updateCache(instanceName, health) - return health - - } catch (verifyError) { - // Failed to verify - return error state - const health: HealthStatus = { - state: 'error', - instanceStatus: 'running', - lastChecked: new Date(), - error: 'Failed to verify instance status', - source: 'error' - } - - this.updateCache(instanceName, health) - return health + // HTTP health check failed - instance is still starting + // Any error (503, connection refused, timeout, etc.) means "starting" + const health: HealthStatus = { + state: 'starting', + instanceStatus: 'running', + lastChecked: new Date(), + error: httpError instanceof Error ? httpError.message : 'Health check failed', + source: 'http' } + + this.updateCache(instanceName, health) + return health } } else { - // Instance not running - return backend status + // Instance not running - map backend status directly const health: HealthStatus = { state: this.mapStatusToHealthState(instance.status), instanceStatus: instance.status, @@ -105,56 +79,11 @@ class HealthService { } } catch (error) { - // Failed to get instance - const health: HealthStatus = { - state: 'error', - instanceStatus: 'unknown', - lastChecked: new Date(), - error: error instanceof Error ? error.message : 'Unknown error', - source: 'error' - } - - this.updateCache(instanceName, health) - return health - } - } - - /** - * Classifies HTTP errors into appropriate health states - */ - private classifyHttpError(error: Error, instanceStatus: InstanceStatus): HealthStatus { - const errorMessage = error.message.toLowerCase() - - // Parse HTTP status code from error message if available - if (errorMessage.includes('503')) { - return { - state: 'loading', - instanceStatus, - lastChecked: new Date(), - error: 'Service loading', - source: 'http' - } - } - - if (errorMessage.includes('connection refused') || - errorMessage.includes('econnrefused') || - errorMessage.includes('network error')) { - return { - state: 'starting', - instanceStatus, - lastChecked: new Date(), - error: 'Connection refused', - source: 'http' - } - } - - // Other HTTP errors - return { - state: 'error', - instanceStatus, - lastChecked: new Date(), - error: error.message, - source: 'http' + // Failed to get instance status from backend + // This is a backend communication error, not an instance health error + // Let the error propagate so polling can retry + console.error(`Failed to get instance status for ${instanceName}:`, error) + throw error } } @@ -164,10 +93,9 @@ class HealthService { private mapStatusToHealthState(status: InstanceStatus): HealthState { switch (status) { case 'stopped': return 'stopped' - case 'running': return 'starting' // Unknown without HTTP check + case 'running': return 'starting' // Should not happen as we check HTTP for running case 'failed': return 'failed' case 'restarting': return 'restarting' - default: return 'error' } } @@ -188,15 +116,20 @@ class HealthService { // Invalidate cache this.healthCache.delete(instanceName) - const health = await this.performHealthCheck(instanceName) - this.notifyCallbacks(instanceName, health) + try { + const health = await this.performHealthCheck(instanceName) + this.notifyCallbacks(instanceName, health) - // Update last state and adjust polling interval if needed - const previousState = this.lastHealthState.get(instanceName) - this.lastHealthState.set(instanceName, health.state) + // Update last state and adjust polling interval if needed + const previousState = this.lastHealthState.get(instanceName) + this.lastHealthState.set(instanceName, health.state) - if (previousState !== health.state) { - this.adjustPollingInterval(instanceName, health.state) + if (previousState !== health.state) { + this.adjustPollingInterval(instanceName, health.state) + } + } catch (error) { + // Error getting health - keep polling if active + console.error(`Failed to refresh health for ${instanceName}:`, error) } } @@ -273,7 +206,7 @@ class HealthService { const pollInterval = POLLING_INTERVALS[state] - // Don't poll for stable states (stopped, failed, ready has long interval) + // Don't poll for stable states (stopped, failed) if (pollInterval === 0) { return } @@ -293,6 +226,7 @@ class HealthService { } } catch (error) { console.error(`Health check failed for ${instanceName}:`, error) + // Continue polling even on error } }, pollInterval) diff --git a/webui/src/types/instance.ts b/webui/src/types/instance.ts index 97d3cac..1c42547 100644 --- a/webui/src/types/instance.ts +++ b/webui/src/types/instance.ts @@ -13,14 +13,14 @@ export type BackendTypeValue = typeof BackendType[keyof typeof BackendType] export type InstanceStatus = 'running' | 'stopped' | 'failed' | 'restarting' -export type HealthState = 'stopped' | 'starting' | 'loading' | 'ready' | 'error' | 'failed' | 'restarting' +export type HealthState = 'stopped' | 'starting' | 'ready' | 'failed' | 'restarting' export interface HealthStatus { state: HealthState - instanceStatus: InstanceStatus | 'unknown' + instanceStatus: InstanceStatus lastChecked: Date error?: string - source: 'backend' | 'http' | 'error' + source: 'backend' | 'http' } export interface Instance { From 777e07752b11b9850e455472e37afcc024dea21d Mon Sep 17 00:00:00 2001 From: LordMathis Date: Sun, 26 Oct 2025 19:48:07 +0100 Subject: [PATCH 4/5] Fix health service tests --- webui/src/__tests__/App.test.tsx | 18 +++++++++++++-- .../__tests__/InstanceCard.test.tsx | 7 +++++- .../__tests__/InstanceList.test.tsx | 18 +++++++++++++-- .../__tests__/InstancesContext.test.tsx | 23 +++++++++++++++++++ 4 files changed, 61 insertions(+), 5 deletions(-) diff --git a/webui/src/__tests__/App.test.tsx b/webui/src/__tests__/App.test.tsx index 857ff0e..7497c31 100644 --- a/webui/src/__tests__/App.test.tsx +++ b/webui/src/__tests__/App.test.tsx @@ -12,12 +12,14 @@ import { AuthProvider } from '@/contexts/AuthContext' vi.mock('@/lib/api', () => ({ instancesApi: { list: vi.fn(), + get: vi.fn(), create: vi.fn(), update: vi.fn(), start: vi.fn(), stop: vi.fn(), restart: vi.fn(), delete: vi.fn(), + getHealth: vi.fn(), }, serverApi: { getHelp: vi.fn(), @@ -30,9 +32,21 @@ vi.mock('@/lib/api', () => ({ vi.mock('@/lib/healthService', () => ({ healthService: { subscribe: vi.fn(() => () => {}), - checkHealth: vi.fn(), + refreshHealth: vi.fn(() => Promise.resolve()), + checkHealthAfterOperation: vi.fn(), + performHealthCheck: vi.fn(() => Promise.resolve({ + state: 'ready', + instanceStatus: 'running', + lastChecked: new Date(), + source: 'http' + })), }, - checkHealth: vi.fn(), + checkHealth: vi.fn(() => Promise.resolve({ + state: 'ready', + instanceStatus: 'running', + lastChecked: new Date(), + source: 'http' + })), })) function renderApp() { diff --git a/webui/src/components/__tests__/InstanceCard.test.tsx b/webui/src/components/__tests__/InstanceCard.test.tsx index f45b65b..b8d19f7 100644 --- a/webui/src/components/__tests__/InstanceCard.test.tsx +++ b/webui/src/components/__tests__/InstanceCard.test.tsx @@ -7,7 +7,12 @@ import { BackendType } from '@/types/instance' // Mock the health hook since we're not testing health logic here vi.mock('@/hooks/useInstanceHealth', () => ({ - useInstanceHealth: vi.fn(() => ({ status: 'ok', lastChecked: new Date() })) + useInstanceHealth: vi.fn(() => ({ + state: 'ready', + instanceStatus: 'running', + lastChecked: new Date(), + source: 'http' + })) })) describe('InstanceCard - Instance Actions and State', () => { diff --git a/webui/src/components/__tests__/InstanceList.test.tsx b/webui/src/components/__tests__/InstanceList.test.tsx index cbd9e3f..bae6ea3 100644 --- a/webui/src/components/__tests__/InstanceList.test.tsx +++ b/webui/src/components/__tests__/InstanceList.test.tsx @@ -12,12 +12,14 @@ import { AuthProvider } from '@/contexts/AuthContext' vi.mock('@/lib/api', () => ({ instancesApi: { list: vi.fn(), + get: vi.fn(), create: vi.fn(), update: vi.fn(), start: vi.fn(), stop: vi.fn(), restart: vi.fn(), delete: vi.fn(), + getHealth: vi.fn(), } })) @@ -25,9 +27,21 @@ vi.mock('@/lib/api', () => ({ vi.mock('@/lib/healthService', () => ({ healthService: { subscribe: vi.fn(() => () => {}), - checkHealth: vi.fn(), + refreshHealth: vi.fn(() => Promise.resolve()), + checkHealthAfterOperation: vi.fn(), + performHealthCheck: vi.fn(() => Promise.resolve({ + state: 'ready', + instanceStatus: 'running', + lastChecked: new Date(), + source: 'http' + })), }, - checkHealth: vi.fn(), + checkHealth: vi.fn(() => Promise.resolve({ + state: 'ready', + instanceStatus: 'running', + lastChecked: new Date(), + source: 'http' + })), })) function renderInstanceList(editInstance = vi.fn()) { diff --git a/webui/src/contexts/__tests__/InstancesContext.test.tsx b/webui/src/contexts/__tests__/InstancesContext.test.tsx index c60455f..1920d6a 100644 --- a/webui/src/contexts/__tests__/InstancesContext.test.tsx +++ b/webui/src/contexts/__tests__/InstancesContext.test.tsx @@ -11,15 +11,38 @@ import { AuthProvider } from "../AuthContext"; vi.mock("@/lib/api", () => ({ instancesApi: { list: vi.fn(), + get: vi.fn(), create: vi.fn(), update: vi.fn(), start: vi.fn(), stop: vi.fn(), restart: vi.fn(), delete: vi.fn(), + getHealth: vi.fn(), }, })); +// Mock health service +vi.mock("@/lib/healthService", () => ({ + healthService: { + subscribe: vi.fn(() => () => {}), + refreshHealth: vi.fn(() => Promise.resolve()), + checkHealthAfterOperation: vi.fn(), + performHealthCheck: vi.fn(() => Promise.resolve({ + state: 'ready', + instanceStatus: 'running', + lastChecked: new Date(), + source: 'http' + })), + }, + checkHealth: vi.fn(() => Promise.resolve({ + state: 'ready', + instanceStatus: 'running', + lastChecked: new Date(), + source: 'http' + })), +})); + // Test component to access context function TestComponent() { const { From 13ef13449c809222f6ebe7f3a0d95ff9d20e3bc5 Mon Sep 17 00:00:00 2001 From: LordMathis Date: Sun, 26 Oct 2025 19:52:44 +0100 Subject: [PATCH 5/5] Fix ts type check --- webui/src/components/__tests__/InstanceCard.test.tsx | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/webui/src/components/__tests__/InstanceCard.test.tsx b/webui/src/components/__tests__/InstanceCard.test.tsx index b8d19f7..03d2bce 100644 --- a/webui/src/components/__tests__/InstanceCard.test.tsx +++ b/webui/src/components/__tests__/InstanceCard.test.tsx @@ -2,8 +2,7 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' import { render, screen } from '@testing-library/react' import userEvent from '@testing-library/user-event' import InstanceCard from '@/components/InstanceCard' -import type { Instance } from '@/types/instance' -import { BackendType } from '@/types/instance' +import { type Instance, BackendType } from '@/types/instance' // Mock the health hook since we're not testing health logic here vi.mock('@/hooks/useInstanceHealth', () => ({