mirror of
https://github.com/lordmathis/llamactl.git
synced 2025-11-05 16:44:22 +00:00
Merge pull request #74 from lordmathis/refactor/health-check
refactor: Improve frontend health check
This commit is contained in:
@@ -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)
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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() {
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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', () => {
|
||||||
|
|||||||
@@ -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()) {
|
||||||
|
|||||||
@@ -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')
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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
|
||||||
|
}
|
||||||
|
|
||||||
async checkHealth(instanceName: string): Promise<HealthStatus> {
|
|
||||||
try {
|
try {
|
||||||
await instancesApi.getHealth(instanceName)
|
// Step 1: Get instance details (includes status)
|
||||||
|
const instance = await instancesApi.get(instanceName)
|
||||||
|
|
||||||
return {
|
// Step 2: If running, attempt HTTP health check
|
||||||
status: 'ok',
|
if (instance.status === 'running') {
|
||||||
lastChecked: new Date()
|
try {
|
||||||
}
|
await instancesApi.getHealth(instanceName)
|
||||||
} catch (error) {
|
|
||||||
if (error instanceof Error) {
|
// HTTP health check succeeded - instance is ready
|
||||||
// Check if it's a 503 (service unavailable - loading)
|
const health: HealthStatus = {
|
||||||
if (error.message.includes('503')) {
|
state: 'ready',
|
||||||
return {
|
instanceStatus: 'running',
|
||||||
status: 'loading',
|
lastChecked: new Date(),
|
||||||
message: 'Instance is starting up',
|
source: 'http'
|
||||||
lastChecked: new Date()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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'
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
this.updateCache(instanceName, health)
|
||||||
status: 'error',
|
return health
|
||||||
message: error.message,
|
|
||||||
lastChecked: new Date()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
} catch (error) {
|
||||||
status: 'error',
|
// Failed to get instance status from backend
|
||||||
message: 'Unknown error',
|
// This is a backend communication error, not an instance health error
|
||||||
lastChecked: new Date()
|
// 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) {
|
||||||
|
// Error getting health - keep polling if active
|
||||||
|
console.error(`Failed to refresh health for ${instanceName}:`, error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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 {
|
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)
|
||||||
}
|
}
|
||||||
@@ -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 {
|
||||||
|
|||||||
Reference in New Issue
Block a user