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',