mirror of
https://github.com/lordmathis/llamactl.git
synced 2025-11-05 16:44:22 +00:00
Initial healthcheck implementation
This commit is contained in:
49
ui/src/components/HealthBadge.tsx
Normal file
49
ui/src/components/HealthBadge.tsx
Normal 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
|
||||
@@ -3,8 +3,9 @@ import { Badge } from "@/components/ui/badge";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Instance } from "@/types/instance";
|
||||
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 HealthBadge from "@/components/HealthBadge";
|
||||
|
||||
interface InstanceCardProps {
|
||||
instance: Instance;
|
||||
@@ -54,9 +55,7 @@ function InstanceCard({
|
||||
<CardHeader className="pb-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<CardTitle className="text-lg">{instance.name}</CardTitle>
|
||||
<Badge variant={instance.running ? "default" : "secondary"}>
|
||||
{instance.running ? "Running" : "Stopped"}
|
||||
</Badge>
|
||||
{instance.running ? <HealthBadge health={instance.health} /> : <Badge variant="secondary">Stopped</Badge>}
|
||||
</div>
|
||||
</CardHeader>
|
||||
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
// ui/src/components/InstanceModal.tsx
|
||||
import React, { useState, useEffect } from 'react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
|
||||
@@ -171,7 +171,7 @@ const LogsModal: React.FC<LogsModalProps> = ({
|
||||
|
||||
return (
|
||||
<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>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
@@ -1,4 +1,3 @@
|
||||
// ui/src/components/SystemInfoModal.tsx
|
||||
import React, { useState, useEffect } from 'react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
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 { healthService } from '@/lib/healthService'
|
||||
|
||||
interface InstancesContextState {
|
||||
instances: Instance[]
|
||||
@@ -36,6 +37,31 @@ export const InstancesProvider = ({ children }: InstancesProviderProps) => {
|
||||
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 () => {
|
||||
try {
|
||||
setLoading(true)
|
||||
@@ -82,6 +108,7 @@ export const InstancesProvider = ({ children }: InstancesProviderProps) => {
|
||||
const stopInstance = useCallback(async (name: string) => {
|
||||
try {
|
||||
setError(null)
|
||||
healthService.stopHealthCheck(name)
|
||||
await instancesApi.stop(name)
|
||||
await fetchInstances()
|
||||
} catch (err) {
|
||||
@@ -102,6 +129,7 @@ export const InstancesProvider = ({ children }: InstancesProviderProps) => {
|
||||
const deleteInstance = useCallback(async (name: string) => {
|
||||
try {
|
||||
setError(null)
|
||||
healthService.stopHealthCheck(name)
|
||||
await instancesApi.delete(name)
|
||||
await fetchInstances()
|
||||
} catch (err) {
|
||||
@@ -109,17 +137,14 @@ export const InstancesProvider = ({ children }: InstancesProviderProps) => {
|
||||
}
|
||||
}, [fetchInstances])
|
||||
|
||||
// Fetch instances on mount
|
||||
useEffect(() => {
|
||||
fetchInstances()
|
||||
}, [fetchInstances])
|
||||
|
||||
const value: InstancesContextType = {
|
||||
// State
|
||||
instances,
|
||||
loading,
|
||||
error,
|
||||
// Actions
|
||||
fetchInstances,
|
||||
createInstance,
|
||||
updateInstance,
|
||||
@@ -143,4 +168,4 @@ export const useInstances = (): InstancesContextType => {
|
||||
throw new Error('useInstances must be used within an InstancesProvider')
|
||||
}
|
||||
return context
|
||||
}
|
||||
}
|
||||
90
ui/src/lib/healthService.ts
Normal file
90
ui/src/lib/healthService.ts
Normal 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()
|
||||
@@ -2,8 +2,15 @@ import { CreateInstanceOptions } from '@/schemas/instanceOptions'
|
||||
|
||||
export { type CreateInstanceOptions } from '@/schemas/instanceOptions'
|
||||
|
||||
export interface HealthStatus {
|
||||
status: 'ok' | 'loading' | 'error'
|
||||
message?: string
|
||||
lastChecked: Date
|
||||
}
|
||||
|
||||
export interface Instance {
|
||||
name: string;
|
||||
running: boolean;
|
||||
options?: CreateInstanceOptions;
|
||||
health?: HealthStatus;
|
||||
}
|
||||
Reference in New Issue
Block a user