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