Healthcheck updates, UI to remove wierd bar

This commit is contained in:
2026-07-19 23:11:00 +02:00
parent aac9e82766
commit e047499fed
6 changed files with 402 additions and 2 deletions
+9
View File
@@ -317,6 +317,15 @@ func (app *App) nodesPatchHandler(writer http.ResponseWriter, request *http.Requ
node, node,
fmt.Sprintf("health_check_interval_seconds=%d", node.HealthCheckIntervalSeconds), 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}) writeJSON(writer, http.StatusOK, nodeResponse{Node: node})
} }
@@ -6,7 +6,9 @@ import (
"errors" "errors"
"net/http" "net/http"
"net/http/httptest" "net/http/httptest"
"sync/atomic"
"testing" "testing"
"time"
"codeberg.org/SquidSE/ClusterCanvas/service/internal/auth" "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) { func TestNodesPatchHealthIntervalRejectsNegative(t *testing.T) {
configDir := t.TempDir() configDir := t.TempDir()
cookie := seedCompletedSetup(t, configDir) cookie := seedCompletedSetup(t, configDir)
+27 -1
View File
@@ -156,6 +156,10 @@
line-height: 1; line-height: 1;
} }
.nav-count-badge + .nav-count-badge {
margin-left: 0;
}
.nav-count-badge-alert { .nav-count-badge-alert {
border-color: color-mix(in srgb, var(--ctp-red) 45%, var(--ctp-surface1)); border-color: color-mix(in srgb, var(--ctp-red) 45%, var(--ctp-surface1));
background: color-mix(in srgb, var(--ctp-red) 14%, transparent); background: color-mix(in srgb, var(--ctp-red) 14%, transparent);
@@ -1488,7 +1492,29 @@
} }
.node-detail-back { .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 { .node-detail-header {
+136
View File
@@ -373,6 +373,142 @@ describe('App', () => {
).toBeNull() ).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(<App />)
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 () => { it('shows a red Activity badge for auth failures today', async () => {
const todayIso = new Date( const todayIso = new Date(
new Date().getFullYear(), new Date().getFullYear(),
+13 -1
View File
@@ -2433,11 +2433,13 @@ function ResourceOverviewTab({
canRead, canRead,
canExec, canExec,
canDelete, canDelete,
onNodesChanged,
}: { }: {
section: ResourceSectionId section: ResourceSectionId
canRead: boolean canRead: boolean
canExec: boolean canExec: boolean
canDelete: boolean canDelete: boolean
onNodesChanged: () => void
}) { }) {
const [nodes, setNodes] = useState<ReadonlyArray<Node>>([]) const [nodes, setNodes] = useState<ReadonlyArray<Node>>([])
const [isLoading, setIsLoading] = useState(true) const [isLoading, setIsLoading] = useState(true)
@@ -2532,6 +2534,7 @@ function ResourceOverviewTab({
) )
} finally { } finally {
setCheckingNodeId(null) setCheckingNodeId(null)
onNodesChanged()
} }
} }
@@ -2546,6 +2549,7 @@ function ResourceOverviewTab({
), ),
) )
setPendingDeleteNodeId(null) setPendingDeleteNodeId(null)
onNodesChanged()
} }
if (!canRead) { if (!canRead) {
@@ -2996,12 +3000,14 @@ function ResourceSectionPanel({
onResourceTabChange, onResourceTabChange,
nodeId, nodeId,
nodeDetailTab, nodeDetailTab,
onNodesChanged,
}: { }: {
section: ResourceSectionId section: ResourceSectionId
activeResourceTab: ResourceTabId activeResourceTab: ResourceTabId
onResourceTabChange: (tab: ResourceTabId) => void onResourceTabChange: (tab: ResourceTabId) => void
nodeId: string | null nodeId: string | null
nodeDetailTab: NodeDetailTabId nodeDetailTab: NodeDetailTabId
onNodesChanged: () => void
}) { }) {
const [me, setMe] = useState<MeResponse | null>(null) const [me, setMe] = useState<MeResponse | null>(null)
const [overviewKey, setOverviewKey] = useState(0) const [overviewKey, setOverviewKey] = useState(0)
@@ -3040,6 +3046,7 @@ function ResourceSectionPanel({
section={section} section={section}
nodeId={nodeId} nodeId={nodeId}
activeTab={nodeDetailTab} activeTab={nodeDetailTab}
onNodesChanged={onNodesChanged}
/> />
) )
} }
@@ -3086,6 +3093,7 @@ function ResourceSectionPanel({
canRead={canRead} canRead={canRead}
canExec={canExec} canExec={canExec}
canDelete={canDelete} canDelete={canDelete}
onNodesChanged={onNodesChanged}
/> />
) : null} ) : null}
{activeResourceTab === 'security' ? ( {activeResourceTab === 'security' ? (
@@ -3848,6 +3856,7 @@ function App() {
useState<NodeSectionCounts | null>(null) useState<NodeSectionCounts | null>(null)
const [nodeHealthErrorCounts, setNodeHealthErrorCounts] = const [nodeHealthErrorCounts, setNodeHealthErrorCounts] =
useState<NodeSectionCounts | null>(null) useState<NodeSectionCounts | null>(null)
const [sidebarCountsRefreshToken, setSidebarCountsRefreshToken] = useState(0)
const [authFailuresToday, setAuthFailuresToday] = useState<number | null>( const [authFailuresToday, setAuthFailuresToday] = useState<number | null>(
null, null,
) )
@@ -3969,7 +3978,7 @@ function App() {
isCancelled = true isCancelled = true
window.clearInterval(pollIntervalId) window.clearInterval(pollIntervalId)
} }
}, [bootState.kind, canReadLogs, activeSection]) }, [bootState.kind, canReadLogs, activeSection, sidebarCountsRefreshToken])
useEffect(() => { useEffect(() => {
if (bootState.kind !== 'ready') { if (bootState.kind !== 'ready') {
@@ -4272,6 +4281,9 @@ function App() {
onResourceTabChange={goToResourceTab} onResourceTabChange={goToResourceTab}
nodeId={activeNodeId} nodeId={activeNodeId}
nodeDetailTab={activeNodeDetailTab} nodeDetailTab={activeNodeDetailTab}
onNodesChanged={() => {
setSidebarCountsRefreshToken((token) => token + 1)
}}
/> />
) : ( ) : (
<p>Coming soon</p> <p>Coming soon</p>
+67
View File
@@ -257,10 +257,12 @@ export function NodeDetailPanel({
section, section,
nodeId, nodeId,
activeTab, activeTab,
onNodesChanged,
}: { }: {
section: ResourceSectionId section: ResourceSectionId
nodeId: string nodeId: string
activeTab: NodeDetailTabId activeTab: NodeDetailTabId
onNodesChanged?: () => void
}) { }) {
const [me, setMe] = useState<MeResponse | null>(null) const [me, setMe] = useState<MeResponse | null>(null)
const [node, setNode] = useState<Node | null>(null) const [node, setNode] = useState<Node | null>(null)
@@ -378,6 +380,7 @@ export function NodeDetailPanel({
canExec={canExecNodes} canExec={canExecNodes}
canUpdate={canUpdateNodes} canUpdate={canUpdateNodes}
onNodeUpdated={setNode} onNodeUpdated={setNode}
onNodesChanged={onNodesChanged}
/> />
) : null} ) : null}
{activeTab === 'actions' ? ( {activeTab === 'actions' ? (
@@ -485,11 +488,13 @@ function NodeHealthcheckTab({
canExec, canExec,
canUpdate, canUpdate,
onNodeUpdated, onNodeUpdated,
onNodesChanged,
}: { }: {
node: Node node: Node
canExec: boolean canExec: boolean
canUpdate: boolean canUpdate: boolean
onNodeUpdated: (node: Node) => void onNodeUpdated: (node: Node) => void
onNodesChanged?: () => void
}) { }) {
const [intervalDraft, setIntervalDraft] = useState(() => const [intervalDraft, setIntervalDraft] = useState(() =>
intervalToFormValues(node.health_check_interval_seconds ?? 0), intervalToFormValues(node.health_check_interval_seconds ?? 0),
@@ -498,6 +503,10 @@ function NodeHealthcheckTab({
const [intervalError, setIntervalError] = useState<string | null>(null) const [intervalError, setIntervalError] = useState<string | null>(null)
const [isChecking, setIsChecking] = useState(false) const [isChecking, setIsChecking] = useState(false)
const [checkError, setCheckError] = useState<string | null>(null) const [checkError, setCheckError] = useState<string | null>(null)
const [pendingHealthPoll, setPendingHealthPoll] = useState<{
nodeId: string
previousCheckedAt: string | null
} | null>(null)
useEffect(() => { useEffect(() => {
setIntervalDraft( setIntervalDraft(
@@ -506,6 +515,56 @@ function NodeHealthcheckTab({
setIntervalError(null) setIntervalError(null)
}, [node.id, node.health_check_interval_seconds]) }, [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() { async function handleSaveInterval() {
const seconds = formValuesToIntervalSeconds( const seconds = formValuesToIntervalSeconds(
intervalDraft.value, intervalDraft.value,
@@ -514,6 +573,7 @@ function NodeHealthcheckTab({
setIsSavingInterval(true) setIsSavingInterval(true)
setIntervalError(null) setIntervalError(null)
try { try {
const previousCheckedAt = node.health_last_checked_at ?? null
const response = await patchNode(node.id, { const response = await patchNode(node.id, {
health_check_interval_seconds: seconds, health_check_interval_seconds: seconds,
}) })
@@ -521,6 +581,12 @@ function NodeHealthcheckTab({
setIntervalDraft( setIntervalDraft(
intervalToFormValues(response.node.health_check_interval_seconds ?? 0), intervalToFormValues(response.node.health_check_interval_seconds ?? 0),
) )
if (seconds > 0) {
setPendingHealthPoll({
nodeId: node.id,
previousCheckedAt,
})
}
} catch (err) { } catch (err) {
setIntervalError( setIntervalError(
err instanceof Error ? err.message : 'Failed to save interval', err instanceof Error ? err.message : 'Failed to save interval',
@@ -536,6 +602,7 @@ function NodeHealthcheckTab({
try { try {
const response = await runNodeHealthCheck(node.id) const response = await runNodeHealthCheck(node.id)
onNodeUpdated(response.node) onNodeUpdated(response.node)
onNodesChanged?.()
} catch (err) { } catch (err) {
setCheckError( setCheckError(
err instanceof Error ? err.message : 'Health check failed', err instanceof Error ? err.message : 'Health check failed',