Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -323,6 +323,9 @@ This file defines how coding agents should work in this repository.
auto-refresh only after explicit user opt-in, and polling paused while the
browser tab is hidden. Keep labels, cadence, tests, and packaged static assets
in sync when changing refresh behavior.
- Dashboard refresh must apply `/api/gpus` and `/api/sessions` results
independently, so telemetry failures do not hide session state or overwrite
successful start/release outcome messages after mutations.
- Single-GPU keep workload iteration counts must be positive integers (`relu_iterations` for CUDA, `iterations` for ROCm/Mac M); reject invalid values before keep loops so no public path can create a silent no-op keeper or late background thread crash.
- CUDA workload tuning must use the `relu_iterations` public keyword. Do not
reintroduce the legacy `matmul_iterations` alias without an intentional,
Expand Down
2 changes: 2 additions & 0 deletions docs/guides/cli.md
Original file line number Diff line number Diff line change
Expand Up @@ -133,6 +133,8 @@ The dashboard provides:
Telemetry refresh is manual by default so an idle browser tab does not keep
probing GPU backends. Use **Refresh Now** for a one-shot update, or enable
**Auto refresh** for 10-second polling while the tab is visible.
If telemetry refresh fails but session status succeeds, the dashboard still
updates tracked sessions and keeps successful start/release messages visible.

Unavailable utilization readings are shown as `n/a`; they are excluded from
summary averages and do not draw an idle-looking utilization fill.
Expand Down
30 changes: 30 additions & 0 deletions docs/plans/dashboard-decouple-refresh.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
# Dashboard Refresh Decoupling Plan

## Background

The dashboard refreshed `/api/gpus` and `/api/sessions` with one `Promise.all`.
If telemetry failed while session status succeeded, session state stayed stale.
After start or release actions, a follow-up telemetry warning could also replace
the successful action result message.

## Goal

Keep the dashboard useful when telemetry is flaky: apply whichever refresh
payload succeeds, warn on partial failure, and preserve mutation outcome
messages after start/release operations.

## Solution

- Add RED refresh-helper tests for telemetry failure with successful sessions
and mutation-message preservation.
- Fetch dashboard payloads with independent settled outcomes.
- Update `App.jsx` to apply successful payloads independently and keep mutation
messages as the primary footer text.
- Rebuild the packaged static dashboard assets.

## Verification

- Dashboard refresh helper tests.
- Full dashboard test/build.
- Relevant Python/API checks, pre-commit, docs build, and whitespace check
before PR.
16 changes: 8 additions & 8 deletions src/keep_gpu/mcp/static/assets/dashboard.js

Large diffs are not rendered by default.

56 changes: 38 additions & 18 deletions web/dashboard/src/App.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,10 @@ import {
AUTO_REFRESH_INTERVAL_MS,
canRunAutoRefresh,
canReuseInFlightRefresh,
fetchDashboardPayloads,
formatRefreshWarningMessage,
formatRefreshMode
formatRefreshMode,
nextRefreshMessage
} from "./lib/refresh"

