diff --git a/service/internal/api/nodes_handlers.go b/service/internal/api/nodes_handlers.go
index 600aa00..3b99dcc 100644
--- a/service/internal/api/nodes_handlers.go
+++ b/service/internal/api/nodes_handlers.go
@@ -317,6 +317,15 @@ func (app *App) nodesPatchHandler(writer http.ResponseWriter, request *http.Requ
node,
fmt.Sprintf("health_check_interval_seconds=%d", node.HealthCheckIntervalSeconds),
)
+
+ if node.HealthCheckIntervalSeconds > 0 && app.HealthChecker != nil {
+ nodeID := node.ID
+ actor := user.Username
+ go func() {
+ _, _ = app.HealthChecker.CheckNodeByID(nodeID, actor)
+ }()
+ }
+
writeJSON(writer, http.StatusOK, nodeResponse{Node: node})
}
diff --git a/service/internal/api/nodes_health_handlers_test.go b/service/internal/api/nodes_health_handlers_test.go
index 0589ece..3a4bb3f 100644
--- a/service/internal/api/nodes_health_handlers_test.go
+++ b/service/internal/api/nodes_health_handlers_test.go
@@ -6,7 +6,9 @@ import (
"errors"
"net/http"
"net/http/httptest"
+ "sync/atomic"
"testing"
+ "time"
"codeberg.org/SquidSE/ClusterCanvas/service/internal/auth"
)
@@ -171,6 +173,154 @@ func TestNodesPatchHealthInterval(t *testing.T) {
}
}
+func TestNodesPatchHealthIntervalTriggersBackgroundCheck(t *testing.T) {
+ configDir := t.TempDir()
+ cookie := seedCompletedSetup(t, configDir)
+ router, app := NewRouterWithApp(configDir)
+
+ app.HealthChecker.PingFn = func(hostIP string) auth.PingResult {
+ return auth.PingResult{OK: true, Method: auth.PingMethodICMP}
+ }
+ app.HealthChecker.SSHFn = func(hostIP string, username string, privateKeyPEM string, passphrase string) error {
+ return nil
+ }
+
+ const nodeID = "ffffffff-bbbb-4ccc-8ddd-eeeeeeeeeeee"
+ createBody := []byte(`{
+ "id":"` + nodeID + `",
+ "kind":"container",
+ "name":"bg-check-node",
+ "host_ip":"10.20.30.44",
+ "group_name":"Administrators",
+ "generate":{"algorithm":"ed25519"}
+ }`)
+ createRequest := withSession(
+ httptest.NewRequest(http.MethodPost, "/api/v1/nodes", bytes.NewReader(createBody)),
+ cookie,
+ )
+ createRecorder := httptest.NewRecorder()
+ router.ServeHTTP(createRecorder, createRequest)
+ if createRecorder.Code != http.StatusCreated {
+ t.Fatalf("create status = %d body=%s", createRecorder.Code, createRecorder.Body.String())
+ }
+
+ patchBody := []byte(`{"health_check_interval_seconds":60}`)
+ patchRequest := withSession(
+ httptest.NewRequest(
+ http.MethodPatch,
+ "/api/v1/nodes/"+nodeID,
+ bytes.NewReader(patchBody),
+ ),
+ cookie,
+ )
+ patchRecorder := httptest.NewRecorder()
+ router.ServeHTTP(patchRecorder, patchRequest)
+ if patchRecorder.Code != http.StatusOK {
+ t.Fatalf("patch status = %d body=%s", patchRecorder.Code, patchRecorder.Body.String())
+ }
+
+ deadline := time.Now().Add(2 * time.Second)
+ for {
+ getRequest := withSession(
+ httptest.NewRequest(http.MethodGet, "/api/v1/nodes/"+nodeID, nil),
+ cookie,
+ )
+ getRecorder := httptest.NewRecorder()
+ router.ServeHTTP(getRecorder, getRequest)
+ if getRecorder.Code != http.StatusOK {
+ t.Fatalf("get status = %d body=%s", getRecorder.Code, getRecorder.Body.String())
+ }
+
+ var response nodeResponse
+ if err := json.Unmarshal(getRecorder.Body.Bytes(), &response); err != nil {
+ t.Fatalf("decode: %v", err)
+ }
+ if response.Node.HealthOK != nil && *response.Node.HealthOK {
+ return
+ }
+ if time.Now().After(deadline) {
+ t.Fatalf("timed out waiting for background health check, node=%#v", response.Node)
+ }
+ time.Sleep(20 * time.Millisecond)
+ }
+}
+
+func TestNodesPatchHealthIntervalZeroSkipsBackgroundCheck(t *testing.T) {
+ configDir := t.TempDir()
+ cookie := seedCompletedSetup(t, configDir)
+ router, app := NewRouterWithApp(configDir)
+
+ checked := atomic.Bool{}
+ app.HealthChecker.PingFn = func(hostIP string) auth.PingResult {
+ checked.Store(true)
+ return auth.PingResult{OK: true, Method: auth.PingMethodICMP}
+ }
+ app.HealthChecker.SSHFn = func(hostIP string, username string, privateKeyPEM string, passphrase string) error {
+ checked.Store(true)
+ return nil
+ }
+
+ const nodeID = "aaaaaaaa-cccc-4ddd-8eee-ffffffffffff"
+ createBody := []byte(`{
+ "id":"` + nodeID + `",
+ "kind":"vm",
+ "name":"no-bg-check-node",
+ "host_ip":"10.20.30.45",
+ "group_name":"Administrators",
+ "generate":{"algorithm":"ed25519"}
+ }`)
+ createRequest := withSession(
+ httptest.NewRequest(http.MethodPost, "/api/v1/nodes", bytes.NewReader(createBody)),
+ cookie,
+ )
+ createRecorder := httptest.NewRecorder()
+ router.ServeHTTP(createRecorder, createRequest)
+ if createRecorder.Code != http.StatusCreated {
+ t.Fatalf("create status = %d body=%s", createRecorder.Code, createRecorder.Body.String())
+ }
+
+ patchBody := []byte(`{"health_check_interval_seconds":0}`)
+ patchRequest := withSession(
+ httptest.NewRequest(
+ http.MethodPatch,
+ "/api/v1/nodes/"+nodeID,
+ bytes.NewReader(patchBody),
+ ),
+ cookie,
+ )
+ patchRecorder := httptest.NewRecorder()
+ router.ServeHTTP(patchRecorder, patchRequest)
+ if patchRecorder.Code != http.StatusOK {
+ t.Fatalf("patch status = %d body=%s", patchRecorder.Code, patchRecorder.Body.String())
+ }
+
+ time.Sleep(100 * time.Millisecond)
+
+ getRequest := withSession(
+ httptest.NewRequest(http.MethodGet, "/api/v1/nodes/"+nodeID, nil),
+ cookie,
+ )
+ getRecorder := httptest.NewRecorder()
+ router.ServeHTTP(getRecorder, getRequest)
+ if getRecorder.Code != http.StatusOK {
+ t.Fatalf("get status = %d body=%s", getRecorder.Code, getRecorder.Body.String())
+ }
+
+ var response nodeResponse
+ if err := json.Unmarshal(getRecorder.Body.Bytes(), &response); err != nil {
+ t.Fatalf("decode: %v", err)
+ }
+ if checked.Load() {
+ t.Fatal("expected no background health check when interval is 0")
+ }
+ if response.Node.HealthOK != nil {
+ t.Fatalf("expected health_ok unset, got %#v", response.Node.HealthOK)
+ }
+ if response.Node.HealthLastCheckedAt != nil {
+ t.Fatalf("expected health_last_checked_at unset, got %#v", response.Node.HealthLastCheckedAt)
+ }
+}
+
func TestNodesPatchHealthIntervalRejectsNegative(t *testing.T) {
configDir := t.TempDir()
cookie := seedCompletedSetup(t, configDir)
diff --git a/webui/src/App.css b/webui/src/App.css
index 9a112fb..d891413 100644
--- a/webui/src/App.css
+++ b/webui/src/App.css
@@ -156,6 +156,10 @@
line-height: 1;
}
+.nav-count-badge + .nav-count-badge {
+ margin-left: 0;
+}
+
.nav-count-badge-alert {
border-color: color-mix(in srgb, var(--ctp-red) 45%, var(--ctp-surface1));
background: color-mix(in srgb, var(--ctp-red) 14%, transparent);
@@ -1488,7 +1492,29 @@
}
.node-detail-back {
- margin-bottom: 0.75rem;
+ appearance: none;
+ align-self: flex-start;
+ width: fit-content;
+ margin: 0;
+ padding: 0.15rem 0;
+ border: none;
+ background: transparent;
+ color: var(--ctp-subtext1);
+ font: inherit;
+ font-size: 0.9rem;
+ font-weight: 500;
+ text-align: left;
+ cursor: pointer;
+}
+
+.node-detail-back:hover {
+ color: var(--ctp-text);
+}
+
+.node-detail-back:focus-visible {
+ outline: 2px solid var(--ctp-mauve);
+ outline-offset: 2px;
+ border-radius: 0.2rem;
}
.node-detail-header {
diff --git a/webui/src/App.test.tsx b/webui/src/App.test.tsx
index 4402ada..6b5467f 100644
--- a/webui/src/App.test.tsx
+++ b/webui/src/App.test.tsx
@@ -373,6 +373,142 @@ describe('App', () => {
).toBeNull()
})
+ it('clears the red health badge when all nodes become healthy', async () => {
+ const user = userEvent.setup()
+ let containerHealthOk: boolean | null = false
+
+ vi.stubGlobal(
+ 'fetch',
+ vi.fn().mockImplementation((input: RequestInfo | URL, init?: RequestInit) => {
+ const url = typeof input === 'string' ? input : input.toString()
+ const method = init?.method ?? 'GET'
+
+ if (url.includes('/api/v1/setup/status')) {
+ return Promise.resolve({
+ ok: true,
+ json: async () => ({ completed: true, client_ip: '127.0.0.1' }),
+ })
+ }
+
+ if (url.includes('/api/v1/auth/me')) {
+ return Promise.resolve({
+ ok: true,
+ json: async () => ({
+ user_id: 'u1',
+ username: 'Admin',
+ groups: ['Administrators'],
+ totp_confirmed: false,
+ totp_enabled: false,
+ permissions: ['nodes.read', 'nodes.exec', 'logs.read'],
+ }),
+ })
+ }
+
+ if (url.includes('/api/v1/status')) {
+ return Promise.resolve({
+ ok: true,
+ json: async () => ({
+ service: 'clustercanvas',
+ version: '0.1.0',
+ }),
+ })
+ }
+
+ if (url.includes('/api/v1/auth-logs')) {
+ return Promise.resolve({
+ ok: true,
+ json: async () => ({ events: [] }),
+ })
+ }
+
+ if (
+ method === 'POST' &&
+ url.includes('/api/v1/nodes/c1/health-check')
+ ) {
+ containerHealthOk = true
+ return Promise.resolve({
+ ok: true,
+ json: async () => ({
+ node: {
+ id: 'c1',
+ kind: 'container',
+ name: 'alpha',
+ health_ok: true,
+ health_message: 'ping ok (icmp); ssh ok',
+ },
+ }),
+ })
+ }
+
+ if (url.includes('/api/v1/nodes')) {
+ return Promise.resolve({
+ ok: true,
+ json: async () => ({
+ nodes: [
+ {
+ id: 'c1',
+ kind: 'container',
+ name: 'alpha',
+ host_ip: '10.0.0.1',
+ username: 'root',
+ group_name: 'Administrators',
+ key_algo: 'ed25519',
+ health_ok: containerHealthOk,
+ health_message:
+ containerHealthOk === false
+ ? 'unreachable'
+ : containerHealthOk
+ ? 'ping ok (icmp); ssh ok'
+ : undefined,
+ },
+ ],
+ }),
+ })
+ }
+
+ return Promise.resolve({
+ ok: true,
+ json: async () => ({}),
+ })
+ }),
+ )
+
+ render()
+
+ const containersButton = await screen.findByRole('button', {
+ name: /Containers/,
+ })
+ await waitFor(() => {
+ expect(
+ containersButton.querySelector('.nav-count-badge-alert'),
+ ).toHaveTextContent('1')
+ })
+ expect(
+ containersButton.querySelector(
+ '.nav-count-badge:not(.nav-count-badge-alert)',
+ ),
+ ).toHaveTextContent('1')
+
+ await user.click(containersButton)
+ const healthCheckButton = await screen.findByRole('button', {
+ name: 'Health check',
+ })
+ await user.click(healthCheckButton)
+
+ await waitFor(() => {
+ expect(
+ screen
+ .getByRole('button', { name: /Containers/ })
+ .querySelector('.nav-count-badge-alert'),
+ ).toBeNull()
+ })
+ expect(
+ screen
+ .getByRole('button', { name: /Containers/ })
+ .querySelector('.nav-count-badge'),
+ ).toHaveTextContent('1')
+ })
+
it('shows a red Activity badge for auth failures today', async () => {
const todayIso = new Date(
new Date().getFullYear(),
diff --git a/webui/src/App.tsx b/webui/src/App.tsx
index a460778..5e9e04d 100644
--- a/webui/src/App.tsx
+++ b/webui/src/App.tsx
@@ -2433,11 +2433,13 @@ function ResourceOverviewTab({
canRead,
canExec,
canDelete,
+ onNodesChanged,
}: {
section: ResourceSectionId
canRead: boolean
canExec: boolean
canDelete: boolean
+ onNodesChanged: () => void
}) {
const [nodes, setNodes] = useState>([])
const [isLoading, setIsLoading] = useState(true)
@@ -2532,6 +2534,7 @@ function ResourceOverviewTab({
)
} finally {
setCheckingNodeId(null)
+ onNodesChanged()
}
}
@@ -2546,6 +2549,7 @@ function ResourceOverviewTab({
),
)
setPendingDeleteNodeId(null)
+ onNodesChanged()
}
if (!canRead) {
@@ -2996,12 +3000,14 @@ function ResourceSectionPanel({
onResourceTabChange,
nodeId,
nodeDetailTab,
+ onNodesChanged,
}: {
section: ResourceSectionId
activeResourceTab: ResourceTabId
onResourceTabChange: (tab: ResourceTabId) => void
nodeId: string | null
nodeDetailTab: NodeDetailTabId
+ onNodesChanged: () => void
}) {
const [me, setMe] = useState(null)
const [overviewKey, setOverviewKey] = useState(0)
@@ -3040,6 +3046,7 @@ function ResourceSectionPanel({
section={section}
nodeId={nodeId}
activeTab={nodeDetailTab}
+ onNodesChanged={onNodesChanged}
/>
)
}
@@ -3086,6 +3093,7 @@ function ResourceSectionPanel({
canRead={canRead}
canExec={canExec}
canDelete={canDelete}
+ onNodesChanged={onNodesChanged}
/>
) : null}
{activeResourceTab === 'security' ? (
@@ -3848,6 +3856,7 @@ function App() {
useState(null)
const [nodeHealthErrorCounts, setNodeHealthErrorCounts] =
useState(null)
+ const [sidebarCountsRefreshToken, setSidebarCountsRefreshToken] = useState(0)
const [authFailuresToday, setAuthFailuresToday] = useState(
null,
)
@@ -3969,7 +3978,7 @@ function App() {
isCancelled = true
window.clearInterval(pollIntervalId)
}
- }, [bootState.kind, canReadLogs, activeSection])
+ }, [bootState.kind, canReadLogs, activeSection, sidebarCountsRefreshToken])
useEffect(() => {
if (bootState.kind !== 'ready') {
@@ -4272,6 +4281,9 @@ function App() {
onResourceTabChange={goToResourceTab}
nodeId={activeNodeId}
nodeDetailTab={activeNodeDetailTab}
+ onNodesChanged={() => {
+ setSidebarCountsRefreshToken((token) => token + 1)
+ }}
/>
) : (
Coming soon
diff --git a/webui/src/NodeDetailPanel.tsx b/webui/src/NodeDetailPanel.tsx
index d8b0ca5..8707a02 100644
--- a/webui/src/NodeDetailPanel.tsx
+++ b/webui/src/NodeDetailPanel.tsx
@@ -257,10 +257,12 @@ export function NodeDetailPanel({
section,
nodeId,
activeTab,
+ onNodesChanged,
}: {
section: ResourceSectionId
nodeId: string
activeTab: NodeDetailTabId
+ onNodesChanged?: () => void
}) {
const [me, setMe] = useState(null)
const [node, setNode] = useState(null)
@@ -378,6 +380,7 @@ export function NodeDetailPanel({
canExec={canExecNodes}
canUpdate={canUpdateNodes}
onNodeUpdated={setNode}
+ onNodesChanged={onNodesChanged}
/>
) : null}
{activeTab === 'actions' ? (
@@ -485,11 +488,13 @@ function NodeHealthcheckTab({
canExec,
canUpdate,
onNodeUpdated,
+ onNodesChanged,
}: {
node: Node
canExec: boolean
canUpdate: boolean
onNodeUpdated: (node: Node) => void
+ onNodesChanged?: () => void
}) {
const [intervalDraft, setIntervalDraft] = useState(() =>
intervalToFormValues(node.health_check_interval_seconds ?? 0),
@@ -498,6 +503,10 @@ function NodeHealthcheckTab({
const [intervalError, setIntervalError] = useState(null)
const [isChecking, setIsChecking] = useState(false)
const [checkError, setCheckError] = useState(null)
+ const [pendingHealthPoll, setPendingHealthPoll] = useState<{
+ nodeId: string
+ previousCheckedAt: string | null
+ } | null>(null)
useEffect(() => {
setIntervalDraft(
@@ -506,6 +515,56 @@ function NodeHealthcheckTab({
setIntervalError(null)
}, [node.id, node.health_check_interval_seconds])
+ useEffect(() => {
+ setPendingHealthPoll(null)
+ }, [node.id])
+
+ useEffect(() => {
+ if (pendingHealthPoll === null) {
+ return
+ }
+ const { nodeId, previousCheckedAt } = pendingHealthPoll
+ let isCancelled = false
+
+ async function pollForHealthResult() {
+ const maxAttempts = 20
+ for (let attempt = 0; attempt < maxAttempts; attempt += 1) {
+ if (isCancelled) {
+ return
+ }
+ await new Promise((resolve) => {
+ window.setTimeout(resolve, 300)
+ })
+ if (isCancelled) {
+ return
+ }
+ try {
+ const refreshed = await fetchNode(nodeId)
+ const nextCheckedAt = refreshed.node.health_last_checked_at ?? null
+ if (nextCheckedAt !== previousCheckedAt) {
+ if (!isCancelled) {
+ onNodeUpdated(refreshed.node)
+ onNodesChanged?.()
+ setPendingHealthPoll(null)
+ }
+ return
+ }
+ } catch {
+ // Ignore transient poll errors; interval save already succeeded.
+ }
+ }
+ if (!isCancelled) {
+ setPendingHealthPoll(null)
+ }
+ }
+
+ void pollForHealthResult()
+
+ return () => {
+ isCancelled = true
+ }
+ }, [pendingHealthPoll, onNodeUpdated, onNodesChanged])
+
async function handleSaveInterval() {
const seconds = formValuesToIntervalSeconds(
intervalDraft.value,
@@ -514,6 +573,7 @@ function NodeHealthcheckTab({
setIsSavingInterval(true)
setIntervalError(null)
try {
+ const previousCheckedAt = node.health_last_checked_at ?? null
const response = await patchNode(node.id, {
health_check_interval_seconds: seconds,
})
@@ -521,6 +581,12 @@ function NodeHealthcheckTab({
setIntervalDraft(
intervalToFormValues(response.node.health_check_interval_seconds ?? 0),
)
+ if (seconds > 0) {
+ setPendingHealthPoll({
+ nodeId: node.id,
+ previousCheckedAt,
+ })
+ }
} catch (err) {
setIntervalError(
err instanceof Error ? err.message : 'Failed to save interval',
@@ -536,6 +602,7 @@ function NodeHealthcheckTab({
try {
const response = await runNodeHealthCheck(node.id)
onNodeUpdated(response.node)
+ onNodesChanged?.()
} catch (err) {
setCheckError(
err instanceof Error ? err.message : 'Health check failed',