Initial Meterbox commit with boot disk usage metric.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
JamesandCursor committed 2026-09-19 02:45:52 +00:00
commit 4e44204d9b
55 files changed
+12104

No files matched your search

+24
View File
@@ -0,0 +1,24 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
+8
View File
@@ -0,0 +1,8 @@
{
"$schema": "./node_modules/oxlint/configuration_schema.json",
"plugins": ["react", "typescript", "oxc"],
"rules": {
"react/rules-of-hooks": "error",
"react/only-export-components": ["warn", { "allowConstantExport": true }]
}
}
+18
View File
@@ -0,0 +1,18 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Meterbox</title>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link
href="https://fonts.googleapis.com/css2?family=Bricolage+Grotesque:opsz,wght@12..96,500;12..96,700&family=IBM+Plex+Mono:wght@400;500&family=IBM+Plex+Sans:wght@400;500;600&display=swap"
rel="stylesheet"
/>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
+1756
View File
File diff suppressed because it is too large. Load diff
+29
View File
@@ -0,0 +1,29 @@
{
"name": "frontend",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"lint": "oxlint",
"preview": "vite preview"
},
"dependencies": {
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
"react": "^19.2.8",
"react-dom": "^19.2.8",
"recharts": "^3.10.1"
},
"devDependencies": {
"@types/node": "^24.13.5",
"@types/react": "^19.2.18",
"@types/react-dom": "^19.2.7",
"@vitejs/plugin-react": "^6.1.1",
"oxlint": "^1.81.0",
"typescript": "~6.0.2",
"vite": "^8.3.0"
}
}
+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="48" height="46" fill="none" viewBox="0 0 48 46"><path fill="#863bff" d="M25.946 44.938c-.664.845-2.021.375-2.021-.698V33.937a2.26 2.26 0 0 0-2.262-2.262H10.287c-.92 0-1.456-1.04-.92-1.788l7.48-10.471c1.07-1.497 0-3.578-1.842-3.578H1.237c-.92 0-1.456-1.04-.92-1.788L10.013.474c.214-.297.556-.474.92-.474h28.894c.92 0 1.456 1.04.92 1.788l-7.48 10.471c-1.07 1.498 0 3.579 1.842 3.579h11.377c.943 0 1.473 1.088.89 1.83L25.947 44.94z" style="fill:#863bff;fill:color(display-p3 .5252 .23 1);fill-opacity:1"/><mask id="a" width="48" height="46" x="0" y="0" maskUnits="userSpaceOnUse" style="mask-type:alpha"><path fill="#000" d="M25.842 44.938c-.664.844-2.021.375-2.021-.698V33.937a2.26 2.26 0 0 0-2.262-2.262H10.183c-.92 0-1.456-1.04-.92-1.788l7.48-10.471c1.07-1.498 0-3.579-1.842-3.579H1.133c-.92 0-1.456-1.04-.92-1.787L9.91.473c.214-.297.556-.474.92-.474h28.894c.92 0 1.456 1.04.92 1.788l-7.48 10.471c-1.07 1.498 0 3.578 1.842 3.578h11.377c.943 0 1.473 1.088.89 1.832L25.843 44.94z" style="fill:#000;fill-opacity:1"/></mask><g mask="url(#a)"><g filter="url(#b)"><ellipse cx="5.508" cy="14.704" fill="#ede6ff" rx="5.508" ry="14.704" style="fill:#ede6ff;fill:color(display-p3 .9275 .9033 1);fill-opacity:1" transform="matrix(.00324 1 1 -.00324 -4.47 31.516)"/></g><g filter="url(#c)"><ellipse cx="10.399" cy="29.851" fill="#ede6ff" rx="10.399" ry="29.851" style="fill:#ede6ff;fill:color(display-p3 .9275 .9033 1);fill-opacity:1" transform="matrix(.00324 1 1 -.00324 -39.328 7.883)"/></g><g filter="url(#d)"><ellipse cx="5.508" cy="30.487" fill="#7e14ff" rx="5.508" ry="30.487" style="fill:#7e14ff;fill:color(display-p3 .4922 .0767 1);fill-opacity:1" transform="rotate(89.814 -25.913 -14.639)scale(1 -1)"/></g><g filter="url(#e)"><ellipse cx="5.508" cy="30.599" fill="#7e14ff" rx="5.508" ry="30.599" style="fill:#7e14ff;fill:color(display-p3 .4922 .0767 1);fill-opacity:1" transform="rotate(89.814 -32.644 -3.334)scale(1 -1)"/></g><g filter="url(#f)"><ellipse cx="5.508" cy="30.599" fill="#7e14ff" rx="5.508" ry="30.599" style="fill:#7e14ff;fill:color(display-p3 .4922 .0767 1);fill-opacity:1" transform="matrix(.00324 1 1 -.00324 -34.34 30.47)"/></g><g filter="url(#g)"><ellipse cx="14.072" cy="22.078" fill="#ede6ff" rx="14.072" ry="22.078" style="fill:#ede6ff;fill:color(display-p3 .9275 .9033 1);fill-opacity:1" transform="rotate(93.35 24.506 48.493)scale(-1 1)"/></g><g filter="url(#h)"><ellipse cx="3.47" cy="21.501" fill="#7e14ff" rx="3.47" ry="21.501" style="fill:#7e14ff;fill:color(display-p3 .4922 .0767 1);fill-opacity:1" transform="rotate(89.009 28.708 47.59)scale(-1 1)"/></g><g filter="url(#i)"><ellipse cx="3.47" cy="21.501" fill="#7e14ff" rx="3.47" ry="21.501" style="fill:#7e14ff;fill:color(display-p3 .4922 .0767 1);fill-opacity:1" transform="rotate(89.009 28.708 47.59)scale(-1 1)"/></g><g filter="url(#j)"><ellipse cx=".387" cy="8.972" fill="#7e14ff" rx="4.407" ry="29.108" style="fill:#7e14ff;fill:color(display-p3 .4922 .0767 1);fill-opacity:1" transform="rotate(39.51 .387 8.972)"/></g><g filter="url(#k)"><ellipse cx="47.523" cy="-6.092" fill="#7e14ff" rx="4.407" ry="29.108" style="fill:#7e14ff;fill:color(display-p3 .4922 .0767 1);fill-opacity:1" transform="rotate(37.892 47.523 -6.092)"/></g><g filter="url(#l)"><ellipse cx="41.412" cy="6.333" fill="#47bfff" rx="5.971" ry="9.665" style="fill:#47bfff;fill:color(display-p3 .2799 .748 1);fill-opacity:1" transform="rotate(37.892 41.412 6.333)"/></g><g filter="url(#m)"><ellipse cx="-1.879" cy="38.332" fill="#7e14ff" rx="4.407" ry="29.108" style="fill:#7e14ff;fill:color(display-p3 .4922 .0767 1);fill-opacity:1" transform="rotate(37.892 -1.88 38.332)"/></g><g filter="url(#n)"><ellipse cx="-1.879" cy="38.332" fill="#7e14ff" rx="4.407" ry="29.108" style="fill:#7e14ff;fill:color(display-p3 .4922 .0767 1);fill-opacity:1" transform="rotate(37.892 -1.88 38.332)"/></g><g filter="url(#o)"><ellipse cx="35.651" cy="29.907" fill="#7e14ff" rx="4.407" ry="29.108" style="fill:#7e14ff;fill:color(display-p3 .4922 .0767 1);fill-opacity:1" transform="rotate(37.892 35.651 29.907)"/></g><g filter="url(#p)"><ellipse cx="38.418" cy="32.4" fill="#47bfff" rx="5.971" ry="15.297" style="fill:#47bfff;fill:color(display-p3 .2799 .748 1);fill-opacity:1" transform="rotate(37.892 38.418 32.4)"/></g></g><defs><filter id="b" width="60.045" height="41.654" x="-19.77" y="16.149" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape"/><feGaussianBlur result="effect1_foregroundBlur_2002_17158" stdDeviation="7.659"/></filter><filter id="c" width="90.34" height="51.437" x="-54.613" y="-7.533" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape"/><feGaussianBlur result="effect1_foregroundBlur_2002_17158" stdDeviation="7.659"/></filtLine truncated

After

Width:  |  Height:  |  Size: 9.3 KiB

+24
View File
@@ -0,0 +1,24 @@
<svg xmlns="http://www.w3.org/2000/svg">
<symbol id="bluesky-icon" viewBox="0 0 16 17">
<g clip-path="url(#bluesky-clip)"><path fill="#08060d" d="M7.75 7.735c-.693-1.348-2.58-3.86-4.334-5.097-1.68-1.187-2.32-.981-2.74-.79C.188 2.065.1 2.812.1 3.251s.241 3.602.398 4.13c.52 1.744 2.367 2.333 4.07 2.145-2.495.37-4.71 1.278-1.805 4.512 3.196 3.309 4.38-.71 4.987-2.746.608 2.036 1.307 5.91 4.93 2.746 2.72-2.746.747-4.143-1.747-4.512 1.702.189 3.55-.4 4.07-2.145.156-.528.397-3.691.397-4.13s-.088-1.186-.575-1.406c-.42-.19-1.06-.395-2.741.79-1.755 1.24-3.64 3.752-4.334 5.099"/></g>
<defs><clipPath id="bluesky-clip"><path fill="#fff" d="M.1.85h15.3v15.3H.1z"/></clipPath></defs>
</symbol>
<symbol id="discord-icon" viewBox="0 0 20 19">
<path fill="#08060d" d="M16.224 3.768a14.5 14.5 0 0 0-3.67-1.153c-.158.286-.343.67-.47.976a13.5 13.5 0 0 0-4.067 0c-.128-.306-.317-.69-.476-.976A14.4 14.4 0 0 0 3.868 3.77C1.546 7.28.916 10.703 1.231 14.077a14.7 14.7 0 0 0 4.5 2.306q.545-.748.965-1.587a9.5 9.5 0 0 1-1.518-.74q.191-.14.372-.293c2.927 1.369 6.107 1.369 8.999 0q.183.152.372.294-.723.437-1.52.74.418.838.963 1.588a14.6 14.6 0 0 0 4.504-2.308c.37-3.911-.63-7.302-2.644-10.309m-9.13 8.234c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.894 0 1.614.82 1.599 1.82.001 1-.705 1.82-1.6 1.82m5.91 0c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.893 0 1.614.82 1.599 1.82 0 1-.706 1.82-1.6 1.82"/>
</symbol>
<symbol id="documentation-icon" viewBox="0 0 21 20">
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="m15.5 13.333 1.533 1.322c.645.555.967.833.967 1.178s-.322.623-.967 1.179L15.5 18.333m-3.333-5-1.534 1.322c-.644.555-.966.833-.966 1.178s.322.623.966 1.179l1.534 1.321"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M17.167 10.836v-4.32c0-1.41 0-2.117-.224-2.68-.359-.906-1.118-1.621-2.08-1.96-.599-.21-1.349-.21-2.848-.21-2.623 0-3.935 0-4.983.369-1.684.591-3.013 1.842-3.641 3.428C3 6.449 3 7.684 3 10.154v2.122c0 2.558 0 3.838.706 4.726q.306.383.713.671c.76.536 1.79.64 3.581.66"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M3 10a2.78 2.78 0 0 1 2.778-2.778c.555 0 1.209.097 1.748-.047.48-.129.854-.503.982-.982.145-.54.048-1.194.048-1.749a2.78 2.78 0 0 1 2.777-2.777"/>
</symbol>
<symbol id="github-icon" viewBox="0 0 19 19">
<path fill="#08060d" fill-rule="evenodd" d="M9.356 1.85C5.05 1.85 1.57 5.356 1.57 9.694a7.84 7.84 0 0 0 5.324 7.44c.387.079.528-.168.528-.376 0-.182-.013-.805-.013-1.454-2.165.467-2.616-.935-2.616-.935-.349-.91-.864-1.143-.864-1.143-.71-.48.051-.48.051-.48.787.051 1.2.805 1.2.805.695 1.194 1.817.857 2.268.649.064-.507.27-.857.49-1.052-1.728-.182-3.545-.857-3.545-3.87 0-.857.31-1.558.8-2.104-.078-.195-.349-1 .077-2.078 0 0 .657-.208 2.14.805a7.5 7.5 0 0 1 1.946-.26c.657 0 1.328.092 1.946.26 1.483-1.013 2.14-.805 2.14-.805.426 1.078.155 1.883.078 2.078.502.546.799 1.247.799 2.104 0 3.013-1.818 3.675-3.558 3.87.284.247.528.714.528 1.454 0 1.052-.012 1.896-.012 2.156 0 .208.142.455.528.377a7.84 7.84 0 0 0 5.324-7.441c.013-4.338-3.48-7.844-7.773-7.844" clip-rule="evenodd"/>
</symbol>
<symbol id="social-icon" viewBox="0 0 20 20">
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M12.5 6.667a4.167 4.167 0 1 0-8.334 0 4.167 4.167 0 0 0 8.334 0"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M2.5 16.667a5.833 5.833 0 0 1 8.75-5.053m3.837.474.513 1.035c.07.144.257.282.414.309l.93.155c.596.1.736.536.307.965l-.723.73a.64.64 0 0 0-.152.531l.207.903c.164.715-.213.991-.84.618l-.872-.52a.63.63 0 0 0-.577 0l-.872.52c-.624.373-1.003.094-.84-.618l.207-.903a.64.64 0 0 0-.152-.532l-.723-.729c-.426-.43-.289-.864.306-.964l.93-.156a.64.64 0 0 0 .412-.31l.513-1.034c.28-.562.735-.562 1.012 0"/>
</symbol>
<symbol id="x-icon" viewBox="0 0 19 19">
<path fill="#08060d" fill-rule="evenodd" d="M1.893 1.98c.052.072 1.245 1.769 2.653 3.77l2.892 4.114c.183.261.333.48.333.486s-.068.089-.152.183l-.522.593-.765.867-3.597 4.087c-.375.426-.734.834-.798.905a1 1 0 0 0-.118.148c0 .01.236.017.664.017h.663l.729-.83c.4-.457.796-.906.879-.999a692 692 0 0 0 1.794-2.038c.034-.037.301-.34.594-.675l.551-.624.345-.392a7 7 0 0 1 .34-.374c.006 0 .93 1.306 2.052 2.903l2.084 2.965.045.063h2.275c1.87 0 2.273-.003 2.266-.021-.008-.02-1.098-1.572-3.894-5.547-2.013-2.862-2.28-3.246-2.273-3.266.008-.019.282-.332 2.085-2.38l2-2.274 1.567-1.782c.022-.028-.016-.03-.65-.03h-.674l-.3.342a871 871 0 0 1-1.782 2.025c-.067.075-.405.458-.75.852a100 100 0 0 1-.803.91c-.148.172-.299.344-.99 1.127-.304.343-.32.358-.345.327-.015-.019-.904-1.282-1.976-2.808L6.365 1.85H1.8zm1.782.91 8.078 11.294c.772 1.08 1.413 1.973 1.425 1.984.016.017.241.02 1.05.017l1.03-.004-2.694-3.766L7.796 5.75 5.722 2.852l-1.039-.004-1.039-.004z" clip-rule="evenodd"/>
</symbol>
</svg>

