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:
2026-03-23 14:02:27 +05:30
commit 7dee2d8069
60 changed files with 8719 additions and 0 deletions

24
client/.gitignore vendored Normal file
View 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
View 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
View 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
View 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"
}
}

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 9.3 KiB

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

24
client/public/icons.svg Normal file
View 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
View 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

Binary file not shown.

After

Width:  |  Height:  |  Size: 44 KiB

View 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

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 8.5 KiB

View 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>
)
}

View 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>
)
}

View 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>
)
}

View 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>
)
}

View 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>
)
}

View 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>
)
}

View 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>
)
}

View 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>
)
}

View 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>
)
}

View 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>
)
}

View 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>
)
}

View 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>
)
}

View 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>
)
}

View 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>
)
}

View 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 }

View 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,
}

View 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 }

View 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,
}

View 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 }

View 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,
}

View 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 }

View 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 }
}

View 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
View 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
View 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
View 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>
)

View 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
View 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
View 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,
},
},
},
})