mirror of
https://github.com/csehviktor/status-monitor.git
synced 2025-08-08 18:06:14 +02:00
improve ui
This commit is contained in:
@@ -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())
|
||||
|
||||
@@ -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=="],
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
|
||||
59
ui/src/components/ConnectionStatus.tsx
Normal file
59
ui/src/components/ConnectionStatus.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
51
ui/src/components/MetricCard.tsx
Normal file
51
ui/src/components/MetricCard.tsx
Normal 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
140
ui/src/pages/agent.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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
12
ui/src/services/store.ts
Normal 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);
|
||||
}
|
||||
@@ -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)}%`;
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user