import {
Expand Down Expand Up @@ -96,7 +98,8 @@ export default function App() {

const refresh = useCallback(async ({
userInitiated = false,
afterMutation = false
afterMutation = false,
previousMessage = null
} = {}) => {
if (canReuseInFlightRefresh(refreshPromiseRef.current, afterMutation)) {
return refreshPromiseRef.current
Expand All @@ -111,18 +114,32 @@ export default function App() {
const refreshPromise = (async () => {
setRefreshing(true)
try {
const [gpuPayload, sessionPayload] = await Promise.all([
api("GET", "/api/gpus"),
api("GET", "/api/sessions")
])

setGpus(gpuPayload.gpus ?? [])
setSessions(sessionPayload.active_jobs ?? [])
if (userInitiated) {
setMessage("Dashboard refreshed.")
const result = await fetchDashboardPayloads(api)
if (result.gpus !== null) {
setGpus(result.gpus)
}
if (result.sessions !== null) {
setSessions(result.sessions)
}
const nextMessage = nextRefreshMessage({
afterMutation,
previousMessage,
userInitiated,
warning: result.warning
})
if (nextMessage) {
setMessage(nextMessage)
}
} catch (error) {
setMessage(formatRefreshWarningMessage(error))
const nextMessage = nextRefreshMessage({
afterMutation,
previousMessage,
userInitiated,
warning: formatRefreshWarningMessage(error)
})
if (nextMessage) {
setMessage(nextMessage)
}
} finally {
setRefreshing(false)
refreshPromiseRef.current = null
Expand Down Expand Up @@ -169,9 +186,10 @@ export default function App() {
try {
const payload = buildSessionPayload(form)
const result = await api("POST", "/api/sessions", payload)
const successMessage = `Session started: ${result.job_id}`
setForm(defaultForm)
setMessage(`Session started: ${result.job_id}`)
await refresh({ afterMutation: true })
setMessage(successMessage)
await refresh({ afterMutation: true, previousMessage: successMessage })
} catch (error) {
setMessage(`Start failed: ${error.message}`)
} finally {
Expand All @@ -188,8 +206,9 @@ export default function App() {

try {
const result = await api("DELETE", `/api/sessions/${jobId}`)
setMessage(formatStopResultMessage(result))
await refresh({ afterMutation: true })
const successMessage = formatStopResultMessage(result)
setMessage(successMessage)
await refresh({ afterMutation: true, previousMessage: successMessage })
} catch (error) {
setMessage(`Release failed (${jobId}): ${error.message}`)
} finally {
Expand All @@ -206,8 +225,9 @@ export default function App() {

try {
const result = await api("DELETE", "/api/sessions")
setMessage(formatStopResultMessage(result))
await refresh({ afterMutation: true })
const successMessage = formatStopResultMessage(result)
setMessage(successMessage)
await refresh({ afterMutation: true, previousMessage: successMessage })
} catch (error) {
setMessage(`Stop-all failed: ${error.message}`)
} finally {
Expand Down
33 changes: 33 additions & 0 deletions web/dashboard/src/lib/refresh.js
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,39 @@ export function formatRefreshWarningMessage(error) {
return `Refresh warning: ${message || "unknown error"}`
}

export async function fetchDashboardPayloads(requestJson) {
const [gpuResult, sessionResult] = await Promise.allSettled([
requestJson("GET", "/api/gpus"),
requestJson("GET", "/api/sessions")
])

return {
gpus: gpuResult.status === "fulfilled" ? gpuResult.value?.gpus ?? [] : null,
sessions:
sessionResult.status === "fulfilled"
? sessionResult.value?.active_jobs ?? []
: null,
warning:
gpuResult.status === "rejected"
? formatRefreshWarningMessage(gpuResult.reason)
: sessionResult.status === "rejected"
? formatRefreshWarningMessage(sessionResult.reason)
: null
}
}

export function nextRefreshMessage({
afterMutation = false,
previousMessage = null,
userInitiated = false,
warning = null
} = {}) {
if (warning) {
return afterMutation ? previousMessage : warning
}
return userInitiated ? "Dashboard refreshed." : previousMessage
}

export function formatRefreshMode(autoRefresh, visibilityState = "visible") {
if (!autoRefresh) {
return "manual refresh"
Expand Down
54 changes: 53 additions & 1 deletion web/dashboard/src/lib/refresh.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,10 @@ import {
AUTO_REFRESH_INTERVAL_MS,
canRunAutoRefresh,
canReuseInFlightRefresh,
fetchDashboardPayloads,
formatRefreshWarningMessage,
formatRefreshMode
formatRefreshMode,
nextRefreshMessage
} from "./refresh"

describe("dashboard refresh helpers", () => {
Expand Down Expand Up @@ -39,4 +41,54 @@ describe("dashboard refresh helpers", () => {
)
expect(formatRefreshWarningMessage(null)).toBe("Refresh warning: unknown error")
})

it("keeps session payloads when telemetry refresh fails", async () => {
const calls = []
const requestJson = async (method, path) => {
calls.push([method, path])
if (path === "/api/gpus") {
throw new Error("telemetry unavailable")
}
return { active_jobs: [{ job_id: "job-a" }] }
}

await expect(fetchDashboardPayloads(requestJson)).resolves.toEqual({
gpus: null,
sessions: [{ job_id: "job-a" }],
warning: "Refresh warning: telemetry unavailable"
})
expect(calls).toEqual([
["GET", "/api/gpus"],
["GET", "/api/sessions"]
])
})

it("keeps telemetry payloads when session refresh fails", async () => {
const requestJson = async (_method, path) => {
if (path === "/api/sessions") {
throw new Error("sessions unavailable")
}
return { gpus: [{ id: 0, utilization: 12 }] }
}

await expect(fetchDashboardPayloads(requestJson)).resolves.toEqual({
gpus: [{ id: 0, utilization: 12 }],
sessions: null,
warning: "Refresh warning: sessions unavailable"
})
})

it("preserves mutation result messages when follow-up refresh warns", () => {
expect(nextRefreshMessage({ userInitiated: true })).toBe("Dashboard refreshed.")
expect(nextRefreshMessage({ warning: "Refresh warning: telemetry unavailable" })).toBe(
"Refresh warning: telemetry unavailable"
)
expect(
nextRefreshMessage({
afterMutation: true,
previousMessage: "Released session: job-a.",
warning: "Refresh warning: telemetry unavailable"
})
).toBe("Released session: job-a.")
})
})
Loading