Merge pull request #74 from lordmathis/refactor/health-check

refactor: Improve frontend health check
This commit is contained in:
2025-10-26 19:54:38 +01:00
committed by GitHub
11 changed files with 329 additions and 101 deletions

View File

@@ -350,6 +350,10 @@ func (p *process) handleAutoRestart(err error) {
maxRestarts := *opts.MaxRestarts maxRestarts := *opts.MaxRestarts
p.restarts++ 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", log.Printf("Auto-restarting instance %s (attempt %d/%d) in %v",
p.instance.Name, p.restarts, maxRestarts, time.Duration(restartDelay)*time.Second) p.instance.Name, p.restarts, maxRestarts, time.Duration(restartDelay)*time.Second)

View File

@@ -13,18 +13,21 @@ const (
Stopped Status = iota Stopped Status = iota
Running Running
Failed Failed
Restarting
) )
var nameToStatus = map[string]Status{ var nameToStatus = map[string]Status{
"stopped": Stopped, "stopped": Stopped,
"running": Running, "running": Running,
"failed": Failed, "failed": Failed,
"restarting": Restarting,
} }
var statusToName = map[Status]string{ var statusToName = map[Status]string{
Stopped: "stopped", Stopped: "stopped",
Running: "running", Running: "running",
Failed: "failed", Failed: "failed",
Restarting: "restarting",
} }
// Status enum JSON marshaling methods // Status enum JSON marshaling methods

View File

