feat(frontend): add dashboard page /#81
Conversation
4a91ad6 to
ed8b3e0
Compare
ed8b3e0 to
f32b7b1
Compare
/
…tributes sorting
6649c2d to
5d1cfed
Compare
62d7711 to
28e2b82
Compare
There was a problem hiding this comment.
Pull request overview
This PR builds out the dashboard route (/) for the frontend: it adds a per-cluster collapsible card showing live status, shard layout, logs, CPU and memory charts, plus start/stop/restart actions. It introduces several new Svelte components, a formatBytes utility, new SSE-backed live remote queries (getClustersLive, getClusterLogsLive, getClusterStatsLive) using eventsource-parser, a layerchart dependency for the area charts, and a custom text-tiny theme token. It also reorganizes some CSS (moves the dotted background into the form layout only) and bumps @sveltejs/kit to ^2.59.1.
Changes:
- Add live SSE remote queries for cluster list, logs, and stats; refresh on action success/failure.
- New dashboard UI:
Collapsiblecluster cards withClusterStatus/Shards/Logs/CPU/Memory/Actionscomponents and a dedicated(app)layout. - Tooling: add
eventsource-parserandlayerchartdeps, custom--text-tiny, oxfmt tailwind stylesheet config, minor utility CSS cleanup.
Reviewed changes
Copilot reviewed 17 out of 18 changed files in this pull request and generated 12 comments.
Show a summary per file
| File | Description |
|---|---|
| pnpm-lock.yaml | Lockfile updates for new deps (layerchart, eventsource-parser) and kit bump. |
| packages/frontend/package.json | Bump @sveltejs/kit, add eventsource-parser, layerchart. |
| packages/frontend/.oxfmtrc.json | Point tailwind sort at src/app.css. |
| packages/frontend/src/app.css | Use relative imports, add layerchart/skeleton-4.css, remove body background. |
| packages/frontend/src/lib/styles/theme.css | Add --text-tiny theme token (questionable @theme text-tiny syntax). |
| packages/frontend/src/lib/styles/utilities.css | Reorder @apply ordering for form. |
| packages/frontend/src/lib/utils/formatBytes.ts | New byte-formatting helper. |
| packages/frontend/src/lib/remotes/clusters.remote.ts | Sorted cluster list; new SSE live queries; action remotes now refresh on success. |
| packages/frontend/src/routes/(app)/+layout.svelte | New width-capped main wrapper. |
| packages/frontend/src/routes/(app)/+page.svelte | Replaced polling dashboard with Collapsible + new sub-components driven by live stream. |
| packages/frontend/src/routes/(app)/components/ClusterStatus.svelte | Status badge with color/animation per state. |
| packages/frontend/src/routes/(app)/components/ClusterShards.svelte | Grid of shard IDs sized via sqrt heuristic. |
| packages/frontend/src/routes/(app)/components/ClusterMemory.svelte | Memory area chart fed by live stats. |
| packages/frontend/src/routes/(app)/components/ClusterCPU.svelte | CPU area chart fed by live stats. |
| packages/frontend/src/routes/(app)/components/ClusterLogs.svelte | Logs panel combining initial fetch + live stream. |
| packages/frontend/src/routes/(app)/components/ClusterActions.svelte | Start/restart/stop buttons gated by status with loading state. |
| packages/frontend/src/routes/(form)/+layout.svelte | Add dotted body background scoped to form layout. |
| packages/frontend/src/routes/(form)/login/+page.svelte | Import order/style cleanup; minor enhance callback formatting. |
Files not reviewed (1)
- pnpm-lock.yaml: Language not supported
Comments suppressed due to low confidence (7)
packages/frontend/src/routes/(app)/components/ClusterCPU.svelte:29
- Same pruning issue as in
ClusterMemory.svelte: only one entry is shifted per push, so if stats events arrive in a burst the buffer can retain entries older than 60 s. Use a loop (or filter) to drop all expired samples.
$effect(() => {
getClusterStatsLive(id).then((stats) => {
cpu.push({
percentage: stats.cpuPercentage,
date: new Date(),
});
if (cpu[0].date.getTime() < Date.now() - 60000) cpu.shift();
});
});
packages/frontend/src/routes/(app)/components/ClusterMemory.svelte:51
- When
memoryis empty (first render before any stats arrive),memory.at(-1)?.dateevaluates toundefined, makingxDomain[Date, undefined]. Depending on howlayercharthandles an undefined upper bound, this can throw or produce an invalid axis. Provide a sensible fallback such asnew Date()for the upper bound.
xDomain={[new Date(Date.now() - 60000), memory.at(-1)?.date]}
packages/frontend/src/routes/(app)/components/ClusterCPU.svelte:50
- Same issue as in
ClusterMemory.svelte:cpu.at(-1)?.dateisundefinedwhile the buffer is empty, leavingxDomainwith an undefined upper bound. Provide a fallback likenew Date().
xDomain={[new Date(Date.now() - 60000), cpu.at(-1)?.date]}
packages/frontend/src/routes/(app)/components/ClusterCPU.svelte:55
Number.prototype.toPrecision(2)returns a string in exponential notation for some values (e.g.0.001→"0.0010",12345→"1.2e+4"). For a CPU percentage display this can produce unexpected output like"1.2e+1 %". ConsidertoFixed(2)or clamping the input to a reasonable range first. The same concern applies to the tooltip formatter below.
({cpu.at(-1)?.percentage.toPrecision(2) ?? 0} %)
</span>
</h3>
</div>
<div class="grow p-1">
<AreaChart
data={cpu}
x="date"
y="percentage"
xDomain={[new Date(Date.now() - 60000), cpu.at(-1)?.date]}
axis={false}
props={{
tooltip: {
item: {
format: (y: number) => y.toPrecision(2) + "%",
packages/frontend/src/routes/(app)/components/ClusterActions.svelte:65
- In the
.catchblock,JSON.parse(error)assumeserroris a JSON string with a.messagefield. If the remote call rejects with a regularErrorinstance, a network error, or anything that isn't a JSON-parseable string,JSON.parsewill itself throw inside the catch handler, swallowing the original failure and producing an uncaught error. Consider robust extraction (e.g.error?.message ?? String(error)) and only attemptingJSON.parsewhen appropriate.
remote(id)
.catch((error) => {
toaster.create({
type: "error",
title: "Error",
description: JSON.parse(error).message,
});
getClusters().refresh();
})
.finally(() => (loading = null));
packages/frontend/src/lib/remotes/clusters.remote.ts:226
- Both
getClusterLogsLiveandgetClusterStatsLiveopen SSE connections that are never explicitly aborted. When the component is destroyed (e.g. user collapses the panel or navigates away), the underlyingfetchand async iteration continue until the server closes them, leaking connections and CPU. Consider plumbing anAbortControllerand aborting in the effect's cleanup function ($effect(() => { ... return () => controller.abort(); })). The same applies togetClustersLiveat the page level.
export const getClusterLogsLive = query.live(
"unchecked",
async function* (id: GetClusterDto["id"]) {
const token = getRequestEvent().cookies.get("token");
const response = await fetch(`${env.API_URL}/clusters/${id}/logs/stream`, {
headers: {
Accept: "text/event-stream",
Authorization: `Bearer ${token}`,
},
});
switch (response.status) {
case 200:
if (!response.body) return error(500, "An error occured");
const chunks = response.body
.pipeThrough(new TextDecoderStream())
.pipeThrough(new EventSourceParserStream())
// @ts-ignore svelte-check false positive
.values();
for await (const chunk of chunks) yield JSON.parse(chunk.data) as GetClusterLogsDto[number];
return;
case 401:
return error(401, "Unauthorized");
default:
return error(500, "An error occured");
}
},
);
export const getClusterStatsLive = query.live(
"unchecked",
async function* (id: GetClusterDto["id"]) {
const token = getRequestEvent().cookies.get("token");
const response = await fetch(new URL(`/clusters/${id}/stats/stream?interval=2`, env.API_URL), {
headers: {
Accept: "text/event-stream",
Authorization: `Bearer ${token}`,
},
});
switch (response.status) {
case 200:
if (!response.body) return error(500, "An error occured");
const chunks = response.body
.pipeThrough(new TextDecoderStream())
.pipeThrough(new EventSourceParserStream())
// @ts-ignore svelte-check false positive
.values();
for await (const chunk of chunks) yield JSON.parse(chunk.data) as GetClusterStatsDto;
break;
case 400:
return error(400, "Cluster has no container or is not running");
case 401:
return error(401, "Unauthorized");
case 404:
return error(404, "Cluster not found");
default:
return error(500, "An error occured");
}
},
);
packages/frontend/src/routes/(app)/components/ClusterLogs.svelte:22
- The
livearray grows unbounded as long as the component is mounted. For a long-lived dashboard view streaming many log lines this will eventually exhaust memory. Consider capping the buffer size (e.g. keep only the last N entries).
let live: GetClusterLogsDto = $state([]);
$effect(() => {
getClusterLogsLive(id).then((log) => live.push(log));
});
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| onclick={() => { | ||
| loading = label; | ||
| remote(id) | ||
| .catch((error) => { | ||
| toaster.create({ | ||
| type: "error", | ||
| title: "Error", | ||
| description: JSON.parse(error).message, | ||
| }); | ||
| getClusters().refresh(); | ||
| }) | ||
| .finally(() => (loading = null)); | ||
| }} |
| <style> | ||
| :global(body) { | ||
| background-image: radial-gradient(rgba(255, 255, 255, 0.05) 1px, transparent 1px); | ||
| background-size: 15px 15px; | ||
| background-attachment: fixed; | ||
| } | ||
| </style> |
| let columns = $derived(Math.round(Math.sqrt((shards.length + 1) * (16 / 8)))); | ||
| let rows = $derived(Math.ceil((shards.length + 1) / columns)); |
| let live: GetClusterLogsDto = $state([]); | ||
| $effect(() => { | ||
| getClusterLogsLive(id).then((log) => live.push(log)); | ||
| }); | ||
| </script> | ||
|
|
||
| <div | ||
| class="flex flex-col p-2 overflow-y-hidden rounded-lg bg-surface-50-950 gap-2 border border-surface-200-800" | ||
| > | ||
| <div class="flex gap-1 items-center pl-2"> | ||
| <LogsIcon size={18} class="text-primary-800-200" /> | ||
| <h3 class="font-bold text-surface-800-200">Logs</h3> | ||
| </div> | ||
|
|
||
| {#await getClusterLogs(id) then logs} | ||
| <div | ||
| class="flex flex-col-reverse text-tiny whitespace-pre overflow-auto bg-transparent p-0" | ||
| style:scrollbar-width="thin" | ||
| transition:fade={{ duration: 150 }} | ||
| > | ||
| {#each logs.concat(live).toReversed() as { content, stream }} | ||
| <p | ||
| class={{ | ||
| "text-nowrap": true, | ||
| "text-error-500": stream === "STDERR", | ||
| }} | ||
| > | ||
| {content} | ||
| </p> | ||
| {/each} |
463b575 to
ee50de6
Compare
Description
This PR introduces the implementation of the dashboard page (
/).Context / Motivation
This page is a centralized place to access key information about the clusters status.
Screenshots
Checklist