From aa6b171614a51c9fc715c334cd1deb18756908a3 Mon Sep 17 00:00:00 2001 From: csehviktor Date: Thu, 10 Jul 2025 03:00:11 +0200 Subject: [PATCH] improve ui --- server/src/server/websocket.rs | 2 +- ui/bun.lock | 6 ++ ui/package.json | 2 + ui/src/App.tsx | 2 + ui/src/components/AgentCard.tsx | 22 ++-- ui/src/components/ConnectionStatus.tsx | 59 +++++++++++ ui/src/components/Header.tsx | 16 ++- ui/src/components/MetricCard.tsx | 51 +++++++++ ui/src/pages/agent.tsx | 140 +++++++++++++++++++++++++ ui/src/pages/home.tsx | 7 +- ui/src/services/store.ts | 12 +++ ui/src/services/utils.ts | 26 ++++- ui/src/services/websocket.ts | 17 +++ 13 files changed, 344 insertions(+), 18 deletions(-) create mode 100644 ui/src/components/ConnectionStatus.tsx create mode 100644 ui/src/components/MetricCard.tsx create mode 100644 ui/src/pages/agent.tsx create mode 100644 ui/src/services/store.ts diff --git a/server/src/server/websocket.rs b/server/src/server/websocket.rs index 08b5e57..3f0adfa 100644 --- a/server/src/server/websocket.rs +++ b/server/src/server/websocket.rs @@ -16,7 +16,7 @@ impl WebsocketRoutes { } pub fn routes(self: Arc) -> impl Filter + Clone { - warp::path("agent") + warp::path("ws") .and(warp::path::param::()) .and(warp::get()) .and(warp::ws()) diff --git a/ui/bun.lock b/ui/bun.lock index 467bb61..2b2aefa 100644 --- a/ui/bun.lock +++ b/ui/bun.lock @@ -10,6 +10,7 @@ }, "devDependencies": { "@eslint/js": "^9.29.0", + "@nanostores/react": "^1.0.0", "@tailwindcss/vite": "^4.1.11", "@types/react": "^19.1.8", "@types/react-dom": "^19.1.6", @@ -18,6 +19,7 @@ "eslint-plugin-react-hooks": "^5.2.0", "eslint-plugin-react-refresh": "^0.4.20", "globals": "^16.2.0", + "nanostores": "^1.0.1", "react-router-dom": "^7.6.3", "tailwindcss": "^4.1.11", "typescript": "~5.8.3", @@ -153,6 +155,8 @@ "@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.29", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-uw6guiW/gcAGPDhLmd77/6lW8QLeiV5RUTsAX46Db6oLhGaVj4lhnPwb184s1bkc8kdVg/+h988dro8GRDpmYQ=="], + "@nanostores/react": ["@nanostores/react@1.0.0", "", { "peerDependencies": { "nanostores": "^0.9.0 || ^0.10.0 || ^0.11.0 || ^1.0.0", "react": ">=18.0.0" } }, "sha512-eDduyNy+lbQJMg6XxZ/YssQqF6b4OXMFEZMYKPJCCmBevp1lg0g+4ZRi94qGHirMtsNfAWKNwsjOhC+q1gvC+A=="], + "@nodelib/fs.scandir": ["@nodelib/fs.scandir@2.1.5", "", { "dependencies": { "@nodelib/fs.stat": "2.0.5", "run-parallel": "^1.1.9" } }, "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g=="], "@nodelib/fs.stat": ["@nodelib/fs.stat@2.0.5", "", {}, "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A=="], @@ -463,6 +467,8 @@ "nanoid": ["nanoid@3.3.11", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w=="], + "nanostores": ["nanostores@1.0.1", "", {}, "sha512-kNZ9xnoJYKg/AfxjrVL4SS0fKX++4awQReGqWnwTRHxeHGZ1FJFVgTqr/eMrNQdp0Tz7M7tG/TDaX8QfHDwVCw=="], + "natural-compare": ["natural-compare@1.4.0", "", {}, "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw=="], "node-releases": ["node-releases@2.0.19", "", {}, "sha512-xxOWJsBKtzAq7DY0J+DTzuz58K8e7sJbdgwkbMWQe8UYB6ekmsQ45q0M/tJDsGaZmbC+l7n57UV8Hl5tHxO9uw=="], diff --git a/ui/package.json b/ui/package.json index 6842c43..f2bf7bb 100644 --- a/ui/package.json +++ b/ui/package.json @@ -16,6 +16,7 @@ }, "devDependencies": { "@eslint/js": "^9.29.0", + "@nanostores/react": "^1.0.0", "@tailwindcss/vite": "^4.1.11", "@types/react": "^19.1.8", "@types/react-dom": "^19.1.6", @@ -24,6 +25,7 @@ "eslint-plugin-react-hooks": "^5.2.0", "eslint-plugin-react-refresh": "^0.4.20", "globals": "^16.2.0", + "nanostores": "^1.0.1", "react-router-dom": "^7.6.3", "tailwindcss": "^4.1.11", "typescript": "~5.8.3", diff --git a/ui/src/App.tsx b/ui/src/App.tsx index fa91cbd..d9053fe 100644 --- a/ui/src/App.tsx +++ b/ui/src/App.tsx @@ -1,11 +1,13 @@ import { BrowserRouter, Route, Routes } from "react-router-dom"; import { HomePage } from "@/pages/home"; +import { AgentPage } from "@/pages/agent"; export default function App() { return ( } /> + } /> ); diff --git a/ui/src/components/AgentCard.tsx b/ui/src/components/AgentCard.tsx index f006b79..79b5fe5 100644 --- a/ui/src/components/AgentCard.tsx +++ b/ui/src/components/AgentCard.tsx @@ -2,7 +2,7 @@ import { UptimeMessage } from "@/services/types"; import { formatRelativeTime, isAgentOnline } from "@/services/utils"; import { Progressbar } from "./Progressbar"; -export type AgentOverviewCardProps = { +type AgentOverviewCardProps = { count: number; title: string; icon: { @@ -42,13 +42,13 @@ export function AgentOverviewCard({ ); } -export type AgentCardProps = { +type AgentCardProps = { data: UptimeMessage; - onClick?: () => void; + onClick: () => void; }; -export function AgentCard({ props }: { props: AgentCardProps }) { - const status = isAgentOnline(props.data) +export function AgentCard({ data, onClick }: AgentCardProps) { + const status = isAgentOnline(data) ? { name: "online", color: "bg-green-500", @@ -66,7 +66,7 @@ export function AgentCard({ props }: { props: AgentCardProps }) { return (
props.onClick?.()} + onClick={() => onClick()} className="bg-[#111111] border border-[#262626] rounded-lg p-6 hover:bg-[#151515] transition-all cursor-pointer group" >
@@ -77,7 +77,7 @@ export function AgentCard({ props }: { props: AgentCardProps }) { {status.name.toUpperCase()}

- {props.data.agent} + {data.agent}

@@ -85,13 +85,11 @@ export function AgentCard({ props }: { props: AgentCardProps }) {
- - {formatRelativeTime(props.data.last_seen)} - - {props.data.uptime.toFixed(2)}% + {formatRelativeTime(data.last_seen)} + {data.uptime.toFixed(2)}%
diff --git a/ui/src/components/ConnectionStatus.tsx b/ui/src/components/ConnectionStatus.tsx new file mode 100644 index 0000000..cdf5b44 --- /dev/null +++ b/ui/src/components/ConnectionStatus.tsx @@ -0,0 +1,59 @@ +export type WebsocketStatus = + | "connected" + | "connecting" + | "disconnected" + | "error"; + +type ConnectionStatusProps = { + status: WebsocketStatus; + timestamp: string | undefined; +}; + +export function ConnectionStatus({ status, timestamp }: ConnectionStatusProps) { + const getConnectionStatus = (status: WebsocketStatus) => { + switch (status) { + case "connected": + return { + color: "bg-green-500", + text: "connected", + animation: "animate-pulse-slow", + }; + case "connecting": + return { + color: "bg-yellow-500", + text: "connecting", + animation: "animate-pulse", + }; + case "disconnected": + return { + color: "bg-gray-500", + text: "disconnected", + animation: "", + }; + case "error": + return { + color: "bg-red-500", + text: "error", + animation: "animate-pulse", + }; + } + }; + + const config = getConnectionStatus(status); + + return ( +
+
+ + {config.text} + + {timestamp && ( + + {new Date(timestamp).toLocaleTimeString()} + + )} +
+ ); +} diff --git a/ui/src/components/Header.tsx b/ui/src/components/Header.tsx index c8a0a23..aaa55a3 100644 --- a/ui/src/components/Header.tsx +++ b/ui/src/components/Header.tsx @@ -1,6 +1,7 @@ -import { Zap } from "lucide-react"; +import { ChevronLeft, Zap } from "lucide-react"; +import { useNavigate } from "react-router-dom"; -export type HeaderProps = { +type HeaderProps = { title: string; subtitle: string; hasBackButton: boolean; @@ -8,10 +9,21 @@ export type HeaderProps = { }; export function Header({ props }: { props: HeaderProps }) { + const navigate = useNavigate(); + return (
+ {props.hasBackButton && ( + + )} +
diff --git a/ui/src/components/MetricCard.tsx b/ui/src/components/MetricCard.tsx new file mode 100644 index 0000000..688776e --- /dev/null +++ b/ui/src/components/MetricCard.tsx @@ -0,0 +1,51 @@ +export type MetricStatus = "nil" | "good" | "warning" | "critical"; + +type MetricCardProps = { + title: string; + value: string; + status: MetricStatus; + subtitle?: string; +}; + +export function MetricCard({ + props, + children, +}: { + props: MetricCardProps; + children: React.ReactNode; +}) { + const indicateColor = getStatusColor(props.status); + + return ( +
+
+

+ {props.title} +

+
+
+
+
+ {props.value} +
+
{props.subtitle}
+
{children}
+
+
+ ); +} + +const getStatusColor = (status: MetricStatus) => { + switch (status) { + case "nil": + return "bg-gray-500"; + case "good": + return "bg-green-500"; + case "warning": + return "bg-yellow-500"; + case "critical": + return "bg-red-500"; + } +}; diff --git a/ui/src/pages/agent.tsx b/ui/src/pages/agent.tsx new file mode 100644 index 0000000..deacea8 --- /dev/null +++ b/ui/src/pages/agent.tsx @@ -0,0 +1,140 @@ +import { + ConnectionStatus, + WebsocketStatus, +} from "@/components/ConnectionStatus"; +import { Header } from "@/components/Header"; +import { MetricCard } from "@/components/MetricCard"; +import { getLastMessage, setLastMessage } from "@/services/store"; +import { StatusMessage } from "@/services/types"; +import { + formatBytes, + calcPercentage, + formatPercentage, +} from "@/services/utils"; +import { initializeConnection } from "@/services/websocket"; +import { useEffect, useState } from "react"; +import { useParams } from "react-router-dom"; + +export function AgentPage() { + const { agent } = useParams(); + const [status, setStatus] = useState(); + const [message, setMessage] = useState( + getLastMessage(), + ); + + useEffect(() => { + if (!agent) return; + + 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]); + + const { metrics } = message || {}; + + const getMetricsStatus = (percentage: number | undefined) => { + if (!percentage) return "nil"; + + if (percentage < 50) return "good"; + if (percentage < 80) return "warning"; + return "critical"; + }; + + const cpuUsage = metrics?.cpu.usage ?? 0; + const memoryUsage = calcPercentage( + metrics?.memory.used, + metrics?.memory.total, + ); + const diskUsage = calcPercentage( + (metrics?.disk.total ?? 0) - (metrics?.disk.free ?? 0), + metrics?.disk.total, + ); + const networkUsage = + (metrics?.network.up ?? 0) + (metrics?.network.down ?? 0); + + return ( +
+
+ ), + }} + /> + +
+
+ +

cpu chart

+
+ + +

memory chart

+
+ + +

network chart

+
+
+ + +
+
+
+ +
+
+ ); +} diff --git a/ui/src/pages/home.tsx b/ui/src/pages/home.tsx index 7077c95..30fa396 100644 --- a/ui/src/pages/home.tsx +++ b/ui/src/pages/home.tsx @@ -6,8 +6,10 @@ import { UptimeMessage } from "@/services/types"; import { isAgentOnline } from "@/services/utils"; import { Box } from "lucide-react"; import { useEffect, useState } from "react"; +import { useNavigate } from "react-router-dom"; export function HomePage() { + const navigate = useNavigate(); const [agents, setAgents] = useState([]); useEffect(() => { @@ -82,7 +84,10 @@ export function HomePage() { {agents.map((agent, index) => ( + navigate(`/agents/${agent.agent}`) + } /> ))}
diff --git a/ui/src/services/store.ts b/ui/src/services/store.ts new file mode 100644 index 0000000..bfa854c --- /dev/null +++ b/ui/src/services/store.ts @@ -0,0 +1,12 @@ +import type { StatusMessage } from "@/services/types"; +import { atom } from "nanostores"; + +const store = atom(null); + +export function getLastMessage(): StatusMessage | null { + return store.get(); +} + +export function setLastMessage(message: StatusMessage) { + store.set(message); +} diff --git a/ui/src/services/utils.ts b/ui/src/services/utils.ts index c990716..16ca2af 100644 --- a/ui/src/services/utils.ts +++ b/ui/src/services/utils.ts @@ -6,7 +6,7 @@ export function isAgentOnline(data: UptimeMessage): boolean { return timeDiff < 10000; } -export const formatRelativeTime = (timestamp: string): string => { +export function formatRelativeTime(timestamp: string): string { const now = new Date(); const past = new Date(timestamp); const diffInSeconds = Math.floor((now.getTime() - past.getTime()) / 1000); @@ -25,4 +25,26 @@ export const formatRelativeTime = (timestamp: string): string => { const days = Math.floor(diffInSeconds / 86400); return `${days}d ago`; } -}; +} + +export function formatBytes(bytes: number | undefined): string { + if (!bytes || bytes === 0) return "0 B"; + + const k = 1024; + const sizes = ["B", "KB", "MB", "GB", "TB"]; + const i = Math.floor(Math.log(bytes) / Math.log(k)); + + return `${parseFloat((bytes / Math.pow(k, i)).toFixed(1))} ${sizes[i]}`; +} + +export function calcPercentage( + value: number | undefined, + total: number | undefined, +): number { + if (value === undefined || total === undefined) return 0; + return (value / total) * 100; +} + +export function formatPercentage(val: number | undefined) { + return `${(val ?? 0).toFixed(2)}%`; +} diff --git a/ui/src/services/websocket.ts b/ui/src/services/websocket.ts index e69de29..48cd72c 100644 --- a/ui/src/services/websocket.ts +++ b/ui/src/services/websocket.ts @@ -0,0 +1,17 @@ +type Callbacks = { + onMessage: (message: string) => void; + onError?: (error: Event) => void; + onOpen?: () => void; + onClose?: () => void; +}; + +export function initializeConnection(agent: string, callbacks: Callbacks) { + const ws = new WebSocket(`ws://localhost:3000/ws/${agent}`); + + ws.onopen = () => callbacks.onOpen?.(); + ws.onmessage = (event) => callbacks.onMessage(event.data); + ws.onerror = (error) => callbacks.onError?.(error); + ws.onclose = () => callbacks.onClose?.(); + + return () => ws.close(); +}