Healthcheck updates, UI to remove wierd bar
This commit is contained in:
@@ -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
@@ -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 {
|
||||||
|
|||||||
@@ -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
@@ -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>
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
Reference in New Issue
Block a user