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
89 changes: 89 additions & 0 deletions packages/frontend/src/lib/components/LiveAreaStackChart.svelte
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
<script lang="ts" module>
const shades = [800, 700, 600, 500, 400, 300, 200, 100] as const;
const colors = [
"primary",
"secondary",
"tertiary",
"success",
"warning",
"error",
"surface",
] as const;
</script>

<script lang="ts" generics="T extends Record<string, { date: Date }> = any">
import { curveCatmullRom } from "d3-shape";
import { merge } from "es-toolkit/object";
import { AreaChart, type AreaChartProps } from "layerchart";
import { untrack } from "svelte";

interface Props extends Omit<AreaChartProps<T>, "data" | "xDomain" | "seriesLayout" | "series"> {
value?: T;
color?: (typeof colors)[number];
}

let { value, color = "primary", ...props }: Props = $props();

type Data = Record<keyof T, T[keyof T][]>;
let data = $state.raw<Data>();
let xDomain = $state<AreaChartProps<T>["xDomain"]>();

let keys = $derived<NonNullable<AreaChartProps<T>["series"]>[number]["key"][]>(
Object.keys(data ?? {}),
);

$effect(() => {
if (!value) return;

const oneMinuteAgo = new Date(Date.now() - 60_000);
let latest: Date | undefined;
let earliest = new Date(oneMinuteAgo);

if (untrack(() => data)) {
data = Object.fromEntries(
[untrack(() => Object.keys(data!)), Object.keys(value)].flat().map((key: keyof T) => {
const values = untrack(() => data![key]) ?? [];

const index = values.findLastIndex(({ date }) => date < oneMinuteAgo);
const updated = [...values.slice(index !== -1 ? index : 0), value[key] ?? []];

const last = updated.at(-1);
if (last && last.date > (latest ?? new Date(0))) latest = last.date;
if (updated[0].date < earliest) earliest = updated[0].date;

return [key, updated];
}),
) as Data;
} else {
data = Object.fromEntries(
Object.entries(value).map(([key, value]) => [key, [value]]),
) as Data;
}

xDomain = [earliest, latest ?? new Date()];
});
</script>

<AreaChart
grid={false}
{...props}
{xDomain}
seriesLayout="stack"
series={keys.map((key, index) => ({
key,
data: data?.[key],
color: `var(--color-${color}-${shades[index % (shades.length - 1)]})`,
}))}
props={merge<NonNullable<AreaChartProps<T>["props"]>, NonNullable<AreaChartProps<T>["props"]>>(
{
area: { curve: curveCatmullRom },
tooltip: {
root: { class: "preset-filled-surface-100-900!" },
header: {
format: (x) => new Date(x).toLocaleTimeString(),
},
},
},
props.props ?? {},
)}
/>
64 changes: 35 additions & 29 deletions packages/frontend/src/lib/remotes/clusters.remote.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import {
ClusterIdParamSchema,
GetClusterLogsQuerySchema,
GetClusterSchema,
SseIntervalQuerySchema,
type GetAggregateStatsDto,
type GetClusterDto,
type GetClusterLogsDto,
Expand Down Expand Up @@ -242,40 +243,45 @@ export const getClustersLive = query.live(async function* () {
}
});

export const getClustersStatsLive = query.live(async function* () {
const token = getRequestEvent().cookies.get("token");
export const getClustersStatsLive = query.live(
SseIntervalQuerySchema,
async function* ({ interval }) {
const response = await fetch(
new URL(`/clusters/stats/stream?interval=${interval}`, env.API_URL),
{
headers: {
Accept: "text/event-stream",
Authorization: `Bearer ${getRequestEvent().cookies.get("token")}`,
},
},
);

const response = await fetch(new URL("/clusters/stats/stream?interval=2", env.API_URL), {
headers: {
Accept: "text/event-stream",
Authorization: `Bearer ${token}`,
},
});
switch (response.status) {
case 200: {
if (!response.body) {
console.error(await response.text());
return error(500, "An error occurred");
}

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 GetAggregateStatsDto), date: new Date() };
return;
}
case 401:
return redirect(303, "/login");

switch (response.status) {
case 200: {
if (!response.body) {
default:
console.error(await response.text());
return error(500, "An error occurred");
}

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 GetAggregateStatsDto;
return;
}
case 401:
return redirect(303, "/login");

default:
console.error(await response.text());
return error(500, "An error occurred");
}
});
},
);

export const startClusters = query.batch(
"unchecked",
Expand Down
5 changes: 4 additions & 1 deletion packages/frontend/src/routes/(app)/stats/+layout.svelte
Original file line number Diff line number Diff line change
@@ -1,9 +1,12 @@
<script lang="ts">
import { appbar } from "../+layout.svelte";
import type { LayoutProps } from "./$types";

let { children }: LayoutProps = $props();
</script>

<main class="flex flex-col gap-4 max-w-4xl mx-auto">
{@render appbar(["Statistics"])}

<main class="mx-auto flex max-w-4xl flex-col gap-4">
{@render children()}
</main>
71 changes: 14 additions & 57 deletions packages/frontend/src/routes/(app)/stats/+page.svelte
Original file line number Diff line number Diff line change
@@ -1,66 +1,23 @@
<script lang="ts">
import LiveQueryPing from "$lib/components/LiveQueryPing.svelte";
import { getClustersStatsLive } from "$lib/remotes/clusters.remote";
import formatBytes from "$lib/utils/formatBytes";
import { CpuIcon, Icon, MemoryStickIcon } from "@lucide/svelte";
import { Progress } from "@skeletonlabs/skeleton-svelte";
const sum = (acc: number, value: number) => acc + value;

let live = getClustersStatsLive();

let cpu = $derived(
Object.values(await live)
.map((value) => value.cpuPercentage)
.reduce(sum, 0),
);

let memory = $derived(
Object.values(await live)
.map((value) => value.memory.percentage)
.reduce(sum, 0),
);

let memoryUsage = $derived(
Object.values(await live)
.map((value) => value.memory.usage)
.reduce(sum, 0),
);
import CPU from "./components/CPU.svelte";
import Memory from "./components/Memory.svelte";
</script>

{#snippet card(
label: string,
icon: typeof Icon,
percentage: number,
value: string[],
)}
{@const Icon = icon}
<div class="flex flex-col gap-4">
<div class="flex justify-end px-4">
<LiveQueryPing query={getClustersStatsLive({ interval: 1 })} />
</div>

