Add per-node health checks with status indicators and scheduled probes.
This commit is contained in:
+97
-45
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user