After

Width:  |  Height:  |  Size: 4.9 KiB

+1284
View File
File diff suppressed because it is too large. Load diff
+512
View File
@@ -0,0 +1,512 @@
import { useEffect, useMemo, useState, type ReactNode } from "react";
import {
DndContext,
closestCenter,
KeyboardSensor,
PointerSensor,
useSensor,
useSensors,
type DragEndEvent,
} from "@dnd-kit/core";
import {
arrayMove,
SortableContext,
sortableKeyboardCoordinates,
useSortable,
verticalListSortingStrategy,
} from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities";
import {
api,
type BwflowStats,
type ComplianceReport,
type HostInfo,
type RangeKey,
type Sample,
type SeriesResponse,
} from "./api";
import { CpuChart, MemChart, NetChart } from "./charts";
import { BwflowPanel } from "./BwflowPanel";
import { CompliancePanel } from "./CompliancePanel";
import { AuthGate, type AuthContext } from "./AuthGate";
import { SitesPage } from "./SitesPage";
import { SiteDashboard } from "./SiteDashboard";
import { SettingsPage } from "./SettingsPage";
import { useRoute } from "./routing";
import { formatBytes, formatPct, formatRate, formatUptime } from "./format";
import "./App.css";
const RANGES: { key: RangeKey; label: string }[] = [
{ key: "24h", label: "24 hours" },
{ key: "7d", label: "7 days" },
{ key: "4w", label: "4 weeks" },
{ key: "3m", label: "3 months" },
];
const LAYOUT_KEY = "meterbox-layout-v3";
const DEFAULT_ORDER = [
"live",
"toolbar",
"compliance",
"averages",
"chart-cpu",
"chart-mem",
"chart-net",
"bwflow",
] as const;
type SectionId = (typeof DEFAULT_ORDER)[number];
function loadOrder(): SectionId[] {
try {
const raw = localStorage.getItem(LAYOUT_KEY);
if (!raw) return [...DEFAULT_ORDER];
const parsed = JSON.parse(raw) as string[];
const known = new Set<string>(DEFAULT_ORDER);
const next = parsed.filter((id): id is SectionId => known.has(id));
for (const id of DEFAULT_ORDER) {
if (!next.includes(id)) next.push(id);
}
return next;
} catch {
return [...DEFAULT_ORDER];
}
}
function SortableSection({
id,
children,
}: {
id: SectionId;
children: ReactNode;
}) {
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({
id,
});
const style = {
transform: CSS.Transform.toString(transform),
transition,
opacity: isDragging ? 0.85 : 1,
zIndex: isDragging ? 5 : undefined,
};
return (
<div ref={setNodeRef} style={style} className={`sortable-block${isDragging ? " dragging" : ""}`}>
<button
type="button"
className="drag-handle mono"
aria-label={`Drag to reorder ${id}`}
title="Drag to reorder"
{...attributes}
{...listeners}
>
⋮⋮
</button>
<div className="sortable-body">{children}</div>
</div>
);
}
export default function App() {
return <AuthGate>{(ctx) => <AppShell ctx={ctx} />}</AuthGate>;
}
function AppShell({ ctx }: { ctx: AuthContext }) {
const { user, guest, publicAccess, logout, requestLogin } = ctx;
const [route, navigate] = useRoute();
const canDashboard = !guest || publicAccess.dashboard;
const canSites = !guest || publicAccess.site_ids.length > 0;
const navKind =
route.kind === "site" ? "sites" : route.kind === "settings" ? "settings" : route.kind;
useEffect(() => {
if (guest && route.kind === "settings") {
navigate({ kind: canDashboard ? "dashboard" : "sites" });
return;
}
if (guest && route.kind === "dashboard" && !publicAccess.dashboard) {
navigate({ kind: canSites ? "sites" : "dashboard" });
return;
}
if (guest && route.kind === "sites" && publicAccess.site_ids.length === 0) {
navigate({ kind: canDashboard ? "dashboard" : "sites" });
return;
}
if (guest && route.kind === "site" && !publicAccess.site_ids.includes(route.id)) {
navigate({ kind: canSites ? "sites" : "dashboard" });
}
}, [guest, route, publicAccess, canDashboard, canSites, navigate]);
return (
<>
<nav className="app-nav" aria-label="Primary">
<div className="app-nav-brand mono">meterbox</div>
<div className="app-nav-links" role="tablist" aria-label="Pages">
{canDashboard ? (
<NavLink
label="Dashboard"
active={navKind === "dashboard"}
onClick={() => navigate({ kind: "dashboard" })}
/>
) : null}
{canSites ? (
<NavLink
label="Sites"
active={navKind === "sites"}
onClick={() => navigate({ kind: "sites" })}
/>
) : null}
{!guest ? (
<NavLink
label="Settings"
active={navKind === "settings"}
onClick={() => navigate({ kind: "settings" })}
/>
) : null}
</div>
<div className="app-nav-meta mono">
{guest ? <span>guest</span> : <span>{user?.email}</span>}
{guest ? (
<button type="button" className="ghost-btn" onClick={requestLogin}>
sign in
</button>
) : (
<button type="button" className="ghost-btn" onClick={() => void logout()}>
sign out
</button>
)}
</div>
</nav>
{route.kind === "settings" && !guest ? (
<SettingsPage onBack={() => navigate({ kind: "dashboard" })} />
) : route.kind === "site" ? (
<SiteDashboard
siteId={route.id}
readOnly={guest}
onBack={() => navigate({ kind: "sites" })}
/>
) : route.kind === "sites" ? (
<SitesPage
readOnly={guest}
onOpenSite={(id) => navigate({ kind: "site", id })}
/>
) : canDashboard ? (
<Dashboard />
) : (
<div className="shell">
<p className="empty">No public pages enabled. Sign in to continue.</p>
</div>
)}
</>
);
}
function NavLink({
label,
active,
onClick,
}: {
label: string;
active: boolean;
onClick: () => void;
}) {
return (
<button
type="button"
role="tab"
aria-selected={active}
className={active ? "active" : ""}
onClick={onClick}
>
{label}
</button>
);
}
function Dashboard() {
const [range, setRange] = useState<RangeKey>("24h");
const [host, setHost] = useState<HostInfo | null>(null);
const [snap, setSnap] = useState<Sample | null>(null);
const [series, setSeries] = useState<SeriesResponse | null>(null);
const [compliance, setCompliance] = useState<ComplianceReport | null>(null);
const [bwflow, setBwflow] = useState<BwflowStats | null>(null);
const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState(true);
const [order, setOrder] = useState<SectionId[]>(loadOrder);
const sensors = useSensors(
useSensor(PointerSensor, { activationConstraint: { distance: 6 } }),
useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates }),
);
useEffect(() => {
localStorage.setItem(LAYOUT_KEY, JSON.stringify(order));
}, [order]);
useEffect(() => {
let cancelled = false;
async function loadStatic() {
try {
const [h, s, c, b] = await Promise.all([
api.host(),
api.snapshot(),
api.compliance(),
api.bwflow(),
]);
if (!cancelled) {
setHost(h);
setSnap(s);
setCompliance(c);
setBwflow(b);
}
} catch (e) {
if (!cancelled) setError(e instanceof Error ? e.message : "Failed to load host");
}
}
loadStatic();
const id = window.setInterval(async () => {
try {
const [s, c, b] = await Promise.all([api.snapshot(), api.compliance(), api.bwflow()]);
if (!cancelled) {
setSnap(s);
setCompliance(c);
setBwflow(b);
}
} catch {
/* ignore transient */
}
}, 15000);
return () => {
cancelled = true;
window.clearInterval(id);
};
}, []);
useEffect(() => {
let cancelled = false;
async function loadSeries() {
setLoading(true);
setError(null);
try {
const data = await api.series(range);
if (!cancelled) setSeries(data);
} catch (e) {
if (!cancelled) setError(e instanceof Error ? e.message : "Failed to load series");
} finally {
if (!cancelled) setLoading(false);
}
}
loadSeries();
const id = window.setInterval(loadSeries, 60000);
return () => {
cancelled = true;
window.clearInterval(id);
};
}, [range]);
const avg = series?.averages;
function onDragEnd(event: DragEndEvent) {
const { active, over } = event;
if (!over || active.id === over.id) return;
setOrder((items) => {
const oldIndex = items.indexOf(active.id as SectionId);
const newIndex = items.indexOf(over.id as SectionId);
if (oldIndex < 0 || newIndex < 0) return items;
return arrayMove(items, oldIndex, newIndex);
});
}
const sections = useMemo(() => {
const map: Record<SectionId, ReactNode> = {
live: (
<section className="live">
<Stat label="CPU now" value={snap ? formatPct(snap.cpu_pct) : "—"} hint="live sample" />
<Stat
label="Memory now"
value={snap ? formatPct(snap.mem_pct) : "—"}
hint={snap ? formatBytes(snap.mem_used_bytes) : ""}
/>
<Stat
label="Boot disk"
value={snap ? formatPct(snap.disk_pct) : "—"}
hint={
snap && snap.disk_total_bytes > 0
? `${formatBytes(snap.disk_used_bytes)} / ${formatBytes(snap.disk_total_bytes)}`
: "root volume"
}
/>
<Stat label="RX now" value={snap ? formatRate(snap.net_rx_bps) : "—"} hint="inbound" />
<Stat label="TX now" value={snap ? formatRate(snap.net_tx_bps) : "—"} hint="outbound" />
</section>
),
toolbar: (
<div className="toolbar">
<div className="ranges" role="tablist" aria-label="Time range">
{RANGES.map((r) => (
<button
key={r.key}
type="button"
role="tab"
aria-selected={range === r.key}
className={range === r.key ? "active" : ""}
onClick={() => setRange(r.key)}
>
{r.label}
</button>
))}
</div>
<p className="bucket mono">
{series
? `avg over ${range} · bucket ${series.bucket_secs}s · ${series.points.length} pts`
: loading
? "loading…"
: "no data yet"}
</p>
</div>
),
compliance: compliance ? <CompliancePanel report={compliance} /> : null,
averages: (
<section className="averages">
<Stat label="Avg CPU" value={avg ? formatPct(avg.cpu_pct) : "—"} hint={range} />
<Stat label="Avg memory" value={avg ? formatPct(avg.mem_pct) : "—"} hint={range} />
<Stat label="Avg boot disk" value={avg ? formatPct(avg.disk_pct) : "—"} hint={range} />
<Stat label="Avg RX" value={avg ? formatRate(avg.net_rx_bps) : "—"} hint={range} />
<Stat label="Avg TX" value={avg ? formatRate(avg.net_tx_bps) : "—"} hint={range} />
</section>
),
"chart-cpu": (
<article className="panel chart-solo">
<header>
<h2>CPU utilization</h2>
<span className="mono">percent busy</span>
</header>
{series && series.points.length > 0 ? (
<CpuChart points={series.points} range={range} />
) : (
<Empty loading={loading} />
)}
</article>
),
"chart-mem": (
<article className="panel chart-solo">
<header>
<h2>Memory used</h2>
<span className="mono">percent of RAM</span>
</header>
{series && series.points.length > 0 ? (
<MemChart points={series.points} range={range} />
) : (
<Empty loading={loading} />
)}
</article>
),
"chart-net": (
<article className="panel chart-solo">
<header>
<h2>Network throughput</h2>
<span className="mono">bytes/sec · primary interfaces</span>
</header>
{series && series.points.length > 0 ? (
<NetChart points={series.points} range={range} />
) : (
<Empty loading={loading} />
)}
</article>
),
bwflow: bwflow ? <BwflowPanel stats={bwflow} /> : null,
};
return map;
}, [avg, bwflow, compliance, loading, range, series, snap]);
return (
<div className="shell">
<header className="hero">
<div className="hero-copy">
<p className="eyebrow mono">host telemetry</p>
<h1 className="brand">Meterbox</h1>
<p className="lede">
CPU, memory, boot disk, and network for this machine — last 24 hours through 3 months.
</p>
<p className="layout-hint mono">Drag ⋮⋮ to rearrange panels · layout saved in this browser</p>
</div>
<div className="hero-meta mono">
<div>
<span>host</span>
<strong>{host?.hostname ?? "…"}</strong>
</div>
<div>
<span>cores</span>
<strong>{host?.cpus ?? "—"}</strong>
</div>
<div>
<span>ram</span>
<strong>{host ? formatBytes(host.mem_total_bytes) : "—"}</strong>
</div>
<div>
<span>disk</span>
<strong>
{host && host.disk_total_bytes > 0
? `${formatPct(host.disk_pct)} · ${formatBytes(host.disk_total_bytes)}`
: "—"}
</strong>
</div>
<div>
<span>uptime</span>
<strong>{host ? formatUptime(host.uptime_secs) : "—"}</strong>
</div>
<button type="button" className="reset-layout" onClick={() => setOrder([...DEFAULT_ORDER])}>
reset layout
</button>
</div>
</header>
{error && <div className="error">{error}</div>}
<DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={onDragEnd}>
<SortableContext items={order} strategy={verticalListSortingStrategy}>
<div className="sortable-stack">
{order.map((id) => {
const body = sections[id];
if (!body) return null;
return (
<SortableSection key={id} id={id}>
{body}
</SortableSection>
);
})}
</div>
</SortableContext>
</DndContext>
<footer className="foot mono">
<span>{host?.os ?? ""}</span>
<span>kernel {host?.kernel ?? "—"}</span>
<span>binds 127.0.0.1 by default · proxy via Caddy/Rathole</span>
</footer>
</div>
);
}
function Stat({ label, value, hint }: { label: string; value: string; hint?: string }) {
return (
<div className="stat">
<span className="stat-label">{label}</span>
<strong className="stat-value mono">{value}</strong>
{hint ? <span className="stat-hint">{hint}</span> : null}
</div>
);
}
function Empty({ loading }: { loading: boolean }) {
return (
<div className="empty">
{loading
? "Fetching series…"
: "No samples in this window yet. Meterbox collects every 30s — give it a minute."}
</div>
);
}
+267
View File
@@ -0,0 +1,267 @@
import { useCallback, useEffect, useState, type FormEvent, type ReactNode } from "react";
import { api, type PublicAccess, type UserPublic } from "./api";
import { PasswordField } from "./PasswordField";
type Phase = "loading" | "setup" | "login" | "ready";
export type AuthContext = {
user: UserPublic | null;
guest: boolean;
publicAccess: PublicAccess;
logout: () => Promise<void>;
requestLogin: () => void;
};
/** First-run setup, then login — or guest access when public pages are enabled. */
export function AuthGate({ children }: { children: (ctx: AuthContext) => ReactNode }) {
const [phase, setPhase] = useState<Phase>("loading");
const [user, setUser] = useState<UserPublic | null>(null);
const [publicAccess, setPublicAccess] = useState<PublicAccess>({
dashboard: false,
site_ids: [],
});
const [forceLogin, setForceLogin] = useState(false);
const [bootError, setBootError] = useState<string | null>(null);
const refresh = useCallback(async () => {
const status = await api.authStatus();
setPublicAccess(status.public);
if (status.needs_setup) {
setUser(null);
setPhase("setup");
return;
}
if (status.user) {
setUser(status.user);
setForceLogin(false);
setPhase("ready");
return;
}
setUser(null);
const canGuest =
status.public.dashboard || status.public.site_ids.length > 0;
if (canGuest && !forceLogin) {
setPhase("ready");
return;
}
setPhase("login");
}, [forceLogin]);
useEffect(() => {
let cancelled = false;
(async () => {
try {
await refresh();
} catch (e) {
if (!cancelled) {
setBootError(e instanceof Error ? e.message : "Could not check auth");
setPhase("login");
}
}
})();
return () => {
cancelled = true;
};
}, [refresh]);
async function logout() {
await api.logout();
setUser(null);
setForceLogin(false);
await refresh();
}
if (phase === "loading") {
return (
<div className="setup-gate">
<p className="mono">Checking Meterbox setup…</p>
</div>
);
}
if (phase === "setup") {
return (
<SetupForm
onDone={async () => {
await refresh();
}}
/>
);
}
if (phase === "login") {
return (
<LoginForm
bootError={bootError}
allowGuest={publicAccess.dashboard || publicAccess.site_ids.length > 0}
onGuest={() => {
setForceLogin(false);
setPhase("ready");
}}
onDone={async () => {
setBootError(null);
setForceLogin(false);
await refresh();
}}
/>
);
}
return (
<>
{children({
user,
guest: user == null,
publicAccess,
logout,
requestLogin: () => {
setForceLogin(true);
setPhase("login");
},
})}
</>
);
}
function SetupForm({ onDone }: { onDone: () => Promise<void> }) {
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [confirm, setConfirm] = useState("");
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
async function submit(e: FormEvent) {
e.preventDefault();
setError(null);
if (password !== confirm) {
setError("Passwords do not match");
return;
}
setBusy(true);
try {
await api.setup(email.trim(), password);
await onDone();
} catch (err) {
setError(err instanceof Error ? err.message : "Setup failed");
} finally {
setBusy(false);
}
}
return (
<div className="setup-gate">
<div className="setup-card">
<p className="eyebrow mono">first run</p>
<h1 className="brand">Create admin</h1>
<p className="setup-lede">
Set the email and password for the single account that manages this Meterbox. You only do
this once.
</p>
<form className="health-auth" onSubmit={(ev) => void submit(ev)}>
<label>
Email
<input
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
autoComplete="email"
required
/>
</label>
<PasswordField
label="Password"
value={password}
onChange={(e) => setPassword(e.target.value)}
autoComplete="new-password"
minLength={8}
required
/>
<PasswordField
label="Confirm password"
value={confirm}
onChange={(e) => setConfirm(e.target.value)}
autoComplete="new-password"
minLength={8}
required
/>
<button type="submit" disabled={busy}>
{busy ? "…" : "Save admin account"}
</button>
</form>
{error && <div className="error">{error}</div>}
</div>
</div>
);
}
function LoginForm({
onDone,
onGuest,
allowGuest,
bootError,
}: {
onDone: () => Promise<void>;
onGuest: () => void;
allowGuest: boolean;
bootError: string | null;
}) {
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(bootError);
async function submit(e: FormEvent) {
e.preventDefault();
setBusy(true);
setError(null);
try {
await api.login(email.trim(), password);
await onDone();
} catch (err) {
setError(err instanceof Error ? err.message : "Login failed");
} finally {
setBusy(false);
}
}
return (
<div className="setup-gate">
<div className="setup-card">
<p className="eyebrow mono">meterbox</p>
<h1 className="brand">Sign in</h1>
<p className="setup-lede">
Sign in with the admin account to manage this Meterbox dashboard.
</p>
<form className="health-auth" onSubmit={(ev) => void submit(ev)}>
<label>
Email
<input
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
autoComplete="username"
required
/>
</label>
<PasswordField
label="Password"
value={password}
onChange={(e) => setPassword(e.target.value)}
autoComplete="current-password"
minLength={8}
required
/>
<button type="submit" disabled={busy}>
{busy ? "…" : "Sign in"}
</button>
</form>
{allowGuest ? (
<button type="button" className="ghost-btn guest-continue" onClick={onGuest}>
Continue as guest
</button>
) : null}
{error && <div className="error">{error}</div>}
</div>
</div>
);
}
+128
View File
@@ -0,0 +1,128 @@
import type { BwflowStats } from "./api";
import { formatBytes } from "./format";
const MONTHS = [
"Jan",
"Feb",
"Mar",
"Apr",
"May",
"Jun",
"Jul",
"Aug",
"Sep",
"Oct",
"Nov",
"Dec",
];
export function BwflowPanel({ stats }: { stats: BwflowStats }) {
if (!stats.available) {
return (
<section className="bwflow panel-block">
<header className="panel-block-head">
<h2 className="brand">bwflow</h2>
<span className="mono">unavailable</span>
</header>
<p className="bwflow-error">
{stats.error ?? "bwflow daemon socket not reachable"}
<span className="mono"> · {stats.socket}</span>
</p>
</section>
);
}
const pct = stats.budget_used_pct ?? 0;
const throttled = stats.rate_limit_on === true;
const period =
stats.calendar_month && stats.period_start && stats.period_resets
? `${stats.calendar_month} · ${stats.period_start} → resets ${stats.period_resets}`
: "calendar month";
return (
<section className="bwflow panel-block">
<header className="panel-block-head">
<div>
<p className="eyebrow mono" style={{ margin: 0 }}>
outbound budget · calendar month
</p>
<h2 className="brand" style={{ margin: "2px 0 0", fontSize: 26 }}>
bwflow
</h2>
<p className="mono" style={{ margin: "6px 0 0", fontSize: 12, color: "var(--muted)" }}>
{period}
</p>
</div>
<div className="bwflow-badges mono">
<span className={throttled ? "badge warn" : "badge ok"}>
{throttled ? `throttled ${stats.throttle_kbps} kbps` : "rate limit off"}
</span>
<span className="badge">{stats.interface}</span>
</div>
</header>
<div className="bwflow-stats">
<div className="stat">
<span className="stat-label">Egress this month</span>
<strong className="stat-value mono">
{(stats.tx_gb ?? 0).toFixed(2)} GB
</strong>
<span className="stat-hint">of {stats.monthly_budget_gb} GB budget</span>
</div>
<div className="stat">
<span className="stat-label">Tokens left</span>
<strong className="stat-value mono">
{(stats.tokens_remaining_gb ?? 0).toFixed(1)} GB
</strong>
<span className="stat-hint">pre-charge {stats.pre_charge_gb} GB</span>
</div>
<div className="stat">
<span className="stat-label">Budget used</span>
<strong className="stat-value mono">{pct.toFixed(2)}%</strong>
<span className="stat-hint">as of {stats.as_of ?? "—"}</span>
</div>
<div className="stat">
<span className="stat-label">State</span>
<strong className="stat-value mono">{stats.state}</strong>
<span className="stat-hint">{formatBytes(stats.tx_bytes ?? 0)} TX</span>
</div>
</div>
<div className="budget-bar" aria-label={`Budget used ${pct.toFixed(1)} percent`}>
<div className="budget-bar-fill" style={{ width: `${Math.min(100, Math.max(0, pct))}%` }} />
</div>
<p className="mono" style={{ margin: "8px 0 0", fontSize: 11, color: "var(--muted)" }}>
Outbound counters use the calendar month and reset on the 1st. Soft budget{" "}
{stats.monthly_budget_gb} GB (of 10 TB monthly target).
</p>
{stats.months.length > 0 && (
<div className="bwflow-months">
<h3>Calendar-month traffic</h3>
<table>
<thead>
<tr>
<th>Month</th>
<th>Egress ↑</th>
<th>Ingress ↓</th>
<th>Total</th>
</tr>
</thead>
<tbody>
{[...stats.months].reverse().map((m) => (
<tr key={`${m.year}-${m.month}`}>
<td className="mono">
{MONTHS[(m.month - 1) % 12]} {m.year}
</td>
<td className="mono">{formatBytes(m.tx_bytes)}</td>
<td className="mono">{formatBytes(m.rx_bytes)}</td>
<td className="mono">{formatBytes(m.rx_bytes + m.tx_bytes)}</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</section>
);
}
+72
View File
@@ -0,0 +1,72 @@
import type { CSSProperties } from "react";
import type { ComplianceReport } from "./api";
const statusColor: Record<string, string> = {
pass: "#3f7d4e",
fail: "#b91c1c",
warn: "#c2410c",
info: "#5c6b64",
};
const overallStyle: Record<string, CSSProperties> = {
compliant: { background: "rgba(63,125,78,0.12)", borderColor: "rgba(63,125,78,0.35)", color: "#1f4d2a" },
at_risk: { background: "rgba(194,65,12,0.12)", borderColor: "rgba(194,65,12,0.35)", color: "#7c2d12" },
over_limit: { background: "rgba(185,28,28,0.12)", borderColor: "rgba(185,28,28,0.4)", color: "#7f1d1d" },
};
export function CompliancePanel({ report }: { report: ComplianceReport }) {
const banner = overallStyle[report.overall] ?? overallStyle.at_risk;
const label =
report.overall === "compliant"
? "On target"
: report.overall === "over_limit"
? "Over target"
: "Needs attention";
return (
<section className="compliance">
<div className="compliance-banner" style={banner}>
<div>
<p className="eyebrow mono" style={{ margin: 0 }}>
capacity & budgets
</p>
<h2 className="brand" style={{ fontSize: 28, margin: "4px 0 6px" }}>
{label}
</h2>
<p style={{ margin: 0, maxWidth: "62ch" }}>{report.summary}</p>
{report.tier ? (
<p className="mono" style={{ margin: "8px 0 0", fontSize: 12, opacity: 0.85 }}>
{report.tier}
</p>
) : null}
</div>
</div>
<div className="compliance-grid">
{report.checks.map((c) => (
<article key={c.id} className="check">
<header>
<span className="check-status mono" style={{ color: statusColor[c.status] ?? statusColor.info }}>
{c.status}
</span>
<h3>{c.label}</h3>
</header>
<p className="check-detail">{c.detail}</p>
<div className="check-meta mono">
<span>now {c.current}</span>
<span>{c.limit}</span>
</div>
</article>
))}
</div>
{report.notes.length > 0 && (
<ul className="compliance-notes">
{report.notes.map((n) => (
<li key={n}>{n}</li>
))}
</ul>
)}
</section>
);
}
+464
View File
@@ -0,0 +1,464 @@
import { useCallback, useEffect, useState } from "react";
import { api, type HealthSite, type ProbeResult } from "./api";
type FormState = {
name: string;
url: string;
interval_secs: number;
timeout_ms: number;
expected_status: number;
expect_body_contains: string;
enabled: boolean;
};
type HealthTab = "sites" | "manage";
const emptyForm = (): FormState => ({
name: "",
url: "https://",
interval_secs: 60,
timeout_ms: 10000,
expected_status: 200,
expect_body_contains: "",
enabled: true,
});
export function HealthPanel({
onOpenSite,
readOnly = false,
}: {
onOpenSite: (id: number) => void;
readOnly?: boolean;
}) {
const [tab, setTab] = useState<HealthTab>("sites");
const [sites, setSites] = useState<HealthSite[]>([]);
const [form, setForm] = useState<FormState>(emptyForm);
const [editingId, setEditingId] = useState<number | null>(null);
const [busy, setBusy] = useState(false);
const [probeBusy, setProbeBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const [probe, setProbe] = useState<ProbeResult | null>(null);
const [notice, setNotice] = useState<string | null>(null);
const refreshSites = useCallback(async () => {
try {
setSites(await api.listSites());
} catch (e) {
setError(e instanceof Error ? e.message : "Failed to load sites");
}
}, []);
useEffect(() => {
let cancelled = false;
(async () => {
try {
await refreshSites();
} catch (e) {
if (!cancelled) setError(e instanceof Error ? e.message : "Failed to load sites");
}
})();
return () => {
cancelled = true;
};
}, [refreshSites]);
useEffect(() => {
const id = window.setInterval(() => {
void refreshSites();
}, 20000);
return () => window.clearInterval(id);
}, [refreshSites]);
function openAdd() {
setEditingId(null);
setForm(emptyForm());
setProbe(null);
setError(null);
setNotice(null);
setTab("manage");
}
function startEdit(site: HealthSite) {
setEditingId(site.id);
setForm({
name: site.name,
url: site.url,
interval_secs: site.interval_secs,
timeout_ms: site.timeout_ms,
expected_status: site.expected_status,
expect_body_contains: site.expect_body_contains ?? "",
enabled: site.enabled,
});
setProbe(null);
setError(null);
setNotice(null);
setTab("manage");
}
async function runProbe() {
setProbeBusy(true);
setError(null);
setNotice(null);
try {
const result = await api.probeSite({
url: form.url.trim(),
expected_status: form.expected_status,
expect_body_contains: form.expect_body_contains.trim() || undefined,
timeout_ms: form.timeout_ms,
});
setProbe(result);
if (!result.ok) {
setError(result.reasons.join(" · ") || "Probe failed");
} else {
setNotice("Probe passed — origin reachable, no Cloudflare fallback/challenge.");
}
} catch (e) {
setError(e instanceof Error ? e.message : "Probe failed");
} finally {
setProbeBusy(false);
}
}
async function saveSite() {
setBusy(true);
setError(null);
setNotice(null);
try {
const payload = {
name: form.name.trim(),
url: form.url.trim(),
enabled: form.enabled,
interval_secs: form.interval_secs,
timeout_ms: form.timeout_ms,
expected_status: form.expected_status,
expect_body_contains: form.expect_body_contains.trim() || null,
};
const res =
editingId == null
? await api.createSite(payload)
: await api.updateSite(editingId, payload);
setProbe(res.probe);
setNotice(
editingId == null
? `Added ${res.site.name} after passing origin/Cloudflare checks.`
: `Updated ${res.site.name}.`,
);
setForm(emptyForm());
setEditingId(null);
await refreshSites();
setTab("sites");
} catch (e) {
setError(e instanceof Error ? e.message : "Save failed");
} finally {
setBusy(false);
}
}
async function removeSite(id: number) {
if (!window.confirm("Remove this health check?")) return;
setBusy(true);
try {
await api.deleteSite(id);
if (editingId === id) {
setEditingId(null);
setForm(emptyForm());
}
await refreshSites();
} catch (e) {
setError(e instanceof Error ? e.message : "Delete failed");
} finally {
setBusy(false);
}
}
async function checkNow(id: number) {
setBusy(true);
setError(null);
try {
const res = await api.checkSite(id);
setProbe(res.probe);
if (!res.probe.ok) setError(res.probe.reasons.join(" · "));
await refreshSites();
} catch (e) {
setError(e instanceof Error ? e.message : "Check failed");
} finally {
setBusy(false);
}
}
const up = sites.filter((s) => s.last_ok === true).length;
const down = sites.filter((s) => s.last_ok === false).length;
const unknown = sites.filter((s) => s.last_ok == null).length;
return (
<section className="health-panel">
<div className="health-banner">
<div>
<p className="eyebrow mono">status</p>
<h2 className="brand" style={{ fontSize: 28, margin: "4px 0 6px" }}>
{sites.length === 0
? "No sites yet"
: down > 0
? `${down} down`
: `${up} healthy`}
</h2>
</div>
<div className="health-banner-meta mono">
<span>
{up} up · {down} down · {unknown} pending
</span>
</div>
</div>
{!readOnly ? (
<div className="health-tabs" role="tablist" aria-label="Site health">
<button
type="button"
role="tab"
aria-selected={tab === "sites"}
className={tab === "sites" ? "active" : ""}
onClick={() => setTab("sites")}
>
Sites
</button>
<button
type="button"
role="tab"
aria-selected={tab === "manage"}
className={tab === "manage" ? "active" : ""}
onClick={() => {
if (editingId != null) {
setTab("manage");
return;
}
openAdd();
}}
>
{editingId == null ? "Add site" : "Edit site"}
</button>
</div>
) : null}
{tab === "sites" || readOnly ? (
<div className="health-sites" role="tabpanel">
{sites.length === 0 ? (
<div className="health-empty">
<p className="empty">No health checks yet.</p>
{!readOnly ? (
<button type="button" className="ghost-btn" onClick={() => openAdd()}>
Add a site
</button>
) : null}
</div>
) : (
sites.map((site) => (
<article key={site.id} className={`health-site ${siteStatusClass(site)}`}>
<header>
<div>
<h3>
<button
type="button"
className="site-name-link"
onClick={() => onOpenSite(site.id)}
>
{site.name}
</button>
</h3>
<a className="mono health-url" href={site.url} target="_blank" rel="noreferrer">
{site.url}
</a>
</div>
<span className="health-pill mono">{statusLabel(site)}</span>
</header>
<div className="health-site-meta mono">
<span>every {site.interval_secs}s</span>
<span>{site.last_latency_ms != null ? `${site.last_latency_ms} ms` : "—"}</span>
<span>
{site.last_status != null ? `HTTP ${site.last_status}` : "no check yet"}
</span>
{site.last_via_cloudflare ? <span>via CF</span> : null}
{site.last_cf_fallback ? <span className="bad">CF fallback</span> : null}
{site.last_cf_challenge ? <span className="bad">CF challenge</span> : null}
</div>
{site.last_error ? <p className="health-error">{site.last_error}</p> : null}
<div className="health-site-actions">
<button type="button" className="ghost-btn" onClick={() => onOpenSite(site.id)}>
History
</button>
{!readOnly ? (
<>
<button
type="button"
className="ghost-btn"
onClick={() => void checkNow(site.id)}
>
Check now
</button>
<button type="button" className="ghost-btn" onClick={() => startEdit(site)}>
Edit
</button>
<button
type="button"
className="ghost-btn danger"
onClick={() => void removeSite(site.id)}
>
Remove
</button>
</>
) : null}
</div>
</article>
))
)}
</div>
) : (
<div className="health-manage" role="tabpanel">
<form
className="health-form"
onSubmit={(e) => {
e.preventDefault();
void saveSite();
}}
>
<h3>{editingId == null ? "Add site" : "Edit site"}</h3>
<label>
Name
<input
value={form.name}
onChange={(e) => setForm({ ...form, name: e.target.value })}
placeholder="docs"
required
/>
</label>
<label>
URL
<input
value={form.url}
onChange={(e) => setForm({ ...form, url: e.target.value })}
placeholder="https://example.com/health"
required
/>
</label>
<div className="health-form-row">
<label>
Interval (s)
<input
type="number"
min={15}
max={3600}
value={form.interval_secs}
onChange={(e) => setForm({ ...form, interval_secs: Number(e.target.value) })}
/>
</label>
<label>
Timeout (ms)
<input
type="number"
min={1000}
max={60000}
value={form.timeout_ms}
onChange={(e) => setForm({ ...form, timeout_ms: Number(e.target.value) })}
/>
</label>
<label>
Expect status
<input
type="number"
min={100}
max={599}
value={form.expected_status}
onChange={(e) => setForm({ ...form, expected_status: Number(e.target.value) })}
/>
</label>
</div>
<label>
Body must contain (optional)
<input
value={form.expect_body_contains}
onChange={(e) => setForm({ ...form, expect_body_contains: e.target.value })}
placeholder='e.g. "ok" or a unique origin marker'
/>
</label>
<label className="health-check">
<input
type="checkbox"
checked={form.enabled}
onChange={(e) => setForm({ ...form, enabled: e.target.checked })}
/>
Enabled
</label>
<div className="health-form-actions">
<button
type="button"
className="ghost-btn"
disabled={probeBusy}
onClick={() => void runProbe()}
>
{probeBusy ? "Probing…" : "Test URL"}
</button>
<button type="submit" disabled={busy}>
{busy ? "…" : editingId == null ? "Add after probe" : "Save after probe"}
</button>
<button
type="button"
className="ghost-btn"
onClick={() => {
setEditingId(null);
setForm(emptyForm());
setProbe(null);
setError(null);
setNotice(null);
setTab("sites");
}}
>
Back to sites
</button>
</div>
</form>
{notice && <div className="health-notice">{notice}</div>}
{error && <div className="error">{error}</div>}
{probe && <ProbeDetails probe={probe} />}
</div>
)}
{tab === "sites" && error && <div className="error">{error}</div>}
{tab === "sites" && probe && <ProbeDetails probe={probe} />}
</section>
);
}
function siteStatusClass(site: HealthSite): string {
if (site.last_ok === true) return "is-up";
if (site.last_ok === false) return "is-down";
return "is-pending";
}
function statusLabel(site: HealthSite): string {
if (!site.enabled) return "paused";
if (site.last_ok === true) return "up";
if (site.last_ok === false) return "down";
return "pending";
}
function ProbeDetails({ probe }: { probe: ProbeResult }) {
return (
<div className={`probe-box ${probe.ok ? "ok" : "fail"}`}>
<div className="probe-head mono">
<strong>{probe.ok ? "probe ok" : "probe failed"}</strong>
<span>HTTP {probe.status || "—"}</span>
<span>{probe.latency_ms} ms</span>
{probe.via_cloudflare ? <span>Cloudflare edge</span> : <span>direct/origin</span>}
{probe.cloudflare_fallback ? <span className="bad">fallback</span> : null}
{probe.cloudflare_challenge ? <span className="bad">challenge</span> : null}
</div>
<ul>
{probe.reasons.map((r) => (
<li key={r}>{r}</li>
))}
</ul>
{probe.body_snippet ? (
<pre className="probe-snippet mono">{probe.body_snippet}</pre>
) : null}
</div>
);
}
+61
View File
@@ -0,0 +1,61 @@
import { useState, type InputHTMLAttributes } from "react";
type PasswordFieldProps = Omit<InputHTMLAttributes<HTMLInputElement>, "type"> & {
label: string;
};
export function PasswordField({ label, id, ...inputProps }: PasswordFieldProps) {
const [show, setShow] = useState(false);
const inputId = id ?? label.toLowerCase().replace(/\s+/g, "-");
return (
<label htmlFor={inputId}>
{label}
<span className="password-field">
<input id={inputId} type={show ? "text" : "password"} {...inputProps} />
<button
type="button"
className="password-toggle"
aria-label={show ? `Hide ${label.toLowerCase()}` : `Show ${label.toLowerCase()}`}
aria-pressed={show}
onClick={() => setShow((v) => !v)}
>
<EyeIcon open={show} />
</button>
</span>
</label>
);
}
function EyeIcon({ open }: { open: boolean }) {
if (open) {
return (
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" aria-hidden="true">
<path
d="M3 3l18 18"
stroke="currentColor"
strokeWidth="1.75"
strokeLinecap="round"
/>
<path
d="M10.6 10.6a2.5 2.5 0 003.5 3.5M9.9 5.1A10.5 10.5 0 0121 12c-.5 1-1.2 2-2.1 2.9M6.1 6.1C4.7 7.2 3.6 8.6 3 12c1.7 4.2 5.4 7 9 7 1.3 0 2.6-.3 3.8-.9"
stroke="currentColor"
strokeWidth="1.75"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
);
}
return (
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" aria-hidden="true">
<path
d="M2.5 12S6.5 5.5 12 5.5 21.5 12 21.5 12 17.5 18.5 12 18.5 2.5 12 2.5 12z"
stroke="currentColor"
strokeWidth="1.75"
strokeLinejoin="round"
/>
<circle cx="12" cy="12" r="2.75" stroke="currentColor" strokeWidth="1.75" />
</svg>
);
}
+169
View File
@@ -0,0 +1,169 @@
import { useEffect, useState } from "react";
import { api, type AppSettings, type HealthSite } from "./api";
export function SettingsPage({ onBack }: { onBack: () => void }) {
const [settings, setSettings] = useState<AppSettings | null>(null);
const [sites, setSites] = useState<HealthSite[]>([]);
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const [notice, setNotice] = useState<string | null>(null);
useEffect(() => {
let cancelled = false;
(async () => {
try {
const [s, list] = await Promise.all([api.getSettings(), api.listSites()]);
if (!cancelled) {
setSettings(s);
setSites(list);
}
} catch (e) {
if (!cancelled) setError(e instanceof Error ? e.message : "Failed to load settings");
}
})();
return () => {
cancelled = true;
};
}, []);
async function save() {
if (!settings) return;
setBusy(true);
setError(null);
setNotice(null);
try {
const saved = await api.saveSettings(settings);
setSettings(saved);
setNotice("Settings saved.");
} catch (e) {
setError(e instanceof Error ? e.message : "Save failed");
} finally {
setBusy(false);
}
}
function toggleSite(id: number) {
setSettings((prev) => {
if (!prev) return prev;
const has = prev.public_site_ids.includes(id);
return {
...prev,
public_site_ids: has
? prev.public_site_ids.filter((x) => x !== id)
: [...prev.public_site_ids, id],
};
});
}
if (!settings && !error) {
return (
<div className="shell sites-shell">
<p className="mono">Loading settings…</p>
</div>
);
}
if (!settings) {
return (
<div className="shell sites-shell">
<button type="button" className="ghost-btn" onClick={onBack}>
← Back
</button>
<div className="error">{error}</div>
</div>
);
}
return (
<div className="shell sites-shell">
<header className="page-hero">
<div>
<button type="button" className="ghost-btn site-back" onClick={onBack}>
← Dashboard
</button>
<p className="eyebrow mono">admin</p>
<h1 className="brand">Settings</h1>
<p className="lede">Retention and public read access for Meterbox.</p>
</div>
</header>
<section className="site-section settings-section">
<header>
<h2>Health logs</h2>
</header>
<label className="settings-check">
<input
type="checkbox"
checked={settings.cleanup_logs_older_than_3_months}
onChange={(e) =>
setSettings({
...settings,
cleanup_logs_older_than_3_months: e.target.checked,
})
}
/>
<span>
Clean up health check logs older than 3 months
<small>When enabled, failed/ok probe history older than 90 days is deleted.</small>
</span>
</label>
</section>
<section className="site-section settings-section">
<header>
<h2>Public access</h2>
<span className="mono">viewable without sign-in</span>
</header>
<p className="settings-hint">
Guests can only view selected pages. Adding, editing, and settings still require admin
sign-in.
</p>
<label className="settings-check">
<input
type="checkbox"
checked={settings.public_dashboard}
onChange={(e) =>
setSettings({
...settings,
public_dashboard: e.target.checked,
})
}
/>
<span>
Host telemetry dashboard
<small>CPU, memory, network, compliance, bandwidth.</small>
</span>
</label>
<div className="settings-sites">
<h3>Sites</h3>
{sites.length === 0 ? (
<p className="empty">No sites yet. Add one under Sites first.</p>
) : (
sites.map((site) => (
<label key={site.id} className="settings-check">
<input
type="checkbox"
checked={settings.public_site_ids.includes(site.id)}
onChange={() => toggleSite(site.id)}
/>
<span>
{site.name}
<small className="mono">{site.url}</small>
</span>
</label>
))
)}
</div>
</section>
<div className="settings-actions">
<button type="button" disabled={busy} onClick={() => void save()}>
{busy ? "Saving…" : "Save settings"}
</button>
</div>
{notice && <div className="health-notice">{notice}</div>}
{error && <div className="error">{error}</div>}
</div>
);
}
+358
View File
@@ -0,0 +1,358 @@
import { useCallback, useEffect, useMemo, useState } from "react";
import {
api,
type HealthCheckResult,
type HealthIncident,
type HealthRangeKey,
type SiteHistory,
} from "./api";
import { HealthUptimeChart } from "./charts";
import { formatDuration, formatWhen } from "./format";
const RANGES: { key: HealthRangeKey; label: string }[] = [
{ key: "1h", label: "1 hour" },
{ key: "12h", label: "12 hours" },
{ key: "24h", label: "24 hours" },
{ key: "7d", label: "7 days" },
{ key: "30d", label: "30 days" },
];
const LOG_PAGE_SIZE = 10;
type LogFilter = "all" | "not_200" | "down";
export function SiteDashboard({
siteId,
onBack,
readOnly = false,
}: {
siteId: number;
onBack: () => void;
readOnly?: boolean;
}) {
const [range, setRange] = useState<HealthRangeKey>("24h");
const [history, setHistory] = useState<SiteHistory | null>(null);
const [error, setError] = useState<string | null>(null);
const [busy, setBusy] = useState(false);
const [logFilter, setLogFilter] = useState<LogFilter>("all");
const [logPage, setLogPage] = useState(0);
const refresh = useCallback(async () => {
try {
setHistory(await api.siteHistory(siteId, range));
setError(null);
} catch (e) {
setError(e instanceof Error ? e.message : "Failed to load history");
}
}, [siteId, range]);
useEffect(() => {
let cancelled = false;
setHistory(null);
setLogPage(0);
(async () => {
try {
const data = await api.siteHistory(siteId, range);
if (!cancelled) setHistory(data);
} catch (e) {
if (!cancelled) setError(e instanceof Error ? e.message : "Failed to load history");
}
})();
return () => {
cancelled = true;
};
}, [siteId, range]);
useEffect(() => {
const id = window.setInterval(() => {
void refresh();
}, 20000);
return () => window.clearInterval(id);
}, [refresh]);
useEffect(() => {
setLogPage(0);
}, [logFilter, range]);
async function checkNow() {
setBusy(true);
setError(null);
try {
await api.checkSite(siteId);
await refresh();
} catch (e) {
setError(e instanceof Error ? e.message : "Check failed");
} finally {
setBusy(false);
}
}
const filteredLogs = useMemo(() => {
if (!history) return [];
return history.results.filter((r) => {
if (logFilter === "not_200") return r.status !== 200;
if (logFilter === "down") return !r.ok;
return true;
});
}, [history, logFilter]);
const pageCount = Math.max(1, Math.ceil(filteredLogs.length / LOG_PAGE_SIZE));
const safePage = Math.min(logPage, pageCount - 1);
const pageLogs = filteredLogs.slice(
safePage * LOG_PAGE_SIZE,
safePage * LOG_PAGE_SIZE + LOG_PAGE_SIZE,
);
if (!history && !error) {
return (
<div className="shell sites-shell">
<p className="mono">Loading site history…</p>
</div>
);
}
if (!history) {
return (
<div className="shell sites-shell">
<button type="button" className="ghost-btn" onClick={onBack}>
← Back to sites
</button>
<div className="error">{error}</div>
</div>
);
}
const { site, summary, incidents, series } = history;
const status =
site.last_ok === true ? "up" : site.last_ok === false ? "down" : "pending";
return (
<div className="shell sites-shell">
<header className="page-hero site-dash-hero">
<div>
<button type="button" className="ghost-btn site-back" onClick={onBack}>
← Sites
</button>
<p className="eyebrow mono">site dashboard</p>
<h1 className="brand">{site.name}</h1>
<a className="mono health-url" href={site.url} target="_blank" rel="noreferrer">
{site.url}
</a>
</div>
<div className="site-dash-actions">
<span className={`health-pill mono is-${status}`}>{status}</span>
{!readOnly ? (
<button
type="button"
className="ghost-btn"
disabled={busy}
onClick={() => void checkNow()}
>
{busy ? "Checking…" : "Check now"}
</button>
) : null}
</div>
</header>
<div className="toolbar">
<div className="ranges" role="tablist" aria-label="Health range">
{RANGES.map((r) => (
<button
key={r.key}
type="button"
role="tab"
aria-selected={range === r.key}
className={range === r.key ? "active" : ""}
onClick={() => setRange(r.key)}
>
{r.label}
</button>
))}
</div>
<p className="bucket mono">
{summary.checks} checks · bucket {history.bucket_secs}s
</p>
</div>
<section className="site-summary">
<SummaryStat
label="Uptime"
value={summary.uptime_pct != null ? `${summary.uptime_pct.toFixed(1)}%` : "—"}
hint={range}
/>
<SummaryStat label="Healthy" value={String(summary.ok)} hint="ok probes" />
<SummaryStat label="Down" value={String(summary.down)} hint="failed probes" />
<SummaryStat
label="Last latency"
value={site.last_latency_ms != null ? `${site.last_latency_ms} ms` : "—"}
hint={site.last_check_at ? formatWhen(site.last_check_at) : "no check yet"}
/>
</section>
{site.last_ok === false && site.last_error ? (
<div className="site-current-error">
<strong className="mono">Currently down</strong>
<p>{site.last_error}</p>
</div>
) : null}
<section className="site-section">
<header>
<h2>Health status</h2>
<span className="mono">{range}</span>
</header>
{series.length === 0 ? (
<p className="empty">No checks in this window yet.</p>
) : (
<HealthUptimeChart points={series} range={range} />
)}
</section>
<section className="site-section">
<header>
<h2>Downtime</h2>
<span className="mono">{incidents.length} events</span>
</header>
{incidents.length === 0 ? (
<p className="empty">No downtime in this window.</p>
) : (
<div className="incident-list">
{incidents.slice(0, 10).map((inc) => (
<IncidentCard key={`${inc.started_at}-${inc.ended_at ?? "open"}`} incident={inc} />
))}
</div>
)}
</section>
<section className="site-section">
<header className="log-header">
<div>
<h2>Check log</h2>
<span className="mono">
{filteredLogs.length} shown · page {safePage + 1}/{pageCount}
</span>
</div>
<div className="log-filters" role="group" aria-label="Log filter">
<button
type="button"
className={logFilter === "all" ? "active" : ""}
onClick={() => setLogFilter("all")}
>
All
</button>
<button
type="button"
className={logFilter === "not_200" ? "active" : ""}
onClick={() => setLogFilter("not_200")}
>
Not 200
</button>
<button
type="button"
className={logFilter === "down" ? "active" : ""}
onClick={() => setLogFilter("down")}
>
Down
</button>
</div>
</header>
<div className="check-log compact">
{pageLogs.length === 0 ? (
<p className="empty">No matching checks.</p>
) : (
pageLogs.map((r) => <CheckRow key={r.id} result={r} />)
)}
</div>
<div className="log-pager">
<button
type="button"
className="ghost-btn"
disabled={safePage <= 0}
onClick={() => setLogPage((p) => Math.max(0, p - 1))}
>
Prev
</button>
<span className="mono">
{safePage * LOG_PAGE_SIZE + (pageLogs.length ? 1 : 0)}–
{safePage * LOG_PAGE_SIZE + pageLogs.length} of {filteredLogs.length}
</span>
<button
type="button"
className="ghost-btn"
disabled={safePage >= pageCount - 1}
onClick={() => setLogPage((p) => Math.min(pageCount - 1, p + 1))}
>
Next
</button>
</div>
</section>
{error && <div className="error">{error}</div>}
</div>
);
}
function SummaryStat({
label,
value,
hint,
}: {
label: string;
value: string;
hint: string;
}) {
return (
<div className="stat">
<span className="stat-label">{label}</span>
<strong className="stat-value mono">{value}</strong>
<span className="stat-hint">{hint}</span>
</div>
);
}
function IncidentCard({ incident }: { incident: HealthIncident }) {
return (
<article className={`incident-card ${incident.ongoing ? "ongoing" : ""}`}>
<header>
<strong className="mono">{incident.ongoing ? "Ongoing outage" : "Outage"}</strong>
<span className="mono">
{incident.duration_secs != null ? formatDuration(incident.duration_secs) : "—"}
{" · "}
{incident.checks} check{incident.checks === 1 ? "" : "s"}
</span>
</header>
<div className="incident-times mono">
<span>Down from {formatWhen(incident.started_at)}</span>
<span>
{incident.ended_at ? `Recovered ${formatWhen(incident.ended_at)}` : "Still down"}
</span>
</div>
{incident.status != null && incident.status > 0 ? (
<span className="mono incident-status">HTTP {incident.status}</span>
) : null}
<p className="incident-error">{incident.error}</p>
</article>
);
}
function CheckRow({ result }: { result: HealthCheckResult }) {
return (
<article className={`check-row compact ${result.ok ? "ok" : "down"}`}>
<div className="check-row-head mono">
<span className={`health-pill ${result.ok ? "" : "is-down"}`}>
{result.ok ? "up" : "down"}
</span>
<span>{formatWhen(result.checked_at)}</span>
<span>{result.latency_ms} ms</span>
<span>{result.status > 0 ? `HTTP ${result.status}` : "—"}</span>
{!result.ok && result.error ? (
<span className="check-error-inline" title={result.error}>
{result.error}
</span>
) : null}
</div>
</article>
);
}
+25
View File
@@ -0,0 +1,25 @@
import { HealthPanel } from "./HealthPanel";
export function SitesPage({
onOpenSite,
readOnly = false,
}: {
onOpenSite: (id: number) => void;
readOnly?: boolean;
}) {
return (
<div className="shell sites-shell">
<header className="page-hero">
<div>
<p className="eyebrow mono">monitoring</p>
<h1 className="brand">Sites</h1>
<p className="lede">
HTTP health checks from this host. Open a site for its uptime timeline and downtime
history.
</p>
</div>
</header>
<HealthPanel onOpenSite={onOpenSite} readOnly={readOnly} />
</div>
);
}
+347
View File
@@ -0,0 +1,347 @@
export type RangeKey = "24h" | "7d" | "4w" | "3m";
export type HostInfo = {
hostname: string;
os: string;
kernel: string;
cpus: number;
mem_total_bytes: number;
disk_used_bytes: number;
disk_total_bytes: number;
disk_pct: number;
uptime_secs: number;
};
export type Sample = {
ts: number;
cpu_pct: number;
mem_used_bytes: number;
mem_total_bytes: number;
mem_pct: number;
disk_used_bytes: number;
disk_total_bytes: number;
disk_pct: number;
net_rx_bps: number;
net_tx_bps: number;
load1: number;
load5: number;
load15: number;
};
export type SeriesPoint = {
ts: number;
cpu_pct: number;
mem_pct: number;
mem_used_bytes: number;
mem_total_bytes: number;
disk_pct: number;
disk_used_bytes: number;
disk_total_bytes: number;
net_rx_bps: number;
net_tx_bps: number;
load1: number;
};
export type SeriesResponse = {
range: string;
from_ts: number;
to_ts: number;
bucket_secs: number;
points: SeriesPoint[];
averages: {
cpu_pct: number;
mem_pct: number;
disk_pct: number;
net_rx_bps: number;
net_tx_bps: number;
load1: number;
};
};
export type ComplianceCheck = {
id: string;
label: string;
status: "pass" | "fail" | "warn" | "info" | string;
detail: string;
current: string;
limit: string;
};
export type ComplianceReport = {
overall: "compliant" | "at_risk" | "over_limit" | string;
summary: string;
tier: string;
docs_url: string;
notes: string[];
checks: ComplianceCheck[];
host: {
ocpus: number;
mem_total_bytes: number;
disk_total_bytes: number;
arch: string;
is_arm: boolean;
};
};
export type BwflowMonth = {
year: number;
month: number;
rx_bytes: number;
tx_bytes: number;
};
export type BwflowStats = {
available: boolean;
error: string | null;
socket: string;
state: string | null;
interface: string | null;
as_of: string | null;
tx_bytes: number | null;
tokens_bytes: number | null;
throttle_kbps: number | null;
monthly_budget_gb: number | null;
pre_charge_gb: number | null;
budget_used_pct: number | null;
tokens_remaining_gb: number | null;
tx_gb: number | null;
rate_limit_on: boolean | null;
calendar_month: string | null;
period_start: string | null;
period_resets: string | null;
months: BwflowMonth[];
};
export type UserPublic = {
id: number;
email: string;
created_at: number;
};
export type AuthStatus = {
needs_setup: boolean;
user: UserPublic | null;
public: PublicAccess;
};
export type PublicAccess = {
dashboard: boolean;
site_ids: number[];
};
export type AppSettings = {
cleanup_logs_older_than_3_months: boolean;
public_dashboard: boolean;
public_site_ids: number[];
};
export type AuthSession = {
token: string;
user: UserPublic;
};
export type HealthSite = {
id: number;
name: string;
url: string;
enabled: boolean;
interval_secs: number;
timeout_ms: number;
expected_status: number;
expect_body_contains: string | null;
created_at: number;
updated_at: number;
last_check_at: number | null;
last_ok: boolean | null;
last_status: number | null;
last_latency_ms: number | null;
last_error: string | null;
last_via_cloudflare: boolean | null;
last_cf_fallback: boolean | null;
last_cf_challenge: boolean | null;
};
export type UpsertSite = {
name: string;
url: string;
enabled?: boolean;
interval_secs?: number;
timeout_ms?: number;
expected_status?: number;
expect_body_contains?: string | null;
};
export type ProbeResult = {
ok: boolean;
url: string;
final_url: string;
status: number;
latency_ms: number;
server: string | null;
via_cloudflare: boolean;
cloudflare_fallback: boolean;
cloudflare_challenge: boolean;
body_snippet: string;
error: string | null;
reasons: string[];
};
export type SiteCreateResponse = {
site: HealthSite;
probe: ProbeResult;
};
export type HealthCheckResult = {
id: number;
site_id: number;
checked_at: number;
ok: boolean;
status: number;
latency_ms: number;
via_cloudflare: boolean;
cf_fallback: boolean;
cf_challenge: boolean;
error: string | null;
reasons: string[];
};
export type HealthIncident = {
started_at: number;
ended_at: number | null;
duration_secs: number | null;
ongoing: boolean;
error: string;
status: number | null;
checks: number;
};
export type HealthSeriesPoint = {
ts: number;
uptime_pct: number;
ok: number;
down: number;
avg_latency_ms: number;
had_down: number;
};
export type HealthRangeKey = "1h" | "12h" | "24h" | "7d" | "30d";
export type SiteHistory = {
site: HealthSite;
range: HealthRangeKey | string;
from_ts: number;
to_ts: number;
bucket_secs: number;
summary: {
checks: number;
ok: number;
down: number;
uptime_pct: number | null;
};
incidents: HealthIncident[];
series: HealthSeriesPoint[];
results: HealthCheckResult[];
};
const TOKEN_KEY = "meterbox-token";
export function getToken(): string | null {
return localStorage.getItem(TOKEN_KEY);
}
export function setToken(token: string | null) {
if (token) localStorage.setItem(TOKEN_KEY, token);
else localStorage.removeItem(TOKEN_KEY);
}
async function getJson<T>(path: string, init?: RequestInit): Promise<T> {
const headers = new Headers(init?.headers);
const token = getToken();
if (token) headers.set("Authorization", `Bearer ${token}`);
if (init?.body && !headers.has("Content-Type")) {
headers.set("Content-Type", "application/json");
}
const res = await fetch(path, { ...init, headers });
if (!res.ok) {
if (res.status === 401) setToken(null);
let detail = `${path} failed: ${res.status}`;
try {
const body = (await res.json()) as { error?: string };
if (body.error) detail = body.error;
} catch {
/* ignore */
}
throw new Error(detail);
}
if (res.status === 204) return undefined as T;
return res.json() as Promise<T>;
}
export const api = {
getToken,
host: () => getJson<HostInfo>("/api/host"),
snapshot: () => getJson<Sample | null>("/api/snapshot"),
series: (range: RangeKey) => getJson<SeriesResponse>(`/api/series?range=${range}`),
compliance: () => getJson<ComplianceReport>("/api/compliance"),
bwflow: () => getJson<BwflowStats>("/api/bwflow"),
health: () =>
getJson<{ ok: boolean; samples: number; oldest_ts: number | null }>("/api/health"),
authStatus: () => getJson<AuthStatus>("/api/auth/status"),
setup: async (email: string, password: string) => {
const session = await getJson<AuthSession>("/api/auth/setup", {
method: "POST",
body: JSON.stringify({ email, password }),
});
setToken(session.token);
return session;
},
login: async (email: string, password: string) => {
const session = await getJson<AuthSession>("/api/auth/login", {
method: "POST",
body: JSON.stringify({ email, password }),
});
setToken(session.token);
return session;
},
logout: async () => {
try {
await getJson<void>("/api/auth/logout", { method: "POST" });
} finally {
setToken(null);
}
},
listSites: () => getJson<HealthSite[]>("/api/sites"),
getSite: (id: number) => getJson<HealthSite>(`/api/sites/${id}`),
siteHistory: (id: number, range: HealthRangeKey | string = "24h") =>
getJson<SiteHistory>(`/api/sites/${id}/history?range=${encodeURIComponent(range)}`),
createSite: (body: UpsertSite) =>
getJson<SiteCreateResponse>("/api/sites", {
method: "POST",
body: JSON.stringify(body),
}),
updateSite: (id: number, body: UpsertSite) =>
getJson<SiteCreateResponse>(`/api/sites/${id}`, {
method: "PUT",
body: JSON.stringify(body),
}),
deleteSite: (id: number) =>
getJson<void>(`/api/sites/${id}`, { method: "DELETE" }),
probeSite: (body: {
url: string;
expected_status?: number;
expect_body_contains?: string;
timeout_ms?: number;
}) =>
getJson<ProbeResult>("/api/sites/probe", {
method: "POST",
body: JSON.stringify(body),
}),
checkSite: (id: number) =>
getJson<SiteCreateResponse>(`/api/sites/${id}/check`, { method: "POST" }),
getSettings: () => getJson<AppSettings>("/api/settings"),
saveSettings: (body: AppSettings) =>
getJson<AppSettings>("/api/settings", {
method: "PUT",
body: JSON.stringify(body),
}),
};
Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="77" height="47" fill="none" aria-labelledby="vite-logo-title" viewBox="0 0 77 47"><title id="vite-logo-title">Vite</title><style>.parenthesis{fill:#000}@media (prefers-color-scheme:dark){.parenthesis{fill:#fff}}</style><path fill="#9135ff" d="M40.151 45.71c-.663.844-2.02.374-2.02-.699V34.708a2.26 2.26 0 0 0-2.262-2.262H24.493c-.92 0-1.457-1.04-.92-1.788l7.479-10.471c1.07-1.498 0-3.578-1.842-3.578H15.443c-.92 0-1.456-1.04-.92-1.788l9.696-13.576c.213-.297.556-.474.92-.474h28.894c.92 0 1.456 1.04.92 1.788l-7.48 10.472c-1.07 1.497 0 3.578 1.842 3.578h11.376c.944 0 1.474 1.087.89 1.83L40.153 45.712z"/><mask id="a" width="48" height="47" x="14" y="0" maskUnits="userSpaceOnUse" style="mask-type:alpha"><path fill="#000" d="M40.047 45.71c-.663.843-2.02.374-2.02-.699V34.708a2.26 2.26 0 0 0-2.262-2.262H24.389c-.92 0-1.457-1.04-.92-1.788l7.479-10.472c1.07-1.497 0-3.578-1.842-3.578H15.34c-.92 0-1.456-1.04-.92-1.788l9.696-13.575c.213-.297.556-.474.92-.474H53.93c.92 0 1.456 1.04.92 1.788L47.37 13.03c-1.07 1.498 0 3.578 1.842 3.578h11.376c.944 0 1.474 1.088.89 1.831L40.049 45.712z"/></mask><g mask="url(#a)"><g filter="url(#b)"><ellipse cx="5.508" cy="14.704" fill="#eee6ff" rx="5.508" ry="14.704" transform="rotate(269.814 20.96 11.29)scale(-1 1)"/></g><g filter="url(#c)"><ellipse cx="10.399" cy="29.851" fill="#eee6ff" rx="10.399" ry="29.851" transform="rotate(89.814 -16.902 -8.275)scale(1 -1)"/></g><g filter="url(#d)"><ellipse cx="5.508" cy="30.487" fill="#8900ff" rx="5.508" ry="30.487" transform="rotate(89.814 -19.197 -7.127)scale(1 -1)"/></g><g filter="url(#e)"><ellipse cx="5.508" cy="30.599" fill="#8900ff" rx="5.508" ry="30.599" transform="rotate(89.814 -25.928 4.177)scale(1 -1)"/></g><g filter="url(#f)"><ellipse cx="5.508" cy="30.599" fill="#8900ff" rx="5.508" ry="30.599" transform="rotate(89.814 -25.738 5.52)scale(1 -1)"/></g><g filter="url(#g)"><ellipse cx="14.072" cy="22.078" fill="#eee6ff" rx="14.072" ry="22.078" transform="rotate(93.35 31.245 55.578)scale(-1 1)"/></g><g filter="url(#h)"><ellipse cx="3.47" cy="21.501" fill="#8900ff" rx="3.47" ry="21.501" transform="rotate(89.009 35.419 55.202)scale(-1 1)"/></g><g filter="url(#i)"><ellipse cx="3.47" cy="21.501" fill="#8900ff" rx="3.47" ry="21.501" transform="rotate(89.009 35.419 55.202)scale(-1 1)"/></g><g filter="url(#j)"><ellipse cx="14.592" cy="9.743" fill="#8900ff" rx="4.407" ry="29.108" transform="rotate(39.51 14.592 9.743)"/></g><g filter="url(#k)"><ellipse cx="61.728" cy="-5.321" fill="#8900ff" rx="4.407" ry="29.108" transform="rotate(37.892 61.728 -5.32)"/></g><g filter="url(#l)"><ellipse cx="55.618" cy="7.104" fill="#00c2ff" rx="5.971" ry="9.665" transform="rotate(37.892 55.618 7.104)"/></g><g filter="url(#m)"><ellipse cx="12.326" cy="39.103" fill="#8900ff" rx="4.407" ry="29.108" transform="rotate(37.892 12.326 39.103)"/></g><g filter="url(#n)"><ellipse cx="12.326" cy="39.103" fill="#8900ff" rx="4.407" ry="29.108" transform="rotate(37.892 12.326 39.103)"/></g><g filter="url(#o)"><ellipse cx="49.857" cy="30.678" fill="#8900ff" rx="4.407" ry="29.108" transform="rotate(37.892 49.857 30.678)"/></g><g filter="url(#p)"><ellipse cx="52.623" cy="33.171" fill="#00c2ff" rx="5.971" ry="15.297" transform="rotate(37.892 52.623 33.17)"/></g></g><path d="M6.919 0c-9.198 13.166-9.252 33.575 0 46.789h6.215c-9.25-13.214-9.196-33.623 0-46.789zm62.424 0h-6.215c9.198 13.166 9.252 33.575 0 46.789h6.215c9.25-13.214 9.196-33.623 0-46.789" class="parenthesis"/><defs><filter id="b" width="60.045" height="41.654" x="-5.564" y="16.92" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape"/><feGaussianBlur result="effect1_foregroundBlur_2002_17286" stdDeviation="7.659"/></filter><filter id="c" width="90.34" height="51.437" x="-40.407" y="-6.762" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape"/><feGaussianBlur result="effect1_foregroundBlur_2002_17286" stdDeviation="7.659"/></filter><filter id="d" width="79.355" height="29.4" x="-35.435" y="2.801" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape"/><feGaussianBlur result="effect1_foregroundBlur_2002_17286" stdDeviation="4.596"/></filter><filter id="e" width="79.579" height="29.4" x="-30.84" y="20.8" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape"/><feGaussianBlur result="effect1_foregroundBlur_2002_17286" stdDeviation="4.596"/></filter><filter id="f" width="79.579" height="29.4" x="-29.307" y="21.949" color-interpolation-filters="sRGB" filterUnits="usLine truncated

After

Width:  |  Height:  |  Size: 8.5 KiB

+228
View File
@@ -0,0 +1,228 @@
import {
Area,
AreaChart,
CartesianGrid,
Legend,
Line,
LineChart,
ResponsiveContainer,
Tooltip,
XAxis,
YAxis,
} from "recharts";
import type { HealthSeriesPoint, SeriesPoint } from "./api";
import { formatPct, formatRate, formatTick } from "./format";
type Props = {
points: SeriesPoint[];
range: string;
};
const tooltipStyle = {
background: "#f7faf5",
border: "1px solid rgba(21,32,28,0.12)",
borderRadius: 8,
fontSize: 12,
};
export function CpuChart({ points, range }: Props) {
const data = points.map((p) => ({
...p,
label: formatTick(p.ts, range),
}));
return (
<ResponsiveContainer width="100%" height={240}>
<AreaChart data={data} margin={{ top: 8, right: 8, left: 0, bottom: 0 }}>
<defs>
<linearGradient id="cpuFill" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stopColor="#0f766e" stopOpacity={0.35} />
<stop offset="100%" stopColor="#0f766e" stopOpacity={0.02} />
</linearGradient>
</defs>
<CartesianGrid stroke="rgba(21,32,28,0.08)" vertical={false} />
<XAxis dataKey="label" tick={{ fill: "#5c6b64", fontSize: 11 }} minTickGap={28} />
<YAxis
domain={[0, "auto"]}
tick={{ fill: "#5c6b64", fontSize: 11 }}
tickFormatter={(v) => `${v}%`}
width={44}
/>
<Tooltip
contentStyle={tooltipStyle}
formatter={(value) => [formatPct(Number(value)), "CPU"]}
/>
<Area
type="monotone"
dataKey="cpu_pct"
name="CPU %"
stroke="#0f766e"
fill="url(#cpuFill)"
strokeWidth={2}
isAnimationActive={false}
/>
</AreaChart>
</ResponsiveContainer>
);
}
export function MemChart({ points, range }: Props) {
const data = points.map((p) => ({
...p,
label: formatTick(p.ts, range),
}));
return (
<ResponsiveContainer width="100%" height={240}>
<AreaChart data={data} margin={{ top: 8, right: 8, left: 0, bottom: 0 }}>
<defs>
<linearGradient id="memFill" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stopColor="#c2410c" stopOpacity={0.3} />
<stop offset="100%" stopColor="#c2410c" stopOpacity={0.02} />
</linearGradient>
</defs>
<CartesianGrid stroke="rgba(21,32,28,0.08)" vertical={false} />
<XAxis dataKey="label" tick={{ fill: "#5c6b64", fontSize: 11 }} minTickGap={28} />
<YAxis
domain={[0, 100]}
tick={{ fill: "#5c6b64", fontSize: 11 }}
tickFormatter={(v) => `${v}%`}
width={44}
/>
<Tooltip
contentStyle={tooltipStyle}
formatter={(value) => [formatPct(Number(value)), "Memory"]}
/>
<Area
type="monotone"
dataKey="mem_pct"
name="Memory %"
stroke="#c2410c"
fill="url(#memFill)"
strokeWidth={2}
isAnimationActive={false}
/>
</AreaChart>
</ResponsiveContainer>
);
}
export function NetChart({ points, range }: Props) {
const data = points.map((p) => ({
...p,
label: formatTick(p.ts, range),
}));
return (
<ResponsiveContainer width="100%" height={260}>
<LineChart data={data} margin={{ top: 8, right: 8, left: 0, bottom: 0 }}>
<CartesianGrid stroke="rgba(21,32,28,0.08)" vertical={false} />
<XAxis dataKey="label" tick={{ fill: "#5c6b64", fontSize: 11 }} minTickGap={28} />
<YAxis
tick={{ fill: "#5c6b64", fontSize: 11 }}
tickFormatter={(v) => formatRate(Number(v))}
width={72}
/>
<Tooltip
contentStyle={tooltipStyle}
formatter={(value, name) => [formatRate(Number(value)), String(name)]}
/>
<Legend />
<Line
type="monotone"
dataKey="net_rx_bps"
name="RX"
stroke="#1d4ed8"
strokeWidth={2}
dot={false}
isAnimationActive={false}
/>
<Line
type="monotone"
dataKey="net_tx_bps"
name="TX"
stroke="#0f766e"
strokeWidth={2}
dot={false}
isAnimationActive={false}
/>
</LineChart>
</ResponsiveContainer>
);
}
export function HealthUptimeChart({
points,
range,
}: {
points: HealthSeriesPoint[];
range: string;
}) {
const data = points.map((p) => ({
...p,
label: formatTick(p.ts, range),
status: p.had_down ? 0 : 1,
}));
return (
<div className="health-charts">
<ResponsiveContainer width="100%" height={200}>
<AreaChart data={data} margin={{ top: 8, right: 8, left: 0, bottom: 0 }}>
<defs>
<linearGradient id="upFill" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stopColor="#3f7d4e" stopOpacity={0.35} />
<stop offset="100%" stopColor="#3f7d4e" stopOpacity={0.02} />
</linearGradient>
</defs>
<CartesianGrid stroke="rgba(21,32,28,0.08)" vertical={false} />
<XAxis dataKey="label" tick={{ fill: "#5c6b64", fontSize: 11 }} minTickGap={28} />
<YAxis
domain={[0, 100]}
tick={{ fill: "#5c6b64", fontSize: 11 }}
tickFormatter={(v) => `${v}%`}
width={44}
/>
<Tooltip
contentStyle={tooltipStyle}
formatter={(value, name) => {
if (String(name) === "Uptime") return [`${Number(value).toFixed(1)}%`, "Uptime"];
return [String(value), String(name)];
}}
/>
<Area
type="stepAfter"
dataKey="uptime_pct"
name="Uptime"
stroke="#3f7d4e"
fill="url(#upFill)"
strokeWidth={2}
isAnimationActive={false}
/>
</AreaChart>
</ResponsiveContainer>
<ResponsiveContainer width="100%" height={120}>
<AreaChart data={data} margin={{ top: 4, right: 8, left: 0, bottom: 0 }}>
<CartesianGrid stroke="rgba(21,32,28,0.08)" vertical={false} />
<XAxis dataKey="label" tick={{ fill: "#5c6b64", fontSize: 11 }} minTickGap={28} />
<YAxis
domain={[0, 1]}
ticks={[0, 1]}
tick={{ fill: "#5c6b64", fontSize: 11 }}
tickFormatter={(v) => (Number(v) === 1 ? "up" : "down")}
width={44}
/>
<Tooltip
contentStyle={tooltipStyle}
formatter={(value) => [Number(value) === 1 ? "up" : "down", "Status"]}
/>
<Area
type="stepAfter"
dataKey="status"
name="Status"
stroke="#0f766e"
fill="#0f766e"
fillOpacity={0.2}
strokeWidth={2}
isAnimationActive={false}
/>
</AreaChart>
</ResponsiveContainer>
</div>
);
}
+69
View File
@@ -0,0 +1,69 @@
export function formatBytes(n: number): string {
if (!Number.isFinite(n) || n < 0) return "—";
const units = ["B", "KiB", "MiB", "GiB", "TiB"];
let v = n;
let i = 0;
while (v >= 1024 && i < units.length - 1) {
v /= 1024;
i += 1;
}
return `${v.toFixed(v >= 10 || i === 0 ? 0 : 1)} ${units[i]}`;
}
export function formatRate(bps: number): string {
if (!Number.isFinite(bps) || bps < 0) return "—";
// Show as bits/s style for network familiarity when large, else bytes/s
const bits = bps * 8;
if (bits >= 1_000_000) return `${(bits / 1_000_000).toFixed(2)} Mbit/s`;
if (bits >= 1_000) return `${(bits / 1_000).toFixed(1)} kbit/s`;
return `${bps.toFixed(0)} B/s`;
}
export function formatPct(n: number): string {
if (!Number.isFinite(n)) return "—";
return `${n.toFixed(1)}%`;
}
export function formatUptime(secs: number): string {
const d = Math.floor(secs / 86400);
const h = Math.floor((secs % 86400) / 3600);
const m = Math.floor((secs % 3600) / 60);
if (d > 0) return `${d}d ${h}h`;
if (h > 0) return `${h}h ${m}m`;
return `${m}m`;
}
export function formatTick(ts: number, range: string): string {
const d = new Date(ts * 1000);
if (range === "1h" || range === "12h" || range === "24h") {
return d.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" });
}
if (range === "7d") {
return d.toLocaleString([], { weekday: "short", hour: "2-digit" });
}
return d.toLocaleDateString([], { month: "short", day: "numeric" });
}
export function formatWhen(ts: number): string {
return new Date(ts * 1000).toLocaleString([], {
month: "short",
day: "numeric",
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
});
}
export function formatDuration(secs: number): string {
if (!Number.isFinite(secs) || secs < 0) return "—";
if (secs < 60) return `${Math.round(secs)}s`;
const m = Math.floor(secs / 60);
const s = Math.round(secs % 60);
if (m < 60) return s > 0 ? `${m}m ${s}s` : `${m}m`;
const h = Math.floor(m / 60);
const rm = m % 60;
if (h < 48) return rm > 0 ? `${h}h ${rm}m` : `${h}h`;
const d = Math.floor(h / 24);
const rh = h % 24;
return rh > 0 ? `${d}d ${rh}h` : `${d}d`;
}
+57
View File
@@ -0,0 +1,57 @@
:root {
--ink: #15201c;
--ink-soft: #2a3a34;
--paper: #e6ebe4;
--paper-2: #d5ddd2;
--panel: rgba(255, 255, 255, 0.55);
--stroke: rgba(21, 32, 28, 0.12);
--accent: #0f766e;
--accent-2: #c2410c;
--good: #3f7d4e;
--muted: #5c6b64;
--chart-cpu: #0f766e;
--chart-mem: #c2410c;
--chart-rx: #1d4ed8;
--chart-tx: #0f766e;
--shadow: 0 1px 0 rgba(255, 255, 255, 0.4) inset;
font-family: "IBM Plex Sans", system-ui, sans-serif;
color: var(--ink);
background: var(--paper);
line-height: 1.45;
font-synthesis: none;
text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased;
}
* {
box-sizing: border-box;
}
html,
body,
#root {
margin: 0;
min-height: 100%;
}
body {
min-height: 100vh;
background:
radial-gradient(1200px 600px at 10% -10%, rgba(15, 118, 110, 0.14), transparent 55%),
radial-gradient(900px 500px at 100% 0%, rgba(194, 65, 12, 0.1), transparent 50%),
linear-gradient(180deg, #eef2ec 0%, #dfe6db 100%);
}
button {
font: inherit;
}
.mono {
font-family: "IBM Plex Mono", ui-monospace, monospace;
}
.brand {
font-family: "Bricolage Grotesque", sans-serif;
font-weight: 700;
letter-spacing: -0.03em;
}
+10
View File
@@ -0,0 +1,10 @@
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import App from "./App";
import "./index.css";
createRoot(document.getElementById("root")!).render(
<StrictMode>
<App />
</StrictMode>,
);
+49
View File
@@ -0,0 +1,49 @@
import { useEffect, useState } from "react";
export type AppRoute =
| { kind: "dashboard" }
| { kind: "sites" }
| { kind: "site"; id: number }
| { kind: "settings" };
function pathToRoute(pathname: string): AppRoute {
const path = pathname.replace(/\/+$/, "") || "/";
const siteMatch = path.match(/^\/sites\/(\d+)$/);
if (siteMatch) {
return { kind: "site", id: Number(siteMatch[1]) };
}
if (path === "/sites") return { kind: "sites" };
if (path === "/settings") return { kind: "settings" };
return { kind: "dashboard" };
}
export function routeToPath(route: AppRoute): string {
if (route.kind === "site") return `/sites/${route.id}`;
if (route.kind === "sites") return "/sites";
if (route.kind === "settings") return "/settings";
return "/";
}
export function useRoute(): [AppRoute, (route: AppRoute) => void] {
const [route, setRouteState] = useState<AppRoute>(() =>
pathToRoute(window.location.pathname),
);
useEffect(() => {
function onPopState() {
setRouteState(pathToRoute(window.location.pathname));
}
window.addEventListener("popstate", onPopState);
return () => window.removeEventListener("popstate", onPopState);
}, []);
function navigate(next: AppRoute) {
const path = routeToPath(next);
if (window.location.pathname !== path) {
window.history.pushState({}, "", path);
}
setRouteState(next);
}
return [route, navigate];
}
+26
View File
@@ -0,0 +1,26 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
"target": "es2023",
"lib": ["ES2023", "DOM"],
"module": "esnext",
"types": ["vite/client"],
"allowArbitraryExtensions": true,
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "react-jsx",
/* Linting */
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true
},
"include": ["src"]
}
+7
View File
@@ -0,0 +1,7 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
]
}
+23
View File
@@ -0,0 +1,23 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
"target": "es2023",
"lib": ["ES2023"],
"types": ["node"],
"skipLibCheck": true,
/* Bundler mode */
"module": "nodenext",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
/* Linting */
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true
},
"include": ["vite.config.ts"]
}
+17
View File
@@ -0,0 +1,17 @@
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
export default defineConfig({
plugins: [react()],
server: {
host: "127.0.0.1",
port: 5173,
proxy: {
"/api": "http://127.0.0.1:8787",
},
},
build: {
outDir: "dist",
emptyOutDir: true,
},
});