+
{lastUpdated
diff --git a/app/components/NavBar.tsx b/app/components/NavBar.tsx
index 8031c29..ff46e57 100644
--- a/app/components/NavBar.tsx
+++ b/app/components/NavBar.tsx
@@ -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 (
-
- );
+ 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 (
+
+ );
}
diff --git a/app/components/serverMenu.tsx b/app/components/serverMenu.tsx
new file mode 100644
index 0000000..a9e909f
--- /dev/null
+++ b/app/components/serverMenu.tsx
@@ -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;
+
+export default function ControlPanel({ onClose }: { onClose: () => void }) {
+ const [loading, setLoading] = useState>({});
+ const [toast, setToast] = useState(null);
+ const [statuses, setStatuses] = useState({});
+
+ 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 ;
+ }
+
+ 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 */}
+
+
+ {/* Panel */}
+
+ {/* Header */}
+
+
+
Control panel
+
+ Manage services
+
+
+
+
+
+ {/* Services */}
+
+
Services
+
+ {SERVICES.map((svc) => (
+
+
+ {statusDot(svc)}
+
+ {svc}
+
+
+
+ {["start", "stop", "restart"].map((action) => (
+
+ ))}
+
+
+
+ ))}
+
+
+
+ {/* System */}
+
+
System
+
+
+
+ Reboot server
+
+
+ Immediately restarts the machine
+
+
+
+
+
+
+ {/* Toast */}
+ {toast && (
+
+ {toast.message}
+
+ )}
+
+ >
+ );
+}
diff --git a/app/layout.tsx b/app/layout.tsx
index 4ed168b..d09470c 100644
--- a/app/layout.tsx
+++ b/app/layout.tsx
@@ -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 (
-
- {children}
-
- );
+ return (
+
+ {children}
+
+ );
}
diff --git a/app/lib/getStats.ts b/app/lib/getStats.ts
index 934b941..dee3af7 100644
--- a/app/lib/getStats.ts
+++ b/app/lib/getStats.ts
@@ -50,6 +50,11 @@ export interface Stats {
export async function getStats(): Promise {
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;
}
diff --git a/app/loading.tsx b/app/loading.tsx
new file mode 100644
index 0000000..3064987
--- /dev/null
+++ b/app/loading.tsx
@@ -0,0 +1,9 @@
+"use client";
+
+export default function Loading() {
+ return (
+
+
+
+ );
+}
diff --git a/app/page.tsx b/app/page.tsx
index 4aae247..bfb9bb6 100644
--- a/app/page.tsx
+++ b/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(null);
- const [netSpeed, setNetSpeed] = useState>({});
-
- // We use Refs for these because changing them shouldn't trigger a "refresh"
- // but we need them to calculate the delta (speed) between fetches.
- const prevNetRef = useRef | null>(null);
- const lastFetchRef = useRef(0);
+ const router = useRouter();
+ useCheckAuth();
- useEffect(() => {
- const fetchData = async () => {
- try {
- const now = Date.now();
- const data = await getStats();
+ const [stats, setStats] = useState(null);
+ const [netSpeed, setNetSpeed] = useState<
+ Record
+ >({});
- // 1. Calculate speeds if we have previous data
- if (prevNetRef.current && lastFetchRef.current > 0) {
- const elapsed = (now - lastFetchRef.current) / 1000;
- const speeds: Record = {};
-
- for (const iface of Object.keys(data.network)) {
- const prev = prevNetRef.current[iface];
- if (prev) {
- speeds[iface] = {
- rx: Math.max(0, (data.network[iface].rx - prev.rx) / elapsed),
- tx: Math.max(0, (data.network[iface].tx - prev.tx) / elapsed),
- };
- }
- }
- setNetSpeed(speeds);
- }
+ // We use Refs for these because changing them shouldn't trigger a "refresh"
+ // but we need them to calculate the delta (speed) between fetches.
+ const prevNetRef = useRef | null>(null);
+ const lastFetchRef = useRef(0);
- // 2. Update our "silent" trackers
- prevNetRef.current = data.network;
- lastFetchRef.current = now;
+ useEffect(() => {
+ const fetchData = async () => {
+ try {
+ const now = Date.now();
+ const data = await getStats();
- // 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);
- }
- };
+ // 1. Calculate speeds if we have previous data
+ if (prevNetRef.current && lastFetchRef.current > 0) {
+ const elapsed = (now - lastFetchRef.current) / 1000;
+ const speeds: Record = {};
- // Initial fetch
- fetchData();
+ 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);
+ }
- // Start the 4s loop
- const id = setInterval(fetchData, 4000);
+ // 2. Update our "silent" trackers
+ prevNetRef.current = data.network;
+ lastFetchRef.current = now;
- // Clean up on unmount so we don't have multiple intervals running
- return () => clearInterval(id);
- }, []); // Empty array means this setup only happens ONCE.
+ // 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);
+ }
+ };
- // 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;
+ // Initial fetch
+ fetchData();
- const primarySpeed = primaryIface ? netSpeed[primaryIface] || null : null;
+ // Start the 4s loop
+ const id = setInterval(fetchData, 4000);
- return (
-
-
-
+ // Clean up on unmount so we don't have multiple intervals running
+ return () => clearInterval(id);
+ }, []); // Empty array means this setup only happens ONCE.
-
-
- System Stats
-
-
-
-
+ // Derived values for the UI
+ const primaryIface = stats
+ ? Object.keys(stats.network).find(
+ (k) =>
+ !k.startsWith("docker") &&
+ !k.startsWith("br-") &&
+ stats.network[k].rx > 0,
+ )
+ : null;
-
+ const primarySpeed = primaryIface ? netSpeed[primaryIface] || null : null;
-
-
- );
+ return (
+
+
+
+
+
+
+ System Stats
+
+
+
+
+
+
+
+
+
+ );
}
diff --git a/hooks/useCheckAuth.ts b/hooks/useCheckAuth.ts
new file mode 100644
index 0000000..366b90d
--- /dev/null
+++ b/hooks/useCheckAuth.ts
@@ -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]);
+}
diff --git a/middleware.ts b/middleware.ts
deleted file mode 100644
index 655da3a..0000000
--- a/middleware.ts
+++ /dev/null
@@ -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).*)'],
-};
diff --git a/package-lock.json b/package-lock.json
index 51b2e49..44a6f39 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -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",
diff --git a/package.json b/package.json
index b3af769..a878289 100644
--- a/package.json
+++ b/package.json
@@ -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",
diff --git a/proxy.ts b/proxy.ts
new file mode 100644
index 0000000..292f8c0
--- /dev/null
+++ b/proxy.ts
@@ -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).*)"],
+};