Add per-node health checks with status indicators and scheduled probes.

This commit is contained in:
2026-07-19 22:40:21 +02:00
parent 5307ba1a7b
commit aac9e82766
20 changed files with 1674 additions and 65 deletions
+97 -45
View File
@@ -29,7 +29,7 @@ import {
patchAction,
patchUser,
reauth,
testNodeSSH,
runNodeHealthCheck,
type AccessMode,
type Action,
type ActionEnvVar,
@@ -76,6 +76,7 @@ import { LoginPage } from './LoginPage'
import { NodeDetailPanel } from './NodeDetailPanel'
import {
countAuthFailuresToday,
countNodeHealthErrorsBySection,
countNodesBySection,
type NodeSectionCounts,
} from './sidebarCounts'
@@ -2407,6 +2408,26 @@ function resourceTabsFor(section: ResourceSectionId): ReadonlyArray<{
]
}
function healthDotClass(healthOk: boolean | null | undefined): string {
if (healthOk === true) {
return 'node-health-dot node-health-dot-ok'
}
if (healthOk === false) {
return 'node-health-dot node-health-dot-error'
}
return 'node-health-dot node-health-dot-unknown'
}
function healthDotLabel(healthOk: boolean | null | undefined): string {
if (healthOk === true) {
return 'Healthy'
}
if (healthOk === false) {
return 'Unhealthy'
}
return 'Health unknown'
}
function ResourceOverviewTab({
section,
canRead,
@@ -2421,10 +2442,7 @@ function ResourceOverviewTab({
const [nodes, setNodes] = useState<ReadonlyArray<Node>>([])
const [isLoading, setIsLoading] = useState(true)
const [loadError, setLoadError] = useState<string | null>(null)
const [testingNodeId, setTestingNodeId] = useState<string | null>(null)
const [testResults, setTestResults] = useState(
{} as Record<string, { ok: boolean; message: string }>,
)
const [checkingNodeId, setCheckingNodeId] = useState<string | null>(null)
const [me, setMe] = useState<MeResponse | null>(null)
const [pendingDeleteNodeId, setPendingDeleteNodeId] = useState<string | null>(
null,
@@ -2491,29 +2509,29 @@ function ResourceOverviewTab({
}
}, [section, canRead])
async function handleTestSSH(nodeId: string) {
setTestingNodeId(nodeId)
setTestResults((previous) => {
const next = { ...previous }
delete next[nodeId]
return next
})
async function handleHealthCheck(nodeId: string) {
setCheckingNodeId(nodeId)
try {
const result = await testNodeSSH(nodeId)
setTestResults((previous) => ({
...previous,
[nodeId]: { ok: result.ok, message: result.message },
}))
const response = await runNodeHealthCheck(nodeId)
setNodes((previous) =>
previous.map((node) => (node.id === nodeId ? response.node : node)),
)
} catch (err) {
setTestResults((previous) => ({
...previous,
[nodeId]: {
ok: false,
message: err instanceof Error ? err.message : 'SSH test failed',
},
}))
setNodes((previous) =>
previous.map((node) => {
if (node.id !== nodeId) {
return node
}
return {
...node,
health_ok: false,
health_message:
err instanceof Error ? err.message : 'Health check failed',
}
}),
)
} finally {
setTestingNodeId(null)
setCheckingNodeId(null)
}
}
@@ -2571,11 +2589,16 @@ function ResourceOverviewTab({
) : null}
<ul className="node-list">
{nodes.map((node) => {
const testResult = testResults[node.id]
return (
<li key={node.id} className="node-list-item">
<div className="node-list-heading">
<div className="node-list-heading-main">
<span
className={healthDotClass(node.health_ok)}
title={healthDotLabel(node.health_ok)}
aria-label={healthDotLabel(node.health_ok)}
role="img"
/>
<strong>{node.name}</strong>
<code className="node-id">{node.id}</code>
</div>
@@ -2584,7 +2607,13 @@ function ResourceOverviewTab({
className="node-edit"
onClick={() => {
navigateTo(
pathFor(section, 'overview', 'overview', node.id, 'actions'),
pathFor(
section,
'overview',
'overview',
node.id,
'healthcheck',
),
)
}}
>
@@ -2595,20 +2624,37 @@ function ResourceOverviewTab({
{node.host_ip} · {node.username} · group {node.group_name} ·{' '}
{node.key_algo}
</p>
{node.health_message ? (
<p
className={
node.health_ok
? 'node-test-result node-test-result-ok'
: node.health_ok === false
? 'node-test-result node-test-result-fail'
: 'node-test-result'
}
role="status"
>
{node.health_message}
{node.health_last_checked_at
? ` · ${new Date(node.health_last_checked_at).toLocaleString()}`
: ''}
</p>
) : null}
{canExec || canDelete ? (
<div className="node-actions">
{canExec ? (
<button
type="button"
className="node-test-ssh"
disabled={testingNodeId === node.id}
disabled={checkingNodeId === node.id}
onClick={() => {
void handleTestSSH(node.id)
void handleHealthCheck(node.id)
}}
>
{testingNodeId === node.id
? 'Testing SSH…'
: 'Test SSH connection'}
{checkingNodeId === node.id
? 'Checking health…'
: 'Health check'}
</button>
) : null}
{canDelete ? (
@@ -2622,19 +2668,6 @@ function ResourceOverviewTab({
Delete
</button>
) : null}
{testResult ? (
<p
className={
testResult.ok
? 'node-test-result node-test-result-ok'
: 'node-test-result node-test-result-fail'
}
role="status"
>
{testResult.ok ? 'Succeeded: ' : 'Failed: '}
{testResult.message}
</p>
) : null}
</div>
) : null}
</li>
@@ -3211,6 +3244,7 @@ function LogsPanel() {
{ id: 'update', label: 'update' },
{ id: 'delete', label: 'delete' },
{ id: 'test_ssh', label: 'test_ssh' },
{ id: 'health_check', label: 'health_check' },
]
const authOutcomeFilters: ReadonlyArray<{
@@ -3812,6 +3846,8 @@ function App() {
const [canReadLogs, setCanReadLogs] = useState(false)
const [nodeSectionCounts, setNodeSectionCounts] =
useState<NodeSectionCounts | null>(null)
const [nodeHealthErrorCounts, setNodeHealthErrorCounts] =
useState<NodeSectionCounts | null>(null)
const [authFailuresToday, setAuthFailuresToday] = useState<number | null>(
null,
)
@@ -3893,10 +3929,14 @@ function App() {
})
if (!isCancelled) {
setNodeSectionCounts(countNodesBySection(nodesResponse.nodes))
setNodeHealthErrorCounts(
countNodeHealthErrorsBySection(nodesResponse.nodes),
)
}
} catch {
if (!isCancelled) {
setNodeSectionCounts(null)
setNodeHealthErrorCounts(null)
}
}
@@ -4099,6 +4139,10 @@ function App() {
isResourceSectionId(item.id) && nodeSectionCounts !== null
? nodeSectionCounts[item.id]
: null
const healthErrorCount =
isResourceSectionId(item.id) && nodeHealthErrorCounts !== null
? nodeHealthErrorCounts[item.id]
: null
return (
<button
key={item.id}
@@ -4112,6 +4156,14 @@ function App() {
onClick={() => goToSection(item.id)}
>
<span className="nav-item-label">{item.label}</span>
{healthErrorCount !== null && healthErrorCount > 0 ? (
<span
className="nav-count-badge nav-count-badge-alert"
aria-label={`${healthErrorCount} unhealthy`}
>
{healthErrorCount}
</span>
) : null}
{sectionCount !== null ? (
<span className="nav-count-badge" aria-hidden="true">
{sectionCount}