diff --git a/ui/src/components/LineChartCard.tsx b/ui/src/components/LineChartCard.tsx index b6ec21c..8e427e2 100644 --- a/ui/src/components/LineChartCard.tsx +++ b/ui/src/components/LineChartCard.tsx @@ -10,7 +10,7 @@ export function LineChartCard({ }) { return (
- {data.labels.length > 0 ? ( + {data.labels.length > 1 ? ( <>

{title}

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);