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 {
|
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::path::param::<String>())
|
||||||
.and(warp::get())
|
.and(warp::get())
|
||||||
.and(warp::ws())
|
.and(warp::ws())
|
||||||
|
|||||||
@@ -10,6 +10,7 @@
|
|||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/js": "^9.29.0",
|
"@eslint/js": "^9.29.0",
|
||||||
|
"@nanostores/react": "^1.0.0",
|
||||||
"@tailwindcss/vite": "^4.1.11",
|
"@tailwindcss/vite": "^4.1.11",
|
||||||
"@types/react": "^19.1.8",
|
"@types/react": "^19.1.8",
|
||||||
"@types/react-dom": "^19.1.6",
|
"@types/react-dom": "^19.1.6",
|
||||||
@@ -18,6 +19,7 @@
|
|||||||
"eslint-plugin-react-hooks": "^5.2.0",
|
"eslint-plugin-react-hooks": "^5.2.0",
|
||||||
"eslint-plugin-react-refresh": "^0.4.20",
|
"eslint-plugin-react-refresh": "^0.4.20",
|
||||||
"globals": "^16.2.0",
|
"globals": "^16.2.0",
|
||||||
|
"nanostores": "^1.0.1",
|
||||||
"react-router-dom": "^7.6.3",
|
"react-router-dom": "^7.6.3",
|
||||||
"tailwindcss": "^4.1.11",
|
"tailwindcss": "^4.1.11",
|
||||||
"typescript": "~5.8.3",
|
"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=="],
|
"@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.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=="],
|
"@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=="],
|
"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=="],
|
"natural-compare": ["natural-compare@1.4.0", "", {}, "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw=="],
|
||||||
|
|
||||||
"node-releases": ["node-releases@2.0.19", "", {}, "sha512-xxOWJsBKtzAq7DY0J+DTzuz58K8e7sJbdgwkbMWQe8UYB6ekmsQ45q0M/tJDsGaZmbC+l7n57UV8Hl5tHxO9uw=="],
|
"node-releases": ["node-releases@2.0.19", "", {}, "sha512-xxOWJsBKtzAq7DY0J+DTzuz58K8e7sJbdgwkbMWQe8UYB6ekmsQ45q0M/tJDsGaZmbC+l7n57UV8Hl5tHxO9uw=="],
|
||||||
|
|||||||
@@ -16,6 +16,7 @@
|
|||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/js": "^9.29.0",
|
"@eslint/js": "^9.29.0",
|
||||||
|
"@nanostores/react": "^1.0.0",
|
||||||
"@tailwindcss/vite": "^4.1.11",
|
"@tailwindcss/vite": "^4.1.11",
|
||||||
"@types/react": "^19.1.8",
|
"@types/react": "^19.1.8",
|
||||||
"@types/react-dom": "^19.1.6",
|
"@types/react-dom": "^19.1.6",
|
||||||
@@ -24,6 +25,7 @@
|
|||||||
"eslint-plugin-react-hooks": "^5.2.0",
|
"eslint-plugin-react-hooks": "^5.2.0",
|
||||||
"eslint-plugin-react-refresh": "^0.4.20",
|
"eslint-plugin-react-refresh": "^0.4.20",
|
||||||
"globals": "^16.2.0",
|
"globals": "^16.2.0",
|
||||||
|
"nanostores": "^1.0.1",
|
||||||
"react-router-dom": "^7.6.3",
|
"react-router-dom": "^7.6.3",
|
||||||
"tailwindcss": "^4.1.11",
|
"tailwindcss": "^4.1.11",
|
||||||
"typescript": "~5.8.3",
|
"typescript": "~5.8.3",
|
||||||
|
|||||||
@@ -1,11 +1,13 @@
|
|||||||
import { BrowserRouter, Route, Routes } from "react-router-dom";
|
import { BrowserRouter, Route, Routes } from "react-router-dom";
|
||||||
import { HomePage } from "@/pages/home";
|
import { HomePage } from "@/pages/home";
|
||||||
|
import { AgentPage } from "@/pages/agent";
|
||||||
|
|
||||||
export default function App() {
|
export default function App() {
|
||||||
return (
|
return (
|
||||||
<BrowserRouter>
|
<BrowserRouter>
|
||||||
<Routes>
|
<Routes>
|
||||||
<Route path="/" element={<HomePage />} />
|
<Route path="/" element={<HomePage />} />
|
||||||
|
<Route path="/agents/:agent" element={<AgentPage />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
</BrowserRouter>
|
</BrowserRouter>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { UptimeMessage } from "@/services/types";
|
|||||||
import { formatRelativeTime, isAgentOnline } from "@/services/utils";
|
import { formatRelativeTime, isAgentOnline } from "@/services/utils";
|
||||||
import { Progressbar } from "./Progressbar";
|
import { Progressbar } from "./Progressbar";
|
||||||
|
|
||||||
export type AgentOverviewCardProps = {
|
type AgentOverviewCardProps = {
|
||||||
count: number;
|
count: number;
|
||||||
title: string;
|
title: string;
|
||||||
icon: {
|
icon: {
|
||||||
@@ -42,13 +42,13 @@ export function AgentOverviewCard({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export type AgentCardProps = {
|
type AgentCardProps = {
|
||||||
data: UptimeMessage;
|
data: UptimeMessage;
|
||||||
onClick?: () => void;
|
onClick: () => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function AgentCard({ props }: { props: AgentCardProps }) {
|
export function AgentCard({ data, onClick }: AgentCardProps) {
|
||||||
const status = isAgentOnline(props.data)
|
const status = isAgentOnline(data)
|
||||||
? {
|
? {
|
||||||
name: "online",
|
name: "online",
|
||||||
color: "bg-green-500",
|
color: "bg-green-500",
|
||||||
@@ -66,7 +66,7 @@ export function AgentCard({ props }: { props: AgentCardProps }) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
onClick={() => props.onClick?.()}
|
onClick={() => onClick()}
|
||||||
className="bg-[#111111] border border-[#262626] rounded-lg p-6 hover:bg-[#151515] transition-all cursor-pointer group"
|
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">
|
<div className="flex items-center justify-between space-x-10">
|
||||||
@@ -77,7 +77,7 @@ export function AgentCard({ props }: { props: AgentCardProps }) {
|
|||||||
{status.name.toUpperCase()}
|
{status.name.toUpperCase()}
|
||||||
</div>
|
</div>
|
||||||
<h3 className="text-sm md:text-md lg:text-lg font-semibold text-white transition-colors">
|
<h3 className="text-sm md:text-md lg:text-lg font-semibold text-white transition-colors">
|
||||||
{props.data.agent}
|
{data.agent}
|
||||||
</h3>
|
</h3>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -85,13 +85,11 @@ export function AgentCard({ props }: { props: AgentCardProps }) {
|
|||||||
<div className="flex flex-col max-w-lg gap-y-2">
|
<div className="flex flex-col max-w-lg gap-y-2">
|
||||||
<Progressbar
|
<Progressbar
|
||||||
color={status.color}
|
color={status.color}
|
||||||
percentage={props.data.uptime}
|
percentage={data.uptime}
|
||||||
/>
|
/>
|
||||||
<div className="flex justify-between items-center text-xs">
|
<div className="flex justify-between items-center text-xs">
|
||||||
<span>
|
<span>{formatRelativeTime(data.last_seen)}</span>
|
||||||
{formatRelativeTime(props.data.last_seen)}
|
<span>{data.uptime.toFixed(2)}%</span>
|
||||||
</span>
|
|
||||||
<span>{props.data.uptime.toFixed(2)}%</span>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</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;
|
title: string;
|
||||||
subtitle: string;
|
subtitle: string;
|
||||||
hasBackButton: boolean;
|
hasBackButton: boolean;
|
||||||
@@ -8,10 +9,21 @@ export type HeaderProps = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export function Header({ props }: { props: HeaderProps }) {
|
export function Header({ props }: { props: HeaderProps }) {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<header className="bg-[#0d0d0d] border-b border-[#191919]">
|
<header className="bg-[#0d0d0d] border-b border-[#191919]">
|
||||||
<div className="max-w-7xl mx-auto py-4 flex justify-between items-center">
|
<div className="max-w-7xl mx-auto py-4 flex justify-between items-center">
|
||||||
<div className="flex items-center space-x-3">
|
<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">
|
<div className="w-11 h-11 text-yellow-500 flex items-center justify-center">
|
||||||
<Zap className="w-9 h-9" />
|
<Zap className="w-9 h-9" />
|
||||||
</div>
|
</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 { isAgentOnline } from "@/services/utils";
|
||||||
import { Box } from "lucide-react";
|
import { Box } from "lucide-react";
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
|
import { useNavigate } from "react-router-dom";
|
||||||
|
|
||||||
export function HomePage() {
|
export function HomePage() {
|
||||||
|
const navigate = useNavigate();
|
||||||
const [agents, setAgents] = useState<UptimeMessage[]>([]);
|
const [agents, setAgents] = useState<UptimeMessage[]>([]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -82,7 +84,10 @@ export function HomePage() {
|
|||||||
{agents.map((agent, index) => (
|
{agents.map((agent, index) => (
|
||||||
<AgentCard
|
<AgentCard
|
||||||
key={index}
|
key={index}
|
||||||
props={{ data: agent }}
|
data={agent}
|
||||||
|
onClick={() =>
|
||||||
|
navigate(`/agents/${agent.agent}`)
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</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;
|
return timeDiff < 10000;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const formatRelativeTime = (timestamp: string): string => {
|
export function formatRelativeTime(timestamp: string): string {
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
const past = new Date(timestamp);
|
const past = new Date(timestamp);
|
||||||
const diffInSeconds = Math.floor((now.getTime() - past.getTime()) / 1000);
|
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);
|
const days = Math.floor(diffInSeconds / 86400);
|
||||||
return `${days}d ago`;
|
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