- {data.labels.length > 0 ? (
+ {data.labels.length > 1 ? (
<>
diff --git a/ui/src/components/TimePeriodSelector.tsx b/ui/src/components/TimePeriodSelector.tsx
new file mode 100644
index 0000000..5d6a5c4
--- /dev/null
+++ b/ui/src/components/TimePeriodSelector.tsx
@@ -0,0 +1,34 @@
+import { TimePeriod } from "@/services/store";
+
+type TimePeriodSelectorProps = {
+ currentPeriod: TimePeriod | "realtime";
+ onChange: (period: TimePeriod | "realtime") => void;
+};
+
+export function TimePeriodSelector({
+ currentPeriod,
+ onChange,
+}: TimePeriodSelectorProps) {
+ const periods: { value: TimePeriod | "realtime"; label: string }[] = [
+ { value: "realtime", label: "Real time" },
+ { value: "hour", label: "Past hour" },
+ { value: "day", label: "Past day" },
+ { value: "week", label: "Past week" },
+ { value: "month", label: "Past month" },
+ { value: "all", label: "All" },
+ ];
+
+ return (
+
+ {periods.map((period) => (
+
+ ))}
+
+ );
+}
diff --git a/ui/src/hooks/useChartData.tsx b/ui/src/hooks/useChartData.tsx
index 5d330bd..82e6193 100644
--- a/ui/src/hooks/useChartData.tsx
+++ b/ui/src/hooks/useChartData.tsx
@@ -18,55 +18,57 @@ type ChartDataReturns = {
networkData: ChartData;
};
-export function useChartData(data: StatusMessage | null): ChartDataReturns {
+export function useChartData(
+ data: StatusMessage | StatusMessage[] | null,
+): ChartDataReturns {
+ const currentData = Array.isArray(data) ? data : getRealtimeData();
+
useEffect(() => {
- if (!data) return;
+ if (!data || Array.isArray(data)) return;
addDataPoint(data);
}, [data]);
- const realtimeData = getRealtimeData();
-
return {
cpuData: {
- labels: realtimeData.map((p) => formatTimestamp(p.timestamp)),
+ labels: currentData.map((p) => formatTimestamp(p.timestamp)),
datasets: [
{
label: "Total CPU (%)",
- data: realtimeData.map(({ metrics }) => metrics.cpu.usage),
+ data: currentData.map(({ metrics }) => metrics.cpu.usage),
color: "#3b82f6",
},
{
label: "User (%)",
- data: realtimeData.map(
+ data: currentData.map(
({ metrics }) => metrics.cpu.breakdown.user,
),
color: "#10b981",
},
{
label: "System (%)",
- data: realtimeData.map(
+ data: currentData.map(
({ metrics }) => metrics.cpu.breakdown.system,
),
color: "#f59e0b",
},
{
label: "I/O Wait (%)",
- data: realtimeData.map(
+ data: currentData.map(
({ metrics }) => metrics.cpu.breakdown.iowait,
),
color: "#ef4444",
},
{
label: "Steal (%)",
- data: realtimeData.map(
+ data: currentData.map(
({ metrics }) => metrics.cpu.breakdown.steal,
),
color: "#8b5cf6",
},
{
label: "Idle (%)",
- data: realtimeData.map(
+ data: currentData.map(
({ metrics }) => metrics.cpu.breakdown.idle,
),
color: "#6b7280",
@@ -74,11 +76,11 @@ export function useChartData(data: StatusMessage | null): ChartDataReturns {
],
},
memoryData: {
- labels: realtimeData.map((p) => formatTimestamp(p.timestamp)),
+ labels: currentData.map((p) => formatTimestamp(p.timestamp)),
datasets: [
{
label: "Memory Usage (%)",
- data: realtimeData.map(({ metrics }) =>
+ data: currentData.map(({ metrics }) =>
calcPercentage(
metrics.memory.used,
metrics.memory.total,
@@ -88,7 +90,7 @@ export function useChartData(data: StatusMessage | null): ChartDataReturns {
},
{
label: "Swap Usage (%)",
- data: realtimeData.map(({ metrics }) =>
+ data: currentData.map(({ metrics }) =>
calcPercentage(
metrics.memory.swap_used,
metrics.memory.swap_total,
@@ -99,16 +101,16 @@ export function useChartData(data: StatusMessage | null): ChartDataReturns {
],
},
networkData: {
- labels: realtimeData.map((p) => formatTimestamp(p.timestamp)),
+ labels: currentData.map((p) => formatTimestamp(p.timestamp)),
datasets: [
{
label: "Upload (bps)",
- data: realtimeData.map(({ metrics }) => metrics.network.up),
+ data: currentData.map(({ metrics }) => metrics.network.up),
color: "#ef4444",
},
{
label: "Download (bps)",
- data: realtimeData.map(
+ data: currentData.map(
({ metrics }) => metrics.network.down,
),
color: "#3b82f6",
diff --git a/ui/src/hooks/useWebsocket.tsx b/ui/src/hooks/useWebsocket.tsx
new file mode 100644
index 0000000..699f580
--- /dev/null
+++ b/ui/src/hooks/useWebsocket.tsx
@@ -0,0 +1,25 @@
+import type { WebsocketStatus } from "@/components/ConnectionStatus";
+import type { StatusMessage } from "@/services/types";
+import { initializeConnection } from "@/services/websocket";
+import { useEffect, useState } from "react";
+
+export function useWebsocket(url: string) {
+ const [status, setStatus] = useState
();
+ const [message, setMessage] = useState();
+
+ useEffect(() => {
+ initializeConnection(url, {
+ onMessage: (data) => {
+ const parsed = JSON.parse(data);
+
+ setStatus("connected");
+ setMessage(parsed);
+ },
+ onOpen: () => setStatus("connecting"),
+ onClose: () => setStatus("disconnected"),
+ onError: () => setStatus("error"),
+ });
+ }, [url]);
+
+ return { status, message };
+}
diff --git a/ui/src/pages/agent.tsx b/ui/src/pages/agent.tsx
index eaca8a7..4802461 100644
--- a/ui/src/pages/agent.tsx
+++ b/ui/src/pages/agent.tsx
@@ -1,51 +1,45 @@
-import {
- type WebsocketStatus,
- ConnectionStatus,
-} from "@/components/ConnectionStatus";
-import type { StatusMessage } from "@/services/types";
+import { ConnectionStatus } from "@/components/ConnectionStatus";
import { Header } from "@/components/Header";
import { DonutChart } from "@/components/DonutChart";
import { LineChartCard } from "@/components/LineChartCard";
import { MetricCard } from "@/components/MetricCard";
import { SysinfoCard } from "@/components/SysinfoCard";
import { useChartData } from "@/hooks/useChartData";
-import { getLastMessage, setLastMessage } from "@/services/store";
import {
formatBits,
formatPercentage,
calcPercentage,
formatBytes,
} from "@/services/utils";
-import { initializeConnection } from "@/services/websocket";
-import { useEffect, useState } from "react";
import { useParams } from "react-router-dom";
+import { useWebsocket } from "@/hooks/useWebsocket";
+import { getHistoricalData, TimePeriod } from "@/services/store";
+import { useEffect, useState } from "react";
+import { StatusMessage } from "@/services/types";
+import { TimePeriodSelector } from "@/components/TimePeriodSelector";
export function AgentPage() {
const { agent } = useParams();
- const [status, setStatus] = useState();
- const [message, setMessage] = useState(
- getLastMessage(),
- );
+ const { status, message } = useWebsocket(`ws://localhost:3000/ws/${agent}`);
+
+ const [period, setPeriod] = useState("all");
+ const [history, setHistory] = useState(null);
useEffect(() => {
- if (!agent) return;
+ const fetchData = async () => {
+ const data = await getHistoricalData(
+ `http://localhost:3000/history/${agent}/${period}`,
+ );
+ setHistory(data);
+ };
- initializeConnection(agent, {
- onMessage: (data) => {
- const parsed = JSON.parse(data);
-
- setStatus("connected");
- setMessage(parsed);
- setLastMessage(parsed);
- },
- onOpen: () => setStatus("connecting"),
- onClose: () => setStatus("disconnected"),
- onError: () => setStatus("error"),
- });
- }, [agent]);
+ fetchData();
+ }, [agent, period]);
const { metrics } = message ?? {};
- const { cpuData, memoryData, networkData } = useChartData(message);
+ const { cpuData, memoryData, networkData } = useChartData(
+ period === "realtime" ? message! : history,
+ );
const getMetricStatus = (percentage: number | undefined) => {
if (!percentage) return "nil";
@@ -86,6 +80,8 @@ export function AgentPage() {
/>
+
Overview
+
-
-
-
-
+
+
Details
+
+
+
-
+
+
+
+
+
+
+
diff --git a/ui/src/services/store.ts b/ui/src/services/store.ts
index 9b07ca1..fe68ff8 100644
--- a/ui/src/services/store.ts
+++ b/ui/src/services/store.ts
@@ -1,26 +1,28 @@
import type { StatusMessage } from "@/services/types";
-import { atom } from "nanostores";
-const store = atom
(null);
+export type TimePeriod = "all" | "hour" | "day" | "week" | "month";
-export function getLastMessage(): StatusMessage | null {
- return store.get();
-}
-
-export function setLastMessage(message: StatusMessage) {
- store.set(message);
-}
-
-const realtimeData: StatusMessage[] = [];
+let data: StatusMessage[] = [];
const maxRealtimePoints = 50;
export function addDataPoint(value: StatusMessage) {
- realtimeData.push(value);
+ data.push(value);
- if (realtimeData.length > maxRealtimePoints) {
- realtimeData.shift();
+ if (data.length > maxRealtimePoints) {
+ data.shift();
}
}
+
export function getRealtimeData(): StatusMessage[] {
- return realtimeData ?? [];
+ return data ?? [];
+}
+
+export function setHistoricalData(value: StatusMessage[]) {
+ data = value;
+}
+
+export async function getHistoricalData(url: string): Promise {
+ return await fetch(url)
+ .then((res) => res.json())
+ .then((data) => data as StatusMessage[]);
}
diff --git a/ui/src/services/websocket.ts b/ui/src/services/websocket.ts
index 48cd72c..7cd5fa9 100644
--- a/ui/src/services/websocket.ts
+++ b/ui/src/services/websocket.ts
@@ -5,8 +5,8 @@ type Callbacks = {
onClose?: () => void;
};
-export function initializeConnection(agent: string, callbacks: Callbacks) {
- const ws = new WebSocket(`ws://localhost:3000/ws/${agent}`);
+export function initializeConnection(url: string, callbacks: Callbacks) {
+ const ws = new WebSocket(url);
ws.onopen = () => callbacks.onOpen?.();
ws.onmessage = (event) => callbacks.onMessage(event.data);