Dev console
This commit is contained in:
parent
882ff7696a
commit
130bed1d1f
5 changed files with 4790 additions and 60 deletions
33
app/api/dev/proxy/route.ts
Normal file
33
app/api/dev/proxy/route.ts
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
import { NextRequest, NextResponse } from "next/server";
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
const token = req.cookies.get("token")?.value;
|
||||
if (!token) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
const { method, url, body } = (await req.json()) as {
|
||||
method: string;
|
||||
url: string;
|
||||
body?: unknown;
|
||||
};
|
||||
|
||||
const targetUrl = url.startsWith("http")
|
||||
? url
|
||||
: `http://localhost:3001${url.startsWith("/") ? url : `/${url}`}`;
|
||||
|
||||
const res = await fetch(targetUrl, {
|
||||
method,
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
...(body !== undefined ? { "Content-Type": "application/json" } : {}),
|
||||
},
|
||||
body: body !== undefined ? JSON.stringify(body) : undefined,
|
||||
});
|
||||
|
||||
const text = await res.text();
|
||||
return new NextResponse(text, {
|
||||
status: res.status,
|
||||
headers: { "Content-Type": res.headers.get("Content-Type") ?? "text/plain" },
|
||||
});
|
||||
}
|
||||
481
app/components/DevConsole.tsx
Normal file
481
app/components/DevConsole.tsx
Normal file
|
|
@ -0,0 +1,481 @@
|
|||
"use client";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { LuX, LuTerminal, LuSend } from "react-icons/lu";
|
||||
|
||||
export interface LogEntry {
|
||||
id: number;
|
||||
method: string;
|
||||
path: string;
|
||||
url: string;
|
||||
status: number | null;
|
||||
duration: number | null;
|
||||
timestamp: string;
|
||||
response: string | null;
|
||||
}
|
||||
|
||||
interface DevConsoleProps {
|
||||
open: boolean;
|
||||
width: number;
|
||||
isMobile: boolean;
|
||||
onClose: () => void;
|
||||
onWidthChange: (w: number) => void;
|
||||
logs: LogEntry[];
|
||||
}
|
||||
|
||||
function tryPretty(text: string): string {
|
||||
try {
|
||||
return JSON.stringify(JSON.parse(text), null, 2);
|
||||
} catch {
|
||||
return text;
|
||||
}
|
||||
}
|
||||
|
||||
function methodBg(method: string): string {
|
||||
switch (method) {
|
||||
case "GET": return "#1d4ed8";
|
||||
case "POST": return "#15803d";
|
||||
case "PUT": return "#b45309";
|
||||
case "PATCH": return "#6d28d9";
|
||||
case "DELETE": return "#b91c1c";
|
||||
default: return "#475569";
|
||||
}
|
||||
}
|
||||
|
||||
export default function DevConsole({
|
||||
open, width, isMobile, onClose, onWidthChange, logs,
|
||||
}: DevConsoleProps) {
|
||||
const [activeTab, setActiveTab] = useState<"logs" | "request">("logs");
|
||||
const [expandedId, setExpandedId] = useState<number | null>(null);
|
||||
const [reqMethod, setReqMethod] = useState("GET");
|
||||
const [reqUrl, setReqUrl] = useState("/api/power");
|
||||
const [reqBody, setReqBody] = useState("");
|
||||
const [reqResponse, setReqResponse] = useState<{ status: number; body: string } | null>(null);
|
||||
const [reqLoading, setReqLoading] = useState(false);
|
||||
|
||||
const dragRef = useRef<{ startX: number; startWidth: number } | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const onMove = (e: MouseEvent) => {
|
||||
if (!dragRef.current) return;
|
||||
const delta = dragRef.current.startX - e.clientX;
|
||||
const next = Math.max(300, Math.min(900, dragRef.current.startWidth + delta));
|
||||
onWidthChange(next);
|
||||
};
|
||||
const onUp = () => {
|
||||
if (!dragRef.current) return;
|
||||
dragRef.current = null;
|
||||
document.body.style.userSelect = "";
|
||||
document.body.style.cursor = "";
|
||||
};
|
||||
document.addEventListener("mousemove", onMove);
|
||||
document.addEventListener("mouseup", onUp);
|
||||
return () => {
|
||||
document.removeEventListener("mousemove", onMove);
|
||||
document.removeEventListener("mouseup", onUp);
|
||||
};
|
||||
}, [onWidthChange]);
|
||||
|
||||
async function sendRequest() {
|
||||
setReqLoading(true);
|
||||
setReqResponse(null);
|
||||
try {
|
||||
const isAbsolute = reqUrl.startsWith("http://") || reqUrl.startsWith("https://");
|
||||
let fetchUrl: string;
|
||||
let fetchInit: RequestInit;
|
||||
|
||||
if (isAbsolute) {
|
||||
fetchUrl = "/api/dev/proxy";
|
||||
fetchInit = {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ method: reqMethod, url: reqUrl, body: reqBody || undefined }),
|
||||
};
|
||||
} else {
|
||||
fetchUrl = reqUrl;
|
||||
fetchInit = {
|
||||
method: reqMethod,
|
||||
headers: reqBody ? { "Content-Type": "application/json" } : {},
|
||||
body: ["POST", "PUT", "PATCH"].includes(reqMethod) && reqBody ? reqBody : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
const res = await fetch(fetchUrl, fetchInit);
|
||||
const text = await res.text();
|
||||
setReqResponse({ status: res.status, body: tryPretty(text) });
|
||||
} catch (e) {
|
||||
setReqResponse({ status: 0, body: String(e) });
|
||||
} finally {
|
||||
setReqLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
const panelWidth = isMobile ? "100%" : `${width}px`;
|
||||
const panelLeft = isMobile ? "0" : "auto";
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
position: "fixed",
|
||||
top: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
left: panelLeft,
|
||||
width: panelWidth,
|
||||
zIndex: 50,
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
background: "#0f172a",
|
||||
borderLeft: "1px solid #1e293b",
|
||||
transform: open ? "translateX(0)" : "translateX(100%)",
|
||||
transition: "transform 280ms cubic-bezier(0.4,0,0.2,1)",
|
||||
}}
|
||||
>
|
||||
{/* Drag handle — desktop only */}
|
||||
{!isMobile && (
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
left: 0,
|
||||
top: 0,
|
||||
bottom: 0,
|
||||
width: "4px",
|
||||
cursor: "col-resize",
|
||||
zIndex: 10,
|
||||
}}
|
||||
onMouseDown={(e) => {
|
||||
e.preventDefault();
|
||||
dragRef.current = { startX: e.clientX, startWidth: width };
|
||||
document.body.style.userSelect = "none";
|
||||
document.body.style.cursor = "col-resize";
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Header */}
|
||||
<div style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: "8px",
|
||||
padding: "10px 14px",
|
||||
borderBottom: "1px solid #1e293b",
|
||||
flexShrink: 0,
|
||||
}}>
|
||||
<LuTerminal size={13} style={{ color: "#475569" }} />
|
||||
<span style={{
|
||||
fontSize: "0.65rem",
|
||||
fontWeight: 700,
|
||||
letterSpacing: "0.1em",
|
||||
textTransform: "uppercase",
|
||||
color: "#475569",
|
||||
}}>
|
||||
Dev Console
|
||||
</span>
|
||||
|
||||
<div style={{ marginLeft: "auto", display: "flex", alignItems: "center", gap: "4px" }}>
|
||||
{(["logs", "request"] as const).map((tab) => (
|
||||
<button
|
||||
key={tab}
|
||||
onClick={() => setActiveTab(tab)}
|
||||
style={{
|
||||
fontSize: "0.68rem",
|
||||
fontWeight: 500,
|
||||
padding: "3px 9px",
|
||||
borderRadius: "4px",
|
||||
border: "none",
|
||||
cursor: "pointer",
|
||||
background: activeTab === tab ? "#1e293b" : "transparent",
|
||||
color: activeTab === tab ? "#e2e8f0" : "#475569",
|
||||
textTransform: "capitalize",
|
||||
}}
|
||||
>
|
||||
{tab === "logs" ? `Logs${logs.length > 0 ? ` (${logs.length})` : ""}` : "Request"}
|
||||
</button>
|
||||
))}
|
||||
<button
|
||||
onClick={onClose}
|
||||
style={{
|
||||
marginLeft: "4px",
|
||||
background: "transparent",
|
||||
border: "none",
|
||||
cursor: "pointer",
|
||||
color: "#475569",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
padding: "2px",
|
||||
}}
|
||||
>
|
||||
<LuX size={13} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Logs tab */}
|
||||
{activeTab === "logs" && (
|
||||
<div style={{ flex: 1, overflowY: "auto", fontFamily: "ui-monospace, 'Cascadia Code', monospace" }}>
|
||||
{logs.length === 0 ? (
|
||||
<div style={{ padding: "32px 16px", textAlign: "center", color: "#1e293b", fontSize: "0.75rem" }}>
|
||||
No requests captured yet
|
||||
</div>
|
||||
) : (
|
||||
[...logs].reverse().map((entry) => (
|
||||
<LogRow
|
||||
key={entry.id}
|
||||
entry={entry}
|
||||
expanded={expandedId === entry.id}
|
||||
onToggle={() => setExpandedId(expandedId === entry.id ? null : entry.id)}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Request tab */}
|
||||
{activeTab === "request" && (
|
||||
<div style={{ flex: 1, display: "flex", flexDirection: "column", overflow: "hidden" }}>
|
||||
<div style={{ padding: "12px", borderBottom: "1px solid #1e293b", flexShrink: 0 }}>
|
||||
<div style={{ display: "flex", gap: "6px", marginBottom: "8px" }}>
|
||||
<select
|
||||
value={reqMethod}
|
||||
onChange={(e) => setReqMethod(e.target.value)}
|
||||
style={{
|
||||
background: "#1e293b",
|
||||
border: "1px solid #334155",
|
||||
borderRadius: "6px",
|
||||
color: "#e2e8f0",
|
||||
fontSize: "0.68rem",
|
||||
padding: "5px 6px",
|
||||
cursor: "pointer",
|
||||
fontFamily: "ui-monospace, monospace",
|
||||
outline: "none",
|
||||
}}
|
||||
>
|
||||
{["GET", "POST", "PUT", "PATCH", "DELETE"].map((m) => (
|
||||
<option key={m}>{m}</option>
|
||||
))}
|
||||
</select>
|
||||
<input
|
||||
value={reqUrl}
|
||||
onChange={(e) => setReqUrl(e.target.value)}
|
||||
onKeyDown={(e) => e.key === "Enter" && sendRequest()}
|
||||
placeholder="/api/power"
|
||||
style={{
|
||||
flex: 1,
|
||||
background: "#1e293b",
|
||||
border: "1px solid #334155",
|
||||
borderRadius: "6px",
|
||||
color: "#e2e8f0",
|
||||
fontSize: "0.68rem",
|
||||
padding: "5px 8px",
|
||||
fontFamily: "ui-monospace, monospace",
|
||||
outline: "none",
|
||||
minWidth: 0,
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
onClick={sendRequest}
|
||||
disabled={reqLoading}
|
||||
style={{
|
||||
background: "#2563eb",
|
||||
border: "none",
|
||||
borderRadius: "6px",
|
||||
color: "white",
|
||||
padding: "5px 11px",
|
||||
cursor: reqLoading ? "default" : "pointer",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: "4px",
|
||||
fontSize: "0.68rem",
|
||||
fontWeight: 500,
|
||||
opacity: reqLoading ? 0.6 : 1,
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
<LuSend size={11} />
|
||||
Send
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{["POST", "PUT", "PATCH"].includes(reqMethod) && (
|
||||
<textarea
|
||||
value={reqBody}
|
||||
onChange={(e) => setReqBody(e.target.value)}
|
||||
placeholder='{"key": "value"}'
|
||||
rows={4}
|
||||
style={{
|
||||
width: "100%",
|
||||
background: "#1e293b",
|
||||
border: "1px solid #334155",
|
||||
borderRadius: "6px",
|
||||
color: "#e2e8f0",
|
||||
fontSize: "0.68rem",
|
||||
padding: "6px 8px",
|
||||
fontFamily: "ui-monospace, monospace",
|
||||
resize: "vertical",
|
||||
outline: "none",
|
||||
boxSizing: "border-box",
|
||||
marginBottom: "8px",
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div style={{ fontSize: "0.6rem", color: "#334155" }}>
|
||||
Bearer token attached via cookie ·{" "}
|
||||
<span style={{ color: "#3b4a5e" }}>use</span>{" "}
|
||||
<code style={{ color: "#475569" }}>http://localhost:3001/path</code>{" "}
|
||||
<span style={{ color: "#3b4a5e" }}>to bypass Next.js proxy</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{
|
||||
flex: 1,
|
||||
overflow: "auto",
|
||||
padding: "12px",
|
||||
fontFamily: "ui-monospace, 'Cascadia Code', monospace",
|
||||
}}>
|
||||
{reqLoading && (
|
||||
<div style={{ fontSize: "0.7rem", color: "#475569" }}>Sending…</div>
|
||||
)}
|
||||
{!reqLoading && reqResponse && (
|
||||
<>
|
||||
<div style={{
|
||||
fontSize: "0.65rem",
|
||||
fontWeight: 600,
|
||||
marginBottom: "8px",
|
||||
color: reqResponse.status >= 200 && reqResponse.status < 300 ? "#34d399" : "#f87171",
|
||||
}}>
|
||||
HTTP {reqResponse.status}
|
||||
</div>
|
||||
<pre style={{
|
||||
fontSize: "0.65rem",
|
||||
color: "#94a3b8",
|
||||
margin: 0,
|
||||
whiteSpace: "pre-wrap",
|
||||
wordBreak: "break-all",
|
||||
lineHeight: 1.6,
|
||||
}}>
|
||||
{reqResponse.body}
|
||||
</pre>
|
||||
</>
|
||||
)}
|
||||
{!reqLoading && !reqResponse && (
|
||||
<div style={{ fontSize: "0.7rem", color: "#1e293b" }}>
|
||||
Response will appear here
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function LogRow({
|
||||
entry,
|
||||
expanded,
|
||||
onToggle,
|
||||
}: {
|
||||
entry: LogEntry;
|
||||
expanded: boolean;
|
||||
onToggle: () => void;
|
||||
}) {
|
||||
const [hovered, setHovered] = useState(false);
|
||||
const statusColor =
|
||||
entry.status === null
|
||||
? "#475569"
|
||||
: entry.status >= 200 && entry.status < 300
|
||||
? "#34d399"
|
||||
: "#f87171";
|
||||
|
||||
return (
|
||||
<div style={{ borderBottom: "1px solid #0f1a2a" }}>
|
||||
<div
|
||||
onClick={onToggle}
|
||||
onMouseEnter={() => setHovered(true)}
|
||||
onMouseLeave={() => setHovered(false)}
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: "8px",
|
||||
padding: "6px 12px",
|
||||
cursor: "pointer",
|
||||
background: hovered ? "#1a2540" : "transparent",
|
||||
}}
|
||||
>
|
||||
<span style={{
|
||||
fontSize: "0.58rem",
|
||||
fontWeight: 700,
|
||||
padding: "1px 5px",
|
||||
borderRadius: "3px",
|
||||
minWidth: "38px",
|
||||
textAlign: "center",
|
||||
background: methodBg(entry.method),
|
||||
color: "white",
|
||||
flexShrink: 0,
|
||||
letterSpacing: "0.02em",
|
||||
}}>
|
||||
{entry.method}
|
||||
</span>
|
||||
<span style={{
|
||||
fontSize: "0.63rem",
|
||||
fontWeight: 600,
|
||||
minWidth: "26px",
|
||||
textAlign: "right",
|
||||
color: statusColor,
|
||||
flexShrink: 0,
|
||||
}}>
|
||||
{entry.status ?? "···"}
|
||||
</span>
|
||||
<span style={{
|
||||
fontSize: "0.68rem",
|
||||
color: "#94a3b8",
|
||||
flex: 1,
|
||||
overflow: "hidden",
|
||||
textOverflow: "ellipsis",
|
||||
whiteSpace: "nowrap",
|
||||
}}>
|
||||
{entry.path}
|
||||
</span>
|
||||
<span style={{ fontSize: "0.6rem", color: "#334155", flexShrink: 0 }}>
|
||||
{entry.duration !== null ? `${entry.duration}ms` : ""}
|
||||
</span>
|
||||
<span style={{ fontSize: "0.6rem", color: "#1e293b", flexShrink: 0 }}>
|
||||
{entry.timestamp}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{expanded && (
|
||||
<div style={{
|
||||
background: "#080d18",
|
||||
padding: "8px 12px 10px",
|
||||
borderTop: "1px solid #1e293b",
|
||||
}}>
|
||||
<div style={{
|
||||
fontSize: "0.6rem",
|
||||
color: "#334155",
|
||||
marginBottom: "6px",
|
||||
wordBreak: "break-all",
|
||||
}}>
|
||||
{entry.url}
|
||||
</div>
|
||||
{entry.response !== null && (
|
||||
<pre style={{
|
||||
fontSize: "0.63rem",
|
||||
color: "#94a3b8",
|
||||
margin: 0,
|
||||
overflow: "auto",
|
||||
maxHeight: "220px",
|
||||
whiteSpace: "pre-wrap",
|
||||
wordBreak: "break-all",
|
||||
lineHeight: 1.5,
|
||||
}}>
|
||||
{(() => {
|
||||
try { return JSON.stringify(JSON.parse(entry.response), null, 2); }
|
||||
catch { return entry.response; }
|
||||
})()}
|
||||
</pre>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,12 +1,15 @@
|
|||
"use client";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { LuCode } from "react-icons/lu";
|
||||
|
||||
interface NavBarProps {
|
||||
online: boolean;
|
||||
devConsoleOpen: boolean;
|
||||
onToggleDevConsole: () => void;
|
||||
}
|
||||
|
||||
export default function NavBar({ online }: NavBarProps) {
|
||||
export default function NavBar({ online, devConsoleOpen, onToggleDevConsole }: NavBarProps) {
|
||||
const router = useRouter();
|
||||
const [auth, setAuth] = useState(false);
|
||||
|
||||
|
|
@ -34,21 +37,35 @@ export default function NavBar({ online }: NavBarProps) {
|
|||
)}
|
||||
</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 className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={onToggleDevConsole}
|
||||
className={`flex items-center gap-1.5 text-xs px-3 py-1.5 rounded-full border cursor-pointer transition-colors ${
|
||||
devConsoleOpen
|
||||
? "text-blue-700 bg-blue-50 border-blue-200"
|
||||
: "text-gray-500 bg-gray-50 border-gray-200 hover:border-gray-300 hover:text-gray-700"
|
||||
}`}
|
||||
>
|
||||
<LuCode size={12} />
|
||||
Dev
|
||||
</button>
|
||||
|
||||
<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>
|
||||
</div>
|
||||
</nav>
|
||||
);
|
||||
|
|
|
|||
169
app/page.tsx
169
app/page.tsx
|
|
@ -11,6 +11,7 @@ import UptimeCard from "./components/UptimeCard";
|
|||
import NetworkCard from "./components/NetworkCard";
|
||||
import PowerGrid from "./components/PowerGrid";
|
||||
import LinksGrid from "./components/LinksGrid";
|
||||
import DevConsole, { type LogEntry } from "./components/DevConsole";
|
||||
import { useCheckAuth } from "@/hooks/useCheckAuth";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { getPower, type PowerData } from "./lib/getPower";
|
||||
|
|
@ -25,10 +26,79 @@ export default function Home() {
|
|||
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 [panelOpen, setPanelOpen] = useState(false);
|
||||
const [panelWidth, setPanelWidth] = useState(440);
|
||||
const [isMobile, setIsMobile] = useState(false);
|
||||
const [logs, setLogs] = useState<LogEntry[]>([]);
|
||||
|
||||
const prevNetRef = useRef<Record<string, NetworkInterface> | null>(null);
|
||||
const lastFetchRef = useRef<number>(0);
|
||||
const logIdRef = useRef(0);
|
||||
|
||||
// Mobile detection
|
||||
useEffect(() => {
|
||||
const check = () => setIsMobile(window.innerWidth < 768);
|
||||
check();
|
||||
window.addEventListener("resize", check);
|
||||
return () => window.removeEventListener("resize", check);
|
||||
}, []);
|
||||
|
||||
// Fetch interceptor — captures all /api/* requests
|
||||
useEffect(() => {
|
||||
const original = window.fetch;
|
||||
|
||||
window.fetch = async (input, init) => {
|
||||
const url =
|
||||
typeof input === "string"
|
||||
? input
|
||||
: input instanceof URL
|
||||
? input.href
|
||||
: (input as Request).url;
|
||||
|
||||
const shouldLog =
|
||||
(url.startsWith("/api/") && !url.startsWith("/api/dev/proxy")) ||
|
||||
url.includes("localhost:3001");
|
||||
|
||||
if (!shouldLog) return original(input, init);
|
||||
|
||||
const method = ((init?.method ?? (input instanceof Request ? input.method : "GET")) as string).toUpperCase();
|
||||
const id = ++logIdRef.current;
|
||||
let path = url;
|
||||
try {
|
||||
path = new URL(url, window.location.origin).pathname;
|
||||
} catch {
|
||||
// use raw url
|
||||
}
|
||||
const timestamp = new Date().toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", second: "2-digit" });
|
||||
const start = Date.now();
|
||||
|
||||
setLogs((prev) => [
|
||||
...prev,
|
||||
{ id, method, path, url, status: null, duration: null, timestamp, response: null },
|
||||
]);
|
||||
|
||||
try {
|
||||
const res = await original(input, init);
|
||||
const clone = res.clone();
|
||||
const text = await clone.text();
|
||||
const duration = Date.now() - start;
|
||||
setLogs((prev) =>
|
||||
prev.map((e) => (e.id === id ? { ...e, status: res.status, duration, response: text } : e))
|
||||
);
|
||||
return res;
|
||||
} catch (err) {
|
||||
const duration = Date.now() - start;
|
||||
setLogs((prev) =>
|
||||
prev.map((e) => (e.id === id ? { ...e, status: 0, duration, response: String(err) } : e))
|
||||
);
|
||||
throw err;
|
||||
}
|
||||
};
|
||||
|
||||
return () => {
|
||||
window.fetch = original;
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchData = async () => {
|
||||
|
|
@ -36,7 +106,6 @@ export default function Home() {
|
|||
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 }> = {};
|
||||
|
|
@ -53,12 +122,8 @@ export default function Home() {
|
|||
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) {
|
||||
if (e instanceof Error && e.message === "UNAUTHORIZED") {
|
||||
|
|
@ -71,13 +136,8 @@ export default function Home() {
|
|||
}
|
||||
};
|
||||
|
||||
// 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);
|
||||
}, []);
|
||||
|
||||
|
|
@ -94,9 +154,8 @@ export default function Home() {
|
|||
fetchPower();
|
||||
const id = setInterval(fetchPower, 10000);
|
||||
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) =>
|
||||
|
|
@ -109,39 +168,61 @@ export default function Home() {
|
|||
const primarySpeed = primaryIface ? netSpeed[primaryIface] || null : null;
|
||||
|
||||
return (
|
||||
<div 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
|
||||
style={{
|
||||
paddingRight: panelOpen && !isMobile ? panelWidth : 0,
|
||||
transition: "padding-right 280ms cubic-bezier(0.4,0,0.2,1)",
|
||||
}}
|
||||
>
|
||||
<div className="max-w-5xl mx-auto px-6 pb-20">
|
||||
<NavBar
|
||||
online={!!stats}
|
||||
devConsoleOpen={panelOpen}
|
||||
onToggleDevConsole={() => setPanelOpen((o) => !o)}
|
||||
/>
|
||||
<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>
|
||||
|
||||
<div className="flex items-baseline justify-between mb-5">
|
||||
<h2 className="text-lg font-medium tracking-tight text-gray-900">
|
||||
Power Consumption
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
<PowerGrid power={power} />
|
||||
|
||||
<LinksGrid />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-baseline justify-between mb-5">
|
||||
<h2 className="text-lg font-medium tracking-tight text-gray-900">
|
||||
Power Consumption
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
<PowerGrid power={power} />
|
||||
|
||||
<LinksGrid />
|
||||
</div>
|
||||
<DevConsole
|
||||
open={panelOpen}
|
||||
width={panelWidth}
|
||||
isMobile={isMobile}
|
||||
onClose={() => setPanelOpen(false)}
|
||||
onWidthChange={setPanelWidth}
|
||||
logs={logs}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue