Authentication updates
This commit is contained in:
parent
98b1daa7d3
commit
3015c98246
15 changed files with 657 additions and 165 deletions
19
app/api/auth/check/route.ts
Normal file
19
app/api/auth/check/route.ts
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
import { NextRequest, NextResponse } from "next/server";
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
const token = req.cookies.get("token")?.value;
|
||||
if (!token) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
// hit an endpoint that actually requires auth
|
||||
const res = await fetch("http://localhost:3001/services/sysapi/logs", {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
return NextResponse.json({ success: true });
|
||||
}
|
||||
|
|
@ -1,7 +1,18 @@
|
|||
import { NextResponse } from 'next/server';
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
|
||||
export async function GET() {
|
||||
const res = await fetch('http://localhost:3001/stats');
|
||||
const data = await res.json();
|
||||
return NextResponse.json(data);
|
||||
export async function GET(req: NextRequest) {
|
||||
const token = req.cookies.get("token")?.value;
|
||||
if (!token) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
const res = await fetch("http://localhost:3001/stats", {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: res.status });
|
||||
}
|
||||
|
||||
return NextResponse.json(await res.json());
|
||||
}
|
||||
|
|
|
|||
124
app/auth/page.tsx
Normal file
124
app/auth/page.tsx
Normal file
|
|
@ -0,0 +1,124 @@
|
|||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
|
||||
export default function AuthPage() {
|
||||
const router = useRouter();
|
||||
const [username, setUsername] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [error, setError] = useState("");
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [checking, setChecking] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
async function checkAuth() {
|
||||
try {
|
||||
const res = await fetch("/api/auth/check");
|
||||
if (res.ok) {
|
||||
const callbackUrl =
|
||||
new URLSearchParams(window.location.search).get("callbackUrl") ??
|
||||
"/";
|
||||
router.push(callbackUrl);
|
||||
}
|
||||
} finally {
|
||||
setChecking(false);
|
||||
}
|
||||
}
|
||||
checkAuth();
|
||||
}, [router]);
|
||||
|
||||
async function handleLogin(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
setLoading(true);
|
||||
setError("");
|
||||
|
||||
try {
|
||||
const res = await fetch("/api/auth/login", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ username, password }),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
setError("Invalid username or password.");
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const callbackUrl =
|
||||
new URLSearchParams(window.location.search).get("callbackUrl") ?? "/";
|
||||
router.push(callbackUrl);
|
||||
} catch {
|
||||
setError("Something went wrong. Try again.");
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
if (checking) return null;
|
||||
|
||||
return (
|
||||
<main className="h-full bg-gray-100 flex items-center justify-center">
|
||||
<div className="bg-white border border-gray-300 rounded-2xl md:p-12 p-8 w-full m-[10px] md:max-w-md shadow-sm">
|
||||
{/* Header */}
|
||||
<div className="mb-8">
|
||||
<p className="text-[11px] tracking-widest text-blue-500 uppercase mb-2">
|
||||
dell-xps-nixos-serv
|
||||
</p>
|
||||
<h1 className="text-[28px] font-normal text-gray-900 tracking-tight mb-1.5">
|
||||
Login
|
||||
</h1>
|
||||
<p className="text-sm text-gray-400">Enter system credentials</p>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleLogin}>
|
||||
{/* Username */}
|
||||
<div className="mb-4">
|
||||
<label className="block text-[11px] tracking-wider text-gray-400 uppercase mb-1.5">
|
||||
Username
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
required
|
||||
autoComplete="username"
|
||||
className="w-full px-3.5 py-2.5 border border-gray-200 rounded-xl text-sm text-gray-900 bg-gray-50 outline-none focus:border-blue-300 focus:ring-2 focus:ring-blue-50 transition-colors"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Password */}
|
||||
<div className="mb-6">
|
||||
<label className="block text-[11px] tracking-wider text-gray-400 uppercase mb-1.5">
|
||||
Password
|
||||
</label>
|
||||
<input
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
required
|
||||
autoComplete="current-password"
|
||||
className="w-full px-3.5 py-2.5 border border-gray-200 rounded-xl text-sm text-gray-900 bg-gray-50 outline-none focus:border-blue-300 focus:ring-2 focus:ring-blue-50 transition-colors"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Error */}
|
||||
{error && <p className="text-[13px] text-red-400 mb-4">{error}</p>}
|
||||
|
||||
{/* Submit */}
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className={`w-full py-2.5 rounded-xl text-md border border-blue-600 shadow-sm text-white font-[600] tracking-wide transition-colors ${
|
||||
loading
|
||||
? "bg-blue-200 cursor-not-allowed"
|
||||
: "bg-blue-500 hover:bg-blue-400 cursor-pointer"
|
||||
}`}
|
||||
>
|
||||
{loading ? "Signing in..." : "Sign in"}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,16 +1,43 @@
|
|||
"use client";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useEffect, useState } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { LuSettings2 } from "react-icons/lu";
|
||||
import ControlPanel from "./serverMenu";
|
||||
|
||||
interface HeroProps {
|
||||
lastUpdated: string | null;
|
||||
}
|
||||
|
||||
export default function Hero({ lastUpdated }: HeroProps) {
|
||||
const router = useRouter();
|
||||
const [authed, setAuthed] = useState<boolean>(false);
|
||||
const [menuOpen, setMenuOpen] = useState<boolean>(false);
|
||||
|
||||
useEffect(() => {
|
||||
async function checkAuth() {
|
||||
try {
|
||||
const res = await fetch("/api/auth/check");
|
||||
setAuthed(res.ok);
|
||||
} finally {
|
||||
}
|
||||
}
|
||||
checkAuth();
|
||||
}, [router]);
|
||||
|
||||
return (
|
||||
<div className="mb-11 animate-fade-up">
|
||||
{menuOpen &&
|
||||
typeof window !== "undefined" &&
|
||||
createPortal(
|
||||
<ControlPanel onClose={() => setMenuOpen(false)} />,
|
||||
document.body,
|
||||
)}
|
||||
<p className="text-xs font-medium tracking-widest uppercase text-blue-500 mb-3">
|
||||
dell-xps-nixos-serv
|
||||
</p>
|
||||
<div className="flex gap-[10px] items-center">
|
||||
<div className="flex gap-[10px] items-center w-full">
|
||||
<svg
|
||||
className="w-[50px] h-[50px]"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
|
|
@ -35,6 +62,21 @@ export default function Hero({ lastUpdated }: HeroProps) {
|
|||
>
|
||||
Home server
|
||||
</h1>
|
||||
{authed ? (
|
||||
<div
|
||||
onClick={() => setMenuOpen(true)}
|
||||
className="align-self-end ml-auto px-[20px] py-[20px] rounded-2xl hover:bg-gray-200 cursor-pointer duration-[200ms] text-gray-600 hover:shadow-sm font-[600]"
|
||||
>
|
||||
<LuSettings2 />
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
onClick={() => router.push("/auth")}
|
||||
className="align-self-end ml-auto px-[18px] py-[5px] rounded-xl hover:bg-blue-500 shadow-sm bg-white border-blue-300 hover:border-blue-400 border cursor-pointer duration-[200ms] text-blue-400 hover:shadow-sm hover:text-blue-100 text-[11pt] font-[600]"
|
||||
>
|
||||
Authenticate
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-sm text-gray-400 font-light">
|
||||
{lastUpdated
|
||||
|
|
|
|||
|
|
@ -1,35 +1,55 @@
|
|||
"use client";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
|
||||
interface NavBarProps {
|
||||
online: boolean;
|
||||
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>
|
||||
const router = useRouter();
|
||||
const [auth, setAuth] = useState(false);
|
||||
|
||||
<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>
|
||||
);
|
||||
useEffect(() => {
|
||||
fetch("/api/auth/check")
|
||||
.then((r) => setAuth(r.ok))
|
||||
.catch(() => setAuth(false));
|
||||
}, []);
|
||||
|
||||
async function handleLogout() {
|
||||
await fetch("/api/auth/logout", { method: "POST" });
|
||||
router.push("/auth");
|
||||
}
|
||||
|
||||
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-2">
|
||||
{auth && (
|
||||
<button
|
||||
onClick={handleLogout}
|
||||
className="flex items-center gap-2 text-xs text-red-700 bg-red-50 border border-red-200 px-3 py-1.5 rounded-full cursor-pointer"
|
||||
>
|
||||
Log out
|
||||
</button>
|
||||
)}
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
222
app/components/serverMenu.tsx
Normal file
222
app/components/serverMenu.tsx
Normal file
|
|
@ -0,0 +1,222 @@
|
|||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
|
||||
const SERVICES = [
|
||||
"syncthing",
|
||||
"dashboard",
|
||||
"caddy",
|
||||
"sshd",
|
||||
"cloudflare-dyndns.timer",
|
||||
"cloudflare-dyndns",
|
||||
"docker",
|
||||
"sysapi",
|
||||
];
|
||||
|
||||
type Toast = { message: string; ok: boolean } | null;
|
||||
type ServiceStatuses = Record<string, string>;
|
||||
|
||||
export default function ControlPanel({ onClose }: { onClose: () => void }) {
|
||||
const [loading, setLoading] = useState<Record<string, string>>({});
|
||||
const [toast, setToast] = useState<Toast>(null);
|
||||
const [statuses, setStatuses] = useState<ServiceStatuses>({});
|
||||
|
||||
useEffect(() => {
|
||||
async function fetchStatuses() {
|
||||
try {
|
||||
const res = await fetch("/api/stats");
|
||||
if (!res.ok) return;
|
||||
const data = await res.json();
|
||||
setStatuses(data.services ?? {});
|
||||
} catch {}
|
||||
}
|
||||
fetchStatuses();
|
||||
}, []);
|
||||
|
||||
function showToast(message: string, ok: boolean) {
|
||||
setToast({ message, ok });
|
||||
setTimeout(() => setToast(null), 3000);
|
||||
}
|
||||
|
||||
async function handleService(action: string, service: string) {
|
||||
setLoading((l) => ({ ...l, [`${service}-${action}`]: action }));
|
||||
const res = await fetch(`/api/services/${service}/${action}`, {
|
||||
method: "POST",
|
||||
});
|
||||
showToast(
|
||||
res.ok ? `${service} ${action}ed` : `Failed to ${action} ${service}`,
|
||||
res.ok,
|
||||
);
|
||||
if (res.ok) {
|
||||
setTimeout(async () => {
|
||||
const r = await fetch("/api/stats");
|
||||
if (r.ok) {
|
||||
const data = await r.json();
|
||||
setStatuses(data.services ?? {});
|
||||
}
|
||||
}, 1500);
|
||||
}
|
||||
setLoading((l) => {
|
||||
const n = { ...l };
|
||||
delete n[`${service}-${action}`];
|
||||
return n;
|
||||
});
|
||||
}
|
||||
|
||||
async function handleLogs(service: string) {
|
||||
const res = await fetch(`/api/services/${service}/logs`);
|
||||
if (!res.ok) {
|
||||
showToast("Failed to fetch logs", false);
|
||||
return;
|
||||
}
|
||||
const data = await res.json();
|
||||
console.log(`Logs for ${service}:`, data.stdout);
|
||||
showToast(`Logs fetched for ${service} — check console`, true);
|
||||
}
|
||||
|
||||
async function handleReboot() {
|
||||
if (!confirm("Reboot the server? This will disconnect all sessions."))
|
||||
return;
|
||||
const res = await fetch("/api/system/reboot", { method: "POST" });
|
||||
showToast(res.ok ? "Rebooting..." : "Reboot failed", res.ok);
|
||||
}
|
||||
|
||||
const isLoading = (service: string, action: string) =>
|
||||
loading[`${service}-${action}`] !== undefined;
|
||||
|
||||
const isActive = (svc: string) => statuses[svc] === "active";
|
||||
const isInactive = (svc: string) =>
|
||||
statuses[svc] === "inactive" ||
|
||||
statuses[svc] === "failed" ||
|
||||
statuses[svc] === "dead";
|
||||
|
||||
function statusDot(svc: string) {
|
||||
const s = statuses[svc];
|
||||
const color =
|
||||
s === "active"
|
||||
? "bg-green-400"
|
||||
: s === "failed"
|
||||
? "bg-red-400"
|
||||
: "bg-gray-300";
|
||||
return <span className={`w-2 h-2 rounded-full shrink-0 ${color}`} />;
|
||||
}
|
||||
|
||||
function btnClass(svc: string, action: string): string {
|
||||
const active = isActive(svc);
|
||||
const inactive = isInactive(svc);
|
||||
const busy = isLoading(svc, action);
|
||||
const base =
|
||||
"rounded-md px-2.5 py-1 text-[13px] whitespace-nowrap border transition-colors";
|
||||
|
||||
if (busy) return `${base} border-gray-200 text-gray-300 cursor-not-allowed`;
|
||||
if (action === "start" && inactive)
|
||||
return `${base} bg-green-50 border-green-200 text-green-700 cursor-pointer`;
|
||||
if (action === "stop" && active)
|
||||
return `${base} bg-red-50 border-red-200 text-red-500 cursor-pointer`;
|
||||
if (action === "restart" && active)
|
||||
return `${base} bg-blue-50 border-blue-200 text-blue-500 cursor-pointer`;
|
||||
return `${base} border-gray-200 text-gray-300 cursor-default`;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Backdrop */}
|
||||
<div onClick={onClose} className="fixed inset-0 bg-black/15 z-40" />
|
||||
|
||||
{/* Panel */}
|
||||
<div className="fixed top-0 right-0 bottom-0 md:w-[480px] w-full bg-white border-l border-gray-100 z-50 overflow-y-auto p-8">
|
||||
{/* Header */}
|
||||
<div className="flex items-start justify-between mb-8">
|
||||
<div>
|
||||
<p className="text-[13px] text-blue-500 mb-1.5">Control panel</p>
|
||||
<h2 className="text-[22px] font-normal text-gray-900 tracking-tight m-0">
|
||||
Manage services
|
||||
</h2>
|
||||
</div>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="border border-gray-200 rounded-lg px-3.5 py-1.5 text-[13px] text-gray-400 cursor-pointer hover:bg-gray-50 transition-colors"
|
||||
>
|
||||
Close
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Services */}
|
||||
<div className="border-t border-gray-100 pt-6 mb-6">
|
||||
<p className="text-[13px] text-gray-300 mb-3">Services</p>
|
||||
<div className="flex flex-col gap-2">
|
||||
{SERVICES.map((svc) => (
|
||||
<div
|
||||
key={svc}
|
||||
className="bg-gray-50 border border-gray-100 rounded-xl px-4 py-3 flex md:flex-row flex-col items-center justify-between gap-3"
|
||||
>
|
||||
<div className="flex items-center gap-2.5 flex-1 min-w-0">
|
||||
{statusDot(svc)}
|
||||
<p className="text-[14px] text-gray-900 m-0 truncate">
|
||||
{svc}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex gap-1.5 shrink-0">
|
||||
{["start", "stop", "restart"].map((action) => (
|
||||
<button
|
||||
key={action}
|
||||
onClick={() =>
|
||||
!isLoading(svc, action) && handleService(action, svc)
|
||||
}
|
||||
disabled={isLoading(svc, action)}
|
||||
className={btnClass(svc, action)}
|
||||
>
|
||||
{isLoading(svc, action)
|
||||
? "..."
|
||||
: action.charAt(0).toUpperCase() + action.slice(1)}
|
||||
</button>
|
||||
))}
|
||||
<button
|
||||
onClick={() => handleLogs(svc)}
|
||||
className="rounded-md px-2.5 py-1 text-[13px] whitespace-nowrap border border-blue-100 text-blue-400 cursor-pointer hover:bg-blue-50 transition-colors"
|
||||
>
|
||||
Logs
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* System */}
|
||||
<div className="border-t border-gray-100 pt-6">
|
||||
<p className="text-[13px] text-gray-300 mb-3">System</p>
|
||||
<div className="bg-gray-50 border border-gray-100 rounded-xl px-4 py-3.5 flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-[14px] text-gray-900 m-0 mb-0.5">
|
||||
Reboot server
|
||||
</p>
|
||||
<p className="text-[12px] text-gray-400 m-0">
|
||||
Immediately restarts the machine
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleReboot}
|
||||
className="border border-red-200 rounded-lg px-3.5 py-1.5 text-[13px] text-red-400 cursor-pointer hover:bg-red-50 transition-colors whitespace-nowrap"
|
||||
>
|
||||
Reboot
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Toast */}
|
||||
{toast && (
|
||||
<div
|
||||
className={`mt-6 px-4 py-3 rounded-lg text-[13px] border ${
|
||||
toast.ok
|
||||
? "bg-green-50 text-green-700 border-green-200"
|
||||
: "bg-red-50 text-red-500 border-red-200"
|
||||
}`}
|
||||
>
|
||||
{toast.message}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -3,34 +3,34 @@ import { DM_Sans, Playfair_Display } from "next/font/google"; // Import your spe
|
|||
import "./globals.css";
|
||||
|
||||
const dmSans = DM_Sans({
|
||||
variable: "--font-dm-sans",
|
||||
subsets: ["latin"],
|
||||
display: "swap",
|
||||
variable: "--font-dm-sans",
|
||||
subsets: ["latin"],
|
||||
display: "swap",
|
||||
});
|
||||
|
||||
const playfair = Playfair_Display({
|
||||
variable: "--font-playfair",
|
||||
subsets: ["latin"],
|
||||
display: "swap",
|
||||
variable: "--font-playfair",
|
||||
subsets: ["latin"],
|
||||
display: "swap",
|
||||
});
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Create Next App",
|
||||
description: "Generated by create next app",
|
||||
title: "Create Next App",
|
||||
description: "Generated by create next app",
|
||||
};
|
||||
|
||||
export default function RootLayout({
|
||||
children,
|
||||
children,
|
||||
}: Readonly<{
|
||||
children: React.ReactNode;
|
||||
children: React.ReactNode;
|
||||
}>) {
|
||||
return (
|
||||
<html
|
||||
lang="en"
|
||||
// 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>
|
||||
);
|
||||
return (
|
||||
<html
|
||||
lang="en"
|
||||
// Add the new font variables here
|
||||
className={`${dmSans.variable} ${playfair.variable} h-full antialiased`}
|
||||
>
|
||||
<body className="min-h-full h-full flex flex-col">{children}</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -50,6 +50,11 @@ export interface Stats {
|
|||
|
||||
export async function getStats(): Promise<Stats> {
|
||||
const res = await fetch("/api/stats");
|
||||
|
||||
if (res.status === 401) {
|
||||
throw new Error("UNAUTHORIZED");
|
||||
}
|
||||
|
||||
if (!res.ok) throw new Error(`Failed to fetch stats: ${res.status}`);
|
||||
return res.json() as Promise<Stats>;
|
||||
}
|
||||
|
|
|
|||
9
app/loading.tsx
Normal file
9
app/loading.tsx
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
"use client";
|
||||
|
||||
export default function Loading() {
|
||||
return (
|
||||
<main className="min-h-screen bg-gray-100 flex items-center justify-center">
|
||||
<div className="w-6 h-6 border-2 border-blue-400 border-t-transparent rounded-full animate-spin" />
|
||||
</main>
|
||||
);
|
||||
}
|
||||
164
app/page.tsx
164
app/page.tsx
|
|
@ -10,96 +10,112 @@ import ServicesCard from "./components/ServicesCard";
|
|||
import UptimeCard from "./components/UptimeCard";
|
||||
import NetworkCard from "./components/NetworkCard";
|
||||
import LinksGrid from "./components/LinksGrid";
|
||||
import { useCheckAuth } from "@/hooks/useCheckAuth";
|
||||
import { useRouter } from "next/navigation";
|
||||
|
||||
export default function Home() {
|
||||
const [stats, setStats] = useState<Stats | null>(null);
|
||||
const [netSpeed, setNetSpeed] = useState<Record<string, { rx: number; tx: number }>>({});
|
||||
const router = useRouter();
|
||||
useCheckAuth();
|
||||
|
||||
// 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);
|
||||
const [stats, setStats] = useState<Stats | null>(null);
|
||||
const [netSpeed, setNetSpeed] = useState<
|
||||
Record<string, { rx: number; tx: number }>
|
||||
>({});
|
||||
|
||||
useEffect(() => {
|
||||
const fetchData = async () => {
|
||||
try {
|
||||
const now = Date.now();
|
||||
const data = await getStats();
|
||||
// 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);
|
||||
|
||||
// 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 }> = {};
|
||||
useEffect(() => {
|
||||
const fetchData = async () => {
|
||||
try {
|
||||
const now = Date.now();
|
||||
const data = await getStats();
|
||||
|
||||
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);
|
||||
}
|
||||
// 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 }> = {};
|
||||
|
||||
// 2. Update our "silent" trackers
|
||||
prevNetRef.current = data.network;
|
||||
lastFetchRef.current = now;
|
||||
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);
|
||||
}
|
||||
|
||||
// 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);
|
||||
}
|
||||
};
|
||||
// 2. Update our "silent" trackers
|
||||
prevNetRef.current = data.network;
|
||||
lastFetchRef.current = now;
|
||||
|
||||
// Initial fetch
|
||||
fetchData();
|
||||
// 3. Update the UI state with new data
|
||||
// React's Virtual DOM will only update the changed text/numbers.
|
||||
setStats(data);
|
||||
} catch (e) {
|
||||
if (e instanceof Error && e.message === "UNAUTHORIZED") {
|
||||
router.push(
|
||||
"/auth?callbackUrl=" + encodeURIComponent(window.location.pathname),
|
||||
);
|
||||
return;
|
||||
}
|
||||
console.error("Dashboard fetch failed:", e);
|
||||
}
|
||||
};
|
||||
|
||||
// Start the 4s loop
|
||||
const id = setInterval(fetchData, 4000);
|
||||
// Initial fetch
|
||||
fetchData();
|
||||
|
||||
// Clean up on unmount so we don't have multiple intervals running
|
||||
return () => clearInterval(id);
|
||||
}, []); // Empty array means this setup only happens ONCE.
|
||||
// Start the 4s loop
|
||||
const id = setInterval(fetchData, 4000);
|
||||
|
||||
// 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;
|
||||
// Clean up on unmount so we don't have multiple intervals running
|
||||
return () => clearInterval(id);
|
||||
}, []); // Empty array means this setup only happens ONCE.
|
||||
|
||||
const primarySpeed = primaryIface ? netSpeed[primaryIface] || null : null;
|
||||
// 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;
|
||||
|
||||
return (
|
||||
<main className="max-w-5xl mx-auto px-6 pb-20">
|
||||
<NavBar online={!!stats} />
|
||||
<Hero lastUpdated={stats?.timestamp ?? null} />
|
||||
const primarySpeed = primaryIface ? netSpeed[primaryIface] || null : 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>
|
||||
return (
|
||||
<div className="max-w-5xl mx-auto px-6 pb-20">
|
||||
<NavBar online={!!stats} />
|
||||
<Hero lastUpdated={stats?.timestamp ?? null} />
|
||||
|
||||
<StatsGrid stats={stats} />
|
||||
<div className="flex items-baseline justify-between mb-5">
|
||||
<h2 className="text-lg font-medium tracking-tight text-gray-900">
|
||||
System Stats
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
<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>
|
||||
<StatsGrid stats={stats} />
|
||||
|
||||
<LinksGrid />
|
||||
</main>
|
||||
);
|
||||
<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 />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
20
hooks/useCheckAuth.ts
Normal file
20
hooks/useCheckAuth.ts
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
import { useEffect } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
|
||||
export function useCheckAuth() {
|
||||
const router = useRouter();
|
||||
|
||||
useEffect(() => {
|
||||
async function check() {
|
||||
const res = await fetch("/api/auth/check");
|
||||
console.log(res);
|
||||
if (!res.ok) {
|
||||
console.log("Invalid tokin");
|
||||
router.push(
|
||||
"/auth?callbackUrl=" + encodeURIComponent(window.location.pathname),
|
||||
);
|
||||
}
|
||||
}
|
||||
check();
|
||||
}, [router]);
|
||||
}
|
||||
|
|
@ -1,32 +0,0 @@
|
|||
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).*)'],
|
||||
};
|
||||
12
package-lock.json
generated
12
package-lock.json
generated
|
|
@ -10,7 +10,8 @@
|
|||
"dependencies": {
|
||||
"next": "16.2.1",
|
||||
"react": "19.2.4",
|
||||
"react-dom": "19.2.4"
|
||||
"react-dom": "19.2.4",
|
||||
"react-icons": "^5.6.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/postcss": "^4",
|
||||
|
|
@ -5463,6 +5464,15 @@
|
|||
"react": "^19.2.4"
|
||||
}
|
||||
},
|
||||
"node_modules/react-icons": {
|
||||
"version": "5.6.0",
|
||||
"resolved": "https://registry.npmjs.org/react-icons/-/react-icons-5.6.0.tgz",
|
||||
"integrity": "sha512-RH93p5ki6LfOiIt0UtDyNg/cee+HLVR6cHHtW3wALfo+eOHTp8RnU2kRkI6E+H19zMIs03DyxUG/GfZMOGvmiA==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"react": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/react-is": {
|
||||
"version": "16.13.1",
|
||||
"resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz",
|
||||
|
|
|
|||
|
|
@ -11,7 +11,8 @@
|
|||
"dependencies": {
|
||||
"next": "16.2.1",
|
||||
"react": "19.2.4",
|
||||
"react-dom": "19.2.4"
|
||||
"react-dom": "19.2.4",
|
||||
"react-icons": "^5.6.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/postcss": "^4",
|
||||
|
|
|
|||
25
proxy.ts
Normal file
25
proxy.ts
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
import { NextResponse } from "next/server";
|
||||
import type { NextRequest } from "next/server";
|
||||
|
||||
export function proxy(req: NextRequest) {
|
||||
const token = req.cookies.get("token")?.value;
|
||||
const { pathname } = req.nextUrl;
|
||||
|
||||
// always allow login page and auth api routes
|
||||
if (pathname.startsWith("/auth") || pathname.startsWith("/api/auth")) {
|
||||
return NextResponse.next();
|
||||
}
|
||||
|
||||
// no token — redirect to login
|
||||
if (!token) {
|
||||
const loginUrl = new URL("/auth", req.url);
|
||||
loginUrl.searchParams.set("callbackUrl", pathname);
|
||||
return NextResponse.redirect(loginUrl);
|
||||
}
|
||||
|
||||
return NextResponse.next();
|
||||
}
|
||||
|
||||
export const config = {
|
||||
matcher: ["/((?!_next/static|_next/image|favicon.ico).*)"],
|
||||
};
|
||||
Loading…
Add table
Add a link
Reference in a new issue