@@ -12,12 +12,14 @@ import { AuthProvider } from '@/contexts/AuthContext'
vi.mock('@/lib/api', () => ({ vi.mock('@/lib/api', () => ({
instancesApi: { instancesApi: {
list: vi.fn(), list: vi.fn(),
get: vi.fn(),
create: vi.fn(), create: vi.fn(),
update: vi.fn(), update: vi.fn(),
start: vi.fn(), start: vi.fn(),
stop: vi.fn(), stop: vi.fn(),
restart: vi.fn(), restart: vi.fn(),
delete: vi.fn(), delete: vi.fn(),
getHealth: vi.fn(),
}, },
serverApi: { serverApi: {
getHelp: vi.fn(), getHelp: vi.fn(),
@@ -30,9 +32,21 @@ vi.mock('@/lib/api', () => ({
vi.mock('@/lib/healthService', () => ({ vi.mock('@/lib/healthService', () => ({
healthService: { healthService: {
subscribe: vi.fn(() => () => {}), 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() { function renderApp() {

View File

@@ -2,7 +2,7 @@
import React from "react"; import React from "react";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import type { HealthStatus } from "@/types/instance"; import type { HealthStatus } from "@/types/instance";
import { CheckCircle, Loader2, XCircle } from "lucide-react"; import { CheckCircle, Loader2, XCircle, Clock } from "lucide-react";
interface HealthBadgeProps { interface HealthBadgeProps {
health?: HealthStatus; health?: HealthStatus;
@@ -10,37 +10,33 @@ interface HealthBadgeProps {
const HealthBadge: React.FC<HealthBadgeProps> = ({ health }) => { const HealthBadge: React.FC<HealthBadgeProps> = ({ health }) => {
if (!health) { if (!health) {
health = { return null;
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.state) {
case "ok": case "ready":
return <CheckCircle className="h-3 w-3" />; return <CheckCircle className="h-3 w-3" />;
case "loading": case "starting":
return <Loader2 className="h-3 w-3 animate-spin" />; return <Loader2 className="h-3 w-3 animate-spin" />;
case "error": case "restarting":
return <XCircle className="h-3 w-3" />;
case "unknown":
return <Loader2 className="h-3 w-3 animate-spin" />; return <Loader2 className="h-3 w-3 animate-spin" />;
case "stopped":
return <Clock className="h-3 w-3" />;
case "failed": case "failed":
return <XCircle className="h-3 w-3" />; return <XCircle className="h-3 w-3" />;
} }
}; };
const getVariant = () => { const getVariant = () => {
switch (health.status) { switch (health.state) {
case "ok": case "ready":
return "default"; return "default";
case "loading": case "starting":
return "outline"; return "outline";
case "error": case "restarting":
return "destructive"; return "outline";
case "unknown": case "stopped":
return "secondary"; return "secondary";
case "failed": case "failed":
return "destructive"; return "destructive";
@@ -48,15 +44,15 @@ const HealthBadge: React.FC<HealthBadgeProps> = ({ health }) => {
}; };
const getText = () => { const getText = () => {
switch (health.status) { switch (health.state) {
case "ok": case "ready":
return "Ready"; return "Ready";
case "loading": case "starting":
return "Loading"; return "Starting";
case "error": case "restarting":
return "Error"; return "Restarting";
case "unknown": case "stopped":
return "Unknown"; return "Stopped";
case "failed": case "failed":
return "Failed"; return "Failed";
} }
@@ -66,10 +62,11 @@ const HealthBadge: React.FC<HealthBadgeProps> = ({ health }) => {
<Badge <Badge
variant={getVariant()} variant={getVariant()}
className={`flex items-center gap-1.5 ${ className={`flex items-center gap-1.5 ${
health.status === "ok" health.state === "ready"
? "bg-green-100 text-green-800 border-green-200 dark:bg-green-900 dark:text-green-200 dark:border-green-800" ? "bg-green-100 text-green-800 border-green-200 dark:bg-green-900 dark:text-green-200 dark:border-green-800"
: "" : ""
}`} }`}
title={health.error || `Source: ${health.source}`}
> >
{getIcon()} {getIcon()}
<span className="text-xs">{getText()}</span> <span className="text-xs">{getText()}</span>

View File

@@ -2,12 +2,16 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import { render, screen } from '@testing-library/react' import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event' import userEvent from '@testing-library/user-event'
import InstanceCard from '@/components/InstanceCard' import InstanceCard from '@/components/InstanceCard'
import type { Instance } from '@/types/instance' import { type Instance, BackendType } from '@/types/instance'
import { BackendType } from '@/types/instance'
// Mock the health hook since we're not testing health logic here // Mock the health hook since we're not testing health logic here
vi.mock('@/hooks/useInstanceHealth', () => ({ 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', () => { describe('InstanceCard - Instance Actions and State', () => {

View File

@@ -12,12 +12,14 @@ import { AuthProvider } from '@/contexts/AuthContext'
vi.mock('@/lib/api', () => ({ vi.mock('@/lib/api', () => ({
instancesApi: { instancesApi: {
list: vi.fn(), list: vi.fn(),
get: vi.fn(),
create: vi.fn(), create: vi.fn(),
update: vi.fn(), update: vi.fn(),
start: vi.fn(), start: vi.fn(),
stop: vi.fn(), stop: vi.fn(),
restart: vi.fn(), restart: vi.fn(),
delete: vi.fn(), delete: vi.fn(),
getHealth: vi.fn(),
} }
})) }))
@@ -25,9 +27,21 @@ vi.mock('@/lib/api', () => ({
vi.mock('@/lib/healthService', () => ({ vi.mock('@/lib/healthService', () => ({
healthService: { healthService: {
subscribe: vi.fn(() => () => {}), 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()) { function renderInstanceList(editInstance = vi.fn()) {

View File

@@ -2,6 +2,7 @@ import { type ReactNode, createContext, useContext, useState, useEffect, useCall
import type { CreateInstanceOptions, Instance } from '@/types/instance' import type { CreateInstanceOptions, Instance } from '@/types/instance'
import { instancesApi } from '@/lib/api' import { instancesApi } from '@/lib/api'
import { useAuth } from '@/contexts/AuthContext' import { useAuth } from '@/contexts/AuthContext'
import { healthService } from '@/lib/healthService'
interface InstancesContextState { interface InstancesContextState {
instances: Instance[] instances: Instance[]
@@ -115,6 +116,9 @@ export const InstancesProvider = ({ children }: InstancesProviderProps) => {
// Update only this instance's status // Update only this instance's status
updateInstanceInMap(name, { status: "running" }) updateInstanceInMap(name, { status: "running" })
// Trigger health check after starting
healthService.checkHealthAfterOperation(name, 'start')
} catch (err) { } catch (err) {
setError(err instanceof Error ? err.message : 'Failed to start instance') 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 // Update only this instance's status
updateInstanceInMap(name, { status: "stopped" }) updateInstanceInMap(name, { status: "stopped" })
// Trigger health check after stopping
healthService.checkHealthAfterOperation(name, 'stop')
} catch (err) { } catch (err) {
setError(err instanceof Error ? err.message : 'Failed to stop instance') 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 // Update only this instance's status
updateInstanceInMap(name, { status: "running" }) updateInstanceInMap(name, { status: "running" })
// Trigger health check after restarting
healthService.checkHealthAfterOperation(name, 'restart')
} catch (err) { } catch (err) {
setError(err instanceof Error ? err.message : 'Failed to restart instance') setError(err instanceof Error ? err.message : 'Failed to restart instance')
} }

View File

@@ -11,15 +11,38 @@ import { AuthProvider } from "../AuthContext";
vi.mock("@/lib/api", () => ({ vi.mock("@/lib/api", () => ({
instancesApi: { instancesApi: {
list: vi.fn(), list: vi.fn(),
get: vi.fn(),
create: vi.fn(), create: vi.fn(),
update: vi.fn(), update: vi.fn(),
start: vi.fn(), start: vi.fn(),
stop: vi.fn(), stop: vi.fn(),
restart: vi.fn(), restart: vi.fn(),
delete: 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 // Test component to access context
function TestComponent() { function TestComponent() {
const { const {

View File

@@ -7,23 +7,22 @@ export function useInstanceHealth(instanceName: string, instanceStatus: Instance
const [health, setHealth] = useState<HealthStatus | undefined>() const [health, setHealth] = useState<HealthStatus | undefined>()
useEffect(() => { 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 // Subscribe to health updates for this instance
const unsubscribe = healthService.subscribe(instanceName, (healthStatus) => { const unsubscribe = healthService.subscribe(instanceName, (healthStatus) => {
setHealth(healthStatus) setHealth(healthStatus)
}) })
// Cleanup subscription on unmount or when instanceStatus changes // Cleanup subscription on unmount or when instance changes
return unsubscribe 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]) }, [instanceName, instanceStatus])
return health return health

View File

@@ -1,46 +1,154 @@
import { type HealthStatus } from '@/types/instance' import { type HealthStatus, type InstanceStatus, type HealthState } from '@/types/instance'
import { instancesApi } from '@/lib/api' import { instancesApi } from '@/lib/api'
type HealthCallback = (health: HealthStatus) => void type HealthCallback = (health: HealthStatus) => void
// Polling intervals based on health state (in milliseconds)
const POLLING_INTERVALS: Record<HealthState, number> = {
'starting': 5000, // 5 seconds - frequent during startup
'restarting': 5000, // 5 seconds - restart in progress
'ready': 60000, // 60 seconds - stable state
'stopped': 0, // No polling
'failed': 0, // No polling
}
class HealthService { class HealthService {
private intervals: Map<string, NodeJS.Timeout> = new Map() private intervals: Map<string, NodeJS.Timeout> = new Map()
private callbacks: Map<string, Set<HealthCallback>> = new Map() private callbacks: Map<string, Set<HealthCallback>> = new Map()
private lastHealthState: Map<string, HealthState> = new Map()
private healthCache: Map<string, { health: HealthStatus; timestamp: number }> = new Map()
private readonly CACHE_TTL = 2000 // 2 seconds cache
async checkHealth(instanceName: string): Promise<HealthStatus> { /**
* 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<HealthStatus> {
// Check cache first
const cached = this.healthCache.get(instanceName)
if (cached && Date.now() - cached.timestamp < this.CACHE_TTL) {
return cached.health
}
try {
// 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 { try {
await instancesApi.getHealth(instanceName) await instancesApi.getHealth(instanceName)
return { // HTTP health check succeeded - instance is ready
status: 'ok', const health: HealthStatus = {
lastChecked: new Date() state: 'ready',
instanceStatus: 'running',
lastChecked: new Date(),
source: 'http'
}
this.updateCache(instanceName, health)
return health
} catch (httpError) {
// 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 - map backend status directly
const health: HealthStatus = {
state: this.mapStatusToHealthState(instance.status),
instanceStatus: instance.status,
lastChecked: new Date(),
source: 'backend'
}
this.updateCache(instanceName, health)
return health
}
} catch (error) {
// 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
}
}
/**
* Maps backend instance status to health state
*/
private mapStatusToHealthState(status: InstanceStatus): HealthState {
switch (status) {
case 'stopped': return 'stopped'
case 'running': return 'starting' // Should not happen as we check HTTP for running
case 'failed': return 'failed'
case 'restarting': return 'restarting'
}
}
/**
* 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<void> {
// Invalidate cache
this.healthCache.delete(instanceName)
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)
if (previousState !== health.state) {
this.adjustPollingInterval(instanceName, health.state)
} }
} catch (error) { } catch (error) {
if (error instanceof Error) { // Error getting health - keep polling if active
// Check if it's a 503 (service unavailable - loading) console.error(`Failed to refresh health for ${instanceName}:`, error)
if (error.message.includes('503')) {
return {
status: 'loading',
message: 'Instance is starting up',
lastChecked: new Date()
} }
} }
return { /**
status: 'error', * Trigger health check after instance operation
message: error.message, */
lastChecked: new Date() checkHealthAfterOperation(instanceName: string, operation: 'start' | 'stop' | 'restart'): void {
} // Invalidate cache immediately
} this.healthCache.delete(instanceName)
return { // Perform immediate health check
status: 'error', this.refreshHealth(instanceName).catch(error => {
message: 'Unknown error', console.error(`Failed to check health after ${operation}:`, error)
lastChecked: new Date() })
}
}
} }
/**
* Subscribe to health updates for an instance
*/
subscribe(instanceName: string, callback: HealthCallback): () => void { subscribe(instanceName: string, callback: HealthCallback): () => void {
if (!this.callbacks.has(instanceName)) { if (!this.callbacks.has(instanceName)) {
this.callbacks.set(instanceName, new Set()) this.callbacks.set(instanceName, new Set())
@@ -63,31 +171,71 @@ class HealthService {
if (callbacks.size === 0) { if (callbacks.size === 0) {
this.stopHealthCheck(instanceName) this.stopHealthCheck(instanceName)
this.callbacks.delete(instanceName) this.callbacks.delete(instanceName)
this.lastHealthState.delete(instanceName)
this.healthCache.delete(instanceName)
} }
} }
} }
} }
/**
* Start health checking for an instance
*/
private startHealthCheck(instanceName: string): void { private startHealthCheck(instanceName: string): void {
if (this.intervals.has(instanceName)) { if (this.intervals.has(instanceName)) {
return // Already checking return // Already checking
} }
// Initial check with delay // Initial check immediately
setTimeout(async () => { this.refreshHealth(instanceName).then(() => {
const health = await this.checkHealth(instanceName) const currentState = this.lastHealthState.get(instanceName)
this.notifyCallbacks(instanceName, health) if (currentState) {
this.adjustPollingInterval(instanceName, currentState)
// Start periodic checks }
const interval = setInterval(async () => { }).catch(error => {
const health = await this.checkHealth(instanceName) console.error(`Failed to start health check for ${instanceName}:`, error)
this.notifyCallbacks(instanceName, health) })
}, 60000)
this.intervals.set(instanceName, interval)
}, 5000)
} }
/**
* 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)
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)
// Continue polling even on error
}
}, pollInterval)
this.intervals.set(instanceName, interval)
}
/**
* Stop health checking for an instance
*/
private stopHealthCheck(instanceName: string): void { private stopHealthCheck(instanceName: string): void {
const interval = this.intervals.get(instanceName) const interval = this.intervals.get(instanceName)
if (interval) { if (interval) {
@@ -96,6 +244,9 @@ class HealthService {
} }
} }
/**
* Notify all callbacks with health update
*/
private notifyCallbacks(instanceName: string, health: HealthStatus): void { private notifyCallbacks(instanceName: string, health: HealthStatus): void {
const callbacks = this.callbacks.get(instanceName) const callbacks = this.callbacks.get(instanceName)
if (callbacks) { if (callbacks) {
@@ -103,16 +254,21 @@ class HealthService {
} }
} }
stopAll(): void { /**
* Stop all health checking and cleanup
*/
destroy(): void {
this.intervals.forEach(interval => clearInterval(interval)) this.intervals.forEach(interval => clearInterval(interval))
this.intervals.clear() this.intervals.clear()
this.callbacks.clear() this.callbacks.clear()
this.lastHealthState.clear()
this.healthCache.clear()
} }
} }
export const healthService = new HealthService() 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<HealthStatus> { export async function checkHealth(instanceName: string): Promise<HealthStatus> {
return healthService.checkHealth(instanceName) return healthService.performHealthCheck(instanceName)
} }

View File

@@ -11,12 +11,16 @@ export const BackendType = {
export type BackendTypeValue = typeof BackendType[keyof typeof 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' | 'ready' | 'failed' | 'restarting'
export interface HealthStatus { export interface HealthStatus {
status: 'ok' | 'loading' | 'error' | 'unknown' | 'failed' state: HealthState
message?: string instanceStatus: InstanceStatus
lastChecked: Date lastChecked: Date
error?: string
source: 'backend' | 'http'
} }
export interface Instance { export interface Instance {