Initial ui

This commit is contained in:
Jack Mechem 2026-03-25 23:33:09 -07:00
parent 526822335e
commit 4bfb87448f
18 changed files with 702 additions and 92 deletions

View file

@ -1,25 +1,36 @@
import { execSync } from "child_process"; import { exec } from "child_process";
import fs from "fs"; import { promisify } from "util";
import fs from "fs/promises";
import { NextResponse } from "next/server"; 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<string | null> {
try { try {
return fs.readFileSync(path, "utf8").trim(); const data = await fs.readFile(path, "utf8");
return data.trim();
} catch { } catch {
return null; return null;
} }
} }
function exec(cmd: string): string | null { /**
* Helper to run shell commands asynchronously
*/
async function runCommand(cmd: string): Promise<string | null> {
try { try {
return execSync(cmd, { timeout: 3000 }).toString().trim(); const { stdout } = await execAsync(cmd, { timeout: 3000 });
return stdout.trim();
} catch { } catch {
return null; return null;
} }
} }
function getMemory() { async function getMemory() {
const raw = readFile("/proc/meminfo"); const raw = await readFile("/proc/meminfo");
if (!raw) return null; if (!raw) return null;
const lines: Record<string, number> = {}; const lines: Record<string, number> = {};
for (const line of raw.split("\n")) { for (const line of raw.split("\n")) {
@ -39,10 +50,9 @@ function getMemory() {
}; };
} }
function getCpu() { async function getCpu() {
// Take two samples 100ms apart to calculate usage const sample = async () => {
const sample = () => { const raw = await readFile("/proc/stat");
const raw = readFile("/proc/stat");
if (!raw) return null; if (!raw) return null;
const line = raw.split("\n")[0]; const line = raw.split("\n")[0];
const parts = line.split(/\s+/).slice(1).map(Number); const parts = line.split(/\s+/).slice(1).map(Number);
@ -51,9 +61,10 @@ function getCpu() {
return { idle, total }; return { idle, total };
}; };
const s1 = sample(); const s1 = await sample();
execSync("sleep 0.2"); // Non-blocking delay (200ms) to calculate CPU delta
const s2 = sample(); await new Promise((resolve) => setTimeout(resolve, 200));
const s2 = await sample();
if (!s1 || !s2) return null; if (!s1 || !s2) return null;
@ -61,34 +72,30 @@ function getCpu() {
const totalDiff = s2.total - s1.total; const totalDiff = s2.total - s1.total;
const percent = Math.round((1 - idleDiff / totalDiff) * 100); const percent = Math.round((1 - idleDiff / totalDiff) * 100);
const model = // Parse CPU Info without spawning extra shell processes
exec("grep 'model name' /proc/cpuinfo | head -1 | cut -d: -f2") ?? const cpuInfo = (await readFile("/proc/cpuinfo")) || "";
"Unknown"; const model = cpuInfo.match(/model name\s+:\s+(.*)/)?.[1] || "Unknown";
const cores = const cores = cpuInfo.split("\n").filter((l) => l.startsWith("processor")).length;
exec("grep -c '^processor' /proc/cpuinfo") ?? "?";
return { return {
percent, percent,
model: model.trim(), model: model.trim(),
cores: parseInt(cores), cores,
}; };
} }
function getTemperature() { async function getTemperature() {
// Try common thermal zone paths
const paths = [ const paths = [
"/sys/class/thermal/thermal_zone0/temp", "/sys/class/thermal/thermal_zone0/temp",
"/sys/class/thermal/thermal_zone1/temp", "/sys/class/thermal/thermal_zone1/temp",
"/sys/class/hwmon/hwmon0/temp1_input", "/sys/class/hwmon/hwmon0/temp1_input",
]; ];
for (const path of paths) { for (const path of paths) {
const raw = readFile(path); const raw = await readFile(path);
if (raw) { if (raw) return Math.round(parseInt(raw) / 1000);
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) { if (sensors) {
const match = sensors.match(/\+(\d+\.\d+)/); const match = sensors.match(/\+(\d+\.\d+)/);
if (match) return parseFloat(match[1]); if (match) return parseFloat(match[1]);
@ -96,8 +103,8 @@ function getTemperature() {
return null; return null;
} }
function getDisk() { async function getDisk() {
const raw = exec("df -B1 /"); const raw = await runCommand("df -B1 /");
if (!raw) return null; if (!raw) return null;
const lines = raw.split("\n"); const lines = raw.split("\n");
const parts = lines[1].split(/\s+/); const parts = lines[1].split(/\s+/);
@ -112,8 +119,8 @@ function getDisk() {
}; };
} }
function getUptime() { async function getUptime() {
const raw = readFile("/proc/uptime"); const raw = await readFile("/proc/uptime");
if (!raw) return null; if (!raw) return null;
const seconds = parseFloat(raw.split(" ")[0]); const seconds = parseFloat(raw.split(" ")[0]);
const days = Math.floor(seconds / 86400); const days = Math.floor(seconds / 86400);
@ -122,8 +129,8 @@ function getUptime() {
return { seconds, days, hours, minutes }; return { seconds, days, hours, minutes };
} }
function getNetwork() { async function getNetwork() {
const raw = readFile("/proc/net/dev"); const raw = await readFile("/proc/net/dev");
if (!raw) return null; if (!raw) return null;
const ifaces: Record<string, { rx: number; tx: number }> = {}; const ifaces: Record<string, { rx: number; tx: number }> = {};
for (const line of raw.split("\n").slice(2)) { for (const line of raw.split("\n").slice(2)) {
@ -139,17 +146,23 @@ function getNetwork() {
return ifaces; return ifaces;
} }
function getServices() { async function getServices() {
const services = ["caddy", "syncthing", "sshd", "cloudflare-dyndns"]; const services = ["caddy", "syncthing", "sshd", "cloudflare-dyndns"];
const result: Record<string, string> = {}; const result: Record<string, string> = {};
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; return result;
} }
function getLoadAverage() { async function getLoadAverage() {
const raw = readFile("/proc/loadavg"); const raw = await readFile("/proc/loadavg");
if (!raw) return null; if (!raw) return null;
const parts = raw.split(" "); const parts = raw.split(" ");
return { return {
@ -160,27 +173,33 @@ function getLoadAverage() {
} }
export async function GET() { export async function GET() {
const [memory, cpu, disk, uptime, network, services, loadAvg, temperature] = try {
await Promise.all([ // Parallelize all data gathering
getMemory(), const [memory, cpu, disk, uptime, network, services, loadAvg, temperature] =
getCpu(), await Promise.all([
getDisk(), getMemory(),
getUptime(), getCpu(),
getNetwork(), getDisk(),
getServices(), getUptime(),
getLoadAverage(), getNetwork(),
getTemperature(), getServices(),
]); getLoadAverage(),
getTemperature(),
]);
return NextResponse.json({ return NextResponse.json({
timestamp: new Date().toISOString(), timestamp: new Date().toISOString(),
memory, memory,
cpu, cpu,
disk, disk,
uptime, uptime,
network, network,
services, services,
loadAvg, loadAvg,
temperature, temperature,
}); });
} catch (error) {
console.error("API Error:", error);
return NextResponse.json({ error: "Internal Server Error" }, { status: 500 });
}
} }

24
app/components/Hero.tsx Normal file
View file

@ -0,0 +1,24 @@
interface HeroProps {
lastUpdated: string | null;
}
export default function Hero({ lastUpdated }: HeroProps) {
return (
<div className="mb-11 animate-fade-up">
<p className="text-xs font-medium tracking-widest uppercase text-blue-500 mb-3">
dell-xps-nixos-serv
</p>
<h1
className="text-4xl md:text-5xl font-normal leading-tight tracking-tight text-gray-900 mb-2"
style={{ fontFamily: "'Playfair Display', serif" }}
>
Home server
</h1>
<p className="text-sm text-gray-400 font-light">
{lastUpdated
? `Last updated ${new Date(lastUpdated).toLocaleTimeString()}`
: "Fetching system stats..."}
</p>
</div>
);
}

View file

@ -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 (
<a
href={link.href}
target="_blank"
rel="noopener noreferrer"
className="bg-white border border-gray-200 rounded-2xl p-6 shadow-sm flex flex-col gap-2 hover:shadow-md hover:-translate-y-1 hover:border-blue-200 transition-all duration-200 animate-fade-up no-underline text-inherit"
style={{ animationDelay: `${delay}ms` }}
>
<div className="w-11 h-11 bg-blue-50 rounded-xl flex items-center justify-center text-2xl mb-1">
{link.icon}
</div>
<span className="text-base font-medium text-gray-900">{link.name}</span>
<span className="text-sm text-gray-400 font-light">{link.description}</span>
<span className="mt-auto pt-2 text-sm font-medium text-blue-500">
Open app
</span>
</a>
);
}

View file

@ -0,0 +1,19 @@
import { LINKS } from "../lib/links";
import LinkCard from "./LinkCard";
export default function LinksGrid() {
return (
<div>
<div className="flex items-baseline justify-between mb-5">
<h2 className="text-lg font-medium tracking-tight text-gray-900">
Services &amp; Apps
</h2>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-3.5">
{LINKS.map((link, i) => (
<LinkCard key={link.name} link={link} delay={i * 60} />
))}
</div>
</div>
);
}

35
app/components/NavBar.tsx Normal file
View file

@ -0,0 +1,35 @@
interface NavBarProps {
online: boolean;
}
export default function NavBar({ online }: NavBarProps) {
return (
<nav className="flex items-center justify-between pt-7 pb-6 mb-13 border-b border-gray-200">
<div className="flex items-center gap-3">
<span
className="text-lg font-medium tracking-tight text-gray-900"
style={{ fontFamily: "'Playfair Display', serif" }}
>
Jack&apos;s Servers
</span>
</div>
<div
className={`flex items-center gap-2 text-xs font-medium px-3 py-1.5 rounded-full border ${
online
? "text-green-700 bg-green-50 border-green-200"
: "text-gray-500 bg-gray-50 border-gray-200"
}`}
>
<span
className="w-1.5 h-1.5 rounded-full"
style={{
background: online ? "#22c55e" : "#d1d5db",
animation: online ? "pulse-dot 2s infinite" : "none",
}}
/>
{online ? "Online" : "Connecting..."}
</div>
</nav>
);
}

View file

@ -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 (
<div
className="bg-white border border-gray-200 rounded-2xl p-6 shadow-sm animate-fade-up"
style={{ animationDelay: `${delay}ms` }}
>
<p className="text-[0.68rem] font-medium tracking-widest uppercase text-gray-400 mb-4">
Network
</p>
{iface && speed ? (
<>
<p className="text-[0.68rem] font-medium tracking-widest uppercase text-gray-400 mb-3">
{iface}
</p>
<div className="flex gap-6">
<div className="flex flex-col gap-0.5">
<span className="text-lg font-medium text-blue-500">
{formatBytes(speed.rx)}/s
</span>
<span className="text-[0.62rem] uppercase tracking-widest text-gray-400">
Download
</span>
</div>
<div className="flex flex-col gap-0.5">
<span className="text-lg font-medium text-violet-500">
{formatBytes(speed.tx)}/s
</span>
<span className="text-[0.62rem] uppercase tracking-widest text-gray-400">
Upload
</span>
</div>
</div>
</>
) : (
<div className="skeleton h-11" />
)}
</div>
);
}

View file

@ -0,0 +1,31 @@
interface ServicePillProps {
name: string;
status: string;
}
export default function ServicePill({ name, status }: ServicePillProps) {
const active = status === "active";
return (
<div
className={`flex items-center gap-2.5 px-3.5 py-2.5 rounded-xl border text-sm ${
active
? "bg-green-50 border-green-200"
: "bg-gray-50 border-gray-200 text-gray-400"
}`}
>
<span
className={`w-2 h-2 rounded-full flex-shrink-0 ${
active ? "bg-green-500" : "bg-gray-300"
}`}
/>
<span className="flex-1 font-normal">{name}</span>
<span
className={`text-[0.65rem] uppercase tracking-widest font-medium ${
active ? "text-green-600" : "text-gray-400"
}`}
>
{status}
</span>
</div>
);
}

View file

@ -0,0 +1,28 @@
import ServicePill from "./ServicePill";
interface ServicesCardProps {
services: Record<string, string> | null;
delay?: number;
}
export default function ServicesCard({ services, delay = 0 }: ServicesCardProps) {
return (
<div
className="bg-white border border-gray-200 rounded-2xl p-6 shadow-sm animate-fade-up"
style={{ animationDelay: `${delay}ms` }}
>
<p className="text-[0.68rem] font-medium tracking-widest uppercase text-gray-400 mb-4">
Services
</p>
<div className="flex flex-col gap-2">
{services
? Object.entries(services).map(([name, status]) => (
<ServicePill key={name} name={name} status={status} />
))
: [1, 2, 3, 4].map((i) => (
<div key={i} className="skeleton h-10" />
))}
</div>
</div>
);
}

View file

@ -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 (
<div
className="bg-white border border-gray-200 rounded-2xl p-5 flex flex-col gap-1 shadow-sm hover:shadow-md hover:-translate-y-0.5 transition-all duration-200 animate-fade-up"
style={{ animationDelay: `${delay}ms` }}
>
<span className="text-[0.68rem] font-medium tracking-widest uppercase text-gray-400">
{label}
</span>
<span className="text-3xl font-medium tracking-tight text-gray-900 leading-none mt-1">
{value}
</span>
{sub && (
<span className="text-[0.7rem] text-gray-400 mt-0.5 truncate">{sub}</span>
)}
{percent !== undefined && (
<div className="h-[3px] bg-gray-100 rounded-full mt-3 overflow-hidden">
<div
className="h-full rounded-full transition-all duration-700"
style={{ width: `${pct}%`, background: color }}
/>
</div>
)}
</div>
);
}

View file

@ -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 (
<div className="grid grid-cols-2 md:grid-cols-4 gap-3.5 mb-11">
<StatCard
label="CPU"
value={stats ? `${stats.cpu.percent}%` : "—"}
sub={stats?.cpu.model.replace(/\(R\)/g, "").replace(/\(TM\)/g, "").trim()}
percent={stats?.cpu.percent}
delay={0}
/>
<StatCard
label="Memory"
value={stats ? `${stats.memory.percent}%` : "—"}
sub={stats ? `${stats.memory.used} MB / ${stats.memory.total} MB` : ""}
percent={stats?.memory.percent}
delay={60}
/>
<StatCard
label="Disk"
value={stats ? `${stats.disk.percent}%` : "—"}
sub={stats ? `${stats.disk.used} GB / ${stats.disk.total} GB` : ""}
percent={stats?.disk.percent}
delay={120}
/>
<StatCard
label="Temperature"
value={stats?.temperature != null ? `${stats.temperature}°C` : "—"}
sub={
stats?.temperature != null
? stats.temperature > 80
? "Running hot"
: stats.temperature > 60
? "Warm"
: "Cool"
: ""
}
delay={180}
/>
</div>
);
}

View file

@ -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 (
<div
className="bg-white border border-gray-200 rounded-2xl p-6 shadow-sm animate-fade-up"
style={{ animationDelay: `${delay}ms` }}
>
<p className="text-[0.68rem] font-medium tracking-widest uppercase text-gray-400 mb-4">
Uptime
</p>
{uptime ? (
<div className="flex">
{[
{ val: uptime.days, unit: "days" },
{ val: uptime.hours, unit: "hrs" },
{ val: uptime.minutes, unit: "min" },
].map(({ val, unit }, i) => (
<div
key={unit}
className={`flex flex-col items-center flex-1 ${
i < 2 ? "border-r border-gray-200" : ""
}`}
>
<span className="text-3xl font-medium tracking-tight text-gray-900 leading-none">
{pad(val)}
</span>
<span className="text-[0.62rem] uppercase tracking-widest text-gray-400 mt-1.5">
{unit}
</span>
</div>
))}
</div>
) : (
<div className="skeleton h-12" />
)}
</div>
);
}

View file

@ -1,26 +1,56 @@
@import "tailwindcss"; @import "tailwindcss";
:root { :root {
--background: #ffffff; --background: #f9fafb;
--foreground: #171717; --foreground: #111827;
} }
@theme inline { @theme inline {
--color-background: var(--background); --color-background: var(--background);
--color-foreground: var(--foreground); --color-foreground: var(--foreground);
--font-sans: var(--font-geist-sans);
--font-mono: var(--font-geist-mono); /* 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;
@media (prefers-color-scheme: dark) {
:root {
--background: #ffffff;
--foreground: #171717;
}
} }
body { body {
background: var(--background); background: var(--background);
color: var(--foreground); color: var(--foreground);
font-family: Arial, Helvetica, sans-serif; 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;
} }

View file

@ -1,15 +1,17 @@
import type { Metadata } from "next"; 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"; import "./globals.css";
const geistSans = Geist({ const dmSans = DM_Sans({
variable: "--font-geist-sans", variable: "--font-dm-sans",
subsets: ["latin"], subsets: ["latin"],
display: "swap",
}); });
const geistMono = Geist_Mono({ const playfair = Playfair_Display({
variable: "--font-geist-mono", variable: "--font-playfair",
subsets: ["latin"], subsets: ["latin"],
display: "swap",
}); });
export const metadata: Metadata = { export const metadata: Metadata = {
@ -25,7 +27,8 @@ export default function RootLayout({
return ( return (
<html <html
lang="en" lang="en"
className={`${geistSans.variable} ${geistMono.variable} h-full antialiased`} // Add the new font variables here
className={`${dmSans.variable} ${playfair.variable} h-full antialiased`}
> >
<body className="min-h-full flex flex-col">{children}</body> <body className="min-h-full flex flex-col">{children}</body>
</html> </html>

55
app/lib/getStats.ts Normal file
View file

@ -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<string, NetworkInterface>;
services: Record<string, string>;
loadAvg: LoadAvg;
temperature: number | null;
}
export async function getStats(): Promise<Stats> {
const res = await fetch("/api/stats");
if (!res.ok) throw new Error(`Failed to fetch stats: ${res.status}`);
return res.json() as Promise<Stats>;
}

16
app/lib/links.ts Normal file
View file

@ -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: "⇄",
},
];

15
app/lib/utils.ts Normal file
View file

@ -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");
}

View file

@ -1,8 +1,105 @@
export default async function Home() { "use client";
return (
<div className="flex flex-col w-full pt-[100px] md:px-[100px] px-[10px]"> import { useEffect, useState, useRef } from "react";
<h1 className="text-slate-600 text-[24pt]">Jack&apos;s Server Dashboard</h1> import { getStats, type Stats, type NetworkInterface } from "./lib/getStats";
<div className="text-blue-400 text-[20pt]"><a href="https://syncthing.jackmechem.dev">Syncthing</a></div>
</div> 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<Stats | null>(null);
const [netSpeed, setNetSpeed] = useState<Record<string, { rx: number; tx: number }>>({});
// 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<Record<string, NetworkInterface> | null>(null);
const lastFetchRef = useRef<number>(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<string, { rx: number; tx: number }> = {};
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 (
<main className="max-w-5xl mx-auto px-6 pb-20">
<NavBar online={!!stats} />
<Hero lastUpdated={stats?.timestamp ?? null} />
<div className="flex items-baseline justify-between mb-5">
<h2 className="text-lg font-medium tracking-tight text-gray-900">
System Stats
</h2>
</div>
<StatsGrid stats={stats} />
<div className="grid grid-cols-1 md:grid-cols-2 gap-3.5 mb-11">
<ServicesCard services={stats?.services ?? null} delay={200} />
<div className="flex flex-col gap-3.5">
<UptimeCard uptime={stats?.uptime ?? null} delay={250} />
<NetworkCard
iface={primaryIface ?? null}
speed={primarySpeed}
delay={300}
/>
</div>
</div>
<LinksGrid />
</main>
);
} }

32
middleware.ts Normal file
View file

@ -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).*)'],
};