Initial commit: Eventify Server Health Monitor
Full-stack real-time server monitoring dashboard with: - Express.js backend with SSH-based metrics collection - React + TypeScript + Tailwind CSS + shadcn/ui frontend - CPU, memory, disk, Docker container, and Nginx monitoring - Pure SVG charts (CPU area chart + memory donut) - Gilroy font, 10s auto-refresh polling - Multi-page dashboard with sidebar navigation Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
19
.claude/launch.json
Normal file
19
.claude/launch.json
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
{
|
||||||
|
"version": "0.0.1",
|
||||||
|
"configurations": [
|
||||||
|
{
|
||||||
|
"name": "backend",
|
||||||
|
"runtimeExecutable": "npx",
|
||||||
|
"runtimeArgs": ["tsx", "src/index.ts"],
|
||||||
|
"port": 3001,
|
||||||
|
"cwd": "server"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "frontend",
|
||||||
|
"runtimeExecutable": "npx",
|
||||||
|
"runtimeArgs": ["vite", "--port", "5173"],
|
||||||
|
"port": 5173,
|
||||||
|
"cwd": "client"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
5
.gitignore
vendored
Normal file
5
.gitignore
vendored
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
node_modules/
|
||||||
|
dist/
|
||||||
|
.env
|
||||||
|
*.local
|
||||||
|
.DS_Store
|
||||||
24
client/.gitignore
vendored
Normal file
24
client/.gitignore
vendored
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
# Logs
|
||||||
|
logs
|
||||||
|
*.log
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
pnpm-debug.log*
|
||||||
|
lerna-debug.log*
|
||||||
|
|
||||||
|
node_modules
|
||||||
|
dist
|
||||||
|
dist-ssr
|
||||||
|
*.local
|
||||||
|
|
||||||
|
# Editor directories and files
|
||||||
|
.vscode/*
|
||||||
|
!.vscode/extensions.json
|
||||||
|
.idea
|
||||||
|
.DS_Store
|
||||||
|
*.suo
|
||||||
|
*.ntvs*
|
||||||
|
*.njsproj
|
||||||
|
*.sln
|
||||||
|
*.sw?
|
||||||
21
client/components.json
Normal file
21
client/components.json
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://ui.shadcn.com/schema.json",
|
||||||
|
"style": "new-york",
|
||||||
|
"rsc": false,
|
||||||
|
"tsx": true,
|
||||||
|
"tailwind": {
|
||||||
|
"config": "",
|
||||||
|
"css": "src/index.css",
|
||||||
|
"baseColor": "neutral",
|
||||||
|
"cssVariables": true,
|
||||||
|
"prefix": ""
|
||||||
|
},
|
||||||
|
"aliases": {
|
||||||
|
"components": "@/components",
|
||||||
|
"utils": "@/lib/utils",
|
||||||
|
"ui": "@/components/ui",
|
||||||
|
"lib": "@/lib",
|
||||||
|
"hooks": "@/hooks"
|
||||||
|
},
|
||||||
|
"iconLibrary": "lucide"
|
||||||
|
}
|
||||||
13
client/index.html
Normal file
13
client/index.html
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>Eventify Server Monitor</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/src/main.tsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
31
client/package.json
Normal file
31
client/package.json
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
{
|
||||||
|
"name": "client",
|
||||||
|
"private": true,
|
||||||
|
"version": "0.0.0",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "tsc && vite build",
|
||||||
|
"preview": "vite preview"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@tailwindcss/vite": "^4.2.2",
|
||||||
|
"tailwindcss": "^4.2.2",
|
||||||
|
"typescript": "~5.9.3",
|
||||||
|
"vite": "^8.0.1"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@tanstack/react-query": "^5.95.0",
|
||||||
|
"@types/react": "^19.2.14",
|
||||||
|
"@types/react-dom": "^19.2.3",
|
||||||
|
"@vitejs/plugin-react-swc": "^4.3.0",
|
||||||
|
"class-variance-authority": "^0.7.1",
|
||||||
|
"clsx": "^2.1.1",
|
||||||
|
"lucide-react": "^0.577.0",
|
||||||
|
"radix-ui": "^1.4.3",
|
||||||
|
"react": "^19.2.4",
|
||||||
|
"react-dom": "^19.2.4",
|
||||||
|
"tailwind-merge": "^3.5.0",
|
||||||
|
"tailwindcss-animate": "^1.0.7"
|
||||||
|
}
|
||||||
|
}
|
||||||
1
client/public/favicon.svg
Normal file
1
client/public/favicon.svg
Normal file
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 9.3 KiB |
BIN
client/public/fonts/Gilroy-Bold.ttf
Normal file
BIN
client/public/fonts/Gilroy-Bold.ttf
Normal file
Binary file not shown.
BIN
client/public/fonts/Gilroy-ExtraBold.ttf
Normal file
BIN
client/public/fonts/Gilroy-ExtraBold.ttf
Normal file
Binary file not shown.
BIN
client/public/fonts/Gilroy-Medium.ttf
Normal file
BIN
client/public/fonts/Gilroy-Medium.ttf
Normal file
Binary file not shown.
BIN
client/public/fonts/Gilroy-Regular.ttf
Normal file
BIN
client/public/fonts/Gilroy-Regular.ttf
Normal file
Binary file not shown.
BIN
client/public/fonts/Gilroy-SemiBold.ttf
Normal file
BIN
client/public/fonts/Gilroy-SemiBold.ttf
Normal file
Binary file not shown.
24
client/public/icons.svg
Normal file
24
client/public/icons.svg
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<symbol id="bluesky-icon" viewBox="0 0 16 17">
|
||||||
|
<g clip-path="url(#bluesky-clip)"><path fill="#08060d" d="M7.75 7.735c-.693-1.348-2.58-3.86-4.334-5.097-1.68-1.187-2.32-.981-2.74-.79C.188 2.065.1 2.812.1 3.251s.241 3.602.398 4.13c.52 1.744 2.367 2.333 4.07 2.145-2.495.37-4.71 1.278-1.805 4.512 3.196 3.309 4.38-.71 4.987-2.746.608 2.036 1.307 5.91 4.93 2.746 2.72-2.746.747-4.143-1.747-4.512 1.702.189 3.55-.4 4.07-2.145.156-.528.397-3.691.397-4.13s-.088-1.186-.575-1.406c-.42-.19-1.06-.395-2.741.79-1.755 1.24-3.64 3.752-4.334 5.099"/></g>
|
||||||
|
<defs><clipPath id="bluesky-clip"><path fill="#fff" d="M.1.85h15.3v15.3H.1z"/></clipPath></defs>
|
||||||
|
</symbol>
|
||||||
|
<symbol id="discord-icon" viewBox="0 0 20 19">
|
||||||
|
<path fill="#08060d" d="M16.224 3.768a14.5 14.5 0 0 0-3.67-1.153c-.158.286-.343.67-.47.976a13.5 13.5 0 0 0-4.067 0c-.128-.306-.317-.69-.476-.976A14.4 14.4 0 0 0 3.868 3.77C1.546 7.28.916 10.703 1.231 14.077a14.7 14.7 0 0 0 4.5 2.306q.545-.748.965-1.587a9.5 9.5 0 0 1-1.518-.74q.191-.14.372-.293c2.927 1.369 6.107 1.369 8.999 0q.183.152.372.294-.723.437-1.52.74.418.838.963 1.588a14.6 14.6 0 0 0 4.504-2.308c.37-3.911-.63-7.302-2.644-10.309m-9.13 8.234c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.894 0 1.614.82 1.599 1.82.001 1-.705 1.82-1.6 1.82m5.91 0c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.893 0 1.614.82 1.599 1.82 0 1-.706 1.82-1.6 1.82"/>
|
||||||
|
</symbol>
|
||||||
|
<symbol id="documentation-icon" viewBox="0 0 21 20">
|
||||||
|
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="m15.5 13.333 1.533 1.322c.645.555.967.833.967 1.178s-.322.623-.967 1.179L15.5 18.333m-3.333-5-1.534 1.322c-.644.555-.966.833-.966 1.178s.322.623.966 1.179l1.534 1.321"/>
|
||||||
|
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M17.167 10.836v-4.32c0-1.41 0-2.117-.224-2.68-.359-.906-1.118-1.621-2.08-1.96-.599-.21-1.349-.21-2.848-.21-2.623 0-3.935 0-4.983.369-1.684.591-3.013 1.842-3.641 3.428C3 6.449 3 7.684 3 10.154v2.122c0 2.558 0 3.838.706 4.726q.306.383.713.671c.76.536 1.79.64 3.581.66"/>
|
||||||
|
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M3 10a2.78 2.78 0 0 1 2.778-2.778c.555 0 1.209.097 1.748-.047.48-.129.854-.503.982-.982.145-.54.048-1.194.048-1.749a2.78 2.78 0 0 1 2.777-2.777"/>
|
||||||
|
</symbol>
|
||||||
|
<symbol id="github-icon" viewBox="0 0 19 19">
|
||||||
|
<path fill="#08060d" fill-rule="evenodd" d="M9.356 1.85C5.05 1.85 1.57 5.356 1.57 9.694a7.84 7.84 0 0 0 5.324 7.44c.387.079.528-.168.528-.376 0-.182-.013-.805-.013-1.454-2.165.467-2.616-.935-2.616-.935-.349-.91-.864-1.143-.864-1.143-.71-.48.051-.48.051-.48.787.051 1.2.805 1.2.805.695 1.194 1.817.857 2.268.649.064-.507.27-.857.49-1.052-1.728-.182-3.545-.857-3.545-3.87 0-.857.31-1.558.8-2.104-.078-.195-.349-1 .077-2.078 0 0 .657-.208 2.14.805a7.5 7.5 0 0 1 1.946-.26c.657 0 1.328.092 1.946.26 1.483-1.013 2.14-.805 2.14-.805.426 1.078.155 1.883.078 2.078.502.546.799 1.247.799 2.104 0 3.013-1.818 3.675-3.558 3.87.284.247.528.714.528 1.454 0 1.052-.012 1.896-.012 2.156 0 .208.142.455.528.377a7.84 7.84 0 0 0 5.324-7.441c.013-4.338-3.48-7.844-7.773-7.844" clip-rule="evenodd"/>
|
||||||
|
</symbol>
|
||||||
|
<symbol id="social-icon" viewBox="0 0 20 20">
|
||||||
|
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M12.5 6.667a4.167 4.167 0 1 0-8.334 0 4.167 4.167 0 0 0 8.334 0"/>
|
||||||
|
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M2.5 16.667a5.833 5.833 0 0 1 8.75-5.053m3.837.474.513 1.035c.07.144.257.282.414.309l.93.155c.596.1.736.536.307.965l-.723.73a.64.64 0 0 0-.152.531l.207.903c.164.715-.213.991-.84.618l-.872-.52a.63.63 0 0 0-.577 0l-.872.52c-.624.373-1.003.094-.84-.618l.207-.903a.64.64 0 0 0-.152-.532l-.723-.729c-.426-.43-.289-.864.306-.964l.93-.156a.64.64 0 0 0 .412-.31l.513-1.034c.28-.562.735-.562 1.012 0"/>
|
||||||
|
</symbol>
|
||||||
|
<symbol id="x-icon" viewBox="0 0 19 19">
|
||||||
|
<path fill="#08060d" fill-rule="evenodd" d="M1.893 1.98c.052.072 1.245 1.769 2.653 3.77l2.892 4.114c.183.261.333.48.333.486s-.068.089-.152.183l-.522.593-.765.867-3.597 4.087c-.375.426-.734.834-.798.905a1 1 0 0 0-.118.148c0 .01.236.017.664.017h.663l.729-.83c.4-.457.796-.906.879-.999a692 692 0 0 0 1.794-2.038c.034-.037.301-.34.594-.675l.551-.624.345-.392a7 7 0 0 1 .34-.374c.006 0 .93 1.306 2.052 2.903l2.084 2.965.045.063h2.275c1.87 0 2.273-.003 2.266-.021-.008-.02-1.098-1.572-3.894-5.547-2.013-2.862-2.28-3.246-2.273-3.266.008-.019.282-.332 2.085-2.38l2-2.274 1.567-1.782c.022-.028-.016-.03-.65-.03h-.674l-.3.342a871 871 0 0 1-1.782 2.025c-.067.075-.405.458-.75.852a100 100 0 0 1-.803.91c-.148.172-.299.344-.99 1.127-.304.343-.32.358-.345.327-.015-.019-.904-1.282-1.976-2.808L6.365 1.85H1.8zm1.782.91 8.078 11.294c.772 1.08 1.413 1.973 1.425 1.984.016.017.241.02 1.05.017l1.03-.004-2.694-3.766L7.796 5.75 5.722 2.852l-1.039-.004-1.039-.004z" clip-rule="evenodd"/>
|
||||||
|
</symbol>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 4.9 KiB |
242
client/src/App.tsx
Normal file
242
client/src/App.tsx
Normal file
@@ -0,0 +1,242 @@
|
|||||||
|
import { useEffect, useState } from "react"
|
||||||
|
import { QueryClient, QueryClientProvider } from "@tanstack/react-query"
|
||||||
|
import { Cpu, MemoryStick, HardDrive, Container } from "lucide-react"
|
||||||
|
import { Sidebar } from "@/components/layout/Sidebar"
|
||||||
|
import { Header } from "@/components/layout/Header"
|
||||||
|
import { KpiCard } from "@/components/dashboard/KpiCard"
|
||||||
|
import { CpuChart } from "@/components/dashboard/CpuChart"
|
||||||
|
import { MemoryDonut } from "@/components/dashboard/MemoryDonut"
|
||||||
|
import { DockerTable } from "@/components/dashboard/DockerTable"
|
||||||
|
import { NginxStatus } from "@/components/dashboard/NginxStatus"
|
||||||
|
import { DiskUsageBar } from "@/components/dashboard/DiskUsageBar"
|
||||||
|
import { NetworkStats } from "@/components/dashboard/NetworkStats"
|
||||||
|
import { ContainersPage } from "@/components/pages/ContainersPage"
|
||||||
|
import { MemoryPage } from "@/components/pages/MemoryPage"
|
||||||
|
import { StoragePage } from "@/components/pages/StoragePage"
|
||||||
|
import { NetworkPage } from "@/components/pages/NetworkPage"
|
||||||
|
import { useServerHealth } from "@/hooks/useServerHealth"
|
||||||
|
import { useCpuHistory } from "@/hooks/useCpuHistory"
|
||||||
|
import { formatBytes } from "@/lib/utils"
|
||||||
|
|
||||||
|
const queryClient = new QueryClient({
|
||||||
|
defaultOptions: {
|
||||||
|
queries: {
|
||||||
|
retry: 2,
|
||||||
|
refetchOnWindowFocus: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const PAGE_TITLES: Record<string, string> = {
|
||||||
|
dashboard: "Dashboard",
|
||||||
|
containers: "Containers",
|
||||||
|
memory: "Memory",
|
||||||
|
storage: "Storage",
|
||||||
|
network: "Network",
|
||||||
|
}
|
||||||
|
|
||||||
|
function DashboardContent({ activePage }: { activePage: string }) {
|
||||||
|
const {
|
||||||
|
data,
|
||||||
|
isLoading,
|
||||||
|
isError,
|
||||||
|
error,
|
||||||
|
refetch,
|
||||||
|
refetchInterval,
|
||||||
|
setRefetchInterval,
|
||||||
|
dataUpdatedAt,
|
||||||
|
} = useServerHealth()
|
||||||
|
|
||||||
|
const { history, addDataPoint } = useCpuHistory()
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (data?.system) {
|
||||||
|
addDataPoint(data.system.cpuPercent, data.timestamp)
|
||||||
|
}
|
||||||
|
}, [data, addDataPoint])
|
||||||
|
|
||||||
|
const lastUpdated = dataUpdatedAt ? new Date(dataUpdatedAt).toISOString() : null
|
||||||
|
|
||||||
|
if (isError) {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-1 flex-col">
|
||||||
|
<Header
|
||||||
|
title={PAGE_TITLES[activePage] ?? "Dashboard"}
|
||||||
|
refreshInterval={refetchInterval}
|
||||||
|
onRefreshIntervalChange={setRefetchInterval}
|
||||||
|
onRefresh={() => refetch()}
|
||||||
|
lastUpdated={null}
|
||||||
|
isLoading={false}
|
||||||
|
/>
|
||||||
|
<div className="flex-1 p-6">
|
||||||
|
<div className="rounded-lg border border-red-200 bg-red-50 p-4">
|
||||||
|
<p className="font-medium text-red-800">Failed to fetch server data</p>
|
||||||
|
<p className="mt-1 text-sm text-red-600">
|
||||||
|
{error instanceof Error ? error.message : "Unknown error"}
|
||||||
|
</p>
|
||||||
|
<button
|
||||||
|
onClick={() => refetch()}
|
||||||
|
className="mt-3 rounded-lg bg-red-600 px-4 py-2 text-sm font-medium text-white hover:bg-red-700"
|
||||||
|
>
|
||||||
|
Retry
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isLoading || !data) {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-1 flex-col">
|
||||||
|
<Header
|
||||||
|
title={PAGE_TITLES[activePage] ?? "Dashboard"}
|
||||||
|
refreshInterval={refetchInterval}
|
||||||
|
onRefreshIntervalChange={setRefetchInterval}
|
||||||
|
onRefresh={() => refetch()}
|
||||||
|
lastUpdated={null}
|
||||||
|
isLoading={true}
|
||||||
|
/>
|
||||||
|
<div className="flex-1 space-y-6 p-6">
|
||||||
|
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
||||||
|
{Array.from({ length: 4 }).map((_, i) => (
|
||||||
|
<div key={i} className="h-32 animate-pulse rounded-xl border bg-card" />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-1 gap-4 lg:grid-cols-3">
|
||||||
|
<div className="col-span-2 h-80 animate-pulse rounded-xl border bg-card" />
|
||||||
|
<div className="h-80 animate-pulse rounded-xl border bg-card" />
|
||||||
|
</div>
|
||||||
|
<div className="h-64 animate-pulse rounded-xl border bg-card" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const system = data.system
|
||||||
|
const memory = data.memory
|
||||||
|
const disk = data.disk
|
||||||
|
const containers = data.docker ?? []
|
||||||
|
const nginx = data.nginx
|
||||||
|
|
||||||
|
const renderPage = () => {
|
||||||
|
switch (activePage) {
|
||||||
|
case "containers":
|
||||||
|
return <ContainersPage containers={containers} />
|
||||||
|
case "memory":
|
||||||
|
return <MemoryPage memory={memory} />
|
||||||
|
case "storage":
|
||||||
|
return <StoragePage disk={disk} />
|
||||||
|
case "network":
|
||||||
|
return <NetworkPage containers={containers} nginx={nginx} />
|
||||||
|
default:
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{/* KPI cards */}
|
||||||
|
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
||||||
|
<KpiCard
|
||||||
|
title="CPU Usage"
|
||||||
|
value={system ? `${system.cpuPercent.toFixed(1)}%` : "--"}
|
||||||
|
subtitle={system ? `Load: ${system.loadAvg[0].toFixed(2)}` : "Unavailable"}
|
||||||
|
icon={Cpu}
|
||||||
|
iconColor="text-blue-500"
|
||||||
|
trend={
|
||||||
|
system
|
||||||
|
? system.cpuPercent < 50
|
||||||
|
? { value: "Normal", positive: true }
|
||||||
|
: { value: "High", positive: false }
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<KpiCard
|
||||||
|
title="Memory"
|
||||||
|
value={memory ? formatBytes(memory.used) : "--"}
|
||||||
|
subtitle={memory ? `of ${formatBytes(memory.total)}` : "Unavailable"}
|
||||||
|
icon={MemoryStick}
|
||||||
|
iconColor="text-amber-500"
|
||||||
|
trend={
|
||||||
|
memory
|
||||||
|
? memory.usedPercent < 70
|
||||||
|
? { value: `${memory.usedPercent.toFixed(0)}% used`, positive: true }
|
||||||
|
: { value: `${memory.usedPercent.toFixed(0)}% used`, positive: false }
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<KpiCard
|
||||||
|
title="Disk"
|
||||||
|
value={disk ? formatBytes(disk.used) : "--"}
|
||||||
|
subtitle={disk ? `of ${formatBytes(disk.total)}` : "Unavailable"}
|
||||||
|
icon={HardDrive}
|
||||||
|
iconColor="text-green-500"
|
||||||
|
trend={
|
||||||
|
disk
|
||||||
|
? disk.usedPercent < 70
|
||||||
|
? { value: `${disk.usedPercent.toFixed(0)}% used`, positive: true }
|
||||||
|
: { value: `${disk.usedPercent.toFixed(0)}% used`, positive: false }
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<KpiCard
|
||||||
|
title="Containers"
|
||||||
|
value={String(containers.length)}
|
||||||
|
subtitle={`${containers.filter((c) => c.status.toLowerCase().includes("up")).length} running`}
|
||||||
|
icon={Container}
|
||||||
|
iconColor="text-purple-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Charts row */}
|
||||||
|
<div className="grid grid-cols-1 gap-4 lg:grid-cols-3">
|
||||||
|
<div className="lg:col-span-2">
|
||||||
|
<CpuChart
|
||||||
|
history={history}
|
||||||
|
loadAvg={system?.loadAvg ?? [0, 0, 0]}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{memory && <MemoryDonut memory={memory} />}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Docker table */}
|
||||||
|
<DockerTable containers={containers} />
|
||||||
|
|
||||||
|
{/* Bottom row */}
|
||||||
|
<div className="grid grid-cols-1 gap-4 md:grid-cols-3">
|
||||||
|
{nginx && <NginxStatus nginx={nginx} />}
|
||||||
|
<NetworkStats containers={containers} />
|
||||||
|
{disk && <DiskUsageBar disk={disk} />}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-1 flex-col">
|
||||||
|
<Header
|
||||||
|
title={PAGE_TITLES[activePage] ?? "Dashboard"}
|
||||||
|
refreshInterval={refetchInterval}
|
||||||
|
onRefreshIntervalChange={setRefetchInterval}
|
||||||
|
onRefresh={() => refetch()}
|
||||||
|
lastUpdated={lastUpdated}
|
||||||
|
isLoading={isLoading}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<main className="flex-1 space-y-6 overflow-y-auto bg-background p-6">
|
||||||
|
{renderPage()}
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function App() {
|
||||||
|
const [activePage, setActivePage] = useState("dashboard")
|
||||||
|
|
||||||
|
return (
|
||||||
|
<QueryClientProvider client={queryClient}>
|
||||||
|
<div className="flex h-screen overflow-hidden">
|
||||||
|
<Sidebar activePage={activePage} onNavigate={setActivePage} />
|
||||||
|
<DashboardContent activePage={activePage} />
|
||||||
|
</div>
|
||||||
|
</QueryClientProvider>
|
||||||
|
)
|
||||||
|
}
|
||||||
BIN
client/src/assets/hero.png
Normal file
BIN
client/src/assets/hero.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 44 KiB |
1
client/src/assets/typescript.svg
Normal file
1
client/src/assets/typescript.svg
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" aria-hidden="true" width="32" height="32" viewBox="0 0 256 256"><path fill="#007ACC" d="M0 128v128h256V0H0z"/><path fill="#FFF" d="m56.612 128.85l-.081 10.483h33.32v94.68h23.568v-94.68h33.321v-10.28c0-5.69-.122-10.444-.284-10.566c-.122-.162-20.4-.244-44.983-.203l-44.74.122l-.121 10.443Zm149.955-10.742c6.501 1.625 11.459 4.51 16.01 9.224c2.357 2.52 5.851 7.111 6.136 8.208c.08.325-11.053 7.802-17.798 11.988c-.244.162-1.22-.894-2.317-2.52c-3.291-4.795-6.745-6.867-12.028-7.233c-7.76-.528-12.759 3.535-12.718 10.321c0 1.992.284 3.17 1.097 4.795c1.707 3.536 4.876 5.649 14.832 9.956c18.326 7.883 26.168 13.084 31.045 20.48c5.445 8.249 6.664 21.415 2.966 31.208c-4.063 10.646-14.14 17.879-28.323 20.276c-4.388.772-14.79.65-19.504-.203c-10.28-1.828-20.033-6.908-26.047-13.572c-2.357-2.6-6.949-9.387-6.664-9.874c.122-.163 1.178-.813 2.356-1.504c1.138-.65 5.446-3.129 9.509-5.485l7.355-4.267l1.544 2.276c2.154 3.29 6.867 7.801 9.712 9.305c8.167 4.307 19.383 3.698 24.909-1.26c2.357-2.153 3.332-4.388 3.332-7.68c0-2.966-.366-4.266-1.91-6.501c-1.99-2.845-6.054-5.242-17.595-10.24c-13.206-5.69-18.895-9.224-24.096-14.832c-3.007-3.25-5.852-8.452-7.03-12.8c-.975-3.617-1.22-12.678-.447-16.335c2.723-12.76 12.353-21.659 26.25-24.3c4.51-.853 14.994-.528 19.424.569Z"/></svg>
|
||||||
|
After Width: | Height: | Size: 1.3 KiB |
1
client/src/assets/vite.svg
Normal file
1
client/src/assets/vite.svg
Normal file
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 8.5 KiB |
164
client/src/components/dashboard/CpuChart.tsx
Normal file
164
client/src/components/dashboard/CpuChart.tsx
Normal file
@@ -0,0 +1,164 @@
|
|||||||
|
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card"
|
||||||
|
|
||||||
|
interface CpuChartProps {
|
||||||
|
history: Array<{ cpuPercent: number; timestamp: string }>
|
||||||
|
loadAvg: [number, number, number]
|
||||||
|
}
|
||||||
|
|
||||||
|
const CHART_WIDTH = 600
|
||||||
|
const CHART_HEIGHT = 220
|
||||||
|
const PADDING = { top: 10, right: 10, bottom: 30, left: 45 }
|
||||||
|
const INNER_W = CHART_WIDTH - PADDING.left - PADDING.right
|
||||||
|
const INNER_H = CHART_HEIGHT - PADDING.top - PADDING.bottom
|
||||||
|
|
||||||
|
export function CpuChart({ history, loadAvg }: CpuChartProps) {
|
||||||
|
const currentCpu = history.length > 0 ? history[history.length - 1].cpuPercent : 0
|
||||||
|
|
||||||
|
// Build points
|
||||||
|
const points = history.map((point, i) => {
|
||||||
|
const x = PADDING.left + (history.length > 1 ? (i / (history.length - 1)) * INNER_W : INNER_W / 2)
|
||||||
|
const y = PADDING.top + INNER_H - (point.cpuPercent / 100) * INNER_H
|
||||||
|
return { x, y, time: point.timestamp, cpu: point.cpuPercent }
|
||||||
|
})
|
||||||
|
|
||||||
|
// Build line path
|
||||||
|
const linePath = points.length > 1
|
||||||
|
? points.map((p, i) => `${i === 0 ? "M" : "L"} ${p.x} ${p.y}`).join(" ")
|
||||||
|
: ""
|
||||||
|
|
||||||
|
// Build area path (line + close to bottom)
|
||||||
|
const areaPath = points.length > 1
|
||||||
|
? `${linePath} L ${points[points.length - 1].x} ${PADDING.top + INNER_H} L ${points[0].x} ${PADDING.top + INNER_H} Z`
|
||||||
|
: ""
|
||||||
|
|
||||||
|
// Y-axis ticks
|
||||||
|
const yTicks = [0, 25, 50, 75, 100]
|
||||||
|
|
||||||
|
// X-axis labels (show ~5 evenly spaced)
|
||||||
|
const xLabelCount = Math.min(5, points.length)
|
||||||
|
const xLabels = points.length > 1
|
||||||
|
? Array.from({ length: xLabelCount }, (_, i) => {
|
||||||
|
const idx = Math.round((i / (xLabelCount - 1)) * (points.length - 1))
|
||||||
|
const p = points[idx]
|
||||||
|
const time = new Date(p.time).toLocaleTimeString("en-US", {
|
||||||
|
hour: "2-digit",
|
||||||
|
minute: "2-digit",
|
||||||
|
second: "2-digit",
|
||||||
|
hour12: false,
|
||||||
|
})
|
||||||
|
return { x: p.x, label: time }
|
||||||
|
})
|
||||||
|
: points.length === 1
|
||||||
|
? [{ x: points[0].x, label: new Date(points[0].time).toLocaleTimeString("en-US", { hour: "2-digit", minute: "2-digit", second: "2-digit", hour12: false }) }]
|
||||||
|
: []
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card className="h-full">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>CPU & Load Average</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Current: {currentCpu.toFixed(1)}% | Load: {loadAvg.map((l) => l.toFixed(2)).join(", ")}
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="w-full overflow-hidden">
|
||||||
|
<svg
|
||||||
|
viewBox={`0 0 ${CHART_WIDTH} ${CHART_HEIGHT}`}
|
||||||
|
className="w-full h-auto"
|
||||||
|
preserveAspectRatio="xMidYMid meet"
|
||||||
|
>
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="cpuAreaGrad" x1="0" y1="0" x2="0" y2="1">
|
||||||
|
<stop offset="5%" stopColor="#3b82f6" stopOpacity={0.3} />
|
||||||
|
<stop offset="95%" stopColor="#3b82f6" stopOpacity={0.02} />
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
|
||||||
|
{/* Grid lines */}
|
||||||
|
{yTicks.map((tick) => {
|
||||||
|
const y = PADDING.top + INNER_H - (tick / 100) * INNER_H
|
||||||
|
return (
|
||||||
|
<line
|
||||||
|
key={tick}
|
||||||
|
x1={PADDING.left}
|
||||||
|
y1={y}
|
||||||
|
x2={PADDING.left + INNER_W}
|
||||||
|
y2={y}
|
||||||
|
stroke="#e5e7eb"
|
||||||
|
strokeWidth={1}
|
||||||
|
strokeDasharray={tick === 0 ? undefined : "4 4"}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
|
||||||
|
{/* Y-axis labels */}
|
||||||
|
{yTicks.map((tick) => {
|
||||||
|
const y = PADDING.top + INNER_H - (tick / 100) * INNER_H
|
||||||
|
return (
|
||||||
|
<text
|
||||||
|
key={tick}
|
||||||
|
x={PADDING.left - 8}
|
||||||
|
y={y + 4}
|
||||||
|
textAnchor="end"
|
||||||
|
fontSize={11}
|
||||||
|
fill="#6b7280"
|
||||||
|
>
|
||||||
|
{tick}%
|
||||||
|
</text>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
|
||||||
|
{/* Area fill */}
|
||||||
|
{areaPath && (
|
||||||
|
<path
|
||||||
|
d={areaPath}
|
||||||
|
fill="url(#cpuAreaGrad)"
|
||||||
|
style={{ transition: "d 0.3s ease" }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Line */}
|
||||||
|
{linePath && (
|
||||||
|
<path
|
||||||
|
d={linePath}
|
||||||
|
fill="none"
|
||||||
|
stroke="#3b82f6"
|
||||||
|
strokeWidth={2}
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeLinecap="round"
|
||||||
|
style={{ transition: "d 0.3s ease" }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Dots */}
|
||||||
|
{points.map((p, i) => (
|
||||||
|
<circle
|
||||||
|
key={i}
|
||||||
|
cx={p.x}
|
||||||
|
cy={p.y}
|
||||||
|
r={points.length <= 5 ? 4 : 2.5}
|
||||||
|
fill="#3b82f6"
|
||||||
|
stroke="white"
|
||||||
|
strokeWidth={1.5}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{/* X-axis labels */}
|
||||||
|
{xLabels.map((label, i) => (
|
||||||
|
<text
|
||||||
|
key={i}
|
||||||
|
x={label.x}
|
||||||
|
y={CHART_HEIGHT - 5}
|
||||||
|
textAnchor="middle"
|
||||||
|
fontSize={10}
|
||||||
|
fill="#6b7280"
|
||||||
|
>
|
||||||
|
{label.label}
|
||||||
|
</text>
|
||||||
|
))}
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)
|
||||||
|
}
|
||||||
63
client/src/components/dashboard/DiskUsageBar.tsx
Normal file
63
client/src/components/dashboard/DiskUsageBar.tsx
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
import type { DiskStats } from "@/types/server"
|
||||||
|
import { formatBytes, cn } from "@/lib/utils"
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
|
||||||
|
import { Progress } from "@/components/ui/progress"
|
||||||
|
import { HardDrive } from "lucide-react"
|
||||||
|
|
||||||
|
interface DiskUsageBarProps {
|
||||||
|
disk: DiskStats
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DiskUsageBar({ disk }: DiskUsageBarProps) {
|
||||||
|
const percent = disk.usedPercent
|
||||||
|
const colorClass =
|
||||||
|
percent >= 80
|
||||||
|
? "[&_[data-slot=progress-indicator]]:bg-red-500"
|
||||||
|
: percent >= 60
|
||||||
|
? "[&_[data-slot=progress-indicator]]:bg-amber-500"
|
||||||
|
: "[&_[data-slot=progress-indicator]]:bg-green-500"
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card className="h-full">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center gap-2">
|
||||||
|
<HardDrive className="size-4 text-muted-foreground" />
|
||||||
|
Disk Usage
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="flex flex-col gap-4">
|
||||||
|
<div className="text-center">
|
||||||
|
<p className="text-2xl font-bold text-foreground">
|
||||||
|
{formatBytes(disk.used)} / {formatBytes(disk.total)}
|
||||||
|
</p>
|
||||||
|
<p className="mt-1 text-sm text-muted-foreground">
|
||||||
|
{formatBytes(disk.free)} free
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex items-center justify-between text-sm">
|
||||||
|
<span className="text-muted-foreground">Usage</span>
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
"font-semibold",
|
||||||
|
percent >= 80
|
||||||
|
? "text-red-600"
|
||||||
|
: percent >= 60
|
||||||
|
? "text-amber-600"
|
||||||
|
: "text-green-600"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{percent.toFixed(1)}%
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<Progress value={percent} className={cn("h-3", colorClass)} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Mount: {disk.mountPoint}
|
||||||
|
</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)
|
||||||
|
}
|
||||||
78
client/src/components/dashboard/DockerTable.tsx
Normal file
78
client/src/components/dashboard/DockerTable.tsx
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
import type { ContainerStats } from "@/types/server"
|
||||||
|
import { formatBytes } from "@/lib/utils"
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
|
||||||
|
import {
|
||||||
|
Table,
|
||||||
|
TableBody,
|
||||||
|
TableCell,
|
||||||
|
TableHead,
|
||||||
|
TableHeader,
|
||||||
|
TableRow,
|
||||||
|
} from "@/components/ui/table"
|
||||||
|
|
||||||
|
interface DockerTableProps {
|
||||||
|
containers: ContainerStats[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DockerTable({ containers }: DockerTableProps) {
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Docker Containers</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead className="w-10">Status</TableHead>
|
||||||
|
<TableHead>Name</TableHead>
|
||||||
|
<TableHead className="text-right">CPU %</TableHead>
|
||||||
|
<TableHead className="text-right">Memory</TableHead>
|
||||||
|
<TableHead className="text-right">Mem %</TableHead>
|
||||||
|
<TableHead className="text-right">Net I/O</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{containers.length === 0 && (
|
||||||
|
<TableRow>
|
||||||
|
<TableCell colSpan={6} className="text-center text-muted-foreground">
|
||||||
|
No containers running
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
)}
|
||||||
|
{containers.map((container) => {
|
||||||
|
const isRunning = container.status.toLowerCase().includes("up")
|
||||||
|
return (
|
||||||
|
<TableRow key={container.name}>
|
||||||
|
<TableCell>
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
"inline-flex size-2.5 rounded-full",
|
||||||
|
isRunning ? "bg-green-500" : "bg-red-500"
|
||||||
|
)}
|
||||||
|
aria-label={isRunning ? "Running" : "Stopped"}
|
||||||
|
/>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="font-medium">{container.name}</TableCell>
|
||||||
|
<TableCell className="text-right font-mono text-sm">
|
||||||
|
{container.cpuPercent.toFixed(2)}%
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-right font-mono text-sm">
|
||||||
|
{formatBytes(container.memUsage)} / {formatBytes(container.memLimit)}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-right font-mono text-sm">
|
||||||
|
{container.memPercent.toFixed(1)}%
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-right font-mono text-sm">
|
||||||
|
{formatBytes(container.netInput)} / {formatBytes(container.netOutput)}
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)
|
||||||
|
}
|
||||||
74
client/src/components/dashboard/KpiCard.tsx
Normal file
74
client/src/components/dashboard/KpiCard.tsx
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
import type { LucideIcon } from "lucide-react"
|
||||||
|
import { TrendingUp, TrendingDown } from "lucide-react"
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
import { Card, CardContent } from "@/components/ui/card"
|
||||||
|
|
||||||
|
interface KpiCardProps {
|
||||||
|
title: string
|
||||||
|
value: string
|
||||||
|
subtitle?: string
|
||||||
|
icon: LucideIcon
|
||||||
|
iconColor?: string
|
||||||
|
trend?: { value: string; positive: boolean }
|
||||||
|
}
|
||||||
|
|
||||||
|
export function KpiCard({
|
||||||
|
title,
|
||||||
|
value,
|
||||||
|
subtitle,
|
||||||
|
icon: Icon,
|
||||||
|
iconColor = "text-primary",
|
||||||
|
trend,
|
||||||
|
}: KpiCardProps) {
|
||||||
|
return (
|
||||||
|
<Card className="gap-4 py-5">
|
||||||
|
<CardContent className="flex flex-col gap-3">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className="text-sm font-medium text-muted-foreground">
|
||||||
|
{title}
|
||||||
|
</span>
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"flex size-9 items-center justify-center rounded-lg bg-muted",
|
||||||
|
iconColor
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Icon className="size-4" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<p className="text-2xl font-bold tracking-tight text-foreground">
|
||||||
|
{value}
|
||||||
|
</p>
|
||||||
|
{(subtitle || trend) && (
|
||||||
|
<div className="mt-1 flex items-center gap-1.5">
|
||||||
|
{trend && (
|
||||||
|
<>
|
||||||
|
{trend.positive ? (
|
||||||
|
<TrendingUp className="size-3.5 text-green-500" />
|
||||||
|
) : (
|
||||||
|
<TrendingDown className="size-3.5 text-red-500" />
|
||||||
|
)}
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
"text-xs font-medium",
|
||||||
|
trend.positive ? "text-green-600" : "text-red-600"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{trend.value}
|
||||||
|
</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{subtitle && (
|
||||||
|
<span className="text-xs text-muted-foreground">
|
||||||
|
{subtitle}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)
|
||||||
|
}
|
||||||
92
client/src/components/dashboard/MemoryDonut.tsx
Normal file
92
client/src/components/dashboard/MemoryDonut.tsx
Normal file
@@ -0,0 +1,92 @@
|
|||||||
|
import type { MemoryStats } from "@/types/server"
|
||||||
|
import { formatBytes } from "@/lib/utils"
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
|
||||||
|
|
||||||
|
interface MemoryDonutProps {
|
||||||
|
memory: MemoryStats
|
||||||
|
}
|
||||||
|
|
||||||
|
const COLORS = ["#f59e0b", "#3b82f6", "#22c55e"]
|
||||||
|
const LABELS = ["Used", "Cached", "Free"]
|
||||||
|
|
||||||
|
export function MemoryDonut({ memory }: MemoryDonutProps) {
|
||||||
|
const actualUsed = Math.max(0, memory.total - memory.available)
|
||||||
|
const freeBytes = Math.max(0, memory.total - actualUsed - memory.cached)
|
||||||
|
const total = actualUsed + memory.cached + freeBytes
|
||||||
|
|
||||||
|
const segments = [
|
||||||
|
{ value: actualUsed, bytes: actualUsed },
|
||||||
|
{ value: memory.cached, bytes: memory.cached },
|
||||||
|
{ value: freeBytes, bytes: freeBytes },
|
||||||
|
]
|
||||||
|
|
||||||
|
// Calculate stroke-dasharray segments for SVG circle
|
||||||
|
const radius = 70
|
||||||
|
const circumference = 2 * Math.PI * radius
|
||||||
|
let cumulativeOffset = 0
|
||||||
|
|
||||||
|
const arcs = segments.map((seg, i) => {
|
||||||
|
const pct = total > 0 ? seg.value / total : 0
|
||||||
|
const dashLength = pct * circumference
|
||||||
|
const gap = circumference - dashLength
|
||||||
|
const offset = -cumulativeOffset
|
||||||
|
cumulativeOffset += dashLength
|
||||||
|
return { dashLength, gap, offset, color: COLORS[i], label: LABELS[i], bytes: seg.bytes }
|
||||||
|
})
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card className="h-full">
|
||||||
|
<CardHeader className="pb-2">
|
||||||
|
<CardTitle>Memory Usage</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="flex flex-col items-center">
|
||||||
|
{/* SVG Donut */}
|
||||||
|
<div className="relative">
|
||||||
|
<svg width="200" height="200" viewBox="0 0 200 200">
|
||||||
|
{arcs.map((arc, i) => (
|
||||||
|
<circle
|
||||||
|
key={i}
|
||||||
|
cx="100"
|
||||||
|
cy="100"
|
||||||
|
r={radius}
|
||||||
|
fill="none"
|
||||||
|
stroke={arc.color}
|
||||||
|
strokeWidth="20"
|
||||||
|
strokeDasharray={`${arc.dashLength} ${arc.gap}`}
|
||||||
|
strokeDashoffset={arc.offset}
|
||||||
|
strokeLinecap="round"
|
||||||
|
transform="rotate(-90 100 100)"
|
||||||
|
style={{ transition: "stroke-dasharray 0.5s ease" }}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</svg>
|
||||||
|
{/* Center text */}
|
||||||
|
<div className="absolute inset-0 flex items-center justify-center">
|
||||||
|
<div className="text-center">
|
||||||
|
<p className="text-2xl font-bold text-foreground">
|
||||||
|
{memory.usedPercent.toFixed(0)}%
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
{formatBytes(memory.used)} / {formatBytes(memory.total)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Legend */}
|
||||||
|
<div className="flex flex-wrap justify-center gap-4 pt-4">
|
||||||
|
{arcs.map((arc) => (
|
||||||
|
<div key={arc.label} className="flex items-center gap-1.5">
|
||||||
|
<div className="h-2.5 w-2.5 rounded-full" style={{ backgroundColor: arc.color }} />
|
||||||
|
<span className="text-xs text-muted-foreground">
|
||||||
|
{arc.label} ({formatBytes(arc.bytes)})
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)
|
||||||
|
}
|
||||||
57
client/src/components/dashboard/NetworkStats.tsx
Normal file
57
client/src/components/dashboard/NetworkStats.tsx
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
import { useMemo } from "react"
|
||||||
|
import { ArrowDownToLine, ArrowUpFromLine, Network } from "lucide-react"
|
||||||
|
import type { ContainerStats } from "@/types/server"
|
||||||
|
import { formatBytes } from "@/lib/utils"
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
|
||||||
|
|
||||||
|
interface NetworkStatsProps {
|
||||||
|
containers: ContainerStats[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export function NetworkStats({ containers }: NetworkStatsProps) {
|
||||||
|
const totals = useMemo(() => {
|
||||||
|
let inbound = 0
|
||||||
|
let outbound = 0
|
||||||
|
for (const c of containers) {
|
||||||
|
inbound += c.netInput
|
||||||
|
outbound += c.netOutput
|
||||||
|
}
|
||||||
|
return { inbound, outbound }
|
||||||
|
}, [containers])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card className="h-full">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center gap-2">
|
||||||
|
<Network className="size-4 text-muted-foreground" />
|
||||||
|
Network I/O
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="flex flex-col gap-5">
|
||||||
|
<div className="flex items-center gap-3 rounded-lg bg-muted/50 p-3">
|
||||||
|
<div className="flex size-10 items-center justify-center rounded-lg bg-green-100">
|
||||||
|
<ArrowDownToLine className="size-5 text-green-600" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-xs text-muted-foreground">Inbound</p>
|
||||||
|
<p className="text-lg font-bold text-foreground">
|
||||||
|
{formatBytes(totals.inbound)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-3 rounded-lg bg-muted/50 p-3">
|
||||||
|
<div className="flex size-10 items-center justify-center rounded-lg bg-blue-100">
|
||||||
|
<ArrowUpFromLine className="size-5 text-blue-600" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-xs text-muted-foreground">Outbound</p>
|
||||||
|
<p className="text-lg font-bold text-foreground">
|
||||||
|
{formatBytes(totals.outbound)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)
|
||||||
|
}
|
||||||
52
client/src/components/dashboard/NginxStatus.tsx
Normal file
52
client/src/components/dashboard/NginxStatus.tsx
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
import type { NginxStatus as NginxStatusType } from "@/types/server"
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
|
||||||
|
import { Badge } from "@/components/ui/badge"
|
||||||
|
import { Shield } from "lucide-react"
|
||||||
|
|
||||||
|
interface NginxStatusProps {
|
||||||
|
nginx: NginxStatusType
|
||||||
|
}
|
||||||
|
|
||||||
|
export function NginxStatus({ nginx }: NginxStatusProps) {
|
||||||
|
const isActive = nginx.status === "active"
|
||||||
|
const isFailed = nginx.status === "failed"
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card className="h-full">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center gap-2">
|
||||||
|
<Shield className="size-4 text-muted-foreground" />
|
||||||
|
Nginx Status
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="flex flex-col items-center gap-4">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
{isActive && (
|
||||||
|
<span className="relative flex size-3">
|
||||||
|
<span className="absolute inline-flex size-full animate-ping rounded-full bg-green-400 opacity-75" />
|
||||||
|
<span className="relative inline-flex size-3 rounded-full bg-green-500" />
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<Badge
|
||||||
|
variant={isActive ? "default" : "destructive"}
|
||||||
|
className={cn(
|
||||||
|
"px-4 py-1.5 text-sm",
|
||||||
|
isActive && "bg-green-500 hover:bg-green-600"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{nginx.status.charAt(0).toUpperCase() + nginx.status.slice(1)}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Checked: {new Date(nginx.checkedAt).toLocaleTimeString()}
|
||||||
|
</p>
|
||||||
|
{isFailed && (
|
||||||
|
<p className="text-xs font-medium text-red-500">
|
||||||
|
Service requires attention
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)
|
||||||
|
}
|
||||||
45
client/src/components/dashboard/RefreshControl.tsx
Normal file
45
client/src/components/dashboard/RefreshControl.tsx
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
import { useMemo } from "react"
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
interface RefreshControlProps {
|
||||||
|
lastUpdated: string | null
|
||||||
|
isConnected: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatRelativeTime(timestamp: string): string {
|
||||||
|
const diff = Date.now() - new Date(timestamp).getTime()
|
||||||
|
const seconds = Math.floor(diff / 1000)
|
||||||
|
if (seconds < 5) return "just now"
|
||||||
|
if (seconds < 60) return `${seconds}s ago`
|
||||||
|
const minutes = Math.floor(seconds / 60)
|
||||||
|
return `${minutes}m ago`
|
||||||
|
}
|
||||||
|
|
||||||
|
export function RefreshControl({ lastUpdated, isConnected }: RefreshControlProps) {
|
||||||
|
const relativeTime = useMemo(() => {
|
||||||
|
if (!lastUpdated) return "..."
|
||||||
|
return formatRelativeTime(lastUpdated)
|
||||||
|
}, [lastUpdated])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-2 rounded-lg border px-3 py-1.5">
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
"relative flex size-2",
|
||||||
|
isConnected ? "text-green-500" : "text-muted-foreground"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{isConnected && (
|
||||||
|
<span className="absolute inline-flex size-full animate-ping rounded-full bg-green-400 opacity-75" />
|
||||||
|
)}
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
"relative inline-flex size-2 rounded-full",
|
||||||
|
isConnected ? "bg-green-500" : "bg-muted-foreground"
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
|
<span className="text-xs text-muted-foreground">{relativeTime}</span>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
86
client/src/components/layout/Header.tsx
Normal file
86
client/src/components/layout/Header.tsx
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
import { RefreshCw, Bell } from "lucide-react"
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from "@/components/ui/select"
|
||||||
|
import { RefreshControl } from "@/components/dashboard/RefreshControl"
|
||||||
|
|
||||||
|
interface HeaderProps {
|
||||||
|
title?: string
|
||||||
|
refreshInterval: number
|
||||||
|
onRefreshIntervalChange: (ms: number) => void
|
||||||
|
onRefresh: () => void
|
||||||
|
lastUpdated: string | null
|
||||||
|
isLoading?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Header({
|
||||||
|
title = "Dashboard",
|
||||||
|
refreshInterval,
|
||||||
|
onRefreshIntervalChange,
|
||||||
|
onRefresh,
|
||||||
|
lastUpdated,
|
||||||
|
isLoading,
|
||||||
|
}: HeaderProps) {
|
||||||
|
const today = new Date().toLocaleDateString("en-US", {
|
||||||
|
month: "short",
|
||||||
|
day: "numeric",
|
||||||
|
year: "numeric",
|
||||||
|
})
|
||||||
|
|
||||||
|
return (
|
||||||
|
<header className="flex items-center justify-between border-b bg-card px-6 py-4">
|
||||||
|
{/* Left */}
|
||||||
|
<div>
|
||||||
|
<h1 className="text-xl font-bold text-foreground">{title}</h1>
|
||||||
|
<p className="text-sm text-muted-foreground">{today}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Right */}
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<RefreshControl lastUpdated={lastUpdated} isConnected={!isLoading} />
|
||||||
|
|
||||||
|
<Select
|
||||||
|
value={String(refreshInterval)}
|
||||||
|
onValueChange={(v) => onRefreshIntervalChange(Number(v))}
|
||||||
|
>
|
||||||
|
<SelectTrigger size="sm" className="w-24">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="5000">5s</SelectItem>
|
||||||
|
<SelectItem value="10000">10s</SelectItem>
|
||||||
|
<SelectItem value="30000">30s</SelectItem>
|
||||||
|
<SelectItem value="60000">60s</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={onRefresh}
|
||||||
|
className={cn(
|
||||||
|
"flex size-9 items-center justify-center rounded-lg border transition-colors hover:bg-muted",
|
||||||
|
isLoading && "animate-spin"
|
||||||
|
)}
|
||||||
|
aria-label="Refresh data"
|
||||||
|
>
|
||||||
|
<RefreshCw className="size-4 text-muted-foreground" />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
className="flex size-9 items-center justify-center rounded-lg border transition-colors hover:bg-muted"
|
||||||
|
aria-label="Notifications"
|
||||||
|
>
|
||||||
|
<Bell className="size-4 text-muted-foreground" />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div className="flex size-9 items-center justify-center rounded-full bg-primary text-xs font-semibold text-primary-foreground">
|
||||||
|
BS
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
)
|
||||||
|
}
|
||||||
120
client/src/components/layout/Sidebar.tsx
Normal file
120
client/src/components/layout/Sidebar.tsx
Normal file
@@ -0,0 +1,120 @@
|
|||||||
|
import {
|
||||||
|
LayoutDashboard,
|
||||||
|
Container,
|
||||||
|
MemoryStick,
|
||||||
|
HardDrive,
|
||||||
|
Wifi,
|
||||||
|
Settings,
|
||||||
|
HelpCircle,
|
||||||
|
Server,
|
||||||
|
} from "lucide-react"
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
import { Separator } from "@/components/ui/separator"
|
||||||
|
|
||||||
|
interface NavItem {
|
||||||
|
id: string
|
||||||
|
label: string
|
||||||
|
icon: React.ElementType
|
||||||
|
}
|
||||||
|
|
||||||
|
const mainNav: NavItem[] = [
|
||||||
|
{ id: "dashboard", label: "Dashboard", icon: LayoutDashboard },
|
||||||
|
{ id: "containers", label: "Containers", icon: Container },
|
||||||
|
{ id: "memory", label: "Memory", icon: MemoryStick },
|
||||||
|
{ id: "storage", label: "Storage", icon: HardDrive },
|
||||||
|
{ id: "network", label: "Network", icon: Wifi },
|
||||||
|
]
|
||||||
|
|
||||||
|
const bottomNav: NavItem[] = [
|
||||||
|
{ id: "settings", label: "Settings", icon: Settings },
|
||||||
|
{ id: "help", label: "Help & Support", icon: HelpCircle },
|
||||||
|
]
|
||||||
|
|
||||||
|
interface SidebarProps {
|
||||||
|
activePage: string
|
||||||
|
onNavigate: (page: string) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
function NavButton({
|
||||||
|
item,
|
||||||
|
active,
|
||||||
|
onClick,
|
||||||
|
}: {
|
||||||
|
item: NavItem
|
||||||
|
active: boolean
|
||||||
|
onClick: () => void
|
||||||
|
}) {
|
||||||
|
const Icon = item.icon
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
onClick={onClick}
|
||||||
|
className={cn(
|
||||||
|
"flex w-full items-center gap-3 rounded-lg px-3 py-2.5 text-sm font-medium transition-colors",
|
||||||
|
active
|
||||||
|
? "bg-primary text-primary-foreground"
|
||||||
|
: "text-muted-foreground hover:bg-muted hover:text-foreground"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Icon className="size-5 shrink-0" />
|
||||||
|
<span>{item.label}</span>
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Sidebar({ activePage, onNavigate }: SidebarProps) {
|
||||||
|
return (
|
||||||
|
<aside className="flex h-screen w-60 shrink-0 flex-col border-r bg-sidebar-background">
|
||||||
|
{/* Brand */}
|
||||||
|
<div className="flex items-center gap-3 px-5 py-6">
|
||||||
|
<div className="flex size-9 items-center justify-center rounded-lg bg-primary">
|
||||||
|
<Server className="size-5 text-primary-foreground" />
|
||||||
|
</div>
|
||||||
|
<span className="text-lg font-bold tracking-tight text-foreground">
|
||||||
|
Eventify
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Main nav */}
|
||||||
|
<nav className="flex flex-1 flex-col gap-1 px-3">
|
||||||
|
{mainNav.map((item) => (
|
||||||
|
<NavButton
|
||||||
|
key={item.id}
|
||||||
|
item={item}
|
||||||
|
active={activePage === item.id}
|
||||||
|
onClick={() => onNavigate(item.id)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
|
||||||
|
<Separator className="my-4" />
|
||||||
|
|
||||||
|
{bottomNav.map((item) => (
|
||||||
|
<NavButton
|
||||||
|
key={item.id}
|
||||||
|
item={item}
|
||||||
|
active={activePage === item.id}
|
||||||
|
onClick={() => onNavigate(item.id)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{/* Spacer */}
|
||||||
|
<div className="flex-1" />
|
||||||
|
|
||||||
|
{/* Status card */}
|
||||||
|
<div className="mb-4 rounded-xl border bg-muted/40 p-4">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="relative flex size-2.5">
|
||||||
|
<span className="absolute inline-flex size-full animate-ping rounded-full bg-green-400 opacity-75" />
|
||||||
|
<span className="relative inline-flex size-2.5 rounded-full bg-green-500" />
|
||||||
|
</span>
|
||||||
|
<span className="text-xs font-semibold text-foreground">
|
||||||
|
Server Status
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<p className="mt-2 text-xs text-muted-foreground">
|
||||||
|
All Systems Operational
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
</aside>
|
||||||
|
)
|
||||||
|
}
|
||||||
188
client/src/components/pages/ContainersPage.tsx
Normal file
188
client/src/components/pages/ContainersPage.tsx
Normal file
@@ -0,0 +1,188 @@
|
|||||||
|
import { useMemo } from "react"
|
||||||
|
import type { ContainerStats } from "@/types/server"
|
||||||
|
import { formatBytes, cn } from "@/lib/utils"
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
|
||||||
|
import {
|
||||||
|
Table,
|
||||||
|
TableBody,
|
||||||
|
TableCell,
|
||||||
|
TableHead,
|
||||||
|
TableHeader,
|
||||||
|
TableRow,
|
||||||
|
} from "@/components/ui/table"
|
||||||
|
import { Container, Cpu, MemoryStick, Network } from "lucide-react"
|
||||||
|
|
||||||
|
interface ContainersPageProps {
|
||||||
|
containers: ContainerStats[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ContainersPage({ containers }: ContainersPageProps) {
|
||||||
|
const running = containers.filter((c) => c.status.toLowerCase().includes("up")).length
|
||||||
|
const stopped = containers.length - running
|
||||||
|
|
||||||
|
const totals = useMemo(() => {
|
||||||
|
let cpu = 0
|
||||||
|
let mem = 0
|
||||||
|
let netIn = 0
|
||||||
|
let netOut = 0
|
||||||
|
for (const c of containers) {
|
||||||
|
cpu += c.cpuPercent
|
||||||
|
mem += c.memUsage
|
||||||
|
netIn += c.netInput
|
||||||
|
netOut += c.netOutput
|
||||||
|
}
|
||||||
|
return { cpu, mem, netIn, netOut }
|
||||||
|
}, [containers])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Summary cards */}
|
||||||
|
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
||||||
|
<Card>
|
||||||
|
<CardContent className="pt-6">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="flex size-10 items-center justify-center rounded-lg bg-green-100">
|
||||||
|
<Container className="size-5 text-green-600" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-sm text-muted-foreground">Running</p>
|
||||||
|
<p className="text-2xl font-bold">{running}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
<Card>
|
||||||
|
<CardContent className="pt-6">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="flex size-10 items-center justify-center rounded-lg bg-red-100">
|
||||||
|
<Container className="size-5 text-red-600" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-sm text-muted-foreground">Stopped</p>
|
||||||
|
<p className="text-2xl font-bold">{stopped}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
<Card>
|
||||||
|
<CardContent className="pt-6">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="flex size-10 items-center justify-center rounded-lg bg-blue-100">
|
||||||
|
<Cpu className="size-5 text-blue-600" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-sm text-muted-foreground">Total CPU</p>
|
||||||
|
<p className="text-2xl font-bold">{totals.cpu.toFixed(2)}%</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
<Card>
|
||||||
|
<CardContent className="pt-6">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="flex size-10 items-center justify-center rounded-lg bg-purple-100">
|
||||||
|
<MemoryStick className="size-5 text-purple-600" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-sm text-muted-foreground">Total Memory</p>
|
||||||
|
<p className="text-2xl font-bold">{formatBytes(totals.mem)}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Detailed table */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>All Containers</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead className="w-10">Status</TableHead>
|
||||||
|
<TableHead>Name</TableHead>
|
||||||
|
<TableHead>Image</TableHead>
|
||||||
|
<TableHead>Uptime</TableHead>
|
||||||
|
<TableHead className="text-right">CPU %</TableHead>
|
||||||
|
<TableHead className="text-right">Memory</TableHead>
|
||||||
|
<TableHead className="text-right">Mem %</TableHead>
|
||||||
|
<TableHead className="text-right">Net I/O</TableHead>
|
||||||
|
<TableHead className="text-right">Block I/O</TableHead>
|
||||||
|
<TableHead className="text-right">PIDs</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{containers.length === 0 && (
|
||||||
|
<TableRow>
|
||||||
|
<TableCell colSpan={10} className="text-center text-muted-foreground">
|
||||||
|
No containers found
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
)}
|
||||||
|
{containers.map((c) => {
|
||||||
|
const isRunning = c.status.toLowerCase().includes("up")
|
||||||
|
return (
|
||||||
|
<TableRow key={c.name}>
|
||||||
|
<TableCell>
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
"inline-flex size-2.5 rounded-full",
|
||||||
|
isRunning ? "bg-green-500" : "bg-red-500"
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="font-medium">{c.name}</TableCell>
|
||||||
|
<TableCell className="max-w-48 truncate text-xs text-muted-foreground">
|
||||||
|
{c.image}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-xs text-muted-foreground">{c.status}</TableCell>
|
||||||
|
<TableCell className="text-right font-mono text-sm">
|
||||||
|
{c.cpuPercent.toFixed(2)}%
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-right font-mono text-sm">
|
||||||
|
{formatBytes(c.memUsage)} / {formatBytes(c.memLimit)}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-right font-mono text-sm">
|
||||||
|
{c.memPercent.toFixed(1)}%
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-right font-mono text-sm">
|
||||||
|
{formatBytes(c.netInput)} / {formatBytes(c.netOutput)}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-right font-mono text-sm">
|
||||||
|
{formatBytes(c.blockInput)} / {formatBytes(c.blockOutput)}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-right font-mono text-sm">{c.pids}</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Network totals */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center gap-2">
|
||||||
|
<Network className="size-4 text-muted-foreground" />
|
||||||
|
Aggregate Network I/O
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div className="rounded-lg bg-muted/50 p-4 text-center">
|
||||||
|
<p className="text-sm text-muted-foreground">Total Inbound</p>
|
||||||
|
<p className="text-xl font-bold">{formatBytes(totals.netIn)}</p>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-lg bg-muted/50 p-4 text-center">
|
||||||
|
<p className="text-sm text-muted-foreground">Total Outbound</p>
|
||||||
|
<p className="text-xl font-bold">{formatBytes(totals.netOut)}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
111
client/src/components/pages/MemoryPage.tsx
Normal file
111
client/src/components/pages/MemoryPage.tsx
Normal file
@@ -0,0 +1,111 @@
|
|||||||
|
import type { MemoryStats } from "@/types/server"
|
||||||
|
import { formatBytes } from "@/lib/utils"
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
|
||||||
|
import { MemoryDonut } from "@/components/dashboard/MemoryDonut"
|
||||||
|
import { Progress } from "@/components/ui/progress"
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
interface MemoryPageProps {
|
||||||
|
memory: MemoryStats | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export function MemoryPage({ memory }: MemoryPageProps) {
|
||||||
|
if (!memory) {
|
||||||
|
return (
|
||||||
|
<div className="rounded-lg border bg-muted/50 p-8 text-center text-muted-foreground">
|
||||||
|
Memory data unavailable
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const actualUsed = Math.max(0, memory.total - memory.available)
|
||||||
|
const segments = [
|
||||||
|
{ label: "Used", value: actualUsed, color: "bg-amber-500" },
|
||||||
|
{ label: "Cached", value: memory.cached, color: "bg-blue-500" },
|
||||||
|
{ label: "Free", value: Math.max(0, memory.total - actualUsed - memory.cached), color: "bg-green-500" },
|
||||||
|
]
|
||||||
|
|
||||||
|
const pctUsed = memory.usedPercent
|
||||||
|
const pctColor = pctUsed >= 80 ? "text-red-600" : pctUsed >= 60 ? "text-amber-600" : "text-green-600"
|
||||||
|
const barColor = pctUsed >= 80
|
||||||
|
? "[&_[data-slot=progress-indicator]]:bg-red-500"
|
||||||
|
: pctUsed >= 60
|
||||||
|
? "[&_[data-slot=progress-indicator]]:bg-amber-500"
|
||||||
|
: "[&_[data-slot=progress-indicator]]:bg-green-500"
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Top summary cards */}
|
||||||
|
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
||||||
|
<Card>
|
||||||
|
<CardContent className="pt-6">
|
||||||
|
<p className="text-sm text-muted-foreground">Total</p>
|
||||||
|
<p className="text-2xl font-bold">{formatBytes(memory.total)}</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
<Card>
|
||||||
|
<CardContent className="pt-6">
|
||||||
|
<p className="text-sm text-muted-foreground">Used</p>
|
||||||
|
<p className="text-2xl font-bold">{formatBytes(memory.used)}</p>
|
||||||
|
<p className={cn("text-sm font-medium", pctColor)}>{pctUsed.toFixed(1)}%</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
<Card>
|
||||||
|
<CardContent className="pt-6">
|
||||||
|
<p className="text-sm text-muted-foreground">Available</p>
|
||||||
|
<p className="text-2xl font-bold">{formatBytes(memory.available)}</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
<Card>
|
||||||
|
<CardContent className="pt-6">
|
||||||
|
<p className="text-sm text-muted-foreground">Cached</p>
|
||||||
|
<p className="text-2xl font-bold">{formatBytes(memory.cached)}</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Chart + breakdown */}
|
||||||
|
<div className="grid grid-cols-1 gap-4 lg:grid-cols-2">
|
||||||
|
<MemoryDonut memory={memory} />
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Memory Breakdown</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-6">
|
||||||
|
{/* Overall progress */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex items-center justify-between text-sm">
|
||||||
|
<span className="text-muted-foreground">Overall Usage</span>
|
||||||
|
<span className={cn("font-semibold", pctColor)}>{pctUsed.toFixed(1)}%</span>
|
||||||
|
</div>
|
||||||
|
<Progress value={pctUsed} className={cn("h-3", barColor)} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Segment bars */}
|
||||||
|
{segments.map((seg) => {
|
||||||
|
const pct = memory.total > 0 ? (seg.value / memory.total) * 100 : 0
|
||||||
|
return (
|
||||||
|
<div key={seg.label} className="space-y-1.5">
|
||||||
|
<div className="flex items-center justify-between text-sm">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className={cn("inline-block size-3 rounded-full", seg.color)} />
|
||||||
|
<span className="text-muted-foreground">{seg.label}</span>
|
||||||
|
</div>
|
||||||
|
<span className="font-mono text-sm">{formatBytes(seg.value)} ({pct.toFixed(1)}%)</span>
|
||||||
|
</div>
|
||||||
|
<div className="h-2 w-full rounded-full bg-muted">
|
||||||
|
<div
|
||||||
|
className={cn("h-full rounded-full transition-all", seg.color)}
|
||||||
|
style={{ width: `${pct}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
177
client/src/components/pages/NetworkPage.tsx
Normal file
177
client/src/components/pages/NetworkPage.tsx
Normal file
@@ -0,0 +1,177 @@
|
|||||||
|
import { useMemo } from "react"
|
||||||
|
import type { ContainerStats, NginxStatus } from "@/types/server"
|
||||||
|
import { formatBytes, cn } from "@/lib/utils"
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
|
||||||
|
import {
|
||||||
|
Table,
|
||||||
|
TableBody,
|
||||||
|
TableCell,
|
||||||
|
TableHead,
|
||||||
|
TableHeader,
|
||||||
|
TableRow,
|
||||||
|
} from "@/components/ui/table"
|
||||||
|
import { ArrowDownToLine, ArrowUpFromLine, Globe, Shield } from "lucide-react"
|
||||||
|
|
||||||
|
interface NetworkPageProps {
|
||||||
|
containers: ContainerStats[]
|
||||||
|
nginx: NginxStatus | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export function NetworkPage({ containers, nginx }: NetworkPageProps) {
|
||||||
|
const totals = useMemo(() => {
|
||||||
|
let inbound = 0
|
||||||
|
let outbound = 0
|
||||||
|
for (const c of containers) {
|
||||||
|
inbound += c.netInput
|
||||||
|
outbound += c.netOutput
|
||||||
|
}
|
||||||
|
return { inbound, outbound, total: inbound + outbound }
|
||||||
|
}, [containers])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Summary cards */}
|
||||||
|
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
||||||
|
<Card>
|
||||||
|
<CardContent className="pt-6">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="flex size-10 items-center justify-center rounded-lg bg-green-100">
|
||||||
|
<ArrowDownToLine className="size-5 text-green-600" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-sm text-muted-foreground">Total Inbound</p>
|
||||||
|
<p className="text-2xl font-bold">{formatBytes(totals.inbound)}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
<Card>
|
||||||
|
<CardContent className="pt-6">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="flex size-10 items-center justify-center rounded-lg bg-blue-100">
|
||||||
|
<ArrowUpFromLine className="size-5 text-blue-600" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-sm text-muted-foreground">Total Outbound</p>
|
||||||
|
<p className="text-2xl font-bold">{formatBytes(totals.outbound)}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
<Card>
|
||||||
|
<CardContent className="pt-6">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="flex size-10 items-center justify-center rounded-lg bg-purple-100">
|
||||||
|
<Globe className="size-5 text-purple-600" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-sm text-muted-foreground">Total Traffic</p>
|
||||||
|
<p className="text-2xl font-bold">{formatBytes(totals.total)}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
<Card>
|
||||||
|
<CardContent className="pt-6">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="flex size-10 items-center justify-center rounded-lg bg-emerald-100">
|
||||||
|
<Shield className="size-5 text-emerald-600" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-sm text-muted-foreground">Nginx</p>
|
||||||
|
<p className="text-2xl font-bold capitalize">{nginx?.status ?? "Unknown"}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Nginx status */}
|
||||||
|
{nginx && (
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center gap-2">
|
||||||
|
<Shield className="size-4 text-muted-foreground" />
|
||||||
|
Nginx Reverse Proxy
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<span className="relative flex size-3">
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
"absolute inline-flex size-full animate-ping rounded-full opacity-75",
|
||||||
|
nginx.status === "active" ? "bg-green-400" : "bg-red-400"
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
"relative inline-flex size-3 rounded-full",
|
||||||
|
nginx.status === "active" ? "bg-green-500" : "bg-red-500"
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
"rounded-full px-3 py-1 text-sm font-medium",
|
||||||
|
nginx.status === "active"
|
||||||
|
? "bg-green-100 text-green-700"
|
||||||
|
: "bg-red-100 text-red-700"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{nginx.status === "active" ? "Active" : nginx.status === "inactive" ? "Inactive" : "Failed"}
|
||||||
|
</span>
|
||||||
|
<span className="text-sm text-muted-foreground">
|
||||||
|
Last checked: {new Date(nginx.checkedAt).toLocaleTimeString()}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Per-container network */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Network I/O by Container</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead>Container</TableHead>
|
||||||
|
<TableHead className="text-right">Inbound</TableHead>
|
||||||
|
<TableHead className="text-right">Outbound</TableHead>
|
||||||
|
<TableHead className="text-right">Total</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{containers.length === 0 && (
|
||||||
|
<TableRow>
|
||||||
|
<TableCell colSpan={4} className="text-center text-muted-foreground">
|
||||||
|
No containers found
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
)}
|
||||||
|
{[...containers]
|
||||||
|
.sort((a, b) => (b.netInput + b.netOutput) - (a.netInput + a.netOutput))
|
||||||
|
.map((c) => (
|
||||||
|
<TableRow key={c.name}>
|
||||||
|
<TableCell className="font-medium">{c.name}</TableCell>
|
||||||
|
<TableCell className="text-right font-mono text-sm">
|
||||||
|
{formatBytes(c.netInput)}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-right font-mono text-sm">
|
||||||
|
{formatBytes(c.netOutput)}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-right font-mono text-sm">
|
||||||
|
{formatBytes(c.netInput + c.netOutput)}
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
145
client/src/components/pages/StoragePage.tsx
Normal file
145
client/src/components/pages/StoragePage.tsx
Normal file
@@ -0,0 +1,145 @@
|
|||||||
|
import type { DiskStats } from "@/types/server"
|
||||||
|
import { formatBytes, cn } from "@/lib/utils"
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
|
||||||
|
import { Progress } from "@/components/ui/progress"
|
||||||
|
import { HardDrive } from "lucide-react"
|
||||||
|
|
||||||
|
interface StoragePageProps {
|
||||||
|
disk: DiskStats | null
|
||||||
|
}
|
||||||
|
|
||||||
|
const DONUT_RADIUS = 80
|
||||||
|
const DONUT_CIRCUMFERENCE = 2 * Math.PI * DONUT_RADIUS
|
||||||
|
|
||||||
|
export function StoragePage({ disk }: StoragePageProps) {
|
||||||
|
if (!disk) {
|
||||||
|
return (
|
||||||
|
<div className="rounded-lg border bg-muted/50 p-8 text-center text-muted-foreground">
|
||||||
|
Disk data unavailable
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const pct = disk.usedPercent
|
||||||
|
const pctColor = pct >= 80 ? "text-red-600" : pct >= 60 ? "text-amber-600" : "text-green-600"
|
||||||
|
const strokeColor = pct >= 80 ? "#ef4444" : pct >= 60 ? "#f59e0b" : "#22c55e"
|
||||||
|
const barColor = pct >= 80
|
||||||
|
? "[&_[data-slot=progress-indicator]]:bg-red-500"
|
||||||
|
: pct >= 60
|
||||||
|
? "[&_[data-slot=progress-indicator]]:bg-amber-500"
|
||||||
|
: "[&_[data-slot=progress-indicator]]:bg-green-500"
|
||||||
|
|
||||||
|
const usedDash = (pct / 100) * DONUT_CIRCUMFERENCE
|
||||||
|
const freeDash = DONUT_CIRCUMFERENCE - usedDash
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Summary cards */}
|
||||||
|
<div className="grid grid-cols-1 gap-4 sm:grid-cols-3">
|
||||||
|
<Card>
|
||||||
|
<CardContent className="pt-6">
|
||||||
|
<p className="text-sm text-muted-foreground">Total Capacity</p>
|
||||||
|
<p className="text-2xl font-bold">{formatBytes(disk.total)}</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
<Card>
|
||||||
|
<CardContent className="pt-6">
|
||||||
|
<p className="text-sm text-muted-foreground">Used</p>
|
||||||
|
<p className="text-2xl font-bold">{formatBytes(disk.used)}</p>
|
||||||
|
<p className={cn("text-sm font-medium", pctColor)}>{pct.toFixed(1)}%</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
<Card>
|
||||||
|
<CardContent className="pt-6">
|
||||||
|
<p className="text-sm text-muted-foreground">Free</p>
|
||||||
|
<p className="text-2xl font-bold">{formatBytes(disk.free)}</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Donut + details */}
|
||||||
|
<div className="grid grid-cols-1 gap-4 lg:grid-cols-2">
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center gap-2">
|
||||||
|
<HardDrive className="size-4 text-muted-foreground" />
|
||||||
|
Disk Usage
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="flex flex-col items-center">
|
||||||
|
<div className="relative">
|
||||||
|
<svg width="220" height="220" viewBox="0 0 220 220">
|
||||||
|
{/* Background circle */}
|
||||||
|
<circle
|
||||||
|
cx="110" cy="110" r={DONUT_RADIUS}
|
||||||
|
fill="none" stroke="#e5e7eb" strokeWidth="22"
|
||||||
|
/>
|
||||||
|
{/* Used arc */}
|
||||||
|
<circle
|
||||||
|
cx="110" cy="110" r={DONUT_RADIUS}
|
||||||
|
fill="none" stroke={strokeColor} strokeWidth="22"
|
||||||
|
strokeDasharray={`${usedDash} ${freeDash}`}
|
||||||
|
strokeDashoffset={0}
|
||||||
|
strokeLinecap="round"
|
||||||
|
transform="rotate(-90 110 110)"
|
||||||
|
style={{ transition: "stroke-dasharray 0.5s ease" }}
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
<div className="absolute inset-0 flex items-center justify-center">
|
||||||
|
<div className="text-center">
|
||||||
|
<p className={cn("text-3xl font-bold", pctColor)}>{pct.toFixed(0)}%</p>
|
||||||
|
<p className="text-xs text-muted-foreground">used</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-4 flex gap-6">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="inline-block size-3 rounded-full" style={{ backgroundColor: strokeColor }} />
|
||||||
|
<span className="text-sm text-muted-foreground">Used ({formatBytes(disk.used)})</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="inline-block size-3 rounded-full bg-gray-200" />
|
||||||
|
<span className="text-sm text-muted-foreground">Free ({formatBytes(disk.free)})</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Details</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-6">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex items-center justify-between text-sm">
|
||||||
|
<span className="text-muted-foreground">Usage</span>
|
||||||
|
<span className={cn("font-semibold", pctColor)}>{pct.toFixed(1)}%</span>
|
||||||
|
</div>
|
||||||
|
<Progress value={pct} className={cn("h-3", barColor)} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="flex justify-between rounded-lg bg-muted/50 p-3">
|
||||||
|
<span className="text-sm text-muted-foreground">Mount Point</span>
|
||||||
|
<span className="font-mono text-sm">{disk.mountPoint}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between rounded-lg bg-muted/50 p-3">
|
||||||
|
<span className="text-sm text-muted-foreground">Total</span>
|
||||||
|
<span className="font-mono text-sm">{formatBytes(disk.total)}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between rounded-lg bg-muted/50 p-3">
|
||||||
|
<span className="text-sm text-muted-foreground">Used</span>
|
||||||
|
<span className="font-mono text-sm">{formatBytes(disk.used)}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between rounded-lg bg-muted/50 p-3">
|
||||||
|
<span className="text-sm text-muted-foreground">Available</span>
|
||||||
|
<span className="font-mono text-sm">{formatBytes(disk.free)}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
48
client/src/components/ui/badge.tsx
Normal file
48
client/src/components/ui/badge.tsx
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
import { cva, type VariantProps } from "class-variance-authority"
|
||||||
|
import { Slot } from "radix-ui"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
const badgeVariants = cva(
|
||||||
|
"inline-flex w-fit shrink-0 items-center justify-center gap-1 overflow-hidden rounded-full border border-transparent px-2 py-0.5 text-xs font-medium whitespace-nowrap transition-[color,box-shadow] focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&>svg]:pointer-events-none [&>svg]:size-3",
|
||||||
|
{
|
||||||
|
variants: {
|
||||||
|
variant: {
|
||||||
|
default: "bg-primary text-primary-foreground [a&]:hover:bg-primary/90",
|
||||||
|
secondary:
|
||||||
|
"bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90",
|
||||||
|
destructive:
|
||||||
|
"bg-destructive text-white focus-visible:ring-destructive/20 dark:bg-destructive/60 dark:focus-visible:ring-destructive/40 [a&]:hover:bg-destructive/90",
|
||||||
|
outline:
|
||||||
|
"border-border text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground",
|
||||||
|
ghost: "[a&]:hover:bg-accent [a&]:hover:text-accent-foreground",
|
||||||
|
link: "text-primary underline-offset-4 [a&]:hover:underline",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
variant: "default",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
function Badge({
|
||||||
|
className,
|
||||||
|
variant = "default",
|
||||||
|
asChild = false,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"span"> &
|
||||||
|
VariantProps<typeof badgeVariants> & { asChild?: boolean }) {
|
||||||
|
const Comp = asChild ? Slot.Root : "span"
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Comp
|
||||||
|
data-slot="badge"
|
||||||
|
data-variant={variant}
|
||||||
|
className={cn(badgeVariants({ variant }), className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Badge, badgeVariants }
|
||||||
92
client/src/components/ui/card.tsx
Normal file
92
client/src/components/ui/card.tsx
Normal file
@@ -0,0 +1,92 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
function Card({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="card"
|
||||||
|
className={cn(
|
||||||
|
"flex flex-col gap-6 rounded-xl border bg-card py-6 text-card-foreground shadow-sm",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="card-header"
|
||||||
|
className={cn(
|
||||||
|
"@container/card-header grid auto-rows-min grid-rows-[auto_auto] items-start gap-2 px-6 has-data-[slot=card-action]:grid-cols-[1fr_auto] [.border-b]:pb-6",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="card-title"
|
||||||
|
className={cn("leading-none font-semibold", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="card-description"
|
||||||
|
className={cn("text-sm text-muted-foreground", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function CardAction({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="card-action"
|
||||||
|
className={cn(
|
||||||
|
"col-start-2 row-span-2 row-start-1 self-start justify-self-end",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function CardContent({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="card-content"
|
||||||
|
className={cn("px-6", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="card-footer"
|
||||||
|
className={cn("flex items-center px-6 [.border-t]:pt-6", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
Card,
|
||||||
|
CardHeader,
|
||||||
|
CardFooter,
|
||||||
|
CardTitle,
|
||||||
|
CardAction,
|
||||||
|
CardDescription,
|
||||||
|
CardContent,
|
||||||
|
}
|
||||||
31
client/src/components/ui/progress.tsx
Normal file
31
client/src/components/ui/progress.tsx
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import * as React from "react"
|
||||||
|
import { Progress as ProgressPrimitive } from "radix-ui"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
function Progress({
|
||||||
|
className,
|
||||||
|
value,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof ProgressPrimitive.Root>) {
|
||||||
|
return (
|
||||||
|
<ProgressPrimitive.Root
|
||||||
|
data-slot="progress"
|
||||||
|
className={cn(
|
||||||
|
"relative h-2 w-full overflow-hidden rounded-full bg-primary/20",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<ProgressPrimitive.Indicator
|
||||||
|
data-slot="progress-indicator"
|
||||||
|
className="h-full w-full flex-1 bg-primary transition-all"
|
||||||
|
style={{ transform: `translateX(-${100 - (value || 0)}%)` }}
|
||||||
|
/>
|
||||||
|
</ProgressPrimitive.Root>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Progress }
|
||||||
190
client/src/components/ui/select.tsx
Normal file
190
client/src/components/ui/select.tsx
Normal file
@@ -0,0 +1,190 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import * as React from "react"
|
||||||
|
import { CheckIcon, ChevronDownIcon, ChevronUpIcon } from "lucide-react"
|
||||||
|
import { Select as SelectPrimitive } from "radix-ui"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
function Select({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof SelectPrimitive.Root>) {
|
||||||
|
return <SelectPrimitive.Root data-slot="select" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function SelectGroup({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof SelectPrimitive.Group>) {
|
||||||
|
return <SelectPrimitive.Group data-slot="select-group" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function SelectValue({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof SelectPrimitive.Value>) {
|
||||||
|
return <SelectPrimitive.Value data-slot="select-value" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function SelectTrigger({
|
||||||
|
className,
|
||||||
|
size = "default",
|
||||||
|
children,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof SelectPrimitive.Trigger> & {
|
||||||
|
size?: "sm" | "default"
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<SelectPrimitive.Trigger
|
||||||
|
data-slot="select-trigger"
|
||||||
|
data-size={size}
|
||||||
|
className={cn(
|
||||||
|
"flex w-fit items-center justify-between gap-2 rounded-md border border-input bg-transparent px-3 py-2 text-sm whitespace-nowrap shadow-xs transition-[color,box-shadow] outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 data-[placeholder]:text-muted-foreground data-[size=default]:h-9 data-[size=sm]:h-8 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 dark:bg-input/30 dark:hover:bg-input/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 [&_svg:not([class*='text-'])]:text-muted-foreground",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
<SelectPrimitive.Icon asChild>
|
||||||
|
<ChevronDownIcon className="size-4 opacity-50" />
|
||||||
|
</SelectPrimitive.Icon>
|
||||||
|
</SelectPrimitive.Trigger>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SelectContent({
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
position = "item-aligned",
|
||||||
|
align = "center",
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof SelectPrimitive.Content>) {
|
||||||
|
return (
|
||||||
|
<SelectPrimitive.Portal>
|
||||||
|
<SelectPrimitive.Content
|
||||||
|
data-slot="select-content"
|
||||||
|
className={cn(
|
||||||
|
"relative z-50 max-h-(--radix-select-content-available-height) min-w-[8rem] origin-(--radix-select-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border bg-popover text-popover-foreground shadow-md data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95",
|
||||||
|
position === "popper" &&
|
||||||
|
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
position={position}
|
||||||
|
align={align}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<SelectScrollUpButton />
|
||||||
|
<SelectPrimitive.Viewport
|
||||||
|
className={cn(
|
||||||
|
"p-1",
|
||||||
|
position === "popper" &&
|
||||||
|
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)] scroll-my-1"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</SelectPrimitive.Viewport>
|
||||||
|
<SelectScrollDownButton />
|
||||||
|
</SelectPrimitive.Content>
|
||||||
|
</SelectPrimitive.Portal>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SelectLabel({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof SelectPrimitive.Label>) {
|
||||||
|
return (
|
||||||
|
<SelectPrimitive.Label
|
||||||
|
data-slot="select-label"
|
||||||
|
className={cn("px-2 py-1.5 text-xs text-muted-foreground", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SelectItem({
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof SelectPrimitive.Item>) {
|
||||||
|
return (
|
||||||
|
<SelectPrimitive.Item
|
||||||
|
data-slot="select-item"
|
||||||
|
className={cn(
|
||||||
|
"relative flex w-full cursor-default items-center gap-2 rounded-sm py-1.5 pr-8 pl-2 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 [&_svg:not([class*='text-'])]:text-muted-foreground *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
data-slot="select-item-indicator"
|
||||||
|
className="absolute right-2 flex size-3.5 items-center justify-center"
|
||||||
|
>
|
||||||
|
<SelectPrimitive.ItemIndicator>
|
||||||
|
<CheckIcon className="size-4" />
|
||||||
|
</SelectPrimitive.ItemIndicator>
|
||||||
|
</span>
|
||||||
|
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
|
||||||
|
</SelectPrimitive.Item>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SelectSeparator({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof SelectPrimitive.Separator>) {
|
||||||
|
return (
|
||||||
|
<SelectPrimitive.Separator
|
||||||
|
data-slot="select-separator"
|
||||||
|
className={cn("pointer-events-none -mx-1 my-1 h-px bg-border", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SelectScrollUpButton({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof SelectPrimitive.ScrollUpButton>) {
|
||||||
|
return (
|
||||||
|
<SelectPrimitive.ScrollUpButton
|
||||||
|
data-slot="select-scroll-up-button"
|
||||||
|
className={cn(
|
||||||
|
"flex cursor-default items-center justify-center py-1",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<ChevronUpIcon className="size-4" />
|
||||||
|
</SelectPrimitive.ScrollUpButton>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SelectScrollDownButton({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof SelectPrimitive.ScrollDownButton>) {
|
||||||
|
return (
|
||||||
|
<SelectPrimitive.ScrollDownButton
|
||||||
|
data-slot="select-scroll-down-button"
|
||||||
|
className={cn(
|
||||||
|
"flex cursor-default items-center justify-center py-1",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<ChevronDownIcon className="size-4" />
|
||||||
|
</SelectPrimitive.ScrollDownButton>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectGroup,
|
||||||
|
SelectItem,
|
||||||
|
SelectLabel,
|
||||||
|
SelectScrollDownButton,
|
||||||
|
SelectScrollUpButton,
|
||||||
|
SelectSeparator,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
}
|
||||||
26
client/src/components/ui/separator.tsx
Normal file
26
client/src/components/ui/separator.tsx
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
import { Separator as SeparatorPrimitive } from "radix-ui"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
function Separator({
|
||||||
|
className,
|
||||||
|
orientation = "horizontal",
|
||||||
|
decorative = true,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof SeparatorPrimitive.Root>) {
|
||||||
|
return (
|
||||||
|
<SeparatorPrimitive.Root
|
||||||
|
data-slot="separator"
|
||||||
|
decorative={decorative}
|
||||||
|
orientation={orientation}
|
||||||
|
className={cn(
|
||||||
|
"shrink-0 bg-border data-[orientation=horizontal]:h-px data-[orientation=horizontal]:w-full data-[orientation=vertical]:h-full data-[orientation=vertical]:w-px",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Separator }
|
||||||
114
client/src/components/ui/table.tsx
Normal file
114
client/src/components/ui/table.tsx
Normal file
@@ -0,0 +1,114 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
function Table({ className, ...props }: React.ComponentProps<"table">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="table-container"
|
||||||
|
className="relative w-full overflow-x-auto"
|
||||||
|
>
|
||||||
|
<table
|
||||||
|
data-slot="table"
|
||||||
|
className={cn("w-full caption-bottom text-sm", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function TableHeader({ className, ...props }: React.ComponentProps<"thead">) {
|
||||||
|
return (
|
||||||
|
<thead
|
||||||
|
data-slot="table-header"
|
||||||
|
className={cn("[&_tr]:border-b", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function TableBody({ className, ...props }: React.ComponentProps<"tbody">) {
|
||||||
|
return (
|
||||||
|
<tbody
|
||||||
|
data-slot="table-body"
|
||||||
|
className={cn("[&_tr:last-child]:border-0", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function TableFooter({ className, ...props }: React.ComponentProps<"tfoot">) {
|
||||||
|
return (
|
||||||
|
<tfoot
|
||||||
|
data-slot="table-footer"
|
||||||
|
className={cn(
|
||||||
|
"border-t bg-muted/50 font-medium [&>tr]:last:border-b-0",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function TableRow({ className, ...props }: React.ComponentProps<"tr">) {
|
||||||
|
return (
|
||||||
|
<tr
|
||||||
|
data-slot="table-row"
|
||||||
|
className={cn(
|
||||||
|
"border-b transition-colors hover:bg-muted/50 data-[state=selected]:bg-muted",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function TableHead({ className, ...props }: React.ComponentProps<"th">) {
|
||||||
|
return (
|
||||||
|
<th
|
||||||
|
data-slot="table-head"
|
||||||
|
className={cn(
|
||||||
|
"h-10 px-2 text-left align-middle font-medium whitespace-nowrap text-foreground [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function TableCell({ className, ...props }: React.ComponentProps<"td">) {
|
||||||
|
return (
|
||||||
|
<td
|
||||||
|
data-slot="table-cell"
|
||||||
|
className={cn(
|
||||||
|
"p-2 align-middle whitespace-nowrap [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function TableCaption({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"caption">) {
|
||||||
|
return (
|
||||||
|
<caption
|
||||||
|
data-slot="table-caption"
|
||||||
|
className={cn("mt-4 text-sm text-muted-foreground", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
Table,
|
||||||
|
TableHeader,
|
||||||
|
TableBody,
|
||||||
|
TableFooter,
|
||||||
|
TableHead,
|
||||||
|
TableRow,
|
||||||
|
TableCell,
|
||||||
|
TableCaption,
|
||||||
|
}
|
||||||
55
client/src/components/ui/tooltip.tsx
Normal file
55
client/src/components/ui/tooltip.tsx
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
import { Tooltip as TooltipPrimitive } from "radix-ui"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
function TooltipProvider({
|
||||||
|
delayDuration = 0,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof TooltipPrimitive.Provider>) {
|
||||||
|
return (
|
||||||
|
<TooltipPrimitive.Provider
|
||||||
|
data-slot="tooltip-provider"
|
||||||
|
delayDuration={delayDuration}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function Tooltip({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof TooltipPrimitive.Root>) {
|
||||||
|
return <TooltipPrimitive.Root data-slot="tooltip" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function TooltipTrigger({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof TooltipPrimitive.Trigger>) {
|
||||||
|
return <TooltipPrimitive.Trigger data-slot="tooltip-trigger" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function TooltipContent({
|
||||||
|
className,
|
||||||
|
sideOffset = 0,
|
||||||
|
children,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof TooltipPrimitive.Content>) {
|
||||||
|
return (
|
||||||
|
<TooltipPrimitive.Portal>
|
||||||
|
<TooltipPrimitive.Content
|
||||||
|
data-slot="tooltip-content"
|
||||||
|
sideOffset={sideOffset}
|
||||||
|
className={cn(
|
||||||
|
"z-50 w-fit origin-(--radix-tooltip-content-transform-origin) animate-in rounded-md bg-foreground px-3 py-1.5 text-xs text-balance text-background fade-in-0 zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
<TooltipPrimitive.Arrow className="z-50 size-2.5 translate-y-[calc(-50%_-_2px)] rotate-45 rounded-[2px] bg-foreground fill-foreground" />
|
||||||
|
</TooltipPrimitive.Content>
|
||||||
|
</TooltipPrimitive.Portal>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider }
|
||||||
26
client/src/hooks/useCpuHistory.ts
Normal file
26
client/src/hooks/useCpuHistory.ts
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
import { useCallback, useRef, useState } from "react"
|
||||||
|
|
||||||
|
export interface CpuDataPoint {
|
||||||
|
cpuPercent: number
|
||||||
|
timestamp: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const MAX_POINTS = 30
|
||||||
|
|
||||||
|
export function useCpuHistory() {
|
||||||
|
const historyRef = useRef<CpuDataPoint[]>([])
|
||||||
|
const [history, setHistory] = useState<CpuDataPoint[]>([])
|
||||||
|
const lastTimestampRef = useRef<string | null>(null)
|
||||||
|
|
||||||
|
const addDataPoint = useCallback((cpuPercent: number, timestamp: string) => {
|
||||||
|
if (timestamp === lastTimestampRef.current) return
|
||||||
|
lastTimestampRef.current = timestamp
|
||||||
|
|
||||||
|
const next = [...historyRef.current, { cpuPercent, timestamp }]
|
||||||
|
if (next.length > MAX_POINTS) next.shift()
|
||||||
|
historyRef.current = next
|
||||||
|
setHistory(next)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
return { history, addDataPoint }
|
||||||
|
}
|
||||||
49
client/src/hooks/useServerHealth.ts
Normal file
49
client/src/hooks/useServerHealth.ts
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
import { useQuery } from "@tanstack/react-query"
|
||||||
|
import { useState } from "react"
|
||||||
|
import type { ServerOverview } from "@/types/server"
|
||||||
|
|
||||||
|
const STORAGE_KEY = "server-monitor-refresh-interval"
|
||||||
|
|
||||||
|
function getStoredInterval(): number {
|
||||||
|
try {
|
||||||
|
const stored = localStorage.getItem(STORAGE_KEY)
|
||||||
|
if (stored) {
|
||||||
|
const parsed = parseInt(stored, 10)
|
||||||
|
if ([5000, 10000, 30000, 60000].includes(parsed)) return parsed
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// localStorage unavailable
|
||||||
|
}
|
||||||
|
return 10000
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useServerHealth() {
|
||||||
|
const [refetchInterval, setRefetchInterval] = useState<number>(getStoredInterval)
|
||||||
|
|
||||||
|
const query = useQuery<ServerOverview>({
|
||||||
|
queryKey: ["server-overview"],
|
||||||
|
queryFn: async () => {
|
||||||
|
const res = await fetch("/api/overview")
|
||||||
|
if (!res.ok) throw new Error(`Server responded with ${res.status}`)
|
||||||
|
return res.json()
|
||||||
|
},
|
||||||
|
refetchInterval,
|
||||||
|
refetchIntervalInBackground: true,
|
||||||
|
staleTime: 0,
|
||||||
|
})
|
||||||
|
|
||||||
|
const updateInterval = (ms: number) => {
|
||||||
|
setRefetchInterval(ms)
|
||||||
|
try {
|
||||||
|
localStorage.setItem(STORAGE_KEY, String(ms))
|
||||||
|
} catch {
|
||||||
|
// localStorage unavailable
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
...query,
|
||||||
|
refetchInterval,
|
||||||
|
setRefetchInterval: updateInterval,
|
||||||
|
}
|
||||||
|
}
|
||||||
154
client/src/index.css
Normal file
154
client/src/index.css
Normal file
@@ -0,0 +1,154 @@
|
|||||||
|
@import "tailwindcss";
|
||||||
|
@import "tailwindcss/theme" theme(static);
|
||||||
|
|
||||||
|
/* Gilroy Font Family */
|
||||||
|
@font-face {
|
||||||
|
font-family: "Gilroy";
|
||||||
|
src: url("/fonts/Gilroy-Regular.ttf") format("truetype");
|
||||||
|
font-weight: 400;
|
||||||
|
font-style: normal;
|
||||||
|
font-display: swap;
|
||||||
|
}
|
||||||
|
|
||||||
|
@font-face {
|
||||||
|
font-family: "Gilroy";
|
||||||
|
src: url("/fonts/Gilroy-Medium.ttf") format("truetype");
|
||||||
|
font-weight: 500;
|
||||||
|
font-style: normal;
|
||||||
|
font-display: swap;
|
||||||
|
}
|
||||||
|
|
||||||
|
@font-face {
|
||||||
|
font-family: "Gilroy";
|
||||||
|
src: url("/fonts/Gilroy-SemiBold.ttf") format("truetype");
|
||||||
|
font-weight: 600;
|
||||||
|
font-style: normal;
|
||||||
|
font-display: swap;
|
||||||
|
}
|
||||||
|
|
||||||
|
@font-face {
|
||||||
|
font-family: "Gilroy";
|
||||||
|
src: url("/fonts/Gilroy-Bold.ttf") format("truetype");
|
||||||
|
font-weight: 700;
|
||||||
|
font-style: normal;
|
||||||
|
font-display: swap;
|
||||||
|
}
|
||||||
|
|
||||||
|
@font-face {
|
||||||
|
font-family: "Gilroy";
|
||||||
|
src: url("/fonts/Gilroy-ExtraBold.ttf") format("truetype");
|
||||||
|
font-weight: 800;
|
||||||
|
font-style: normal;
|
||||||
|
font-display: swap;
|
||||||
|
}
|
||||||
|
|
||||||
|
@custom-variant dark (&:is(.dark *));
|
||||||
|
|
||||||
|
:root {
|
||||||
|
--radius: 0.75rem;
|
||||||
|
--font-sans: "Gilroy", system-ui, -apple-system, sans-serif;
|
||||||
|
|
||||||
|
--background: oklch(0.985 0.002 247);
|
||||||
|
--foreground: oklch(0.145 0.015 260);
|
||||||
|
--card: oklch(1 0 0);
|
||||||
|
--card-foreground: oklch(0.145 0.015 260);
|
||||||
|
--popover: oklch(1 0 0);
|
||||||
|
--popover-foreground: oklch(0.145 0.015 260);
|
||||||
|
--primary: oklch(0.55 0.18 255);
|
||||||
|
--primary-foreground: oklch(0.985 0.002 247);
|
||||||
|
--secondary: oklch(0.965 0.002 247);
|
||||||
|
--secondary-foreground: oklch(0.205 0.015 260);
|
||||||
|
--muted: oklch(0.965 0.002 247);
|
||||||
|
--muted-foreground: oklch(0.525 0.015 260);
|
||||||
|
--accent: oklch(0.965 0.002 247);
|
||||||
|
--accent-foreground: oklch(0.205 0.015 260);
|
||||||
|
--destructive: oklch(0.577 0.245 27.325);
|
||||||
|
--destructive-foreground: oklch(0.577 0.245 27.325);
|
||||||
|
--border: oklch(0.922 0.004 247);
|
||||||
|
--input: oklch(0.922 0.004 247);
|
||||||
|
--ring: oklch(0.55 0.18 255);
|
||||||
|
--chart-1: oklch(0.55 0.18 255);
|
||||||
|
--chart-2: oklch(0.6 0.2 145);
|
||||||
|
--chart-3: oklch(0.7 0.15 75);
|
||||||
|
--chart-4: oklch(0.6 0.2 300);
|
||||||
|
--chart-5: oklch(0.65 0.15 200);
|
||||||
|
|
||||||
|
--sidebar-background: oklch(1 0 0);
|
||||||
|
--sidebar-foreground: oklch(0.525 0.015 260);
|
||||||
|
--sidebar-primary: oklch(0.55 0.18 255);
|
||||||
|
--sidebar-primary-foreground: oklch(1 0 0);
|
||||||
|
--sidebar-accent: oklch(0.955 0.015 255);
|
||||||
|
--sidebar-accent-foreground: oklch(0.55 0.18 255);
|
||||||
|
--sidebar-border: oklch(0.922 0.004 247);
|
||||||
|
|
||||||
|
--status-healthy: oklch(0.6 0.2 145);
|
||||||
|
--status-warning: oklch(0.75 0.15 75);
|
||||||
|
--status-critical: oklch(0.577 0.245 27);
|
||||||
|
}
|
||||||
|
|
||||||
|
@theme inline {
|
||||||
|
--color-background: var(--background);
|
||||||
|
--color-foreground: var(--foreground);
|
||||||
|
--color-card: var(--card);
|
||||||
|
--color-card-foreground: var(--card-foreground);
|
||||||
|
--color-popover: var(--popover);
|
||||||
|
--color-popover-foreground: var(--popover-foreground);
|
||||||
|
--color-primary: var(--primary);
|
||||||
|
--color-primary-foreground: var(--primary-foreground);
|
||||||
|
--color-secondary: var(--secondary);
|
||||||
|
--color-secondary-foreground: var(--secondary-foreground);
|
||||||
|
--color-muted: var(--muted);
|
||||||
|
--color-muted-foreground: var(--muted-foreground);
|
||||||
|
--color-accent: var(--accent);
|
||||||
|
--color-accent-foreground: var(--accent-foreground);
|
||||||
|
--color-destructive: var(--destructive);
|
||||||
|
--color-destructive-foreground: var(--destructive-foreground);
|
||||||
|
--color-border: var(--border);
|
||||||
|
--color-input: var(--input);
|
||||||
|
--color-ring: var(--ring);
|
||||||
|
--color-chart-1: var(--chart-1);
|
||||||
|
--color-chart-2: var(--chart-2);
|
||||||
|
--color-chart-3: var(--chart-3);
|
||||||
|
--color-chart-4: var(--chart-4);
|
||||||
|
--color-chart-5: var(--chart-5);
|
||||||
|
--color-sidebar-background: var(--sidebar-background);
|
||||||
|
--color-sidebar-foreground: var(--sidebar-foreground);
|
||||||
|
--color-sidebar-primary: var(--sidebar-primary);
|
||||||
|
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
|
||||||
|
--color-sidebar-accent: var(--sidebar-accent);
|
||||||
|
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
|
||||||
|
--color-sidebar-border: var(--sidebar-border);
|
||||||
|
--color-status-healthy: var(--status-healthy);
|
||||||
|
--color-status-warning: var(--status-warning);
|
||||||
|
--color-status-critical: var(--status-critical);
|
||||||
|
--radius-sm: calc(var(--radius) - 4px);
|
||||||
|
--radius-md: calc(var(--radius) - 2px);
|
||||||
|
--radius-lg: var(--radius);
|
||||||
|
--radius-xl: calc(var(--radius) + 4px);
|
||||||
|
--font-sans: var(--font-sans);
|
||||||
|
}
|
||||||
|
|
||||||
|
* {
|
||||||
|
border-color: var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
font-family: var(--font-sans);
|
||||||
|
background: var(--background);
|
||||||
|
color: var(--foreground);
|
||||||
|
margin: 0;
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
-moz-osx-font-smoothing: grayscale;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Respect reduced motion preference */
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
*,
|
||||||
|
*::before,
|
||||||
|
*::after {
|
||||||
|
animation-duration: 0.01ms !important;
|
||||||
|
animation-iteration-count: 1 !important;
|
||||||
|
transition-duration: 0.01ms !important;
|
||||||
|
scroll-behavior: auto !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
18
client/src/lib/utils.ts
Normal file
18
client/src/lib/utils.ts
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
import { clsx, type ClassValue } from "clsx"
|
||||||
|
import { twMerge } from "tailwind-merge"
|
||||||
|
|
||||||
|
export function cn(...inputs: ClassValue[]) {
|
||||||
|
return twMerge(clsx(inputs))
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatBytes(bytes: number, decimals = 1): string {
|
||||||
|
if (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(decimals)) + " " + sizes[i]
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatUptime(uptimeStr: string): string {
|
||||||
|
return uptimeStr.replace("up ", "").trim()
|
||||||
|
}
|
||||||
10
client/src/main.tsx
Normal file
10
client/src/main.tsx
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
import { StrictMode } from "react"
|
||||||
|
import { createRoot } from "react-dom/client"
|
||||||
|
import App from "./App"
|
||||||
|
import "./index.css"
|
||||||
|
|
||||||
|
createRoot(document.getElementById("root")!).render(
|
||||||
|
<StrictMode>
|
||||||
|
<App />
|
||||||
|
</StrictMode>
|
||||||
|
)
|
||||||
54
client/src/types/server.ts
Normal file
54
client/src/types/server.ts
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
export interface SystemHealth {
|
||||||
|
hostname: string
|
||||||
|
uptime: string
|
||||||
|
loadAvg: [number, number, number]
|
||||||
|
cpuPercent: number
|
||||||
|
os: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MemoryStats {
|
||||||
|
total: number
|
||||||
|
used: number
|
||||||
|
free: number
|
||||||
|
cached: number
|
||||||
|
available: number
|
||||||
|
usedPercent: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DiskStats {
|
||||||
|
total: number
|
||||||
|
used: number
|
||||||
|
free: number
|
||||||
|
usedPercent: number
|
||||||
|
mountPoint: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ContainerStats {
|
||||||
|
name: string
|
||||||
|
status: string
|
||||||
|
image: string
|
||||||
|
cpuPercent: number
|
||||||
|
memUsage: number
|
||||||
|
memLimit: number
|
||||||
|
memPercent: number
|
||||||
|
netInput: number
|
||||||
|
netOutput: number
|
||||||
|
blockInput: number
|
||||||
|
blockOutput: number
|
||||||
|
pids: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface NginxStatus {
|
||||||
|
status: "active" | "inactive" | "failed"
|
||||||
|
checkedAt: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ServerOverview {
|
||||||
|
system: SystemHealth | null
|
||||||
|
memory: MemoryStats | null
|
||||||
|
disk: DiskStats | null
|
||||||
|
docker: ContainerStats[] | null
|
||||||
|
nginx: NginxStatus | null
|
||||||
|
errors: Record<string, string>
|
||||||
|
timestamp: string
|
||||||
|
}
|
||||||
31
client/tsconfig.json
Normal file
31
client/tsconfig.json
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2023",
|
||||||
|
"useDefineForClassFields": true,
|
||||||
|
"module": "ESNext",
|
||||||
|
"lib": ["ES2023", "DOM", "DOM.Iterable"],
|
||||||
|
"types": ["vite/client"],
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
|
||||||
|
/* Bundler mode */
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"allowImportingTsExtensions": true,
|
||||||
|
"verbatimModuleSyntax": true,
|
||||||
|
"moduleDetection": "force",
|
||||||
|
"noEmit": true,
|
||||||
|
|
||||||
|
/* Linting */
|
||||||
|
"strict": true,
|
||||||
|
"noUnusedLocals": true,
|
||||||
|
"noUnusedParameters": true,
|
||||||
|
"erasableSyntaxOnly": true,
|
||||||
|
"noFallthroughCasesInSwitch": true,
|
||||||
|
"noUncheckedSideEffectImports": true,
|
||||||
|
"baseUrl": ".",
|
||||||
|
"paths": {
|
||||||
|
"@/*": ["./src/*"]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"include": ["src"]
|
||||||
|
}
|
||||||
22
client/vite.config.ts
Normal file
22
client/vite.config.ts
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
import path from "path"
|
||||||
|
import tailwindcss from "@tailwindcss/vite"
|
||||||
|
import react from "@vitejs/plugin-react-swc"
|
||||||
|
import { defineConfig } from "vite"
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [react(), tailwindcss()],
|
||||||
|
resolve: {
|
||||||
|
alias: {
|
||||||
|
"@": path.resolve(__dirname, "./src"),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
server: {
|
||||||
|
port: 5174,
|
||||||
|
proxy: {
|
||||||
|
"/api": {
|
||||||
|
target: "http://localhost:3002",
|
||||||
|
changeOrigin: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
5330
package-lock.json
generated
Normal file
5330
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
15
package.json
Normal file
15
package.json
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
{
|
||||||
|
"name": "eventify-server-monitor",
|
||||||
|
"private": true,
|
||||||
|
"workspaces": ["client", "server"],
|
||||||
|
"scripts": {
|
||||||
|
"dev": "concurrently \"npm run dev:server\" \"npm run dev:client\"",
|
||||||
|
"dev:server": "npm run dev -w server",
|
||||||
|
"dev:client": "npm run dev -w client",
|
||||||
|
"build": "npm run build -w client",
|
||||||
|
"start": "npm run start -w server"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"concurrently": "^9.1.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
23
server/package.json
Normal file
23
server/package.json
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
{
|
||||||
|
"name": "server",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "tsx watch src/index.ts",
|
||||||
|
"start": "tsx src/index.ts"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"express": "^4.21.0",
|
||||||
|
"ssh2": "^1.16.0",
|
||||||
|
"cors": "^2.8.5",
|
||||||
|
"dotenv": "^16.4.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/express": "^5.0.0",
|
||||||
|
"@types/ssh2": "^1.15.0",
|
||||||
|
"@types/cors": "^2.8.17",
|
||||||
|
"typescript": "^5.8.0",
|
||||||
|
"tsx": "^4.19.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
21
server/src/config.ts
Normal file
21
server/src/config.ts
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
import { homedir } from 'os';
|
||||||
|
import { resolve } from 'path';
|
||||||
|
import 'dotenv/config';
|
||||||
|
|
||||||
|
function resolveHome(filepath: string): string {
|
||||||
|
if (filepath.startsWith('~')) {
|
||||||
|
return resolve(homedir(), filepath.slice(2));
|
||||||
|
}
|
||||||
|
return resolve(filepath);
|
||||||
|
}
|
||||||
|
|
||||||
|
export const config = {
|
||||||
|
ssh: {
|
||||||
|
host: process.env.SSH_HOST ?? 'ec2-174-129-72-160.compute-1.amazonaws.com',
|
||||||
|
user: process.env.SSH_USER ?? 'ubuntu',
|
||||||
|
keyPath: resolveHome(
|
||||||
|
process.env.SSH_KEY_PATH ?? '~/.ssh/eventify_keys_21_03_2026.pem'
|
||||||
|
),
|
||||||
|
},
|
||||||
|
port: parseInt(process.env.PORT ?? '3002', 10),
|
||||||
|
} as const;
|
||||||
57
server/src/index.ts
Normal file
57
server/src/index.ts
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
import express from 'express';
|
||||||
|
import cors from 'cors';
|
||||||
|
import { fileURLToPath } from 'url';
|
||||||
|
import { dirname, resolve } from 'path';
|
||||||
|
import { existsSync } from 'fs';
|
||||||
|
import { config } from './config.js';
|
||||||
|
import { healthRouter } from './routes/health.js';
|
||||||
|
import { sshManager } from './ssh/client.js';
|
||||||
|
|
||||||
|
const __filename = fileURLToPath(import.meta.url);
|
||||||
|
const __dirname = dirname(__filename);
|
||||||
|
|
||||||
|
const app = express();
|
||||||
|
|
||||||
|
// ── Middleware ──
|
||||||
|
app.use(
|
||||||
|
cors({
|
||||||
|
origin: ['http://localhost:5173', 'http://localhost:5174'],
|
||||||
|
credentials: true,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
app.use(express.json());
|
||||||
|
|
||||||
|
// ── API Routes ──
|
||||||
|
app.use('/api', healthRouter);
|
||||||
|
|
||||||
|
// ── Static file serving for production SPA ──
|
||||||
|
const clientDistPath = resolve(__dirname, '../../client/dist');
|
||||||
|
if (existsSync(clientDistPath)) {
|
||||||
|
app.use(express.static(clientDistPath));
|
||||||
|
|
||||||
|
// SPA fallback: serve index.html for any non-API route
|
||||||
|
app.get('*', (_req, res) => {
|
||||||
|
res.sendFile(resolve(clientDistPath, 'index.html'));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Start server ──
|
||||||
|
const server = app.listen(config.port, () => {
|
||||||
|
console.log(`[Server] Listening on http://localhost:${config.port}`);
|
||||||
|
console.log(`[Server] SSH target: ${config.ssh.user}@${config.ssh.host}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Graceful shutdown ──
|
||||||
|
async function shutdown(signal: string) {
|
||||||
|
console.log(`\n[Server] ${signal} received, shutting down gracefully...`);
|
||||||
|
|
||||||
|
server.close(() => {
|
||||||
|
console.log('[Server] HTTP server closed');
|
||||||
|
});
|
||||||
|
|
||||||
|
await sshManager.disconnect();
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
process.on('SIGINT', () => shutdown('SIGINT'));
|
||||||
|
process.on('SIGTERM', () => shutdown('SIGTERM'));
|
||||||
95
server/src/routes/health.ts
Normal file
95
server/src/routes/health.ts
Normal file
@@ -0,0 +1,95 @@
|
|||||||
|
import { Router } from 'express';
|
||||||
|
import { getSystemInfo } from '../services/system.js';
|
||||||
|
import { getMemoryInfo } from '../services/memory.js';
|
||||||
|
import { getDiskInfo } from '../services/disk.js';
|
||||||
|
import { getDockerInfo } from '../services/docker.js';
|
||||||
|
import { getNginxStatus } from '../services/nginx.js';
|
||||||
|
import type { OverviewResponse } from '../types/index.js';
|
||||||
|
|
||||||
|
export const healthRouter = Router();
|
||||||
|
|
||||||
|
// GET /api/overview - all metrics collected sequentially to avoid SSH channel exhaustion
|
||||||
|
healthRouter.get('/overview', async (_req, res) => {
|
||||||
|
try {
|
||||||
|
const errors: Record<string, string> = {};
|
||||||
|
|
||||||
|
const safe = async <T>(name: string, fn: () => Promise<T>): Promise<T | null> => {
|
||||||
|
try {
|
||||||
|
return await fn();
|
||||||
|
} catch (err) {
|
||||||
|
errors[name] = (err as Error).message ?? 'Unknown error';
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const system = await safe('system', getSystemInfo);
|
||||||
|
const memory = await safe('memory', getMemoryInfo);
|
||||||
|
const disk = await safe('disk', getDiskInfo);
|
||||||
|
const docker = await safe('docker', getDockerInfo);
|
||||||
|
const nginx = await safe('nginx', getNginxStatus);
|
||||||
|
|
||||||
|
const response: OverviewResponse = {
|
||||||
|
system,
|
||||||
|
memory,
|
||||||
|
disk,
|
||||||
|
docker,
|
||||||
|
nginx,
|
||||||
|
errors,
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
|
||||||
|
res.json(response);
|
||||||
|
} catch (err) {
|
||||||
|
res.status(500).json({ error: 'Failed to fetch server overview', details: (err as Error).message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// GET /api/system
|
||||||
|
healthRouter.get('/system', async (_req, res) => {
|
||||||
|
try {
|
||||||
|
const data = await getSystemInfo();
|
||||||
|
res.json(data);
|
||||||
|
} catch (err) {
|
||||||
|
res.status(500).json({ error: 'Failed to fetch system info', details: (err as Error).message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// GET /api/memory
|
||||||
|
healthRouter.get('/memory', async (_req, res) => {
|
||||||
|
try {
|
||||||
|
const data = await getMemoryInfo();
|
||||||
|
res.json(data);
|
||||||
|
} catch (err) {
|
||||||
|
res.status(500).json({ error: 'Failed to fetch memory info', details: (err as Error).message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// GET /api/disk
|
||||||
|
healthRouter.get('/disk', async (_req, res) => {
|
||||||
|
try {
|
||||||
|
const data = await getDiskInfo();
|
||||||
|
res.json(data);
|
||||||
|
} catch (err) {
|
||||||
|
res.status(500).json({ error: 'Failed to fetch disk info', details: (err as Error).message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// GET /api/docker
|
||||||
|
healthRouter.get('/docker', async (_req, res) => {
|
||||||
|
try {
|
||||||
|
const data = await getDockerInfo();
|
||||||
|
res.json(data);
|
||||||
|
} catch (err) {
|
||||||
|
res.status(500).json({ error: 'Failed to fetch docker info', details: (err as Error).message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// GET /api/nginx
|
||||||
|
healthRouter.get('/nginx', async (_req, res) => {
|
||||||
|
try {
|
||||||
|
const data = await getNginxStatus();
|
||||||
|
res.json(data);
|
||||||
|
} catch (err) {
|
||||||
|
res.status(500).json({ error: 'Failed to fetch nginx status', details: (err as Error).message });
|
||||||
|
}
|
||||||
|
});
|
||||||
28
server/src/services/disk.ts
Normal file
28
server/src/services/disk.ts
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
import { sshManager } from '../ssh/client.js';
|
||||||
|
import type { DiskInfo } from '../types/index.js';
|
||||||
|
|
||||||
|
export async function getDiskInfo(): Promise<DiskInfo> {
|
||||||
|
const output = await sshManager.execCommand('df -B1 /');
|
||||||
|
|
||||||
|
// Example output:
|
||||||
|
// Filesystem 1B-blocks Used Available Use% Mounted on
|
||||||
|
// /dev/xvda1 32212254720 18345678912 13866575808 57% /
|
||||||
|
|
||||||
|
const lines = output.split('\n');
|
||||||
|
const dataLine = lines.find((l) => l.startsWith('/'));
|
||||||
|
|
||||||
|
if (!dataLine) {
|
||||||
|
return { total: 0, used: 0, free: 0, usedPercent: 0, mountPoint: '/' };
|
||||||
|
}
|
||||||
|
|
||||||
|
const parts = dataLine.trim().split(/\s+/);
|
||||||
|
// parts: [filesystem, 1B-blocks, used, available, use%, mounted-on]
|
||||||
|
|
||||||
|
const total = parseInt(parts[1], 10) || 0;
|
||||||
|
const used = parseInt(parts[2], 10) || 0;
|
||||||
|
const free = parseInt(parts[3], 10) || 0;
|
||||||
|
const usedPercent = parseFloat(parts[4]?.replace('%', '') ?? '0') || 0;
|
||||||
|
const mountPoint = parts[5] ?? '/';
|
||||||
|
|
||||||
|
return { total, used, free, usedPercent, mountPoint };
|
||||||
|
}
|
||||||
92
server/src/services/docker.ts
Normal file
92
server/src/services/docker.ts
Normal file
@@ -0,0 +1,92 @@
|
|||||||
|
import { sshManager } from '../ssh/client.js';
|
||||||
|
import type { DockerContainer } from '../types/index.js';
|
||||||
|
|
||||||
|
function parseByteString(str: string): number {
|
||||||
|
const cleaned = str.trim();
|
||||||
|
const match = cleaned.match(/^([\d.]+)\s*([A-Za-z]+)$/);
|
||||||
|
if (!match) return 0;
|
||||||
|
|
||||||
|
const value = parseFloat(match[1]);
|
||||||
|
const unit = match[2].toLowerCase();
|
||||||
|
|
||||||
|
const multipliers: Record<string, number> = {
|
||||||
|
b: 1,
|
||||||
|
kb: 1000,
|
||||||
|
mb: 1_000_000,
|
||||||
|
gb: 1_000_000_000,
|
||||||
|
tb: 1_000_000_000_000,
|
||||||
|
kib: 1024,
|
||||||
|
mib: 1024 ** 2,
|
||||||
|
gib: 1024 ** 3,
|
||||||
|
tib: 1024 ** 4,
|
||||||
|
};
|
||||||
|
|
||||||
|
return Math.round(value * (multipliers[unit] ?? 0));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getDockerInfo(): Promise<DockerContainer[]> {
|
||||||
|
// Run both commands in a single SSH channel
|
||||||
|
const output = await sshManager.execCommand(
|
||||||
|
"docker stats --no-stream --format '{{.Name}}\t{{.CPUPerc}}\t{{.MemUsage}}\t{{.MemPerc}}\t{{.NetIO}}\t{{.BlockIO}}\t{{.PIDs}}' && echo '---DOCKER_SEP---' && docker ps --format '{{.Names}}\t{{.Status}}\t{{.Image}}'"
|
||||||
|
);
|
||||||
|
|
||||||
|
const [statsOutput, psOutput] = output.split('---DOCKER_SEP---').map((s) => s.trim());
|
||||||
|
|
||||||
|
// Build lookup from docker ps output
|
||||||
|
const psMap = new Map<string, { status: string; image: string }>();
|
||||||
|
if (psOutput) {
|
||||||
|
for (const line of psOutput.split('\n')) {
|
||||||
|
const trimmed = line.trim();
|
||||||
|
if (!trimmed) continue;
|
||||||
|
const [name, status, image] = trimmed.split('\t');
|
||||||
|
if (name) {
|
||||||
|
psMap.set(name, { status: status ?? 'unknown', image: image ?? 'unknown' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const containers: DockerContainer[] = [];
|
||||||
|
|
||||||
|
if (statsOutput) {
|
||||||
|
for (const line of statsOutput.split('\n')) {
|
||||||
|
const trimmed = line.trim();
|
||||||
|
if (!trimmed) continue;
|
||||||
|
|
||||||
|
const parts = trimmed.split('\t');
|
||||||
|
if (parts.length < 7) continue;
|
||||||
|
|
||||||
|
const [name, cpuPerc, memUsage, memPerc, netIO, blockIO, pids] = parts;
|
||||||
|
|
||||||
|
const memParts = memUsage.split('/').map((s) => s.trim());
|
||||||
|
const memUsageBytes = parseByteString(memParts[0] ?? '0B');
|
||||||
|
const memLimitBytes = parseByteString(memParts[1] ?? '0B');
|
||||||
|
|
||||||
|
const netParts = netIO.split('/').map((s) => s.trim());
|
||||||
|
const netInput = parseByteString(netParts[0] ?? '0B');
|
||||||
|
const netOutputBytes = parseByteString(netParts[1] ?? '0B');
|
||||||
|
|
||||||
|
const blockParts = blockIO.split('/').map((s) => s.trim());
|
||||||
|
const blockInput = parseByteString(blockParts[0] ?? '0B');
|
||||||
|
const blockOutputBytes = parseByteString(blockParts[1] ?? '0B');
|
||||||
|
|
||||||
|
const psInfo = psMap.get(name) ?? { status: 'unknown', image: 'unknown' };
|
||||||
|
|
||||||
|
containers.push({
|
||||||
|
name,
|
||||||
|
status: psInfo.status,
|
||||||
|
image: psInfo.image,
|
||||||
|
cpuPercent: parseFloat(cpuPerc.replace('%', '')) || 0,
|
||||||
|
memUsage: memUsageBytes,
|
||||||
|
memLimit: memLimitBytes,
|
||||||
|
memPercent: parseFloat(memPerc.replace('%', '')) || 0,
|
||||||
|
netInput,
|
||||||
|
netOutput: netOutputBytes,
|
||||||
|
blockInput,
|
||||||
|
blockOutput: blockOutputBytes,
|
||||||
|
pids: parseInt(pids, 10) || 0,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return containers;
|
||||||
|
}
|
||||||
30
server/src/services/memory.ts
Normal file
30
server/src/services/memory.ts
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
import { sshManager } from '../ssh/client.js';
|
||||||
|
import type { MemoryInfo } from '../types/index.js';
|
||||||
|
|
||||||
|
export async function getMemoryInfo(): Promise<MemoryInfo> {
|
||||||
|
const output = await sshManager.execCommand('free -b');
|
||||||
|
|
||||||
|
// Example output:
|
||||||
|
// total used free shared buff/cache available
|
||||||
|
// Mem: 16508469248 3456789504 8765432320 123456789 4286247424 12345678848
|
||||||
|
// Swap: 2147483648 0 2147483648
|
||||||
|
|
||||||
|
const lines = output.split('\n');
|
||||||
|
const memLine = lines.find((l) => l.startsWith('Mem:'));
|
||||||
|
|
||||||
|
if (!memLine) {
|
||||||
|
return { total: 0, used: 0, free: 0, cached: 0, available: 0, usedPercent: 0 };
|
||||||
|
}
|
||||||
|
|
||||||
|
const parts = memLine.trim().split(/\s+/);
|
||||||
|
// parts: ["Mem:", total, used, free, shared, buff/cache, available]
|
||||||
|
|
||||||
|
const total = parseInt(parts[1], 10) || 0;
|
||||||
|
const used = parseInt(parts[2], 10) || 0;
|
||||||
|
const free = parseInt(parts[3], 10) || 0;
|
||||||
|
const cached = parseInt(parts[5], 10) || 0; // buff/cache column
|
||||||
|
const available = parseInt(parts[6], 10) || 0;
|
||||||
|
const usedPercent = total > 0 ? Math.round((used / total) * 10000) / 100 : 0;
|
||||||
|
|
||||||
|
return { total, used, free, cached, available, usedPercent };
|
||||||
|
}
|
||||||
28
server/src/services/nginx.ts
Normal file
28
server/src/services/nginx.ts
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
import { sshManager } from '../ssh/client.js';
|
||||||
|
import type { NginxStatus } from '../types/index.js';
|
||||||
|
|
||||||
|
export async function getNginxStatus(): Promise<NginxStatus> {
|
||||||
|
let statusText: string;
|
||||||
|
|
||||||
|
try {
|
||||||
|
statusText = await sshManager.execCommand('systemctl is-active nginx');
|
||||||
|
} catch {
|
||||||
|
statusText = 'failed';
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalized = statusText.trim().toLowerCase();
|
||||||
|
let status: NginxStatus['status'];
|
||||||
|
|
||||||
|
if (normalized === 'active') {
|
||||||
|
status = 'active';
|
||||||
|
} else if (normalized === 'inactive') {
|
||||||
|
status = 'inactive';
|
||||||
|
} else {
|
||||||
|
status = 'failed';
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
status,
|
||||||
|
checkedAt: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
}
|
||||||
36
server/src/services/system.ts
Normal file
36
server/src/services/system.ts
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
import { sshManager } from '../ssh/client.js';
|
||||||
|
import type { SystemInfo } from '../types/index.js';
|
||||||
|
|
||||||
|
export async function getSystemInfo(): Promise<SystemInfo> {
|
||||||
|
// Run all commands in a single SSH channel to avoid channel exhaustion
|
||||||
|
const output = await sshManager.execCommand(
|
||||||
|
"hostname && echo '---SEP---' && uptime -p && echo '---SEP---' && cat /proc/loadavg && echo '---SEP---' && top -bn1 | grep 'Cpu(s)' | awk '{print $2}' && echo '---SEP---' && cat /etc/os-release | grep PRETTY_NAME"
|
||||||
|
);
|
||||||
|
|
||||||
|
const parts = output.split('---SEP---').map((s) => s.trim());
|
||||||
|
const hostnameOut = parts[0] || 'unknown';
|
||||||
|
const uptimeOut = parts[1] || 'unknown';
|
||||||
|
const loadAvgOut = parts[2] || '0 0 0';
|
||||||
|
const cpuOut = parts[3] || '0';
|
||||||
|
const osOut = parts[4] || 'PRETTY_NAME="Unknown"';
|
||||||
|
|
||||||
|
const loadParts = loadAvgOut.split(/\s+/);
|
||||||
|
const loadAvg: [number, number, number] = [
|
||||||
|
parseFloat(loadParts[0]) || 0,
|
||||||
|
parseFloat(loadParts[1]) || 0,
|
||||||
|
parseFloat(loadParts[2]) || 0,
|
||||||
|
];
|
||||||
|
|
||||||
|
const cpuPercent = parseFloat(cpuOut.replace('%', '')) || 0;
|
||||||
|
|
||||||
|
const osMatch = osOut.match(/PRETTY_NAME="?([^"]*)"?/);
|
||||||
|
const os = osMatch ? osMatch[1] : 'Unknown';
|
||||||
|
|
||||||
|
return {
|
||||||
|
hostname: hostnameOut,
|
||||||
|
uptime: uptimeOut,
|
||||||
|
loadAvg,
|
||||||
|
cpuPercent,
|
||||||
|
os,
|
||||||
|
};
|
||||||
|
}
|
||||||
141
server/src/ssh/client.ts
Normal file
141
server/src/ssh/client.ts
Normal file
@@ -0,0 +1,141 @@
|
|||||||
|
import { Client } from 'ssh2';
|
||||||
|
import { readFileSync } from 'fs';
|
||||||
|
import { config } from '../config.js';
|
||||||
|
|
||||||
|
class SSHConnectionManager {
|
||||||
|
private client: Client | null = null;
|
||||||
|
private connected = false;
|
||||||
|
private connecting = false;
|
||||||
|
private reconnectTimer: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
|
||||||
|
async connect(): Promise<void> {
|
||||||
|
if (this.connected || this.connecting) return;
|
||||||
|
this.connecting = true;
|
||||||
|
|
||||||
|
return new Promise<void>((resolve, reject) => {
|
||||||
|
const conn = new Client();
|
||||||
|
|
||||||
|
conn.on('ready', () => {
|
||||||
|
console.log('[SSH] Connection established');
|
||||||
|
this.client = conn;
|
||||||
|
this.connected = true;
|
||||||
|
this.connecting = false;
|
||||||
|
resolve();
|
||||||
|
});
|
||||||
|
|
||||||
|
conn.on('error', (err) => {
|
||||||
|
console.error('[SSH] Connection error:', err.message);
|
||||||
|
this.handleDisconnect();
|
||||||
|
if (this.connecting) {
|
||||||
|
this.connecting = false;
|
||||||
|
reject(err);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
conn.on('close', () => {
|
||||||
|
console.warn('[SSH] Connection closed');
|
||||||
|
this.handleDisconnect();
|
||||||
|
});
|
||||||
|
|
||||||
|
conn.on('end', () => {
|
||||||
|
this.handleDisconnect();
|
||||||
|
});
|
||||||
|
|
||||||
|
let privateKey: Buffer;
|
||||||
|
try {
|
||||||
|
privateKey = readFileSync(config.ssh.keyPath);
|
||||||
|
} catch (err) {
|
||||||
|
this.connecting = false;
|
||||||
|
reject(new Error(`Failed to read SSH key at ${config.ssh.keyPath}: ${(err as Error).message}`));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
conn.connect({
|
||||||
|
host: config.ssh.host,
|
||||||
|
username: config.ssh.user,
|
||||||
|
privateKey,
|
||||||
|
readyTimeout: 10_000,
|
||||||
|
keepaliveInterval: 30_000,
|
||||||
|
keepaliveCountMax: 3,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private handleDisconnect(): void {
|
||||||
|
this.connected = false;
|
||||||
|
this.client = null;
|
||||||
|
|
||||||
|
if (!this.reconnectTimer) {
|
||||||
|
this.reconnectTimer = setTimeout(() => {
|
||||||
|
this.reconnectTimer = null;
|
||||||
|
console.log('[SSH] Attempting reconnect...');
|
||||||
|
this.connect().catch((err) => {
|
||||||
|
console.error('[SSH] Reconnect failed:', err.message);
|
||||||
|
});
|
||||||
|
}, 5_000);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async ensureConnected(): Promise<void> {
|
||||||
|
if (!this.connected || !this.client) {
|
||||||
|
await this.connect();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async execCommand(cmd: string): Promise<string> {
|
||||||
|
await this.ensureConnected();
|
||||||
|
|
||||||
|
if (!this.client) {
|
||||||
|
throw new Error('SSH client is not connected');
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Promise<string>((resolve, reject) => {
|
||||||
|
const timeout = setTimeout(() => {
|
||||||
|
reject(new Error(`Command timed out after 10s: ${cmd}`));
|
||||||
|
}, 10_000);
|
||||||
|
|
||||||
|
this.client!.exec(cmd, (err, stream) => {
|
||||||
|
if (err) {
|
||||||
|
clearTimeout(timeout);
|
||||||
|
reject(err);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let stdout = '';
|
||||||
|
let stderr = '';
|
||||||
|
|
||||||
|
stream.on('data', (data: Buffer) => {
|
||||||
|
stdout += data.toString();
|
||||||
|
});
|
||||||
|
|
||||||
|
stream.stderr.on('data', (data: Buffer) => {
|
||||||
|
stderr += data.toString();
|
||||||
|
});
|
||||||
|
|
||||||
|
stream.on('close', (code: number) => {
|
||||||
|
clearTimeout(timeout);
|
||||||
|
if (code !== 0 && !stdout) {
|
||||||
|
reject(new Error(`Command exited with code ${code}: ${stderr.trim()}`));
|
||||||
|
} else {
|
||||||
|
resolve(stdout.trim());
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async disconnect(): Promise<void> {
|
||||||
|
if (this.reconnectTimer) {
|
||||||
|
clearTimeout(this.reconnectTimer);
|
||||||
|
this.reconnectTimer = null;
|
||||||
|
}
|
||||||
|
if (this.client) {
|
||||||
|
this.client.end();
|
||||||
|
this.client = null;
|
||||||
|
this.connected = false;
|
||||||
|
console.log('[SSH] Connection closed gracefully');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const sshManager = new SSHConnectionManager();
|
||||||
56
server/src/types/index.ts
Normal file
56
server/src/types/index.ts
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
// ─── Server Health Monitoring API Types ───
|
||||||
|
|
||||||
|
export interface SystemInfo {
|
||||||
|
hostname: string;
|
||||||
|
uptime: string;
|
||||||
|
loadAvg: [number, number, number];
|
||||||
|
cpuPercent: number;
|
||||||
|
os: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MemoryInfo {
|
||||||
|
total: number;
|
||||||
|
used: number;
|
||||||
|
free: number;
|
||||||
|
cached: number;
|
||||||
|
available: number;
|
||||||
|
usedPercent: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DiskInfo {
|
||||||
|
total: number;
|
||||||
|
used: number;
|
||||||
|
free: number;
|
||||||
|
usedPercent: number;
|
||||||
|
mountPoint: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DockerContainer {
|
||||||
|
name: string;
|
||||||
|
status: string;
|
||||||
|
image: string;
|
||||||
|
cpuPercent: number;
|
||||||
|
memUsage: number;
|
||||||
|
memLimit: number;
|
||||||
|
memPercent: number;
|
||||||
|
netInput: number;
|
||||||
|
netOutput: number;
|
||||||
|
blockInput: number;
|
||||||
|
blockOutput: number;
|
||||||
|
pids: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface NginxStatus {
|
||||||
|
status: 'active' | 'inactive' | 'failed';
|
||||||
|
checkedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface OverviewResponse {
|
||||||
|
system: SystemInfo | null;
|
||||||
|
memory: MemoryInfo | null;
|
||||||
|
disk: DiskInfo | null;
|
||||||
|
docker: DockerContainer[] | null;
|
||||||
|
nginx: NginxStatus | null;
|
||||||
|
errors: Record<string, string>;
|
||||||
|
timestamp: string;
|
||||||
|
}
|
||||||
13
server/tsconfig.json
Normal file
13
server/tsconfig.json
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2022",
|
||||||
|
"module": "ESNext",
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"strict": true,
|
||||||
|
"outDir": "dist",
|
||||||
|
"rootDir": "src",
|
||||||
|
"skipLibCheck": true
|
||||||
|
},
|
||||||
|
"include": ["src"]
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user