<div
class="@container relative flex card aspect-video preset-filled-surface-100-900 border border-surface-200-800"
class={[
"grid grid-cols-1 gap-4 lg:grid-cols-2",
"*:border *:border-surface-200-800 *:bg-surface-100-900",
]}
>
<div class="z-10 flex items-center justify-center w-full">
<Icon class="flex-3 size-36 text-surface-50-950 opacity-50" />

<div class="flex-4 flex flex-col gap-2 justify-center">
<p class="font-bold text-xl text-surface-500">{label}</p>
{#each value as value}
<p class="text-5xl font-bold opacity-75">{value}</p>
{/each}
</div>
</div>

<Progress value={percentage} class="absolute h-full top-0 left-0">
<Progress.Track class="h-full bg-transparent">
<Progress.Range
class="bg-primary-600-400 shadow-[4px_0px_6px_0px_rgba(0,0,0,0.1)] rounded-l-xl rounded-r-none transition-[width]"
/>
</Progress.Track>
</Progress>
<CPU />
<Memory />
</div>
{/snippet}

<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
{@render card("CPU", CpuIcon, cpu, [cpu.toPrecision(2) + "%"])}
{@render card("Memory", MemoryStickIcon, memory, [
memory.toPrecision(2) + "%",
formatBytes(memoryUsage),
])}
</div>
44 changes: 44 additions & 0 deletions packages/frontend/src/routes/(app)/stats/components/CPU.svelte
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
<script lang="ts">
import LiveAreaStackChart from "$lib/components/LiveAreaStackChart.svelte";
import Widget from "$lib/components/Widget.svelte";
import { getClustersStatsLive } from "$lib/remotes/clusters.remote";
import formatPercentage from "$lib/utils/formatPercentage";
import type { GetAggregateStatsDto } from "@hallmaster/backend/dto";
import { CpuIcon } from "@lucide/svelte";
import { onMount, type ComponentProps } from "svelte";

let value = $state<ComponentProps<LiveAreaStackChart["value"]>>();

onMount(() => {
let unmounted = false;
(async () => {
for await (const { date, ...stats } of getClustersStatsLive({ interval: 1 })) {
if (unmounted) break;
value = Object.fromEntries(
Object.entries(stats as GetAggregateStatsDto).map(([key, { cpuPercentage }]) => [
`Cluster ${key.padStart(2, "0")}`,
{ date, value: cpuPercentage / 100 },
]),
);
}
})();

return () => (unmounted = true);
});

let sum = $derived.by<GetAggregateStatsDto[number]["cpuPercentage"]>(() => {
if (!value) return 0;

return Object.values(value).reduce((acc, { value }) => acc + value, 0);
});
</script>

<Widget icon={CpuIcon} title="CPU" value={formatPercentage(sum)}>
<LiveAreaStackChart
{value}
x="date"
y="value"
axis={false}
props={{ tooltip: { item: { format: formatPercentage } } }}
/>
</Widget>
45 changes: 45 additions & 0 deletions packages/frontend/src/routes/(app)/stats/components/Memory.svelte
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
<script lang="ts">
import LiveAreaStackChart from "$lib/components/LiveAreaStackChart.svelte";
import Widget from "$lib/components/Widget.svelte";
import { getClustersStatsLive } from "$lib/remotes/clusters.remote";
import formatBytes from "$lib/utils/formatBytes";
import type { GetAggregateStatsDto } from "@hallmaster/backend/dto";
import { MemoryStickIcon } from "@lucide/svelte";
import { onMount, type ComponentProps } from "svelte";

let value = $state<ComponentProps<LiveAreaStackChart["value"]>>();

onMount(() => {
let unmounted = false;
(async () => {
for await (const { date, ...stats } of getClustersStatsLive({ interval: 1 })) {
if (unmounted) break;
value = Object.fromEntries(
Object.entries(stats as GetAggregateStatsDto).map(([key, { memory }]) => [
`Cluster ${key.padStart(2, "0")}`,
{ date, value: memory.usage },
]),
);
}
})();

return () => (unmounted = true);
});

let sum = $derived.by<GetAggregateStatsDto[number]["memory"]["usage"]>(() => {
if (!value) return 0;

return Object.values(value).reduce((acc, { value }) => acc + value, 0);
});
</script>

<Widget icon={MemoryStickIcon} title="Memory" value={formatBytes(sum)}>
<LiveAreaStackChart
{value}
x="date"
y="value"
axis={false}
color="secondary"
props={{ tooltip: { item: { format: formatBytes } } }}
/>
</Widget>
Loading