From 4bfb87448f2e156269c01cade1403bc22ed3c009 Mon Sep 17 00:00:00 2001 From: Jack Mechem Date: Wed, 25 Mar 2026 23:33:09 -0700 Subject: [PATCH] Initial ui --- app/api/stats/route.ts | 145 ++++++++++++++++++-------------- app/components/Hero.tsx | 24 ++++++ app/components/LinkCard.tsx | 27 ++++++ app/components/LinksGrid.tsx | 19 +++++ app/components/NavBar.tsx | 35 ++++++++ app/components/NetworkCard.tsx | 47 +++++++++++ app/components/ServicePill.tsx | 31 +++++++ app/components/ServicesCard.tsx | 28 ++++++ app/components/StatCard.tsx | 39 +++++++++ app/components/StatsGrid.tsx | 48 +++++++++++ app/components/UptimeCard.tsx | 45 ++++++++++ app/globals.css | 62 ++++++++++---- app/layout.tsx | 15 ++-- app/lib/getStats.ts | 55 ++++++++++++ app/lib/links.ts | 16 ++++ app/lib/utils.ts | 15 ++++ app/page.tsx | 111 ++++++++++++++++++++++-- middleware.ts | 32 +++++++ 18 files changed, 702 insertions(+), 92 deletions(-) create mode 100644 app/components/Hero.tsx create mode 100644 app/components/LinkCard.tsx create mode 100644 app/components/LinksGrid.tsx create mode 100644 app/components/NavBar.tsx create mode 100644 app/components/NetworkCard.tsx create mode 100644 app/components/ServicePill.tsx create mode 100644 app/components/ServicesCard.tsx create mode 100644 app/components/StatCard.tsx create mode 100644 app/components/StatsGrid.tsx create mode 100644 app/components/UptimeCard.tsx create mode 100644 app/lib/getStats.ts create mode 100644 app/lib/links.ts create mode 100644 app/lib/utils.ts create mode 100644 middleware.ts diff --git a/app/api/stats/route.ts b/app/api/stats/route.ts index c7991a6..edf2afe 100644 --- a/app/api/stats/route.ts +++ b/app/api/stats/route.ts @@ -1,25 +1,36 @@ -import { execSync } from "child_process"; -import fs from "fs"; +import { exec } from "child_process"; +import { promisify } from "util"; +import fs from "fs/promises"; import { NextResponse } from "next/server"; -function readFile(path: string): string | null { +const execAsync = promisify(exec); + +/** + * Helper to read files asynchronously + */ +async function readFile(path: string): Promise { try { - return fs.readFileSync(path, "utf8").trim(); + const data = await fs.readFile(path, "utf8"); + return data.trim(); } catch { return null; } } -function exec(cmd: string): string | null { +/** + * Helper to run shell commands asynchronously + */ +async function runCommand(cmd: string): Promise { try { - return execSync(cmd, { timeout: 3000 }).toString().trim(); + const { stdout } = await execAsync(cmd, { timeout: 3000 }); + return stdout.trim(); } catch { return null; } } -function getMemory() { - const raw = readFile("/proc/meminfo"); +async function getMemory() { + const raw = await readFile("/proc/meminfo"); if (!raw) return null; const lines: Record = {}; for (const line of raw.split("\n")) { @@ -39,10 +50,9 @@ function getMemory() { }; } -function getCpu() { - // Take two samples 100ms apart to calculate usage - const sample = () => { - const raw = readFile("/proc/stat"); +async function getCpu() { + const sample = async () => { + const raw = await readFile("/proc/stat"); if (!raw) return null; const line = raw.split("\n")[0]; const parts = line.split(/\s+/).slice(1).map(Number); @@ -51,9 +61,10 @@ function getCpu() { return { idle, total }; }; - const s1 = sample(); - execSync("sleep 0.2"); - const s2 = sample(); + const s1 = await sample(); + // Non-blocking delay (200ms) to calculate CPU delta + await new Promise((resolve) => setTimeout(resolve, 200)); + const s2 = await sample(); if (!s1 || !s2) return null; @@ -61,34 +72,30 @@ function getCpu() { const totalDiff = s2.total - s1.total; const percent = Math.round((1 - idleDiff / totalDiff) * 100); - const model = - exec("grep 'model name' /proc/cpuinfo | head -1 | cut -d: -f2") ?? - "Unknown"; - const cores = - exec("grep -c '^processor' /proc/cpuinfo") ?? "?"; + // Parse CPU Info without spawning extra shell processes + const cpuInfo = (await readFile("/proc/cpuinfo")) || ""; + const model = cpuInfo.match(/model name\s+:\s+(.*)/)?.[1] || "Unknown"; + const cores = cpuInfo.split("\n").filter((l) => l.startsWith("processor")).length; return { percent, model: model.trim(), - cores: parseInt(cores), + cores, }; } -function getTemperature() { - // Try common thermal zone paths +async function getTemperature() { const paths = [ "/sys/class/thermal/thermal_zone0/temp", "/sys/class/thermal/thermal_zone1/temp", "/sys/class/hwmon/hwmon0/temp1_input", ]; for (const path of paths) { - const raw = readFile(path); - if (raw) { - return Math.round(parseInt(raw) / 1000); - } + const raw = await readFile(path); + if (raw) return Math.round(parseInt(raw) / 1000); } - // Try sensors command as fallback - const sensors = exec("sensors 2>/dev/null | grep 'Core 0' | head -1"); + + const sensors = await runCommand("sensors 2>/dev/null | grep 'Core 0' | head -1"); if (sensors) { const match = sensors.match(/\+(\d+\.\d+)/); if (match) return parseFloat(match[1]); @@ -96,8 +103,8 @@ function getTemperature() { return null; } -function getDisk() { - const raw = exec("df -B1 /"); +async function getDisk() { + const raw = await runCommand("df -B1 /"); if (!raw) return null; const lines = raw.split("\n"); const parts = lines[1].split(/\s+/); @@ -112,8 +119,8 @@ function getDisk() { }; } -function getUptime() { - const raw = readFile("/proc/uptime"); +async function getUptime() { + const raw = await readFile("/proc/uptime"); if (!raw) return null; const seconds = parseFloat(raw.split(" ")[0]); const days = Math.floor(seconds / 86400); @@ -122,8 +129,8 @@ function getUptime() { return { seconds, days, hours, minutes }; } -function getNetwork() { - const raw = readFile("/proc/net/dev"); +async function getNetwork() { + const raw = await readFile("/proc/net/dev"); if (!raw) return null; const ifaces: Record = {}; for (const line of raw.split("\n").slice(2)) { @@ -139,17 +146,23 @@ function getNetwork() { return ifaces; } -function getServices() { +async function getServices() { const services = ["caddy", "syncthing", "sshd", "cloudflare-dyndns"]; const result: Record = {}; - for (const svc of services) { - result[svc] = exec(`systemctl is-active ${svc}`) ?? "unknown"; - } + + // Run all status checks in parallel + await Promise.all( + services.map(async (svc) => { + const status = await runCommand(`systemctl is-active ${svc}`); + result[svc] = status ?? "unknown"; + }) + ); + return result; } -function getLoadAverage() { - const raw = readFile("/proc/loadavg"); +async function getLoadAverage() { + const raw = await readFile("/proc/loadavg"); if (!raw) return null; const parts = raw.split(" "); return { @@ -160,27 +173,33 @@ function getLoadAverage() { } export async function GET() { - const [memory, cpu, disk, uptime, network, services, loadAvg, temperature] = - await Promise.all([ - getMemory(), - getCpu(), - getDisk(), - getUptime(), - getNetwork(), - getServices(), - getLoadAverage(), - getTemperature(), - ]); + try { + // Parallelize all data gathering + const [memory, cpu, disk, uptime, network, services, loadAvg, temperature] = + await Promise.all([ + getMemory(), + getCpu(), + getDisk(), + getUptime(), + getNetwork(), + getServices(), + getLoadAverage(), + getTemperature(), + ]); - return NextResponse.json({ - timestamp: new Date().toISOString(), - memory, - cpu, - disk, - uptime, - network, - services, - loadAvg, - temperature, - }); + return NextResponse.json({ + timestamp: new Date().toISOString(), + memory, + cpu, + disk, + uptime, + network, + services, + loadAvg, + temperature, + }); + } catch (error) { + console.error("API Error:", error); + return NextResponse.json({ error: "Internal Server Error" }, { status: 500 }); + } } diff --git a/app/components/Hero.tsx b/app/components/Hero.tsx new file mode 100644 index 0000000..7afb238 --- /dev/null +++ b/app/components/Hero.tsx @@ -0,0 +1,24 @@ +interface HeroProps { + lastUpdated: string | null; +} + +export default function Hero({ lastUpdated }: HeroProps) { + return ( +
+

+ dell-xps-nixos-serv +

+

+ Home server +

+

+ {lastUpdated + ? `Last updated ${new Date(lastUpdated).toLocaleTimeString()}` + : "Fetching system stats..."} +

+
+ ); +} diff --git a/app/components/LinkCard.tsx b/app/components/LinkCard.tsx new file mode 100644 index 0000000..1c478b4 --- /dev/null +++ b/app/components/LinkCard.tsx @@ -0,0 +1,27 @@ +import { type AppLink } from "../lib/links"; + +interface LinkCardProps { + link: AppLink; + delay?: number; +} + +export default function LinkCard({ link, delay = 0 }: LinkCardProps) { + return ( + +
+ {link.icon} +
+ {link.name} + {link.description} + + Open app → + +
+ ); +} diff --git a/app/components/LinksGrid.tsx b/app/components/LinksGrid.tsx new file mode 100644 index 0000000..72d4e1d --- /dev/null +++ b/app/components/LinksGrid.tsx @@ -0,0 +1,19 @@ +import { LINKS } from "../lib/links"; +import LinkCard from "./LinkCard"; + +export default function LinksGrid() { + return ( +
+
+

+ Services & Apps +

+
+
+ {LINKS.map((link, i) => ( + + ))} +
+
+ ); +} diff --git a/app/components/NavBar.tsx b/app/components/NavBar.tsx new file mode 100644 index 0000000..8031c29 --- /dev/null +++ b/app/components/NavBar.tsx @@ -0,0 +1,35 @@ +interface NavBarProps { + online: boolean; +} + +export default function NavBar({ online }: NavBarProps) { + return ( + + ); +} diff --git a/app/components/NetworkCard.tsx b/app/components/NetworkCard.tsx new file mode 100644 index 0000000..84fc869 --- /dev/null +++ b/app/components/NetworkCard.tsx @@ -0,0 +1,47 @@ +import { formatBytes } from "../lib/utils"; + +interface NetworkCardProps { + iface: string | null; + speed: { rx: number; tx: number } | null; + delay?: number; +} + +export default function NetworkCard({ iface, speed, delay = 0 }: NetworkCardProps) { + return ( +
+

+ Network +

+ {iface && speed ? ( + <> +

+ {iface} +

+
+
+ + ↓ {formatBytes(speed.rx)}/s + + + Download + +
+
+ + ↑ {formatBytes(speed.tx)}/s + + + Upload + +
+
+ + ) : ( +
+ )} +
+ ); +} diff --git a/app/components/ServicePill.tsx b/app/components/ServicePill.tsx new file mode 100644 index 0000000..312742e --- /dev/null +++ b/app/components/ServicePill.tsx @@ -0,0 +1,31 @@ +interface ServicePillProps { + name: string; + status: string; +} + +export default function ServicePill({ name, status }: ServicePillProps) { + const active = status === "active"; + return ( +
+ + {name} + + {status} + +
+ ); +} diff --git a/app/components/ServicesCard.tsx b/app/components/ServicesCard.tsx new file mode 100644 index 0000000..3261159 --- /dev/null +++ b/app/components/ServicesCard.tsx @@ -0,0 +1,28 @@ +import ServicePill from "./ServicePill"; + +interface ServicesCardProps { + services: Record | null; + delay?: number; +} + +export default function ServicesCard({ services, delay = 0 }: ServicesCardProps) { + return ( +
+

+ Services +

+
+ {services + ? Object.entries(services).map(([name, status]) => ( + + )) + : [1, 2, 3, 4].map((i) => ( +
+ ))} +
+
+ ); +} diff --git a/app/components/StatCard.tsx b/app/components/StatCard.tsx new file mode 100644 index 0000000..f110a70 --- /dev/null +++ b/app/components/StatCard.tsx @@ -0,0 +1,39 @@ +import { statColor } from "../lib/utils"; + +interface StatCardProps { + label: string; + value: string; + sub?: string; + percent?: number; + delay?: number; +} + +export default function StatCard({ label, value, sub, percent, delay = 0 }: StatCardProps) { + const pct = percent ?? 0; + const color = statColor(pct); + + return ( +
+ + {label} + + + {value} + + {sub && ( + {sub} + )} + {percent !== undefined && ( +
+
+
+ )} +
+ ); +} diff --git a/app/components/StatsGrid.tsx b/app/components/StatsGrid.tsx new file mode 100644 index 0000000..3b8be57 --- /dev/null +++ b/app/components/StatsGrid.tsx @@ -0,0 +1,48 @@ +import { type Stats } from "../lib/getStats"; +import StatCard from "./StatCard"; + +interface StatsGridProps { + stats: Stats | null; +} + +export default function StatsGrid({ stats }: StatsGridProps) { + return ( +
+ + + + 80 + ? "Running hot" + : stats.temperature > 60 + ? "Warm" + : "Cool" + : "" + } + delay={180} + /> +
+ ); +} diff --git a/app/components/UptimeCard.tsx b/app/components/UptimeCard.tsx new file mode 100644 index 0000000..1cdd73e --- /dev/null +++ b/app/components/UptimeCard.tsx @@ -0,0 +1,45 @@ +import { pad } from "../lib/utils"; +import { type Uptime } from "../lib/getStats"; + +interface UptimeCardProps { + uptime: Uptime | null; + delay?: number; +} + +export default function UptimeCard({ uptime, delay = 0 }: UptimeCardProps) { + return ( +
+

+ Uptime +

+ {uptime ? ( +
+ {[ + { val: uptime.days, unit: "days" }, + { val: uptime.hours, unit: "hrs" }, + { val: uptime.minutes, unit: "min" }, + ].map(({ val, unit }, i) => ( +
+ + {pad(val)} + + + {unit} + +
+ ))} +
+ ) : ( +
+ )} +
+ ); +} diff --git a/app/globals.css b/app/globals.css index c33d5d8..1928c63 100644 --- a/app/globals.css +++ b/app/globals.css @@ -1,26 +1,56 @@ @import "tailwindcss"; :root { - --background: #ffffff; - --foreground: #171717; + --background: #f9fafb; + --foreground: #111827; } @theme inline { - --color-background: var(--background); - --color-foreground: var(--foreground); - --font-sans: var(--font-geist-sans); - --font-mono: var(--font-geist-mono); -} - -@media (prefers-color-scheme: dark) { - :root { - --background: #ffffff; - --foreground: #171717; - } + --color-background: var(--background); + --color-foreground: var(--foreground); + + /* Link Tailwind to the Next.js Font Variables */ + --font-sans: var(--font-dm-sans), ui-sans-serif, system-ui; + --font-serif: var(--font-playfair), ui-serif, Georgia; } body { - background: var(--background); - color: var(--foreground); - font-family: Arial, Helvetica, sans-serif; + background: var(--background); + color: var(--foreground); + font-family: var(--font-sans); + -webkit-font-smoothing: antialiased; +} + +/* --- Animations --- */ + +@keyframes fadeUp { + from { + opacity: 0; + transform: translateY(10px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +@keyframes shimmer { + from { background-position: 200% 0; } + to { background-position: -200% 0; } +} + +@keyframes pulse-dot { + 0%, 100% { opacity: 1; } + 50% { opacity: 0.25; } +} + +.animate-fade-up { + animation: fadeUp 0.45s ease both; +} + +.skeleton { + background: linear-gradient(90deg, #f3f4f6 25%, #e9eaec 50%, #f3f4f6 75%); + background-size: 200% 100%; + animation: shimmer 1.4s infinite; + border-radius: 8px; } diff --git a/app/layout.tsx b/app/layout.tsx index 976eb90..4ed168b 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -1,15 +1,17 @@ import type { Metadata } from "next"; -import { Geist, Geist_Mono } from "next/font/google"; +import { DM_Sans, Playfair_Display } from "next/font/google"; // Import your specific fonts import "./globals.css"; -const geistSans = Geist({ - variable: "--font-geist-sans", +const dmSans = DM_Sans({ + variable: "--font-dm-sans", subsets: ["latin"], + display: "swap", }); -const geistMono = Geist_Mono({ - variable: "--font-geist-mono", +const playfair = Playfair_Display({ + variable: "--font-playfair", subsets: ["latin"], + display: "swap", }); export const metadata: Metadata = { @@ -25,7 +27,8 @@ export default function RootLayout({ return ( {children} diff --git a/app/lib/getStats.ts b/app/lib/getStats.ts new file mode 100644 index 0000000..934b941 --- /dev/null +++ b/app/lib/getStats.ts @@ -0,0 +1,55 @@ +export interface Memory { + total: number; + used: number; + available: number; + percent: number; +} + +export interface Cpu { + percent: number; + model: string; + cores: number; +} + +export interface Disk { + total: number; + used: number; + available: number; + percent: number; +} + +export interface Uptime { + seconds: number; + days: number; + hours: number; + minutes: number; +} + +export interface NetworkInterface { + rx: number; + tx: number; +} + +export interface LoadAvg { + "1m": number; + "5m": number; + "15m": number; +} + +export interface Stats { + timestamp: string; + memory: Memory; + cpu: Cpu; + disk: Disk; + uptime: Uptime; + network: Record; + services: Record; + loadAvg: LoadAvg; + temperature: number | null; +} + +export async function getStats(): Promise { + const res = await fetch("/api/stats"); + if (!res.ok) throw new Error(`Failed to fetch stats: ${res.status}`); + return res.json() as Promise; +} diff --git a/app/lib/links.ts b/app/lib/links.ts new file mode 100644 index 0000000..2f79342 --- /dev/null +++ b/app/lib/links.ts @@ -0,0 +1,16 @@ +export interface AppLink { + name: string; + description: string; + href: string; + icon: string; +} + +// Add new services here +export const LINKS: AppLink[] = [ + { + name: "Syncthing", + description: "File synchronization", + href: "https://syncthing.jackmechem.dev", + icon: "⇄", + }, +]; diff --git a/app/lib/utils.ts b/app/lib/utils.ts new file mode 100644 index 0000000..4c058a4 --- /dev/null +++ b/app/lib/utils.ts @@ -0,0 +1,15 @@ +export function formatBytes(bytes: number): string { + if (bytes > 1e9) return (bytes / 1e9).toFixed(1) + " GB"; + if (bytes > 1e6) return (bytes / 1e6).toFixed(1) + " MB"; + return (bytes / 1e3).toFixed(1) + " KB"; +} + +export function statColor(percent: number): string { + if (percent > 80) return "#ef4444"; + if (percent > 60) return "#f59e0b"; + return "#3b82f6"; +} + +export function pad(n: number): string { + return String(n).padStart(2, "0"); +} diff --git a/app/page.tsx b/app/page.tsx index 86ec8ed..4aae247 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -1,8 +1,105 @@ -export default async function Home() { - return ( -
-

Jack's Server Dashboard

- -
- ); +"use client"; + +import { useEffect, useState, useRef } from "react"; +import { getStats, type Stats, type NetworkInterface } from "./lib/getStats"; + +import NavBar from "./components/NavBar"; +import Hero from "./components/Hero"; +import StatsGrid from "./components/StatsGrid"; +import ServicesCard from "./components/ServicesCard"; +import UptimeCard from "./components/UptimeCard"; +import NetworkCard from "./components/NetworkCard"; +import LinksGrid from "./components/LinksGrid"; + +export default function Home() { + const [stats, setStats] = useState(null); + const [netSpeed, setNetSpeed] = useState>({}); + + // We use Refs for these because changing them shouldn't trigger a "refresh" + // but we need them to calculate the delta (speed) between fetches. + const prevNetRef = useRef | null>(null); + const lastFetchRef = useRef(0); + + useEffect(() => { + const fetchData = async () => { + try { + const now = Date.now(); + const data = await getStats(); + + // 1. Calculate speeds if we have previous data + if (prevNetRef.current && lastFetchRef.current > 0) { + const elapsed = (now - lastFetchRef.current) / 1000; + const speeds: Record = {}; + + for (const iface of Object.keys(data.network)) { + const prev = prevNetRef.current[iface]; + if (prev) { + speeds[iface] = { + rx: Math.max(0, (data.network[iface].rx - prev.rx) / elapsed), + tx: Math.max(0, (data.network[iface].tx - prev.tx) / elapsed), + }; + } + } + setNetSpeed(speeds); + } + + // 2. Update our "silent" trackers + prevNetRef.current = data.network; + lastFetchRef.current = now; + + // 3. Update the UI state with new data + // React's Virtual DOM will only update the changed text/numbers. + setStats(data); + } catch (e) { + console.error("Dashboard fetch failed:", e); + } + }; + + // Initial fetch + fetchData(); + + // Start the 4s loop + const id = setInterval(fetchData, 4000); + + // Clean up on unmount so we don't have multiple intervals running + return () => clearInterval(id); + }, []); // Empty array means this setup only happens ONCE. + + // Derived values for the UI + const primaryIface = stats + ? Object.keys(stats.network).find( + (k) => !k.startsWith("docker") && !k.startsWith("br-") && stats.network[k].rx > 0 + ) + : null; + + const primarySpeed = primaryIface ? netSpeed[primaryIface] || null : null; + + return ( +
+ + + +
+

+ System Stats +

+
+ + + +
+ +
+ + +
+
+ + +
+ ); } diff --git a/middleware.ts b/middleware.ts new file mode 100644 index 0000000..655da3a --- /dev/null +++ b/middleware.ts @@ -0,0 +1,32 @@ +import { NextResponse } from 'next/server'; +import type { NextRequest } from 'next/server'; + +export function middleware(req: NextRequest) { + const authHeader = req.headers.get('authorization'); + + // Change these to your desired username and password + const USERNAME = process.env.DASHBOARD_USER; + const PASSWORD = process.env.DASHBOARD_PASS; + + if (authHeader) { + const auth = authHeader.split(' ')[1]; + const [user, pwd] = Buffer.from(auth, 'base64').toString().split(':'); + + if (user === USERNAME && pwd === PASSWORD) { + return NextResponse.next(); + } + } + + // If not authenticated, trigger the browser's native login popup + return new NextResponse('Authentication required', { + status: 401, + headers: { + 'WWW-Authenticate': 'Basic realm="Secure Area"', + }, + }); +} + +// Only protect the dashboard, not the static assets +export const config = { + matcher: ['/((?!_next/static|_next/image|favicon.ico).*)'], +};