improve ui

This commit is contained in:
csehviktor
2025-07-10 03:00:11 +02:00
parent 76098532ea
commit aa6b171614
13 changed files with 344 additions and 18 deletions

View File

@@ -16,7 +16,7 @@ impl WebsocketRoutes {
}
pub fn routes(self: Arc<Self>) -> impl Filter<Extract = impl Reply, Error = Rejection> + Clone {
warp::path("agent")
warp::path("ws")
.and(warp::path::param::<String>())
.and(warp::get())
.and(warp::ws())

View File

@@ -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=="],

View File

@@ -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",

View File

@@ -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 (
<BrowserRouter>
<Routes>
<Route path="/" element={<HomePage />} />
<Route path="/agents/:agent" element={<AgentPage />} />
</Routes>
</BrowserRouter>
);

View File

@@ -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 (
<div
onClick={() => props.onClick?.()}
onClick={() => onClick()}
className="bg-[#111111] border border-[#262626] rounded-lg p-6 hover:bg-[#151515] transition-all cursor-pointer group"
>
<div className="flex items-center justify-between space-x-10">
@@ -77,7 +77,7 @@ export function AgentCard({ props }: { props: AgentCardProps }) {
{status.name.toUpperCase()}
</div>
<h3 className="text-sm md:text-md lg:text-lg font-semibold text-white transition-colors">
{props.data.agent}
{data.agent}
</h3>
</div>
@@ -85,13 +85,11 @@ export function AgentCard({ props }: { props: AgentCardProps }) {
<div className="flex flex-col max-w-lg gap-y-2">
<Progressbar
color={status.color}
percentage={props.data.uptime}
percentage={data.uptime}
/>
<div className="flex justify-between items-center text-xs">
<span>
{formatRelativeTime(props.data.last_seen)}
</span>
<span>{props.data.uptime.toFixed(2)}%</span>
<span>{formatRelativeTime(data.last_seen)}</span>
<span>{data.uptime.toFixed(2)}%</span>
</div>
</div>
</div>

View File

@@ -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 (
<div className="flex items-center space-x-3 bg-[#141414] p-2 rounded-lg">
<div
className={`w-2 h-2 rounded-full mt-[3px] ${config.color} ${config.animation}`}
/>
<span className="text-sm text-gray-300 font-semibold">
{config.text}
</span>
{timestamp && (
<span className="text-xs text-gray-500">
{new Date(timestamp).toLocaleTimeString()}
</span>
)}
</div>
);
}

View File

@@ -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 (
<header className="bg-[#0d0d0d] border-b border-[#191919]">
<div className="max-w-7xl mx-auto py-4 flex justify-between items-center">
<div className="flex items-center space-x-3">
{props.hasBackButton && (
<button
onClick={() => navigate(-1)}
className="w-8 h-8 bg-[#262626] hover:bg-[#404040] rounded-lg flex items-center justify-center transition-colors"
>
<ChevronLeft />
</button>
)}
<div className="w-11 h-11 text-yellow-500 flex items-center justify-center">
<Zap className="w-9 h-9" />
</div>

View File

@@ -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 (
<div className="bg-[#0d0d0d] rounded-lg p-6 shadow-lg hover:shadow-xl border border-[#191919]">
<div className="flex items-center justify-between mb-4">
<h3 className="text-gray-300 text-sm font-medium uppercase tracking-wider">
{props.title}
</h3>
<div
className={`w-3 h-3 rounded-full animate-pulse-slow ${indicateColor}`}
/>
</div>
<div className="space-y-2">
<div className="text-2xl font-bold text-white">
{props.value}
</div>
<div className="text-sm text-gray-500">{props.subtitle}</div>
<div>{children}</div>
</div>
</div>
);
}
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";
}
};

140
ui/src/pages/agent.tsx Normal file
View File

@@ -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<WebsocketStatus>();
const [message, setMessage] = useState<StatusMessage | null>(
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 (
<div>
<Header
props={{
title: "Status Monitor",
subtitle: agent!,
hasBackButton: true,
rightComponent: (
<ConnectionStatus
status={status ?? "disconnected"}
timestamp={message?.timestamp}
/>
),
}}
/>
<main className="max-w-7xl mx-auto py-8">
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6 mb-8">
<MetricCard
props={{
title: "CPU USAGE",
value: formatPercentage(cpuUsage),
status: getMetricsStatus(cpuUsage),
subtitle: `${metrics?.cpu.threads ?? 0} threads`,
}}
>
<h1>cpu chart</h1>
</MetricCard>
<MetricCard
props={{
title: "MEMORY USAGE",
value: formatPercentage(memoryUsage),
status: getMetricsStatus(memoryUsage),
subtitle: `${formatBytes(metrics?.memory.used)} / ${formatBytes(metrics?.memory.total)}`,
}}
>
<h1>memory chart</h1>
</MetricCard>
<MetricCard
props={{
title: "NETWORK ACTIVITY",
value: formatBytes(networkUsage),
status: getMetricsStatus(networkUsage),
subtitle: `${formatBytes(metrics?.network.up)}/s ↓ ${formatBytes(metrics?.network.down)}/s`,
}}
>
<h1>network chart</h1>
</MetricCard>
</div>
<MetricCard
props={{
title: "DISK USAGE",
value: formatPercentage(diskUsage),
status: getMetricsStatus(diskUsage),
subtitle: `${formatBytes(metrics?.disk.free)} free of ${formatBytes(metrics?.disk.total)}`,
}}
>
<div className="w-full bg-[#141414] rounded-full h-4">
<div
className={`h-4 rounded-full transition-all duration-500 ${
getMetricsStatus(diskUsage) == "good"
? "bg-green-500"
: getMetricsStatus(diskUsage) == "warning"
? "bg-yellow-500"
: "bg-red-500"
}`}
style={{ width: `${diskUsage}%` }}
/>
</div>
</MetricCard>
</main>
</div>
);
}

View File

@@ -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<UptimeMessage[]>([]);
useEffect(() => {
@@ -82,7 +84,10 @@ export function HomePage() {
{agents.map((agent, index) => (
<AgentCard
key={index}
props={{ data: agent }}
data={agent}
onClick={() =>
navigate(`/agents/${agent.agent}`)
}
/>
))}
</div>

12
ui/src/services/store.ts Normal file
View File

@@ -0,0 +1,12 @@
import type { StatusMessage } from "@/services/types";
import { atom } from "nanostores";
const store = atom<StatusMessage | null>(null);
export function getLastMessage(): StatusMessage | null {
return store.get();
}
export function setLastMessage(message: StatusMessage) {
store.set(message);
}

View File

@@ -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)}%`;
}

View File

@@ -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();
}