Initial ui
This commit is contained in:
parent
526822335e
commit
4bfb87448f
18 changed files with 702 additions and 92 deletions
|
|
@ -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<string | null> {
|
||||
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<string | null> {
|
||||
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<string, number> = {};
|
||||
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<string, { rx: number; tx: number }> = {};
|
||||
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<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;
|
||||
}
|
||||
|
||||
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 });
|
||||
}
|
||||
}
|
||||
|
|
|
|||
24
app/components/Hero.tsx
Normal file
24
app/components/Hero.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
27
app/components/LinkCard.tsx
Normal file
27
app/components/LinkCard.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
19
app/components/LinksGrid.tsx
Normal file
19
app/components/LinksGrid.tsx
Normal 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 & 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
35
app/components/NavBar.tsx
Normal 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'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>
|
||||
);
|
||||
}
|
||||
47
app/components/NetworkCard.tsx
Normal file
47
app/components/NetworkCard.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
31
app/components/ServicePill.tsx
Normal file
31
app/components/ServicePill.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
28
app/components/ServicesCard.tsx
Normal file
28
app/components/ServicesCard.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
39
app/components/StatCard.tsx
Normal file
39
app/components/StatCard.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
48
app/components/StatsGrid.tsx
Normal file
48
app/components/StatsGrid.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
45
app/components/UptimeCard.tsx
Normal file
45
app/components/UptimeCard.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
<html
|
||||
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>
|
||||
</html>
|
||||
|
|
|
|||
55
app/lib/getStats.ts
Normal file
55
app/lib/getStats.ts
Normal 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
16
app/lib/links.ts
Normal 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
15
app/lib/utils.ts
Normal 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");
|
||||
}
|
||||
111
app/page.tsx
111
app/page.tsx
|
|
@ -1,8 +1,105 @@
|
|||
export default async function Home() {
|
||||
return (
|
||||
<div className="flex flex-col w-full pt-[100px] md:px-[100px] px-[10px]">
|
||||
<h1 className="text-slate-600 text-[24pt]">Jack's Server Dashboard</h1>
|
||||
<div className="text-blue-400 text-[20pt]"><a href="https://syncthing.jackmechem.dev">Syncthing</a></div>
|
||||
</div>
|
||||
);
|
||||
"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<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
32
middleware.ts
Normal 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).*)'],
|
||||
};
|
||||
Loading…
Add table
Add a link
Reference in a new issue