WireGuard server with an embedded admin console
Go backend that drives kernel WireGuard over netlink (wireguard-go as the fallback), nftables NAT with MSS clamping, forwarding and buffer sysctls, SQLite for peers, users, sessions, traffic history and the audit log. React console: dashboard with live rates and usage history, peer management with QR codes and .conf downloads, disconnect, session reset, key rotation, expiry, client-supplied keys, settings, users with admin and viewer roles, two-factor authentication with recovery codes, audit log. Docker image on Alpine with compose files for bridged and host networking, CI and GHCR publish workflows, performance notes.
This commit is contained in:
@@ -0,0 +1,15 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta name="color-scheme" content="light dark" />
|
||||
<meta name="referrer" content="no-referrer" />
|
||||
<link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'%3E%3Crect width='32' height='32' rx='7' fill='%231f6f5c'/%3E%3Cpath d='M7 10l4 12 5-9 5 9 4-12' fill='none' stroke='%23fff' stroke-width='3' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E" />
|
||||
<title>WGX</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
Generated
+1338
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"name": "wgx-web",
|
||||
"version": "0.0.0",
|
||||
"private": true,
|
||||
"license": "AGPL-3.0-or-later",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc -p tsconfig.json --noEmit && vite build && touch ../internal/server/static/dist/.gitkeep",
|
||||
"typecheck": "tsc -p tsconfig.json --noEmit",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"lucide-react": "^1.45.0",
|
||||
"react": "^19.3.0",
|
||||
"react-dom": "^19.3.0",
|
||||
"wouter": "^3.11.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "^19.3.0",
|
||||
"@types/react-dom": "^19.3.0",
|
||||
"@vitejs/plugin-react": "^6.1.1",
|
||||
"typescript": "^7.0.2",
|
||||
"vite": "^8.3.0"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import { Route, Switch } from "wouter";
|
||||
import { AuthProvider, LiveProvider, ToastProvider, useAuth } from "./state";
|
||||
import { Layout } from "./components/Layout";
|
||||
import { Login } from "./pages/Login";
|
||||
import { Setup } from "./pages/Setup";
|
||||
import { Dashboard } from "./pages/Dashboard";
|
||||
import { Peers } from "./pages/Peers";
|
||||
import { SettingsPage } from "./pages/Settings";
|
||||
import { UsersPage } from "./pages/Users";
|
||||
import { Audit } from "./pages/Audit";
|
||||
import { Account } from "./pages/Account";
|
||||
|
||||
function Gate() {
|
||||
const { me, loading, needsSetup } = useAuth();
|
||||
if (loading) return <div className="auth">Loading…</div>;
|
||||
if (needsSetup) return <Setup />;
|
||||
if (!me) return <Login />;
|
||||
return (
|
||||
<LiveProvider>
|
||||
<Layout>
|
||||
<Switch>
|
||||
<Route path="/" component={Dashboard} />
|
||||
<Route path="/peers" component={Peers} />
|
||||
<Route path="/peers/:id" component={Peers} />
|
||||
<Route path="/settings" component={SettingsPage} />
|
||||
<Route path="/users" component={UsersPage} />
|
||||
<Route path="/audit" component={Audit} />
|
||||
<Route path="/account" component={Account} />
|
||||
<Route>
|
||||
<div className="empty">Nothing here.</div>
|
||||
</Route>
|
||||
</Switch>
|
||||
</Layout>
|
||||
</LiveProvider>
|
||||
);
|
||||
}
|
||||
|
||||
export function App() {
|
||||
return (
|
||||
<ToastProvider>
|
||||
<AuthProvider>
|
||||
<Gate />
|
||||
</AuthProvider>
|
||||
</ToastProvider>
|
||||
);
|
||||
}
|
||||
+240
@@ -0,0 +1,240 @@
|
||||
// Thin client for the WGX API. Every call goes through `request`, which
|
||||
// turns non-2xx answers into ApiError so pages can show the server's message.
|
||||
|
||||
export class ApiError extends Error {
|
||||
status: number;
|
||||
constructor(status: number, message: string) {
|
||||
super(message);
|
||||
this.status = status;
|
||||
}
|
||||
}
|
||||
|
||||
async function request<T>(method: string, url: string, body?: unknown): Promise<T> {
|
||||
const headers: Record<string, string> = { Accept: "application/json" };
|
||||
const init: RequestInit = { method, headers, credentials: "same-origin" };
|
||||
if (body !== undefined) {
|
||||
headers["Content-Type"] = "application/json";
|
||||
init.body = JSON.stringify(body);
|
||||
}
|
||||
const res = await fetch(url, init);
|
||||
if (res.status === 204) return undefined as T;
|
||||
const text = await res.text();
|
||||
let data: unknown = null;
|
||||
try {
|
||||
data = text ? JSON.parse(text) : null;
|
||||
} catch {
|
||||
data = null;
|
||||
}
|
||||
if (!res.ok) {
|
||||
const msg = (data as { error?: string } | null)?.error ?? res.statusText ?? "request failed";
|
||||
throw new ApiError(res.status, msg);
|
||||
}
|
||||
return data as T;
|
||||
}
|
||||
|
||||
export const get = <T>(url: string) => request<T>("GET", url);
|
||||
export const post = <T>(url: string, body?: unknown) => request<T>("POST", url, body ?? {});
|
||||
export const put = <T>(url: string, body: unknown) => request<T>("PUT", url, body);
|
||||
export const del = <T>(url: string) => request<T>("DELETE", url);
|
||||
|
||||
export async function getText(url: string): Promise<string> {
|
||||
const res = await fetch(url, { credentials: "same-origin" });
|
||||
if (!res.ok) throw new ApiError(res.status, await res.text());
|
||||
return res.text();
|
||||
}
|
||||
|
||||
// --- types ------------------------------------------------------------------
|
||||
|
||||
export interface Me {
|
||||
id: number;
|
||||
username: string;
|
||||
role: "admin" | "viewer";
|
||||
totpEnabled: boolean;
|
||||
recoveryCodesLeft: number;
|
||||
createdAt: string;
|
||||
lastLoginAt?: string;
|
||||
}
|
||||
|
||||
export interface Live {
|
||||
id: string;
|
||||
connected: boolean;
|
||||
endpoint?: string;
|
||||
lastHandshake?: string;
|
||||
rx: number;
|
||||
tx: number;
|
||||
rxRate: number;
|
||||
txRate: number;
|
||||
connectedSince?: string;
|
||||
}
|
||||
|
||||
export interface Peer {
|
||||
id: string;
|
||||
name: string;
|
||||
publicKey: string;
|
||||
serverKeys: boolean;
|
||||
presharedKey: boolean;
|
||||
ipv4: string;
|
||||
ipv6?: string;
|
||||
clientRoutes: string;
|
||||
dns: string;
|
||||
keepalive: number;
|
||||
mtu: number;
|
||||
enabled: boolean;
|
||||
expired: boolean;
|
||||
expiresAt?: string;
|
||||
notes: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
live: Live;
|
||||
}
|
||||
|
||||
export interface PeerInput {
|
||||
name: string;
|
||||
publicKey?: string;
|
||||
ipv4?: string;
|
||||
ipv6?: string;
|
||||
clientRoutes: string;
|
||||
dns: string;
|
||||
keepalive: number | null;
|
||||
mtu: number | null;
|
||||
enabled?: boolean;
|
||||
expiresAt: string | null;
|
||||
notes: string;
|
||||
}
|
||||
|
||||
export interface Settings {
|
||||
endpointHost: string;
|
||||
endpointPort: number;
|
||||
dns: string;
|
||||
clientRoutes: string;
|
||||
mtu: number;
|
||||
keepalive: number;
|
||||
peerIsolation: boolean;
|
||||
clampMSS: boolean;
|
||||
presharedKeys: boolean;
|
||||
connectedWindow: number;
|
||||
}
|
||||
|
||||
export interface Totals {
|
||||
peers: number;
|
||||
active: number;
|
||||
connected: number;
|
||||
rx: number;
|
||||
tx: number;
|
||||
rxRate: number;
|
||||
txRate: number;
|
||||
}
|
||||
|
||||
export interface Snapshot {
|
||||
at: string;
|
||||
totals: Totals;
|
||||
peers: Record<string, Live>;
|
||||
}
|
||||
|
||||
export interface SysctlStatus {
|
||||
key: string;
|
||||
wanted: string;
|
||||
current: string;
|
||||
applied: boolean;
|
||||
required: boolean;
|
||||
why: string;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export interface Status {
|
||||
version: string;
|
||||
backend: "kernel" | "userspace" | "mock";
|
||||
interface: string;
|
||||
publicKey: string;
|
||||
listenPort: number;
|
||||
addresses: string[];
|
||||
subnet4: string;
|
||||
subnet6?: string;
|
||||
egress?: string;
|
||||
firewallError?: string;
|
||||
firewallManaged: boolean;
|
||||
startedAt: string;
|
||||
sysctls: SysctlStatus[];
|
||||
totals: Totals;
|
||||
settings: Settings;
|
||||
}
|
||||
|
||||
export interface TrafficPoint {
|
||||
t: string;
|
||||
rx: number;
|
||||
tx: number;
|
||||
}
|
||||
|
||||
export interface PeerUsage {
|
||||
peerId: string;
|
||||
rx: number;
|
||||
tx: number;
|
||||
}
|
||||
|
||||
export interface AuditEntry {
|
||||
id: number;
|
||||
at: string;
|
||||
actor: string;
|
||||
action: string;
|
||||
target: string;
|
||||
detail: string;
|
||||
ip: string;
|
||||
}
|
||||
|
||||
export interface User {
|
||||
id: number;
|
||||
username: string;
|
||||
role: "admin" | "viewer";
|
||||
totpEnabled: boolean;
|
||||
createdAt: string;
|
||||
lastLoginAt?: string;
|
||||
}
|
||||
|
||||
export interface SessionInfo {
|
||||
current: boolean;
|
||||
createdAt: string;
|
||||
lastSeenAt: string;
|
||||
ip: string;
|
||||
userAgent: string;
|
||||
}
|
||||
|
||||
export type Range = "1h" | "24h" | "7d" | "30d";
|
||||
|
||||
// --- endpoints --------------------------------------------------------------
|
||||
|
||||
export const api = {
|
||||
setupStatus: () => get<{ needsSetup: boolean }>("/api/setup"),
|
||||
setup: (body: { username: string; password: string; endpointHost: string }) => post<Me>("/api/setup", body),
|
||||
login: (username: string, password: string) => post<{ totpRequired: boolean }>("/api/auth/login", { username, password }),
|
||||
loginTotp: (code: string) => post<{ totpRequired: boolean }>("/api/auth/totp", { code }),
|
||||
logout: () => post<{ ok: boolean }>("/api/auth/logout"),
|
||||
me: () => get<Me>("/api/auth/me"),
|
||||
changePassword: (current: string, next: string) => post<{ ok: boolean }>("/api/auth/password", { current, new: next }),
|
||||
totpSetup: () => post<{ secret: string; uri: string }>("/api/auth/totp/setup"),
|
||||
totpConfirm: (code: string) => post<{ recoveryCodes: string[] }>("/api/auth/totp/confirm", { code }),
|
||||
totpDisable: (password: string) => post<{ ok: boolean }>("/api/auth/totp/disable", { password }),
|
||||
sessions: () => get<SessionInfo[]>("/api/auth/sessions"),
|
||||
revokeSessions: () => post<{ ok: boolean }>("/api/auth/sessions/revoke"),
|
||||
|
||||
status: () => get<Status>("/api/status"),
|
||||
peers: () => get<Peer[]>("/api/peers"),
|
||||
peer: (id: string) => get<Peer>(`/api/peers/${id}`),
|
||||
createPeer: (body: PeerInput) => post<Peer & { config: string }>("/api/peers", body),
|
||||
updatePeer: (id: string, body: PeerInput) => put<Peer>(`/api/peers/${id}`, body),
|
||||
deletePeer: (id: string) => del<{ ok: boolean }>(`/api/peers/${id}`),
|
||||
enablePeer: (id: string) => post<Peer>(`/api/peers/${id}/enable`),
|
||||
disablePeer: (id: string) => post<Peer>(`/api/peers/${id}/disable`),
|
||||
resetPeer: (id: string) => post<{ ok: boolean }>(`/api/peers/${id}/reset`),
|
||||
rotatePeer: (id: string) => post<Peer & { config: string }>(`/api/peers/${id}/rotate`),
|
||||
peerConfig: (id: string) => getText(`/api/peers/${id}/config`),
|
||||
peerUsage: (id: string, range: Range) => get<TrafficPoint[]>(`/api/peers/${id}/usage?range=${range}`),
|
||||
usage: (range: Range) => get<TrafficPoint[]>(`/api/usage?range=${range}`),
|
||||
usageByPeer: (range: Range) => get<PeerUsage[]>(`/api/usage/peers?range=${range}`),
|
||||
settings: () => get<Settings>("/api/settings"),
|
||||
saveSettings: (body: Settings) => put<Settings>("/api/settings", body),
|
||||
audit: (limit = 200) => get<AuditEntry[]>(`/api/audit?limit=${limit}`),
|
||||
users: () => get<User[]>("/api/users"),
|
||||
createUser: (body: { username: string; password: string; role: string }) => post<User>("/api/users", body),
|
||||
updateUser: (id: number, body: { role?: string; password?: string; resetTotp?: boolean }) => put<User>(`/api/users/${id}`, body),
|
||||
deleteUser: (id: number) => del<{ ok: boolean }>(`/api/users/${id}`),
|
||||
};
|
||||
@@ -0,0 +1,63 @@
|
||||
import type { ReactNode } from "react";
|
||||
import { Link, useLocation } from "wouter";
|
||||
import { Activity, ClipboardList, LayoutDashboard, LogOut, Settings, Shield, Users, Wifi, WifiOff } from "lucide-react";
|
||||
import { useAuth, useLive } from "../state";
|
||||
|
||||
const items = [
|
||||
{ href: "/", label: "Dashboard", icon: LayoutDashboard },
|
||||
{ href: "/peers", label: "Peers", icon: Activity },
|
||||
{ href: "/settings", label: "Settings", icon: Settings },
|
||||
{ href: "/users", label: "Users", icon: Users },
|
||||
{ href: "/audit", label: "Audit log", icon: ClipboardList },
|
||||
{ href: "/account", label: "Account", icon: Shield },
|
||||
];
|
||||
|
||||
export function Layout({ children }: { children: ReactNode }) {
|
||||
const [location] = useLocation();
|
||||
const { me, signOut } = useAuth();
|
||||
const { connected } = useLive();
|
||||
return (
|
||||
<div className="shell">
|
||||
<aside className="sidebar">
|
||||
<div className="brand">
|
||||
<div className="brand-mark" aria-hidden="true">
|
||||
<svg width="18" height="18" viewBox="0 0 32 32">
|
||||
<path d="M7 10l4 12 5-9 5 9 4-12" fill="none" stroke="currentColor" strokeWidth="3.5" strokeLinecap="round" strokeLinejoin="round" />
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<div className="brand-name">WGX</div>
|
||||
<div className="brand-sub">WireGuard server</div>
|
||||
</div>
|
||||
</div>
|
||||
<nav className="nav">
|
||||
{items.map((it) => {
|
||||
const active = it.href === "/" ? location === "/" : location.startsWith(it.href);
|
||||
const Icon = it.icon;
|
||||
return (
|
||||
<Link key={it.href} href={it.href} className={active ? "active" : ""}>
|
||||
<Icon />
|
||||
<span>{it.label}</span>
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
<div className="sidebar-foot">
|
||||
<div title={connected ? "Live updates connected" : "Live updates reconnecting"} className="nowrap">
|
||||
{connected ? <Wifi size={13} style={{ verticalAlign: -2, color: "var(--ok)" }} /> : <WifiOff size={13} style={{ verticalAlign: -2, color: "var(--warn)" }} />} {connected ? "live" : "reconnecting"}
|
||||
</div>
|
||||
<div className="nowrap">
|
||||
{me?.username} <span className="faint">({me?.role})</span>
|
||||
</div>
|
||||
<button className="btn sm ghost" onClick={() => void signOut()} style={{ alignSelf: "flex-start", marginLeft: -6 }}>
|
||||
<LogOut /> Sign out
|
||||
</button>
|
||||
<a className="nowrap" href="https://github.com/Coffey-Labs/WGX" target="_blank" rel="noreferrer">
|
||||
AGPL-3.0 source
|
||||
</a>
|
||||
</div>
|
||||
</aside>
|
||||
<main className="main">{children}</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,213 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { Copy, Download, KeyRound, Pencil, Power, RefreshCw, Trash2 } from "lucide-react";
|
||||
import { api, type Live, type Peer, type Range, type Settings, type TrafficPoint } from "../api";
|
||||
import { ago, bytes, dateTime, duration, rate, shortKey } from "../format";
|
||||
import { errorMessage, useNow, useToast } from "../state";
|
||||
import { Legend, TrafficChart } from "./charts";
|
||||
import { Confirm, Modal, Segmented, copyText } from "./ui";
|
||||
import { PeerForm } from "./PeerForm";
|
||||
|
||||
const rangeMs: Record<Range, number> = { "1h": 3600e3, "24h": 86400e3, "7d": 7 * 86400e3, "30d": 30 * 86400e3 };
|
||||
|
||||
export function PeerDetail({ peer, live, settings, isAdmin, onClose, onChanged, initialTab = "overview", initialConfig }: { peer: Peer; live: Live; settings: Settings; isAdmin: boolean; onClose: () => void; onChanged: (p?: Peer) => void; initialTab?: "overview" | "config"; initialConfig?: string }) {
|
||||
const toast = useToast();
|
||||
const now = useNow(1000);
|
||||
const [tab, setTab] = useState<"overview" | "config">(initialTab);
|
||||
const [config, setConfig] = useState<string>(initialConfig ?? "");
|
||||
const [range, setRange] = useState<Range>("24h");
|
||||
const [series, setSeries] = useState<TrafficPoint[]>([]);
|
||||
const [editing, setEditing] = useState(false);
|
||||
const [confirm, setConfirm] = useState<null | "delete" | "rotate" | "disable">(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [qrKey, setQrKey] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
if (tab === "config" && !config) api.peerConfig(peer.id).then(setConfig).catch((e) => toast(errorMessage(e), "bad"));
|
||||
}, [tab, config, peer.id, toast]);
|
||||
useEffect(() => {
|
||||
api.peerUsage(peer.id, range).then(setSeries).catch(() => {});
|
||||
}, [peer.id, range, peer.updatedAt]);
|
||||
|
||||
async function act(fn: () => Promise<unknown>, done: string) {
|
||||
setBusy(true);
|
||||
try {
|
||||
await fn();
|
||||
toast(done);
|
||||
onChanged();
|
||||
} catch (e) {
|
||||
toast(errorMessage(e), "bad");
|
||||
} finally {
|
||||
setBusy(false);
|
||||
setConfirm(null);
|
||||
}
|
||||
}
|
||||
|
||||
const state = !peer.enabled ? "disabled" : peer.expired ? "expired" : live.connected ? "on" : "off";
|
||||
const stateLabel = { disabled: "Disabled", expired: "Expired", on: "Connected", off: "Not connected" }[state];
|
||||
|
||||
return (
|
||||
<>
|
||||
<Modal
|
||||
title={peer.name}
|
||||
onClose={onClose}
|
||||
wide
|
||||
footer={
|
||||
isAdmin ? (
|
||||
<div className="btn-row" style={{ justifyContent: "flex-end", width: "100%" }}>
|
||||
<button className="btn sm" onClick={() => setEditing(true)} disabled={busy}>
|
||||
<Pencil /> Edit
|
||||
</button>
|
||||
{peer.enabled ? (
|
||||
<button className="btn sm" onClick={() => setConfirm("disable")} disabled={busy} title="Remove from the interface; drops the session">
|
||||
<Power /> Disconnect
|
||||
</button>
|
||||
) : (
|
||||
<button className="btn sm" onClick={() => act(() => api.enablePeer(peer.id), "Peer enabled")} disabled={busy}>
|
||||
<Power /> Enable
|
||||
</button>
|
||||
)}
|
||||
<button className="btn sm" onClick={() => act(() => api.resetPeer(peer.id), "Session reset")} disabled={busy || !peer.enabled} title="Drop the current session; a client that is sending traffic handshakes again within about 15 seconds">
|
||||
<RefreshCw /> Reset session
|
||||
</button>
|
||||
{peer.serverKeys && (
|
||||
<button className="btn sm" onClick={() => setConfirm("rotate")} disabled={busy} title="New key pair; the old config stops working">
|
||||
<KeyRound /> Rotate keys
|
||||
</button>
|
||||
)}
|
||||
<button className="btn sm danger" onClick={() => setConfirm("delete")} disabled={busy}>
|
||||
<Trash2 /> Delete
|
||||
</button>
|
||||
</div>
|
||||
) : undefined
|
||||
}
|
||||
>
|
||||
<div className="tabs">
|
||||
<button className={tab === "overview" ? "active" : ""} onClick={() => setTab("overview")}>
|
||||
Overview
|
||||
</button>
|
||||
<button className={tab === "config" ? "active" : ""} onClick={() => setTab("config")}>
|
||||
Configuration
|
||||
</button>
|
||||
</div>
|
||||
{tab === "overview" && (
|
||||
<>
|
||||
<div className="grid grid-2">
|
||||
<dl className="kv">
|
||||
<dt>Status</dt>
|
||||
<dd>
|
||||
<span className={`dot ${state}`} />
|
||||
{stateLabel}
|
||||
{live.connected && live.connectedSince && <span className="faint"> for {duration(live.connectedSince, now)}</span>}
|
||||
</dd>
|
||||
<dt>Tunnel address</dt>
|
||||
<dd className="mono">
|
||||
{peer.ipv4}
|
||||
{peer.ipv6 ? `, ${peer.ipv6}` : ""}
|
||||
</dd>
|
||||
<dt>Endpoint</dt>
|
||||
<dd className="mono">{live.endpoint || "—"}</dd>
|
||||
<dt>Last handshake</dt>
|
||||
<dd>
|
||||
{ago(live.lastHandshake, now)} <span className="faint">{dateTime(live.lastHandshake)}</span>
|
||||
</dd>
|
||||
<dt>Rate</dt>
|
||||
<dd className="num">
|
||||
↓ {rate(live.rxRate)} · ↑ {rate(live.txRate)}
|
||||
</dd>
|
||||
<dt>Transfer</dt>
|
||||
<dd className="num">
|
||||
↓ {bytes(live.rx)} · ↑ {bytes(live.tx)}
|
||||
</dd>
|
||||
</dl>
|
||||
<dl className="kv">
|
||||
<dt>Public key</dt>
|
||||
<dd className="mono" title={peer.publicKey}>
|
||||
{shortKey(peer.publicKey)}{" "}
|
||||
<button className="btn icon ghost sm" title="Copy" onClick={() => copyText(peer.publicKey).then((ok) => toast(ok ? "Copied" : "Could not copy", ok ? "ok" : "bad"))}>
|
||||
<Copy />
|
||||
</button>
|
||||
</dd>
|
||||
<dt>Keys</dt>
|
||||
<dd>
|
||||
{peer.serverKeys ? "generated by the server" : "held by the client"}
|
||||
{peer.presharedKey ? " · preshared key" : ""}
|
||||
</dd>
|
||||
<dt>Client routes</dt>
|
||||
<dd className="mono">{peer.clientRoutes}</dd>
|
||||
<dt>DNS</dt>
|
||||
<dd>{peer.dns || <span className="faint">server default ({settings.dns || "none"})</span>}</dd>
|
||||
<dt>Keepalive / MTU</dt>
|
||||
<dd>
|
||||
{peer.keepalive || settings.keepalive}s / {peer.mtu || settings.mtu}
|
||||
</dd>
|
||||
<dt>Expires</dt>
|
||||
<dd>{peer.expiresAt ? dateTime(peer.expiresAt) : <span className="faint">never</span>}</dd>
|
||||
<dt>Created</dt>
|
||||
<dd>{dateTime(peer.createdAt)}</dd>
|
||||
</dl>
|
||||
</div>
|
||||
{peer.notes && <p className="mt muted" style={{ whiteSpace: "pre-wrap" }}>{peer.notes}</p>}
|
||||
<div className="mt" style={{ display: "flex", justifyContent: "space-between", alignItems: "center", gap: 8, flexWrap: "wrap" }}>
|
||||
<Legend />
|
||||
<Segmented value={range} onChange={setRange} options={[{ value: "1h", label: "1h" }, { value: "24h", label: "24h" }, { value: "7d", label: "7d" }, { value: "30d", label: "30d" }]} />
|
||||
</div>
|
||||
<TrafficChart points={series} from={now - rangeMs[range]} to={now} bucketSeconds={range === "30d" ? 3600 : 300} />
|
||||
</>
|
||||
)}
|
||||
{tab === "config" && (
|
||||
<div className="qr">
|
||||
{peer.serverKeys ? (
|
||||
<img key={qrKey} src={`/api/peers/${peer.id}/qr.png?size=384&v=${peer.updatedAt}`} alt="QR code of the client configuration" width={320} height={320} onError={() => setQrKey((k) => k + 1)} />
|
||||
) : (
|
||||
<div className="notice">This peer holds its own private key, so there is no QR code. Fill in the PrivateKey line on the client.</div>
|
||||
)}
|
||||
<pre className="config">{config || "…"}</pre>
|
||||
<div className="btn-row">
|
||||
<button className="btn" onClick={() => copyText(config).then((ok) => toast(ok ? "Configuration copied" : "Could not copy", ok ? "ok" : "bad"))} disabled={!config}>
|
||||
<Copy /> Copy
|
||||
</button>
|
||||
<a className="btn" href={`/api/peers/${peer.id}/config?download=1`}>
|
||||
<Download /> Download .conf
|
||||
</a>
|
||||
<span className="small faint">Anyone with this file can connect as this peer. Viewing it is recorded in the audit log.</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Modal>
|
||||
{editing && (
|
||||
<PeerForm
|
||||
peer={peer}
|
||||
settings={settings}
|
||||
onClose={() => setEditing(false)}
|
||||
onSaved={(p) => {
|
||||
setEditing(false);
|
||||
setConfig("");
|
||||
toast("Peer saved");
|
||||
onChanged(p);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{confirm === "delete" && <Confirm title="Delete peer" danger confirmLabel="Delete" busy={busy} onClose={() => setConfirm(null)} onConfirm={() => act(() => api.deletePeer(peer.id).then(() => onClose()), "Peer deleted")} text={<>Delete <b>{peer.name}</b>? Its keys, address and traffic history are gone for good.</>} />}
|
||||
{confirm === "disable" && <Confirm title="Disconnect peer" confirmLabel="Disconnect" busy={busy} onClose={() => setConfirm(null)} onConfirm={() => act(() => api.disablePeer(peer.id), "Peer disconnected")} text={<>Remove <b>{peer.name}</b> from the interface? Its session drops now and it cannot reconnect until you enable it again.</>} />}
|
||||
{confirm === "rotate" && (
|
||||
<Confirm
|
||||
title="Rotate keys"
|
||||
confirmLabel="Rotate"
|
||||
busy={busy}
|
||||
onClose={() => setConfirm(null)}
|
||||
onConfirm={() =>
|
||||
act(
|
||||
() =>
|
||||
api.rotatePeer(peer.id).then((p) => {
|
||||
setConfig(p.config);
|
||||
setTab("config");
|
||||
}),
|
||||
"Keys rotated; hand out the new configuration",
|
||||
)
|
||||
}
|
||||
text={<>Give <b>{peer.name}</b> a new key pair? The configuration it has now stops working the moment you confirm.</>}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
import { useState, type FormEvent } from "react";
|
||||
import { api, type Peer, type PeerInput, type Settings } from "../api";
|
||||
import { fromLocalInput, toLocalInput } from "../format";
|
||||
import { errorMessage } from "../state";
|
||||
import { Check, Field, Modal } from "./ui";
|
||||
|
||||
// One form for create and edit. On create the key mode is chosen here; on
|
||||
// edit keys and addresses are fixed (rotate keys from the detail view).
|
||||
export function PeerForm({ peer, settings, onClose, onSaved }: { peer?: Peer; settings: Settings; onClose: () => void; onSaved: (p: Peer & { config?: string }) => void }) {
|
||||
const editing = !!peer;
|
||||
const [name, setName] = useState(peer?.name ?? "");
|
||||
const [keyMode, setKeyMode] = useState<"server" | "client">("server");
|
||||
const [publicKey, setPublicKey] = useState("");
|
||||
const [ipv4, setIpv4] = useState("");
|
||||
const [ipv6, setIpv6] = useState("");
|
||||
const [routes, setRoutes] = useState(peer?.clientRoutes ?? settings.clientRoutes);
|
||||
const [dns, setDns] = useState(peer?.dns ?? "");
|
||||
const [keepalive, setKeepalive] = useState(peer ? String(peer.keepalive) : "0");
|
||||
const [mtu, setMtu] = useState(peer ? String(peer.mtu) : "0");
|
||||
const [expires, setExpires] = useState(toLocalInput(peer?.expiresAt));
|
||||
const [notes, setNotes] = useState(peer?.notes ?? "");
|
||||
const [enabled, setEnabled] = useState(peer?.enabled ?? true);
|
||||
const [advanced, setAdvanced] = useState(editing);
|
||||
const [error, setError] = useState("");
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
async function submit(e: FormEvent) {
|
||||
e.preventDefault();
|
||||
setError("");
|
||||
setBusy(true);
|
||||
const body: PeerInput = {
|
||||
name,
|
||||
clientRoutes: routes,
|
||||
dns,
|
||||
keepalive: keepalive === "" ? null : Number(keepalive),
|
||||
mtu: mtu === "" ? null : Number(mtu),
|
||||
enabled,
|
||||
expiresAt: expires ? fromLocalInput(expires) : "1970-01-01T00:00:00Z",
|
||||
notes,
|
||||
};
|
||||
if (!editing) {
|
||||
if (keyMode === "client") body.publicKey = publicKey.trim();
|
||||
if (ipv4.trim()) body.ipv4 = ipv4.trim();
|
||||
if (ipv6.trim()) body.ipv6 = ipv6.trim();
|
||||
}
|
||||
try {
|
||||
const saved = editing ? await api.updatePeer(peer.id, body) : await api.createPeer(body);
|
||||
onSaved(saved);
|
||||
} catch (err) {
|
||||
setError(errorMessage(err));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal
|
||||
title={editing ? `Edit ${peer.name}` : "New peer"}
|
||||
onClose={onClose}
|
||||
footer={
|
||||
<>
|
||||
<button className="btn" type="button" onClick={onClose} disabled={busy}>
|
||||
Cancel
|
||||
</button>
|
||||
<button className="btn primary" type="submit" form="peer-form" disabled={busy}>
|
||||
{busy ? "…" : editing ? "Save" : "Create peer"}
|
||||
</button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<form id="peer-form" onSubmit={submit}>
|
||||
{error && <div className="error">{error}</div>}
|
||||
<Field label="Name" hint="A device or a person: “Laptop”, “Phone”, “Office router”.">
|
||||
<input className="input" autoFocus value={name} onChange={(e) => setName(e.target.value)} required maxLength={64} />
|
||||
</Field>
|
||||
{!editing && (
|
||||
<div className="field">
|
||||
<label>Keys</label>
|
||||
<div className="btn-row">
|
||||
<label className="btn sm" style={{ cursor: "pointer" }}>
|
||||
<input type="radio" name="keys" checked={keyMode === "server"} onChange={() => setKeyMode("server")} /> Generate here (QR code)
|
||||
</label>
|
||||
<label className="btn sm" style={{ cursor: "pointer" }}>
|
||||
<input type="radio" name="keys" checked={keyMode === "client"} onChange={() => setKeyMode("client")} /> Client brings its own public key
|
||||
</label>
|
||||
</div>
|
||||
<span className="hint">Generating here lets you scan a QR code. Bringing a key means the private key never leaves the client, but there is no QR code.</span>
|
||||
</div>
|
||||
)}
|
||||
{!editing && keyMode === "client" && (
|
||||
<Field label="Client public key">
|
||||
<input className="input mono" value={publicKey} onChange={(e) => setPublicKey(e.target.value)} placeholder="base64, 44 characters" required />
|
||||
</Field>
|
||||
)}
|
||||
{!advanced && (
|
||||
<button type="button" className="btn sm ghost" onClick={() => setAdvanced(true)} style={{ marginLeft: -8 }}>
|
||||
More options…
|
||||
</button>
|
||||
)}
|
||||
{advanced && (
|
||||
<>
|
||||
<Field label="Client routes (AllowedIPs)" hint="What the client sends through the tunnel. 0.0.0.0/0, ::/0 is everything; the tunnel subnet alone is split tunnelling.">
|
||||
<input className="input mono" value={routes} onChange={(e) => setRoutes(e.target.value)} />
|
||||
</Field>
|
||||
<div className="form-cols">
|
||||
<Field label="DNS" hint={`Blank uses the server default (${settings.dns || "none"}).`}>
|
||||
<input className="input" value={dns} onChange={(e) => setDns(e.target.value)} placeholder={settings.dns} />
|
||||
</Field>
|
||||
<Field label="Keepalive (s)" hint={`0 uses the server default (${settings.keepalive}).`}>
|
||||
<input className="input" type="number" min={0} max={65535} value={keepalive} onChange={(e) => setKeepalive(e.target.value)} />
|
||||
</Field>
|
||||
<Field label="MTU" hint={`0 uses the server default (${settings.mtu}).`}>
|
||||
<input className="input" type="number" min={0} max={9000} value={mtu} onChange={(e) => setMtu(e.target.value)} />
|
||||
</Field>
|
||||
<Field label="Expires" hint="The peer is disconnected at this time. Blank never expires.">
|
||||
<input className="input" type="datetime-local" value={expires} onChange={(e) => setExpires(e.target.value)} />
|
||||
</Field>
|
||||
{!editing && (
|
||||
<>
|
||||
<Field label="IPv4 address" hint="Blank picks the next free one.">
|
||||
<input className="input mono" value={ipv4} onChange={(e) => setIpv4(e.target.value)} placeholder="auto" />
|
||||
</Field>
|
||||
<Field label="IPv6 address" hint="Only when the server has an IPv6 subnet.">
|
||||
<input className="input mono" value={ipv6} onChange={(e) => setIpv6(e.target.value)} placeholder="auto" />
|
||||
</Field>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<Field label="Notes">
|
||||
<textarea className="input" value={notes} onChange={(e) => setNotes(e.target.value)} maxLength={2000} />
|
||||
</Field>
|
||||
<Check label="Enabled" hint="A disabled peer is removed from the interface and cannot connect." checked={enabled} onChange={setEnabled} />
|
||||
</>
|
||||
)}
|
||||
</form>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
import { useMemo } from "react";
|
||||
import type { TrafficPoint } from "../api";
|
||||
import { bytes } from "../format";
|
||||
|
||||
// Both charts are plain SVG: no library, no runtime dependency, and they
|
||||
// pick their colours up from the CSS variables so light and dark just work.
|
||||
|
||||
export function TrafficChart({ points, from, to, bucketSeconds = 300 }: { points: TrafficPoint[]; from: number; to: number; bucketSeconds?: number }) {
|
||||
const W = 800;
|
||||
const H = 180;
|
||||
const padL = 48;
|
||||
const padB = 22;
|
||||
const padT = 8;
|
||||
const padR = 8;
|
||||
|
||||
const { rxPath, txPath, max, ticks, xTicks } = useMemo(() => {
|
||||
const span = Math.max(1, to - from);
|
||||
const byBucket = new Map<number, TrafficPoint>();
|
||||
for (const p of points) byBucket.set(Math.floor(new Date(p.t).getTime() / 1000), p);
|
||||
// One bar per bucket across the whole window, zeros where nothing was
|
||||
// recorded, so quiet periods read as quiet rather than missing.
|
||||
const start = Math.floor(from / 1000 / bucketSeconds) * bucketSeconds;
|
||||
const end = Math.floor(to / 1000);
|
||||
const rows: { t: number; rx: number; tx: number }[] = [];
|
||||
for (let t = start; t <= end; t += bucketSeconds) {
|
||||
const p = byBucket.get(t);
|
||||
rows.push({ t, rx: p?.rx ?? 0, tx: p?.tx ?? 0 });
|
||||
}
|
||||
const max = Math.max(1, ...rows.map((r) => Math.max(r.rx, r.tx)));
|
||||
const x = (t: number) => padL + ((t * 1000 - from) / span) * (W - padL - padR);
|
||||
const y = (v: number) => padT + (1 - v / max) * (H - padT - padB);
|
||||
const path = (key: "rx" | "tx") => {
|
||||
if (rows.length === 0) return "";
|
||||
let d = `M${x(rows[0].t).toFixed(1)},${y(0).toFixed(1)}`;
|
||||
for (const r of rows) d += ` L${x(r.t).toFixed(1)},${y(r[key]).toFixed(1)}`;
|
||||
d += ` L${x(rows[rows.length - 1].t + bucketSeconds).toFixed(1)},${y(rows[rows.length - 1][key]).toFixed(1)}`;
|
||||
d += ` L${x(rows[rows.length - 1].t + bucketSeconds).toFixed(1)},${y(0).toFixed(1)} Z`;
|
||||
return d;
|
||||
};
|
||||
const ticks = [0, 0.5, 1].map((f) => ({ v: max * f, y: y(max * f) }));
|
||||
const xTicks: { x: number; label: string }[] = [];
|
||||
const n = 6;
|
||||
for (let i = 0; i <= n; i++) {
|
||||
const t = from + (span * i) / n;
|
||||
const d = new Date(t);
|
||||
const label = span > 2 * 86400 * 1000 ? d.toLocaleDateString(undefined, { month: "short", day: "numeric" }) : d.toLocaleTimeString(undefined, { hour: "2-digit", minute: "2-digit" });
|
||||
xTicks.push({ x: padL + (i / n) * (W - padL - padR), label });
|
||||
}
|
||||
return { rxPath: path("rx"), txPath: path("tx"), max, ticks, xTicks };
|
||||
}, [points, from, to, bucketSeconds]);
|
||||
|
||||
return (
|
||||
<svg className="chart" viewBox={`0 0 ${W} ${H}`} preserveAspectRatio="none" role="img" aria-label="Traffic over time">
|
||||
{ticks.map((t) => (
|
||||
<g key={t.v}>
|
||||
<line x1={padL} x2={W - padR} y1={t.y} y2={t.y} stroke="var(--line)" strokeWidth="1" />
|
||||
<text x={padL - 6} y={t.y + 4} fontSize="10" textAnchor="end" fill="var(--fg-faint)">
|
||||
{bytes(t.v)}
|
||||
</text>
|
||||
</g>
|
||||
))}
|
||||
<path d={txPath} fill="var(--tx)" fillOpacity="0.35" stroke="var(--tx)" strokeWidth="1.2" />
|
||||
<path d={rxPath} fill="var(--rx)" fillOpacity="0.35" stroke="var(--rx)" strokeWidth="1.2" />
|
||||
{xTicks.map((t, i) => (
|
||||
<text key={i} x={t.x} y={H - 6} fontSize="10" textAnchor={i === 0 ? "start" : i === xTicks.length - 1 ? "end" : "middle"} fill="var(--fg-faint)">
|
||||
{t.label}
|
||||
</text>
|
||||
))}
|
||||
<title>peak {bytes(max)} per bucket</title>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function Sparkline({ values, color = "var(--accent)" }: { values: number[]; color?: string }) {
|
||||
const W = 110;
|
||||
const H = 26;
|
||||
const d = useMemo(() => {
|
||||
if (values.length < 2) return "";
|
||||
const max = Math.max(1, ...values);
|
||||
const step = W / (values.length - 1);
|
||||
return values.map((v, i) => `${i === 0 ? "M" : "L"}${(i * step).toFixed(1)},${(H - 2 - (v / max) * (H - 4)).toFixed(1)}`).join(" ");
|
||||
}, [values]);
|
||||
return (
|
||||
<svg className="sparkline" viewBox={`0 0 ${W} ${H}`} preserveAspectRatio="none" aria-hidden="true">
|
||||
<path d={d} fill="none" stroke={color} strokeWidth="1.5" strokeLinejoin="round" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function Legend() {
|
||||
return (
|
||||
<div className="legend">
|
||||
<span>
|
||||
<i style={{ background: "var(--rx)" }} /> received from peers
|
||||
</span>
|
||||
<span>
|
||||
<i style={{ background: "var(--tx)" }} /> sent to peers
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
import { useEffect, type ReactNode } from "react";
|
||||
import { X } from "lucide-react";
|
||||
|
||||
export function Modal({ title, onClose, children, wide, footer }: { title: string; onClose: () => void; children: ReactNode; wide?: boolean; footer?: ReactNode }) {
|
||||
useEffect(() => {
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") onClose();
|
||||
};
|
||||
window.addEventListener("keydown", onKey);
|
||||
return () => window.removeEventListener("keydown", onKey);
|
||||
}, [onClose]);
|
||||
return (
|
||||
<div
|
||||
className="modal-back"
|
||||
onMouseDown={(e) => {
|
||||
if (e.target === e.currentTarget) onClose();
|
||||
}}
|
||||
>
|
||||
<div className={`card modal${wide ? " wide" : ""}`} role="dialog" aria-modal="true" aria-label={title}>
|
||||
<div className="card-head">
|
||||
<h2>{title}</h2>
|
||||
<button className="btn icon ghost" onClick={onClose} aria-label="Close">
|
||||
<X />
|
||||
</button>
|
||||
</div>
|
||||
<div className="card-body">{children}</div>
|
||||
{footer && (
|
||||
<div className="card-head" style={{ borderTop: "1px solid var(--line)", borderBottom: 0, justifyContent: "flex-end" }}>
|
||||
{footer}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function Field({ label, hint, children }: { label: string; hint?: string; children: ReactNode }) {
|
||||
return (
|
||||
<div className="field">
|
||||
<label>{label}</label>
|
||||
{children}
|
||||
{hint && <span className="hint">{hint}</span>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function Check({ label, hint, checked, onChange, disabled }: { label: string; hint?: string; checked: boolean; onChange: (v: boolean) => void; disabled?: boolean }) {
|
||||
const id = `chk-${label.replace(/\W+/g, "-").toLowerCase()}`;
|
||||
return (
|
||||
<div className="check">
|
||||
<input id={id} type="checkbox" checked={checked} disabled={disabled} onChange={(e) => onChange(e.target.checked)} />
|
||||
<label htmlFor={id}>
|
||||
{label}
|
||||
{hint && <span className="hint">{hint}</span>}
|
||||
</label>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function Confirm({ title, text, confirmLabel = "Confirm", danger, onConfirm, onClose, busy }: { title: string; text: ReactNode; confirmLabel?: string; danger?: boolean; onConfirm: () => void; onClose: () => void; busy?: boolean }) {
|
||||
return (
|
||||
<Modal
|
||||
title={title}
|
||||
onClose={onClose}
|
||||
footer={
|
||||
<>
|
||||
<button className="btn" onClick={onClose} disabled={busy}>
|
||||
Cancel
|
||||
</button>
|
||||
<button className={`btn ${danger ? "danger" : "primary"}`} onClick={onConfirm} disabled={busy}>
|
||||
{confirmLabel}
|
||||
</button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<p>{text}</p>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
export function Segmented<T extends string>({ value, options, onChange }: { value: T; options: { value: T; label: string }[]; onChange: (v: T) => void }) {
|
||||
return (
|
||||
<div className="segmented" role="tablist">
|
||||
{options.map((o) => (
|
||||
<button key={o.value} role="tab" aria-selected={o.value === value} className={o.value === value ? "active" : ""} onClick={() => onChange(o.value)}>
|
||||
{o.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export async function copyText(text: string): Promise<boolean> {
|
||||
try {
|
||||
await navigator.clipboard.writeText(text);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
const units = ["B", "KB", "MB", "GB", "TB", "PB"];
|
||||
|
||||
export function bytes(n: number): string {
|
||||
if (!Number.isFinite(n) || n < 0) return "0 B";
|
||||
let i = 0;
|
||||
let v = n;
|
||||
while (v >= 1000 && i < units.length - 1) {
|
||||
v /= 1000;
|
||||
i++;
|
||||
}
|
||||
const digits = i === 0 ? 0 : v < 10 ? 2 : v < 100 ? 1 : 0;
|
||||
return `${v.toFixed(digits)} ${units[i]}`;
|
||||
}
|
||||
|
||||
export function rate(bytesPerSecond: number): string {
|
||||
const bits = bytesPerSecond * 8;
|
||||
if (bits < 1000) return `${Math.round(bits)} bit/s`;
|
||||
if (bits < 1e6) return `${(bits / 1e3).toFixed(bits < 1e4 ? 1 : 0)} kbit/s`;
|
||||
if (bits < 1e9) return `${(bits / 1e6).toFixed(bits < 1e7 ? 2 : 1)} Mbit/s`;
|
||||
return `${(bits / 1e9).toFixed(2)} Gbit/s`;
|
||||
}
|
||||
|
||||
export function isZeroTime(s?: string): boolean {
|
||||
return !s || s.startsWith("0001-01-01");
|
||||
}
|
||||
|
||||
export function ago(s?: string, now = Date.now()): string {
|
||||
if (isZeroTime(s)) return "never";
|
||||
const t = new Date(s!).getTime();
|
||||
const d = Math.max(0, Math.round((now - t) / 1000));
|
||||
if (d < 5) return "just now";
|
||||
if (d < 60) return `${d}s ago`;
|
||||
if (d < 3600) return `${Math.floor(d / 60)}m ago`;
|
||||
if (d < 86400) return `${Math.floor(d / 3600)}h ${Math.floor((d % 3600) / 60)}m ago`;
|
||||
return `${Math.floor(d / 86400)}d ago`;
|
||||
}
|
||||
|
||||
export function duration(from?: string, now = Date.now()): string {
|
||||
if (isZeroTime(from)) return "";
|
||||
const d = Math.max(0, Math.round((now - new Date(from!).getTime()) / 1000));
|
||||
const h = Math.floor(d / 3600);
|
||||
const m = Math.floor((d % 3600) / 60);
|
||||
const s = d % 60;
|
||||
if (h > 0) return `${h}h ${m}m`;
|
||||
if (m > 0) return `${m}m ${s}s`;
|
||||
return `${s}s`;
|
||||
}
|
||||
|
||||
export function dateTime(s?: string): string {
|
||||
if (isZeroTime(s)) return "";
|
||||
return new Date(s!).toLocaleString(undefined, { dateStyle: "medium", timeStyle: "short" });
|
||||
}
|
||||
|
||||
export function shortKey(k: string): string {
|
||||
return k.length > 12 ? `${k.slice(0, 8)}…${k.slice(-4)}` : k;
|
||||
}
|
||||
|
||||
// Renders a Date as the value an <input type="datetime-local"> wants.
|
||||
export function toLocalInput(s?: string): string {
|
||||
if (isZeroTime(s)) return "";
|
||||
const d = new Date(s!);
|
||||
const pad = (n: number) => String(n).padStart(2, "0");
|
||||
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`;
|
||||
}
|
||||
|
||||
export function fromLocalInput(v: string): string | null {
|
||||
if (!v) return null;
|
||||
const d = new Date(v);
|
||||
return Number.isNaN(d.getTime()) ? null : d.toISOString();
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import { StrictMode } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { App } from "./App";
|
||||
import "./styles.css";
|
||||
|
||||
createRoot(document.getElementById("root")!).render(
|
||||
<StrictMode>
|
||||
<App />
|
||||
</StrictMode>,
|
||||
);
|
||||
@@ -0,0 +1,251 @@
|
||||
import { useEffect, useState, type FormEvent } from "react";
|
||||
import { api, type SessionInfo } from "../api";
|
||||
import { ago, dateTime } from "../format";
|
||||
import { errorMessage, useAuth, useNow, useToast } from "../state";
|
||||
import { Field, Modal, copyText } from "../components/ui";
|
||||
|
||||
export function Account() {
|
||||
const { me, refresh } = useAuth();
|
||||
const toast = useToast();
|
||||
const now = useNow();
|
||||
const [sessions, setSessions] = useState<SessionInfo[]>([]);
|
||||
const [current, setCurrent] = useState("");
|
||||
const [next, setNext] = useState("");
|
||||
const [confirm, setConfirm] = useState("");
|
||||
const [pwError, setPwError] = useState("");
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [totp, setTotp] = useState<{ secret: string; uri: string } | null>(null);
|
||||
const [code, setCode] = useState("");
|
||||
const [totpError, setTotpError] = useState("");
|
||||
const [recovery, setRecovery] = useState<string[] | null>(null);
|
||||
const [disablePw, setDisablePw] = useState("");
|
||||
const [disabling, setDisabling] = useState(false);
|
||||
|
||||
const loadSessions = () => api.sessions().then(setSessions).catch(() => {});
|
||||
useEffect(() => {
|
||||
void loadSessions();
|
||||
}, []);
|
||||
|
||||
async function changePassword(e: FormEvent) {
|
||||
e.preventDefault();
|
||||
setPwError("");
|
||||
if (next !== confirm) {
|
||||
setPwError("The new passwords do not match.");
|
||||
return;
|
||||
}
|
||||
setBusy(true);
|
||||
try {
|
||||
await api.changePassword(current, next);
|
||||
setCurrent("");
|
||||
setNext("");
|
||||
setConfirm("");
|
||||
toast("Password changed; other sessions were signed out");
|
||||
void loadSessions();
|
||||
} catch (err) {
|
||||
setPwError(errorMessage(err));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function startTotp() {
|
||||
try {
|
||||
setTotp(await api.totpSetup());
|
||||
setCode("");
|
||||
setTotpError("");
|
||||
} catch (err) {
|
||||
toast(errorMessage(err), "bad");
|
||||
}
|
||||
}
|
||||
|
||||
async function confirmTotp(e: FormEvent) {
|
||||
e.preventDefault();
|
||||
setTotpError("");
|
||||
try {
|
||||
const r = await api.totpConfirm(code);
|
||||
setTotp(null);
|
||||
setRecovery(r.recoveryCodes);
|
||||
await refresh();
|
||||
} catch (err) {
|
||||
setTotpError(errorMessage(err));
|
||||
}
|
||||
}
|
||||
|
||||
async function disableTotp(e: FormEvent) {
|
||||
e.preventDefault();
|
||||
try {
|
||||
await api.totpDisable(disablePw);
|
||||
setDisabling(false);
|
||||
setDisablePw("");
|
||||
toast("Two-factor authentication turned off");
|
||||
await refresh();
|
||||
} catch (err) {
|
||||
toast(errorMessage(err), "bad");
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="page-head">
|
||||
<div>
|
||||
<h1>Account</h1>
|
||||
<p>
|
||||
Signed in as <b>{me?.username}</b> ({me?.role}).
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-2">
|
||||
<form className="card" onSubmit={changePassword}>
|
||||
<div className="card-head">
|
||||
<h2>Password</h2>
|
||||
</div>
|
||||
<div className="card-body">
|
||||
{pwError && <div className="error">{pwError}</div>}
|
||||
<Field label="Current password">
|
||||
<input className="input" type="password" value={current} onChange={(e) => setCurrent(e.target.value)} required autoComplete="current-password" />
|
||||
</Field>
|
||||
<Field label="New password" hint="At least 12 characters.">
|
||||
<input className="input" type="password" value={next} onChange={(e) => setNext(e.target.value)} required minLength={12} autoComplete="new-password" />
|
||||
</Field>
|
||||
<Field label="Confirm new password">
|
||||
<input className="input" type="password" value={confirm} onChange={(e) => setConfirm(e.target.value)} required autoComplete="new-password" />
|
||||
</Field>
|
||||
<button className="btn primary" type="submit" disabled={busy}>
|
||||
Change password
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
<div className="card">
|
||||
<div className="card-head">
|
||||
<h2>Two-factor authentication</h2>
|
||||
{me?.totpEnabled ? <span className="badge ok">on</span> : <span className="badge">off</span>}
|
||||
</div>
|
||||
<div className="card-body">
|
||||
{me?.totpEnabled ? (
|
||||
<>
|
||||
<p>A code from your authenticator app is required at every sign-in.</p>
|
||||
<p className="muted small">
|
||||
{me.recoveryCodesLeft} recovery code{me.recoveryCodesLeft === 1 ? "" : "s"} left.
|
||||
</p>
|
||||
{!disabling ? (
|
||||
<button className="btn" onClick={() => setDisabling(true)}>
|
||||
Turn off
|
||||
</button>
|
||||
) : (
|
||||
<form onSubmit={disableTotp}>
|
||||
<Field label="Confirm with your password">
|
||||
<input className="input" type="password" value={disablePw} onChange={(e) => setDisablePw(e.target.value)} required autoComplete="current-password" autoFocus />
|
||||
</Field>
|
||||
<div className="btn-row">
|
||||
<button className="btn danger" type="submit">
|
||||
Turn off two-factor
|
||||
</button>
|
||||
<button className="btn" type="button" onClick={() => setDisabling(false)}>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<p>Add a time-based one-time code from an authenticator app (Aegis, Google Authenticator, 1Password, and so on).</p>
|
||||
<button className="btn primary" onClick={startTotp}>
|
||||
Set up
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="card mt">
|
||||
<div className="card-head">
|
||||
<h2>Sessions</h2>
|
||||
{sessions.length > 1 && (
|
||||
<button
|
||||
className="btn sm"
|
||||
onClick={() =>
|
||||
api
|
||||
.revokeSessions()
|
||||
.then(() => {
|
||||
toast("Other sessions signed out");
|
||||
void loadSessions();
|
||||
})
|
||||
.catch((e) => toast(errorMessage(e), "bad"))
|
||||
}
|
||||
>
|
||||
Sign out everywhere else
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div className="table-wrap">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th></th>
|
||||
<th>Signed in</th>
|
||||
<th>Last seen</th>
|
||||
<th>From</th>
|
||||
<th>Browser</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{sessions.map((s, i) => (
|
||||
<tr key={i}>
|
||||
<td>{s.current && <span className="badge accent">this one</span>}</td>
|
||||
<td className="nowrap">{dateTime(s.createdAt)}</td>
|
||||
<td className="nowrap">{ago(s.lastSeenAt, now)}</td>
|
||||
<td className="mono">{s.ip}</td>
|
||||
<td className="muted small" style={{ maxWidth: 360, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>
|
||||
{s.userAgent}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{totp && (
|
||||
<Modal title="Set up two-factor authentication" onClose={() => setTotp(null)}>
|
||||
<form onSubmit={confirmTotp}>
|
||||
<div className="qr">
|
||||
<img src="/api/auth/totp/qr.png" alt="QR code for your authenticator app" width={256} height={256} style={{ maxWidth: 256 }} />
|
||||
<p className="small muted">
|
||||
Cannot scan? Enter this key by hand: <code>{totp.secret}</code>{" "}
|
||||
<button type="button" className="btn sm ghost" onClick={() => copyText(totp.secret).then((ok) => toast(ok ? "Copied" : "Could not copy", ok ? "ok" : "bad"))}>
|
||||
copy
|
||||
</button>
|
||||
</p>
|
||||
</div>
|
||||
{totpError && <div className="error">{totpError}</div>}
|
||||
<Field label="Enter the six-digit code the app shows">
|
||||
<input className="input" inputMode="numeric" autoComplete="one-time-code" value={code} onChange={(e) => setCode(e.target.value)} required autoFocus />
|
||||
</Field>
|
||||
<button className="btn primary" type="submit">
|
||||
Turn on
|
||||
</button>
|
||||
</form>
|
||||
</Modal>
|
||||
)}
|
||||
{recovery && (
|
||||
<Modal title="Recovery codes" onClose={() => setRecovery(null)}>
|
||||
<p>Each of these signs you in once if you lose your authenticator. Keep them somewhere safe; they are not shown again.</p>
|
||||
<ul className="recovery">
|
||||
{recovery.map((c) => (
|
||||
<li key={c}>{c}</li>
|
||||
))}
|
||||
</ul>
|
||||
<div className="btn-row mt">
|
||||
<button className="btn" onClick={() => copyText(recovery.join("\n")).then((ok) => toast(ok ? "Copied" : "Could not copy", ok ? "ok" : "bad"))}>
|
||||
Copy all
|
||||
</button>
|
||||
<button className="btn primary" onClick={() => setRecovery(null)}>
|
||||
I have saved them
|
||||
</button>
|
||||
</div>
|
||||
</Modal>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { api, type AuditEntry } from "../api";
|
||||
import { dateTime } from "../format";
|
||||
import { errorMessage, useToast } from "../state";
|
||||
|
||||
export function Audit() {
|
||||
const toast = useToast();
|
||||
const [entries, setEntries] = useState<AuditEntry[]>([]);
|
||||
const [query, setQuery] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
api.audit(500).then(setEntries).catch((e) => toast(errorMessage(e), "bad"));
|
||||
}, [toast]);
|
||||
|
||||
const q = query.trim().toLowerCase();
|
||||
const rows = q ? entries.filter((e) => [e.actor, e.action, e.target, e.detail, e.ip].some((v) => v.toLowerCase().includes(q))) : entries;
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="page-head">
|
||||
<div>
|
||||
<h1>Audit log</h1>
|
||||
<p>Every administrative action, newest first. The last 5,000 entries are kept.</p>
|
||||
</div>
|
||||
<input className="input search" placeholder="Filter…" value={query} onChange={(e) => setQuery(e.target.value)} />
|
||||
</div>
|
||||
<div className="card">
|
||||
{rows.length === 0 ? (
|
||||
<div className="empty">Nothing recorded yet.</div>
|
||||
) : (
|
||||
<div className="table-wrap">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>When</th>
|
||||
<th>Who</th>
|
||||
<th>Action</th>
|
||||
<th>Target</th>
|
||||
<th>Detail</th>
|
||||
<th>From</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.map((e) => (
|
||||
<tr key={e.id}>
|
||||
<td className="nowrap">{dateTime(e.at)}</td>
|
||||
<td>{e.actor}</td>
|
||||
<td>
|
||||
<span className={`badge ${e.action.includes("failed") ? "bad" : e.action.includes("deleted") || e.action.includes("disabled") ? "warn" : ""}`}>{e.action}</span>
|
||||
</td>
|
||||
<td>{e.target}</td>
|
||||
<td className="muted">{e.detail}</td>
|
||||
<td className="mono">{e.ip}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,223 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { Link } from "wouter";
|
||||
import { AlertTriangle, Copy } from "lucide-react";
|
||||
import { api, type Peer, type Range, type Status, type TrafficPoint } from "../api";
|
||||
import { ago, bytes, duration, rate } from "../format";
|
||||
import { errorMessage, useLive, useNow, useToast } from "../state";
|
||||
import { Legend, TrafficChart } from "../components/charts";
|
||||
import { Segmented, copyText } from "../components/ui";
|
||||
|
||||
const rangeMs: Record<Range, number> = { "1h": 3600e3, "24h": 86400e3, "7d": 7 * 86400e3, "30d": 30 * 86400e3 };
|
||||
|
||||
export function Dashboard() {
|
||||
const { snapshot, peersVersion } = useLive();
|
||||
const toast = useToast();
|
||||
const now = useNow();
|
||||
const [status, setStatus] = useState<Status | null>(null);
|
||||
const [peers, setPeers] = useState<Peer[]>([]);
|
||||
const [range, setRange] = useState<Range>("24h");
|
||||
const [series, setSeries] = useState<TrafficPoint[]>([]);
|
||||
const [showSysctls, setShowSysctls] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
api.status().then(setStatus).catch((e) => setError(errorMessage(e)));
|
||||
}, []);
|
||||
useEffect(() => {
|
||||
api.peers().then(setPeers).catch((e) => setError(errorMessage(e)));
|
||||
}, [peersVersion]);
|
||||
useEffect(() => {
|
||||
let live = true;
|
||||
const load = () => api.usage(range).then((s) => live && setSeries(s)).catch(() => {});
|
||||
void load();
|
||||
const t = setInterval(load, 60_000);
|
||||
return () => {
|
||||
live = false;
|
||||
clearInterval(t);
|
||||
};
|
||||
}, [range]);
|
||||
|
||||
const totals = snapshot?.totals ?? status?.totals;
|
||||
const connected = peers
|
||||
.map((p) => ({ p, l: snapshot?.peers[p.id] ?? p.live }))
|
||||
.filter((x) => x.l.connected)
|
||||
.sort((a, b) => b.l.rxRate + b.l.txRate - (a.l.rxRate + a.l.txRate));
|
||||
const unapplied = status?.sysctls.filter((s) => !s.applied) ?? [];
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="page-head">
|
||||
<div>
|
||||
<h1>Dashboard</h1>
|
||||
<p>{status ? `${status.interface} on UDP ${status.listenPort} · ${status.backend} data plane` : " "}</p>
|
||||
</div>
|
||||
</div>
|
||||
{error && <div className="error">{error}</div>}
|
||||
{status?.backend === "userspace" && (
|
||||
<div className="notice">
|
||||
<AlertTriangle size={14} style={{ verticalAlign: -2 }} /> Running on the userspace data plane (wireguard-go). Load the <code>wireguard</code> kernel module on the host for several times the throughput.
|
||||
</div>
|
||||
)}
|
||||
{status?.backend === "mock" && <div className="notice">Mock data plane: no real tunnel exists. Traffic and handshakes are simulated.</div>}
|
||||
{status?.firewallError && (
|
||||
<div className="error">
|
||||
<AlertTriangle size={14} style={{ verticalAlign: -2 }} /> Firewall rules were not applied: {status.firewallError}. Peers will connect but cannot reach beyond the server.
|
||||
</div>
|
||||
)}
|
||||
{unapplied.some((s) => s.required) && (
|
||||
<div className="error">
|
||||
<AlertTriangle size={14} style={{ verticalAlign: -2 }} /> IP forwarding is off and could not be enabled. Pass <code>net.ipv4.ip_forward=1</code> in the container's sysctls.
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid grid-4">
|
||||
<Stat label="Connected" value={`${totals?.connected ?? 0}`} sub={`of ${totals?.active ?? 0} enabled · ${totals?.peers ?? 0} total`} />
|
||||
<Stat label="Throughput" value={rate((totals?.rxRate ?? 0) + (totals?.txRate ?? 0))} sub={`↓ ${rate(totals?.rxRate ?? 0)} · ↑ ${rate(totals?.txRate ?? 0)}`} />
|
||||
<Stat label="Received" value={bytes(totals?.rx ?? 0)} sub="from peers, all time" />
|
||||
<Stat label="Sent" value={bytes(totals?.tx ?? 0)} sub="to peers, all time" />
|
||||
</div>
|
||||
|
||||
<div className="grid grid-2 mt" style={{ gridTemplateColumns: "2fr 1fr" }}>
|
||||
<div className="card">
|
||||
<div className="card-head">
|
||||
<h2>Traffic</h2>
|
||||
<div className="toolbar">
|
||||
<Legend />
|
||||
<Segmented value={range} onChange={setRange} options={[{ value: "1h", label: "1h" }, { value: "24h", label: "24h" }, { value: "7d", label: "7d" }, { value: "30d", label: "30d" }]} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="card-body">
|
||||
<TrafficChart points={series} from={now - rangeMs[range]} to={now} bucketSeconds={range === "30d" ? 3600 : 300} />
|
||||
<div className="small faint">Five-minute buckets{range === "30d" ? ", shown per hour" : ""}. Live rates above update every couple of seconds.</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="card">
|
||||
<div className="card-head">
|
||||
<h2>Server</h2>
|
||||
</div>
|
||||
<div className="card-body">
|
||||
{status && (
|
||||
<dl className="kv">
|
||||
<dt>Public key</dt>
|
||||
<dd className="mono">
|
||||
{status.publicKey}{" "}
|
||||
<button className="btn icon ghost sm" title="Copy" onClick={() => copyText(status.publicKey).then((ok) => toast(ok ? "Copied" : "Could not copy", ok ? "ok" : "bad"))}>
|
||||
<Copy />
|
||||
</button>
|
||||
</dd>
|
||||
<dt>Endpoint</dt>
|
||||
<dd className="mono">
|
||||
{status.settings.endpointHost}:{status.settings.endpointPort}
|
||||
</dd>
|
||||
<dt>Tunnel</dt>
|
||||
<dd className="mono">{status.addresses.join(", ")}</dd>
|
||||
<dt>MTU</dt>
|
||||
<dd>{status.settings.mtu}</dd>
|
||||
<dt>Egress</dt>
|
||||
<dd>{status.egress || (status.firewallManaged ? "any" : "not managed")}</dd>
|
||||
<dt>Firewall</dt>
|
||||
<dd>{status.firewallManaged ? (status.firewallError ? <span className="badge bad">failed</span> : <span className="badge ok">nftables</span>) : <span className="badge">host-managed</span>}</dd>
|
||||
<dt>Up since</dt>
|
||||
<dd>{duration(status.startedAt, now) || "—"}</dd>
|
||||
<dt>Version</dt>
|
||||
<dd>{status.version}</dd>
|
||||
</dl>
|
||||
)}
|
||||
{status && status.sysctls.length > 0 && (
|
||||
<div className="mt small">
|
||||
<button className="btn sm ghost" onClick={() => setShowSysctls((v) => !v)} style={{ marginLeft: -8 }}>
|
||||
{showSysctls ? "Hide" : "Show"} kernel tuning ({status.sysctls.length - unapplied.length}/{status.sysctls.length} applied)
|
||||
</button>
|
||||
{showSysctls && (
|
||||
<div className="table-wrap mt">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>sysctl</th>
|
||||
<th>wanted</th>
|
||||
<th>current</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{status.sysctls.map((s) => (
|
||||
<tr key={s.key} title={s.error ? `${s.why}. ${s.error}` : s.why}>
|
||||
<td className="mono">{s.key}</td>
|
||||
<td className="mono">{s.wanted}</td>
|
||||
<td className="mono">
|
||||
{s.current || "?"} {s.applied ? <span className="badge ok">ok</span> : <span className={`badge ${s.required ? "bad" : "warn"}`}>not set</span>}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
{unapplied.length > 0 && <p className="faint mt">Values marked "not set" are global sysctls the container may not change. Apply them on the host; see docs/performance.md.</p>}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="card mt">
|
||||
<div className="card-head">
|
||||
<h2>Connected now</h2>
|
||||
<Link href="/peers" className="small">
|
||||
All peers →
|
||||
</Link>
|
||||
</div>
|
||||
{connected.length === 0 ? (
|
||||
<div className="empty">No peer has handshaken in the last {status?.settings.connectedWindow ?? 180} seconds.</div>
|
||||
) : (
|
||||
<div className="table-wrap">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Peer</th>
|
||||
<th>Address</th>
|
||||
<th>Endpoint</th>
|
||||
<th>Session</th>
|
||||
<th>Handshake</th>
|
||||
<th className="right">Rate</th>
|
||||
<th className="right">Transfer</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{connected.map(({ p, l }) => (
|
||||
<tr key={p.id} className="clickable" onClick={() => (window.location.hash = "")}>
|
||||
<td>
|
||||
<Link href={`/peers/${p.id}`}>
|
||||
<span className="dot on" />
|
||||
{p.name}
|
||||
</Link>
|
||||
</td>
|
||||
<td className="mono">{p.ipv4}</td>
|
||||
<td className="mono">{l.endpoint ?? "—"}</td>
|
||||
<td>{duration(l.connectedSince, now)}</td>
|
||||
<td>{ago(l.lastHandshake, now)}</td>
|
||||
<td className="num right">
|
||||
↓ {rate(l.rxRate)} · ↑ {rate(l.txRate)}
|
||||
</td>
|
||||
<td className="num right">
|
||||
{bytes(l.rx)} / {bytes(l.tx)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function Stat({ label, value, sub }: { label: string; value: string; sub?: string }) {
|
||||
return (
|
||||
<div className="card stat">
|
||||
<div className="stat-label">{label}</div>
|
||||
<div className="stat-value">{value}</div>
|
||||
{sub && <div className="stat-sub">{sub}</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
import { useState, type FormEvent } from "react";
|
||||
import { api } from "../api";
|
||||
import { errorMessage, useAuth } from "../state";
|
||||
import { Field } from "../components/ui";
|
||||
|
||||
export function Login() {
|
||||
const { refresh } = useAuth();
|
||||
const [username, setUsername] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [code, setCode] = useState("");
|
||||
const [stage, setStage] = useState<"password" | "totp">("password");
|
||||
const [error, setError] = useState("");
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
async function submit(e: FormEvent) {
|
||||
e.preventDefault();
|
||||
setError("");
|
||||
setBusy(true);
|
||||
try {
|
||||
if (stage === "password") {
|
||||
const r = await api.login(username, password);
|
||||
if (r.totpRequired) {
|
||||
setStage("totp");
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
await api.loginTotp(code);
|
||||
}
|
||||
await refresh();
|
||||
} catch (err) {
|
||||
setError(errorMessage(err));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="auth">
|
||||
<form className="card" onSubmit={submit}>
|
||||
<div className="card-body">
|
||||
<div className="brand">
|
||||
<div className="brand-mark" aria-hidden="true">
|
||||
<svg width="18" height="18" viewBox="0 0 32 32">
|
||||
<path d="M7 10l4 12 5-9 5 9 4-12" fill="none" stroke="currentColor" strokeWidth="3.5" strokeLinecap="round" strokeLinejoin="round" />
|
||||
</svg>
|
||||
</div>
|
||||
<div className="brand-name">WGX</div>
|
||||
</div>
|
||||
<h1>{stage === "password" ? "Sign in" : "Second factor"}</h1>
|
||||
{error && <div className="error">{error}</div>}
|
||||
{stage === "password" ? (
|
||||
<>
|
||||
<Field label="Username">
|
||||
<input className="input" autoFocus autoComplete="username" value={username} onChange={(e) => setUsername(e.target.value)} required />
|
||||
</Field>
|
||||
<Field label="Password">
|
||||
<input className="input" type="password" autoComplete="current-password" value={password} onChange={(e) => setPassword(e.target.value)} required />
|
||||
</Field>
|
||||
</>
|
||||
) : (
|
||||
<Field label="Authenticator code" hint="Or one of your recovery codes.">
|
||||
<input className="input" autoFocus autoComplete="one-time-code" inputMode="numeric" value={code} onChange={(e) => setCode(e.target.value)} required />
|
||||
</Field>
|
||||
)}
|
||||
<button className="btn primary" type="submit" disabled={busy} style={{ width: "100%", justifyContent: "center" }}>
|
||||
{busy ? "…" : stage === "password" ? "Sign in" : "Verify"}
|
||||
</button>
|
||||
<p className="small faint" style={{ textAlign: "center", margin: "14px 0 0" }}>
|
||||
<a href="https://github.com/Coffey-Labs/WGX" target="_blank" rel="noreferrer">
|
||||
AGPL-3.0 source
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,199 @@
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useLocation, useRoute } from "wouter";
|
||||
import { Plus, Search } from "lucide-react";
|
||||
import { api, type Peer, type Settings } from "../api";
|
||||
import { ago, bytes, rate } from "../format";
|
||||
import { errorMessage, useAuth, useLive, useNow, useToast } from "../state";
|
||||
import { Sparkline } from "../components/charts";
|
||||
import { Segmented } from "../components/ui";
|
||||
import { PeerForm } from "../components/PeerForm";
|
||||
import { PeerDetail } from "../components/PeerDetail";
|
||||
|
||||
type Filter = "all" | "connected" | "offline" | "disabled";
|
||||
|
||||
export function Peers() {
|
||||
const { me } = useAuth();
|
||||
const { snapshot, peersVersion } = useLive();
|
||||
const toast = useToast();
|
||||
const now = useNow();
|
||||
const [, navigate] = useLocation();
|
||||
const [, params] = useRoute("/peers/:id");
|
||||
const [peers, setPeers] = useState<Peer[]>([]);
|
||||
const [settings, setSettings] = useState<Settings | null>(null);
|
||||
const [query, setQuery] = useState("");
|
||||
const [filter, setFilter] = useState<Filter>("all");
|
||||
const [creating, setCreating] = useState(false);
|
||||
const [created, setCreated] = useState<(Peer & { config: string }) | null>(null);
|
||||
const isAdmin = me?.role === "admin";
|
||||
|
||||
// Recent throughput per peer for the sparklines: one sample per snapshot.
|
||||
const history = useRef<Map<string, number[]>>(new Map());
|
||||
useEffect(() => {
|
||||
if (!snapshot) return;
|
||||
for (const [id, l] of Object.entries(snapshot.peers)) {
|
||||
const h = history.current.get(id) ?? [];
|
||||
h.push(l.rxRate + l.txRate);
|
||||
if (h.length > 40) h.shift();
|
||||
history.current.set(id, h);
|
||||
}
|
||||
}, [snapshot]);
|
||||
|
||||
const load = () =>
|
||||
Promise.all([api.peers(), api.settings()])
|
||||
.then(([p, s]) => {
|
||||
setPeers(p);
|
||||
setSettings(s);
|
||||
})
|
||||
.catch((e) => toast(errorMessage(e), "bad"));
|
||||
useEffect(() => {
|
||||
void load();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [peersVersion]);
|
||||
|
||||
const rows = useMemo(() => {
|
||||
const q = query.trim().toLowerCase();
|
||||
return peers
|
||||
.map((p) => ({ p, l: snapshot?.peers[p.id] ?? p.live }))
|
||||
.filter(({ p, l }) => {
|
||||
if (q && !(p.name.toLowerCase().includes(q) || p.ipv4.includes(q) || (p.ipv6 ?? "").includes(q) || p.publicKey.toLowerCase().startsWith(q) || (l.endpoint ?? "").includes(q) || p.notes.toLowerCase().includes(q))) return false;
|
||||
switch (filter) {
|
||||
case "connected":
|
||||
return l.connected;
|
||||
case "offline":
|
||||
return !l.connected && p.enabled && !p.expired;
|
||||
case "disabled":
|
||||
return !p.enabled || p.expired;
|
||||
default:
|
||||
return true;
|
||||
}
|
||||
})
|
||||
.sort((a, b) => {
|
||||
// Connected first, then by name.
|
||||
if (a.l.connected !== b.l.connected) return a.l.connected ? -1 : 1;
|
||||
return a.p.name.localeCompare(b.p.name);
|
||||
});
|
||||
}, [peers, snapshot, query, filter]);
|
||||
|
||||
const selected = params?.id ? peers.find((p) => p.id === params.id) : undefined;
|
||||
const counts = useMemo(() => {
|
||||
let connected = 0;
|
||||
let disabled = 0;
|
||||
for (const p of peers) {
|
||||
const l = snapshot?.peers[p.id] ?? p.live;
|
||||
if (l.connected) connected++;
|
||||
if (!p.enabled || p.expired) disabled++;
|
||||
}
|
||||
return { connected, disabled, offline: peers.length - connected - disabled };
|
||||
}, [peers, snapshot]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="page-head">
|
||||
<div>
|
||||
<h1>Peers</h1>
|
||||
<p>
|
||||
{peers.length} peer{peers.length === 1 ? "" : "s"} · {counts.connected} connected
|
||||
</p>
|
||||
</div>
|
||||
<div className="toolbar">
|
||||
<div style={{ position: "relative" }}>
|
||||
<Search size={14} style={{ position: "absolute", left: 9, top: 10, color: "var(--fg-faint)" }} />
|
||||
<input className="input search" style={{ paddingLeft: 28 }} placeholder="Search name, address, key…" value={query} onChange={(e) => setQuery(e.target.value)} />
|
||||
</div>
|
||||
<Segmented
|
||||
value={filter}
|
||||
onChange={setFilter}
|
||||
options={[
|
||||
{ value: "all", label: "All" },
|
||||
{ value: "connected", label: `Connected ${counts.connected}` },
|
||||
{ value: "offline", label: `Offline ${counts.offline}` },
|
||||
{ value: "disabled", label: `Disabled ${counts.disabled}` },
|
||||
]}
|
||||
/>
|
||||
{isAdmin && (
|
||||
<button className="btn primary" onClick={() => setCreating(true)}>
|
||||
<Plus /> New peer
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="card">
|
||||
{rows.length === 0 ? (
|
||||
<div className="empty">{peers.length === 0 ? "No peers yet. Create one and scan the QR code with the WireGuard app." : "Nothing matches."}</div>
|
||||
) : (
|
||||
<div className="table-wrap">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Peer</th>
|
||||
<th>Address</th>
|
||||
<th>Endpoint</th>
|
||||
<th>Handshake</th>
|
||||
<th className="right">Rate</th>
|
||||
<th></th>
|
||||
<th className="right">Transfer</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.map(({ p, l }) => {
|
||||
const state = !p.enabled ? "disabled" : p.expired ? "expired" : l.connected ? "on" : "off";
|
||||
return (
|
||||
<tr key={p.id} className="clickable" onClick={() => navigate(`/peers/${p.id}`)}>
|
||||
<td>
|
||||
<span className={`dot ${state}`} title={state} />
|
||||
{p.name}
|
||||
{!p.enabled && <span className="badge bad" style={{ marginLeft: 8 }}>disabled</span>}
|
||||
{p.enabled && p.expired && <span className="badge warn" style={{ marginLeft: 8 }}>expired</span>}
|
||||
{!p.serverKeys && <span className="badge" style={{ marginLeft: 8 }} title="The client holds its own private key">client key</span>}
|
||||
</td>
|
||||
<td className="mono nowrap">{p.ipv4}</td>
|
||||
<td className="mono nowrap">{l.endpoint || <span className="faint">—</span>}</td>
|
||||
<td className="nowrap">{ago(l.lastHandshake, now)}</td>
|
||||
<td className="num right">{l.connected ? `↓ ${rate(l.rxRate)} ↑ ${rate(l.txRate)}` : <span className="faint">—</span>}</td>
|
||||
<td>{l.connected && <Sparkline values={history.current.get(p.id) ?? []} />}</td>
|
||||
<td className="num right">
|
||||
{bytes(l.rx)} <span className="faint">/</span> {bytes(l.tx)}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{creating && settings && (
|
||||
<PeerForm
|
||||
settings={settings}
|
||||
onClose={() => setCreating(false)}
|
||||
onSaved={(p) => {
|
||||
setCreating(false);
|
||||
toast(`Peer ${p.name} created`);
|
||||
void load().then(() => {
|
||||
setCreated(p as Peer & { config: string });
|
||||
navigate(`/peers/${p.id}`);
|
||||
});
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{selected && settings && (
|
||||
<PeerDetail
|
||||
key={selected.id + selected.updatedAt}
|
||||
peer={selected}
|
||||
live={snapshot?.peers[selected.id] ?? selected.live}
|
||||
settings={settings}
|
||||
isAdmin={!!isAdmin}
|
||||
initialTab={created?.id === selected.id ? "config" : "overview"}
|
||||
initialConfig={created?.id === selected.id ? created.config : undefined}
|
||||
onClose={() => {
|
||||
setCreated(null);
|
||||
navigate("/peers");
|
||||
}}
|
||||
onChanged={() => void load()}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
import { useEffect, useState, type FormEvent } from "react";
|
||||
import { api, type Settings } from "../api";
|
||||
import { errorMessage, useAuth, useLive, useToast } from "../state";
|
||||
import { Check, Field } from "../components/ui";
|
||||
|
||||
export function SettingsPage() {
|
||||
const { me } = useAuth();
|
||||
const { settingsVersion } = useLive();
|
||||
const toast = useToast();
|
||||
const [s, setS] = useState<Settings | null>(null);
|
||||
const [error, setError] = useState("");
|
||||
const [busy, setBusy] = useState(false);
|
||||
const readOnly = me?.role !== "admin";
|
||||
|
||||
useEffect(() => {
|
||||
api.settings().then(setS).catch((e) => setError(errorMessage(e)));
|
||||
}, [settingsVersion]);
|
||||
|
||||
async function submit(e: FormEvent) {
|
||||
e.preventDefault();
|
||||
if (!s) return;
|
||||
setError("");
|
||||
setBusy(true);
|
||||
try {
|
||||
setS(await api.saveSettings(s));
|
||||
toast("Settings saved");
|
||||
} catch (err) {
|
||||
setError(errorMessage(err));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
if (!s) return <div className="empty">{error || "Loading…"}</div>;
|
||||
const set = <K extends keyof Settings>(k: K, v: Settings[K]) => setS({ ...s, [k]: v });
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="page-head">
|
||||
<div>
|
||||
<h1>Settings</h1>
|
||||
<p>Changes apply at once. New client configurations use the new values; existing clients keep what they have.</p>
|
||||
</div>
|
||||
</div>
|
||||
<form onSubmit={submit} className="stack">
|
||||
{error && <div className="error">{error}</div>}
|
||||
{readOnly && <div className="notice">You have the viewer role; settings are read-only.</div>}
|
||||
<div className="card">
|
||||
<div className="card-head">
|
||||
<h2>Endpoint</h2>
|
||||
</div>
|
||||
<div className="card-body">
|
||||
<div className="form-cols">
|
||||
<Field label="Public host" hint="Hostname or IP address clients connect to.">
|
||||
<input className="input" value={s.endpointHost} onChange={(e) => set("endpointHost", e.target.value)} disabled={readOnly} required />
|
||||
</Field>
|
||||
<Field label="Public port" hint="What clients dial. Usually the listen port; change it if the container's UDP port is remapped.">
|
||||
<input className="input" type="number" min={1} max={65535} value={s.endpointPort} onChange={(e) => set("endpointPort", Number(e.target.value))} disabled={readOnly} required />
|
||||
</Field>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="card">
|
||||
<div className="card-head">
|
||||
<h2>Client defaults</h2>
|
||||
</div>
|
||||
<div className="card-body">
|
||||
<div className="form-cols">
|
||||
<Field label="DNS" hint="Comma separated. Clients use these while connected. Blank hands out none.">
|
||||
<input className="input" value={s.dns} onChange={(e) => set("dns", e.target.value)} disabled={readOnly} />
|
||||
</Field>
|
||||
<Field label="Client routes (AllowedIPs)" hint="0.0.0.0/0, ::/0 sends everything through the tunnel.">
|
||||
<input className="input mono" value={s.clientRoutes} onChange={(e) => set("clientRoutes", e.target.value)} disabled={readOnly} required />
|
||||
</Field>
|
||||
<Field label="MTU" hint="1420 fits an IPv4 underlay at 1500; use 1412 for PPPoE, 1400 or less for IPv6-over-IPv6 or when downloads stall.">
|
||||
<input className="input" type="number" min={1280} max={9000} value={s.mtu} onChange={(e) => set("mtu", Number(e.target.value))} disabled={readOnly} required />
|
||||
</Field>
|
||||
<Field label="Persistent keepalive (s)" hint="25 keeps NAT mappings open on home routers. 0 disables it.">
|
||||
<input className="input" type="number" min={0} max={65535} value={s.keepalive} onChange={(e) => set("keepalive", Number(e.target.value))} disabled={readOnly} required />
|
||||
</Field>
|
||||
</div>
|
||||
<Check label="Preshared keys" hint="Add a per-peer preshared key to new peers: a symmetric layer on top of the key exchange." checked={s.presharedKeys} onChange={(v) => set("presharedKeys", v)} disabled={readOnly} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="card">
|
||||
<div className="card-head">
|
||||
<h2>Network</h2>
|
||||
</div>
|
||||
<div className="card-body">
|
||||
<Check label="Peer isolation" hint="Drop traffic between peers. Each device can reach the server and the internet, but not the other devices." checked={s.peerIsolation} onChange={(v) => set("peerIsolation", v)} disabled={readOnly} />
|
||||
<Check label="Clamp TCP MSS" hint="Rewrite the MSS of forwarded connections to fit the tunnel MTU. Leave on unless you know why not: it is the fix for “connected but pages hang”." checked={s.clampMSS} onChange={(v) => set("clampMSS", v)} disabled={readOnly} />
|
||||
<Field label="Connected window (s)" hint="A peer counts as connected this long after its last handshake. WireGuard rejects a session after 180 s.">
|
||||
<input className="input" type="number" min={30} max={3600} value={s.connectedWindow} onChange={(e) => set("connectedWindow", Number(e.target.value))} disabled={readOnly} required style={{ maxWidth: 160 }} />
|
||||
</Field>
|
||||
</div>
|
||||
</div>
|
||||
{!readOnly && (
|
||||
<div>
|
||||
<button className="btn primary" type="submit" disabled={busy}>
|
||||
{busy ? "…" : "Save settings"}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</form>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
import { useState, type FormEvent } from "react";
|
||||
import { api } from "../api";
|
||||
import { errorMessage, useAuth } from "../state";
|
||||
import { Field } from "../components/ui";
|
||||
|
||||
export function Setup() {
|
||||
const { refresh } = useAuth();
|
||||
const [username, setUsername] = useState("admin");
|
||||
const [password, setPassword] = useState("");
|
||||
const [confirm, setConfirm] = useState("");
|
||||
const [endpointHost, setEndpointHost] = useState(window.location.hostname === "localhost" ? "" : window.location.hostname);
|
||||
const [error, setError] = useState("");
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
async function submit(e: FormEvent) {
|
||||
e.preventDefault();
|
||||
setError("");
|
||||
if (password !== confirm) {
|
||||
setError("The passwords do not match.");
|
||||
return;
|
||||
}
|
||||
setBusy(true);
|
||||
try {
|
||||
await api.setup({ username, password, endpointHost });
|
||||
await refresh();
|
||||
} catch (err) {
|
||||
setError(errorMessage(err));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="auth">
|
||||
<form className="card" onSubmit={submit}>
|
||||
<div className="card-body">
|
||||
<div className="brand">
|
||||
<div className="brand-mark" aria-hidden="true">
|
||||
<svg width="18" height="18" viewBox="0 0 32 32">
|
||||
<path d="M7 10l4 12 5-9 5 9 4-12" fill="none" stroke="currentColor" strokeWidth="3.5" strokeLinecap="round" strokeLinejoin="round" />
|
||||
</svg>
|
||||
</div>
|
||||
<div className="brand-name">WGX</div>
|
||||
</div>
|
||||
<h1>Welcome</h1>
|
||||
<p className="muted" style={{ textAlign: "center", marginBottom: 16 }}>
|
||||
Create the first administrator. This form only works once.
|
||||
</p>
|
||||
{error && <div className="error">{error}</div>}
|
||||
<Field label="Username">
|
||||
<input className="input" autoComplete="username" value={username} onChange={(e) => setUsername(e.target.value)} required />
|
||||
</Field>
|
||||
<Field label="Password" hint="At least 12 characters. Length beats complexity.">
|
||||
<input className="input" type="password" autoComplete="new-password" value={password} onChange={(e) => setPassword(e.target.value)} required minLength={12} />
|
||||
</Field>
|
||||
<Field label="Confirm password">
|
||||
<input className="input" type="password" autoComplete="new-password" value={confirm} onChange={(e) => setConfirm(e.target.value)} required />
|
||||
</Field>
|
||||
<Field label="Public endpoint" hint="The hostname or IP address clients will connect to. You can change it later in Settings.">
|
||||
<input className="input" value={endpointHost} onChange={(e) => setEndpointHost(e.target.value)} placeholder="vpn.example.com" required />
|
||||
</Field>
|
||||
<button className="btn primary" type="submit" disabled={busy} style={{ width: "100%", justifyContent: "center" }}>
|
||||
{busy ? "…" : "Create administrator"}
|
||||
</button>
|
||||
<p className="small faint" style={{ textAlign: "center", margin: "14px 0 0" }}>
|
||||
<a href="https://github.com/Coffey-Labs/WGX" target="_blank" rel="noreferrer">
|
||||
AGPL-3.0 source
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,244 @@
|
||||
import { useEffect, useState, type FormEvent } from "react";
|
||||
import { Plus } from "lucide-react";
|
||||
import { api, type User } from "../api";
|
||||
import { ago, dateTime } from "../format";
|
||||
import { errorMessage, useAuth, useNow, useToast } from "../state";
|
||||
import { Confirm, Field, Modal } from "../components/ui";
|
||||
|
||||
export function UsersPage() {
|
||||
const { me } = useAuth();
|
||||
const toast = useToast();
|
||||
const now = useNow();
|
||||
const [users, setUsers] = useState<User[]>([]);
|
||||
const [creating, setCreating] = useState(false);
|
||||
const [editing, setEditing] = useState<User | null>(null);
|
||||
const [deleting, setDeleting] = useState<User | null>(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const isAdmin = me?.role === "admin";
|
||||
|
||||
const load = () => api.users().then(setUsers).catch((e) => toast(errorMessage(e), "bad"));
|
||||
useEffect(() => {
|
||||
void load();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="page-head">
|
||||
<div>
|
||||
<h1>Users</h1>
|
||||
<p>Administrators manage everything; viewers can look but not touch.</p>
|
||||
</div>
|
||||
{isAdmin && (
|
||||
<button className="btn primary" onClick={() => setCreating(true)}>
|
||||
<Plus /> New user
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div className="card">
|
||||
<div className="table-wrap">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Username</th>
|
||||
<th>Role</th>
|
||||
<th>Two-factor</th>
|
||||
<th>Last sign-in</th>
|
||||
<th>Created</th>
|
||||
{isAdmin && <th></th>}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{users.map((u) => (
|
||||
<tr key={u.id}>
|
||||
<td>
|
||||
{u.username} {u.id === me?.id && <span className="badge accent">you</span>}
|
||||
</td>
|
||||
<td>
|
||||
<span className={`badge ${u.role === "admin" ? "accent" : ""}`}>{u.role}</span>
|
||||
</td>
|
||||
<td>{u.totpEnabled ? <span className="badge ok">on</span> : <span className="badge">off</span>}</td>
|
||||
<td>{ago(u.lastLoginAt, now)}</td>
|
||||
<td>{dateTime(u.createdAt)}</td>
|
||||
{isAdmin && (
|
||||
<td className="actions">
|
||||
<button className="btn sm" onClick={() => setEditing(u)}>
|
||||
Edit
|
||||
</button>{" "}
|
||||
{u.id !== me?.id && (
|
||||
<button className="btn sm danger" onClick={() => setDeleting(u)}>
|
||||
Delete
|
||||
</button>
|
||||
)}
|
||||
</td>
|
||||
)}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
{creating && (
|
||||
<UserForm
|
||||
onClose={() => setCreating(false)}
|
||||
onSaved={() => {
|
||||
setCreating(false);
|
||||
toast("User created");
|
||||
void load();
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{editing && (
|
||||
<UserEdit
|
||||
user={editing}
|
||||
onClose={() => setEditing(null)}
|
||||
onSaved={() => {
|
||||
setEditing(null);
|
||||
toast("User updated");
|
||||
void load();
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{deleting && (
|
||||
<Confirm
|
||||
title="Delete user"
|
||||
danger
|
||||
confirmLabel="Delete"
|
||||
busy={busy}
|
||||
onClose={() => setDeleting(null)}
|
||||
onConfirm={async () => {
|
||||
setBusy(true);
|
||||
try {
|
||||
await api.deleteUser(deleting.id);
|
||||
toast("User deleted");
|
||||
setDeleting(null);
|
||||
void load();
|
||||
} catch (e) {
|
||||
toast(errorMessage(e), "bad");
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}}
|
||||
text={
|
||||
<>
|
||||
Delete <b>{deleting.username}</b>? Their sessions end immediately.
|
||||
</>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function UserForm({ onClose, onSaved }: { onClose: () => void; onSaved: () => void }) {
|
||||
const [username, setUsername] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [role, setRole] = useState("admin");
|
||||
const [error, setError] = useState("");
|
||||
const [busy, setBusy] = useState(false);
|
||||
async function submit(e: FormEvent) {
|
||||
e.preventDefault();
|
||||
setBusy(true);
|
||||
setError("");
|
||||
try {
|
||||
await api.createUser({ username, password, role });
|
||||
onSaved();
|
||||
} catch (err) {
|
||||
setError(errorMessage(err));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
return (
|
||||
<Modal
|
||||
title="New user"
|
||||
onClose={onClose}
|
||||
footer={
|
||||
<>
|
||||
<button className="btn" onClick={onClose} disabled={busy}>
|
||||
Cancel
|
||||
</button>
|
||||
<button className="btn primary" type="submit" form="user-form" disabled={busy}>
|
||||
Create
|
||||
</button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<form id="user-form" onSubmit={submit}>
|
||||
{error && <div className="error">{error}</div>}
|
||||
<Field label="Username">
|
||||
<input className="input" autoFocus value={username} onChange={(e) => setUsername(e.target.value)} required autoComplete="off" />
|
||||
</Field>
|
||||
<Field label="Password" hint="At least 12 characters. Tell them to change it after signing in.">
|
||||
<input className="input" type="password" value={password} onChange={(e) => setPassword(e.target.value)} required minLength={12} autoComplete="new-password" />
|
||||
</Field>
|
||||
<Field label="Role">
|
||||
<select className="input" value={role} onChange={(e) => setRole(e.target.value)}>
|
||||
<option value="admin">Administrator</option>
|
||||
<option value="viewer">Viewer (read-only)</option>
|
||||
</select>
|
||||
</Field>
|
||||
</form>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
function UserEdit({ user, onClose, onSaved }: { user: User; onClose: () => void; onSaved: () => void }) {
|
||||
const { me } = useAuth();
|
||||
const [role, setRole] = useState(user.role);
|
||||
const [password, setPassword] = useState("");
|
||||
const [resetTotp, setResetTotp] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
const [busy, setBusy] = useState(false);
|
||||
async function submit(e: FormEvent) {
|
||||
e.preventDefault();
|
||||
setBusy(true);
|
||||
setError("");
|
||||
try {
|
||||
await api.updateUser(user.id, { role: role !== user.role ? role : undefined, password: password || undefined, resetTotp });
|
||||
onSaved();
|
||||
} catch (err) {
|
||||
setError(errorMessage(err));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
return (
|
||||
<Modal
|
||||
title={`Edit ${user.username}`}
|
||||
onClose={onClose}
|
||||
footer={
|
||||
<>
|
||||
<button className="btn" onClick={onClose} disabled={busy}>
|
||||
Cancel
|
||||
</button>
|
||||
<button className="btn primary" type="submit" form="user-edit" disabled={busy}>
|
||||
Save
|
||||
</button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<form id="user-edit" onSubmit={submit}>
|
||||
{error && <div className="error">{error}</div>}
|
||||
<Field label="Role" hint={user.id === me?.id ? "You cannot change your own role." : undefined}>
|
||||
<select className="input" value={role} onChange={(e) => setRole(e.target.value as User["role"])} disabled={user.id === me?.id}>
|
||||
<option value="admin">Administrator</option>
|
||||
<option value="viewer">Viewer (read-only)</option>
|
||||
</select>
|
||||
</Field>
|
||||
<Field label="New password" hint="Leave blank to keep it. Setting one signs them out everywhere.">
|
||||
<input className="input" type="password" value={password} onChange={(e) => setPassword(e.target.value)} minLength={12} autoComplete="new-password" />
|
||||
</Field>
|
||||
{user.totpEnabled && (
|
||||
<div className="check">
|
||||
<input id="reset-totp" type="checkbox" checked={resetTotp} onChange={(e) => setResetTotp(e.target.checked)} />
|
||||
<label htmlFor="reset-totp">
|
||||
Reset two-factor authentication
|
||||
<span className="hint">For a lost authenticator. They can set it up again from their account page.</span>
|
||||
</label>
|
||||
</div>
|
||||
)}
|
||||
</form>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState, type ReactNode } from "react";
|
||||
import { api, ApiError, type Me, type Snapshot } from "./api";
|
||||
|
||||
// --- auth -------------------------------------------------------------------
|
||||
|
||||
interface AuthState {
|
||||
me: Me | null;
|
||||
loading: boolean;
|
||||
needsSetup: boolean;
|
||||
refresh: () => Promise<void>;
|
||||
signOut: () => Promise<void>;
|
||||
}
|
||||
|
||||
const AuthCtx = createContext<AuthState | null>(null);
|
||||
|
||||
export function AuthProvider({ children }: { children: ReactNode }) {
|
||||
const [me, setMe] = useState<Me | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [needsSetup, setNeedsSetup] = useState(false);
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
try {
|
||||
const setup = await api.setupStatus();
|
||||
setNeedsSetup(setup.needsSetup);
|
||||
if (setup.needsSetup) {
|
||||
setMe(null);
|
||||
return;
|
||||
}
|
||||
setMe(await api.me());
|
||||
} catch (e) {
|
||||
if (e instanceof ApiError && e.status === 401) setMe(null);
|
||||
else setMe(null);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const signOut = useCallback(async () => {
|
||||
try {
|
||||
await api.logout();
|
||||
} finally {
|
||||
setMe(null);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
void refresh();
|
||||
}, [refresh]);
|
||||
|
||||
const value = useMemo(() => ({ me, loading, needsSetup, refresh, signOut }), [me, loading, needsSetup, refresh, signOut]);
|
||||
return <AuthCtx.Provider value={value}>{children}</AuthCtx.Provider>;
|
||||
}
|
||||
|
||||
export function useAuth(): AuthState {
|
||||
const v = useContext(AuthCtx);
|
||||
if (!v) throw new Error("useAuth outside AuthProvider");
|
||||
return v;
|
||||
}
|
||||
|
||||
// --- live updates ----------------------------------------------------------
|
||||
|
||||
interface LiveState {
|
||||
snapshot: Snapshot | null;
|
||||
connected: boolean;
|
||||
// Bumps whenever the peer list changed on the server.
|
||||
peersVersion: number;
|
||||
settingsVersion: number;
|
||||
}
|
||||
|
||||
const LiveCtx = createContext<LiveState>({ snapshot: null, connected: false, peersVersion: 0, settingsVersion: 0 });
|
||||
|
||||
export function LiveProvider({ children }: { children: ReactNode }) {
|
||||
const { me } = useAuth();
|
||||
const [snapshot, setSnapshot] = useState<Snapshot | null>(null);
|
||||
const [connected, setConnected] = useState(false);
|
||||
const [peersVersion, setPeersVersion] = useState(0);
|
||||
const [settingsVersion, setSettingsVersion] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
if (!me) {
|
||||
setSnapshot(null);
|
||||
setConnected(false);
|
||||
return;
|
||||
}
|
||||
const es = new EventSource("/api/events");
|
||||
es.onopen = () => setConnected(true);
|
||||
es.onerror = () => setConnected(false);
|
||||
es.addEventListener("status", (ev) => {
|
||||
try {
|
||||
setSnapshot(JSON.parse((ev as MessageEvent).data) as Snapshot);
|
||||
} catch {
|
||||
/* ignore malformed frames */
|
||||
}
|
||||
});
|
||||
es.addEventListener("peers", () => setPeersVersion((v) => v + 1));
|
||||
es.addEventListener("settings", () => setSettingsVersion((v) => v + 1));
|
||||
return () => es.close();
|
||||
}, [me]);
|
||||
|
||||
const value = useMemo(() => ({ snapshot, connected, peersVersion, settingsVersion }), [snapshot, connected, peersVersion, settingsVersion]);
|
||||
return <LiveCtx.Provider value={value}>{children}</LiveCtx.Provider>;
|
||||
}
|
||||
|
||||
export function useLive(): LiveState {
|
||||
return useContext(LiveCtx);
|
||||
}
|
||||
|
||||
// A ticking clock so relative times ("2m ago") stay honest.
|
||||
export function useNow(intervalMs = 5000): number {
|
||||
const [now, setNow] = useState(() => Date.now());
|
||||
useEffect(() => {
|
||||
const t = setInterval(() => setNow(Date.now()), intervalMs);
|
||||
return () => clearInterval(t);
|
||||
}, [intervalMs]);
|
||||
return now;
|
||||
}
|
||||
|
||||
// --- toasts -----------------------------------------------------------------
|
||||
|
||||
interface Toast {
|
||||
id: number;
|
||||
text: string;
|
||||
kind: "ok" | "bad";
|
||||
}
|
||||
|
||||
const ToastCtx = createContext<(text: string, kind?: "ok" | "bad") => void>(() => {});
|
||||
|
||||
export function ToastProvider({ children }: { children: ReactNode }) {
|
||||
const [toasts, setToasts] = useState<Toast[]>([]);
|
||||
const counter = useRef(0);
|
||||
const push = useCallback((text: string, kind: "ok" | "bad" = "ok") => {
|
||||
const id = ++counter.current;
|
||||
setToasts((t) => [...t, { id, text, kind }]);
|
||||
setTimeout(() => setToasts((t) => t.filter((x) => x.id !== id)), kind === "bad" ? 6000 : 3500);
|
||||
}, []);
|
||||
return (
|
||||
<ToastCtx.Provider value={push}>
|
||||
{children}
|
||||
<div className="toasts" aria-live="polite">
|
||||
{toasts.map((t) => (
|
||||
<div key={t.id} className={`toast ${t.kind}`}>
|
||||
{t.text}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</ToastCtx.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useToast() {
|
||||
return useContext(ToastCtx);
|
||||
}
|
||||
|
||||
export function errorMessage(e: unknown): string {
|
||||
if (e instanceof ApiError) return e.message;
|
||||
if (e instanceof Error) return e.message;
|
||||
return String(e);
|
||||
}
|
||||
@@ -0,0 +1,257 @@
|
||||
:root {
|
||||
--bg: #f4f6f5;
|
||||
--bg-elev: #ffffff;
|
||||
--bg-sunken: #e9edeb;
|
||||
--fg: #16201c;
|
||||
--fg-muted: #5c6b64;
|
||||
--fg-faint: #8b9791;
|
||||
--line: #d8dfdb;
|
||||
--line-strong: #b9c4be;
|
||||
--accent: #1f6f5c;
|
||||
--accent-fg: #ffffff;
|
||||
--accent-soft: #dcefe8;
|
||||
--ok: #1d8f4e;
|
||||
--ok-soft: #dcf3e4;
|
||||
--warn: #b7791f;
|
||||
--warn-soft: #fbeed3;
|
||||
--bad: #c2382f;
|
||||
--bad-soft: #f9dedb;
|
||||
--info: #2a6fb0;
|
||||
--rx: #2a6fb0;
|
||||
--tx: #1f6f5c;
|
||||
--shadow: 0 1px 2px rgba(20, 30, 26, 0.06), 0 8px 24px rgba(20, 30, 26, 0.06);
|
||||
--radius: 10px;
|
||||
--mono: ui-monospace, "SF Mono", Menlo, Consolas, "Liberation Mono", monospace;
|
||||
--sans: system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
|
||||
color-scheme: light;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root {
|
||||
--bg: #111715;
|
||||
--bg-elev: #19211e;
|
||||
--bg-sunken: #0c100f;
|
||||
--fg: #e6ece9;
|
||||
--fg-muted: #9aa8a2;
|
||||
--fg-faint: #6a7772;
|
||||
--line: #26312d;
|
||||
--line-strong: #3a4742;
|
||||
--accent: #3fae90;
|
||||
--accent-fg: #06110d;
|
||||
--accent-soft: #163a31;
|
||||
--ok: #3fc275;
|
||||
--ok-soft: #12321f;
|
||||
--warn: #e0a84a;
|
||||
--warn-soft: #3a2c10;
|
||||
--bad: #ef6b62;
|
||||
--bad-soft: #3d1815;
|
||||
--info: #5f9de0;
|
||||
--rx: #5f9de0;
|
||||
--tx: #3fae90;
|
||||
--shadow: 0 1px 2px rgba(0, 0, 0, 0.4), 0 8px 24px rgba(0, 0, 0, 0.35);
|
||||
color-scheme: dark;
|
||||
}
|
||||
}
|
||||
|
||||
* { box-sizing: border-box; }
|
||||
html, body, #root { height: 100%; }
|
||||
body {
|
||||
margin: 0;
|
||||
font-family: var(--sans);
|
||||
font-size: 14px;
|
||||
line-height: 1.45;
|
||||
color: var(--fg);
|
||||
background: var(--bg);
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
a { color: var(--accent); text-decoration: none; }
|
||||
a:hover { text-decoration: underline; }
|
||||
code, .mono { font-family: var(--mono); font-size: 12.5px; }
|
||||
h1, h2, h3 { margin: 0; font-weight: 600; letter-spacing: -0.01em; }
|
||||
h1 { font-size: 20px; }
|
||||
h2 { font-size: 16px; }
|
||||
h3 { font-size: 14px; }
|
||||
p { margin: 0 0 8px; }
|
||||
button, input, select, textarea { font: inherit; color: inherit; }
|
||||
::selection { background: var(--accent-soft); }
|
||||
|
||||
/* Layout */
|
||||
.shell { display: grid; grid-template-columns: 220px 1fr; min-height: 100%; }
|
||||
.sidebar {
|
||||
background: var(--bg-elev);
|
||||
border-right: 1px solid var(--line);
|
||||
padding: 16px 12px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
position: sticky;
|
||||
top: 0;
|
||||
height: 100vh;
|
||||
}
|
||||
.brand { display: flex; align-items: center; gap: 10px; padding: 4px 8px 16px; }
|
||||
.brand-mark {
|
||||
width: 30px; height: 30px; border-radius: 8px; background: var(--accent);
|
||||
display: grid; place-items: center; color: var(--accent-fg); flex: none;
|
||||
}
|
||||
.brand-name { font-weight: 700; font-size: 16px; letter-spacing: 0.02em; }
|
||||
.brand-sub { font-size: 11px; color: var(--fg-muted); }
|
||||
.nav a {
|
||||
display: flex; align-items: center; gap: 10px;
|
||||
padding: 8px 10px; border-radius: 8px; color: var(--fg-muted); font-weight: 500;
|
||||
}
|
||||
.nav a:hover { background: var(--bg-sunken); text-decoration: none; color: var(--fg); }
|
||||
.nav a.active { background: var(--accent-soft); color: var(--accent); }
|
||||
.nav a svg { width: 17px; height: 17px; }
|
||||
.sidebar-foot { margin-top: auto; padding: 8px; font-size: 12px; color: var(--fg-muted); display: flex; flex-direction: column; gap: 6px; }
|
||||
.main { padding: 24px 28px 48px; min-width: 0; }
|
||||
.page-head { display: flex; align-items: center; justify-content: space-between; gap: 16px; margin-bottom: 20px; flex-wrap: wrap; }
|
||||
.page-head p { color: var(--fg-muted); margin: 2px 0 0; }
|
||||
.toolbar { display: flex; gap: 8px; align-items: center; flex-wrap: wrap; }
|
||||
|
||||
@media (max-width: 860px) {
|
||||
.shell { grid-template-columns: 1fr; }
|
||||
.sidebar { position: static; height: auto; flex-direction: row; flex-wrap: wrap; align-items: center; border-right: 0; border-bottom: 1px solid var(--line); }
|
||||
.brand { padding: 4px 8px; }
|
||||
.nav { display: flex; flex-wrap: wrap; gap: 2px; }
|
||||
.nav a span { display: none; }
|
||||
.sidebar-foot { margin-top: 0; margin-left: auto; }
|
||||
.main { padding: 16px; }
|
||||
}
|
||||
|
||||
/* Cards and grids */
|
||||
.card {
|
||||
background: var(--bg-elev);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius);
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
.card-body { padding: 16px 18px; }
|
||||
.card-head { padding: 12px 18px; border-bottom: 1px solid var(--line); display: flex; align-items: center; justify-content: space-between; gap: 12px; }
|
||||
.grid { display: grid; gap: 14px; }
|
||||
.grid-4 { grid-template-columns: repeat(4, minmax(0, 1fr)); }
|
||||
.grid-3 { grid-template-columns: repeat(3, minmax(0, 1fr)); }
|
||||
.grid-2 { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
||||
@media (max-width: 1100px) { .grid-4 { grid-template-columns: repeat(2, minmax(0, 1fr)); } .grid-3 { grid-template-columns: repeat(2, minmax(0, 1fr)); } }
|
||||
@media (max-width: 640px) { .grid-4, .grid-3, .grid-2 { grid-template-columns: 1fr; } }
|
||||
.stack { display: flex; flex-direction: column; gap: 14px; }
|
||||
|
||||
.stat { padding: 14px 16px; }
|
||||
.stat-label { font-size: 12px; color: var(--fg-muted); text-transform: uppercase; letter-spacing: 0.06em; }
|
||||
.stat-value { font-size: 24px; font-weight: 600; margin-top: 2px; font-variant-numeric: tabular-nums; }
|
||||
.stat-sub { font-size: 12px; color: var(--fg-muted); margin-top: 2px; }
|
||||
|
||||
/* Tables */
|
||||
.table-wrap { overflow-x: auto; }
|
||||
table { width: 100%; border-collapse: collapse; }
|
||||
th, td { text-align: left; padding: 10px 12px; border-bottom: 1px solid var(--line); vertical-align: middle; }
|
||||
th { font-size: 12px; color: var(--fg-muted); font-weight: 600; text-transform: uppercase; letter-spacing: 0.05em; white-space: nowrap; }
|
||||
tr:last-child td { border-bottom: 0; }
|
||||
tbody tr:hover { background: color-mix(in srgb, var(--bg-sunken) 50%, transparent); }
|
||||
tbody tr.clickable { cursor: pointer; }
|
||||
td.num { font-variant-numeric: tabular-nums; white-space: nowrap; }
|
||||
td.actions { white-space: nowrap; text-align: right; }
|
||||
.empty { padding: 40px 16px; text-align: center; color: var(--fg-muted); }
|
||||
|
||||
/* Status dot */
|
||||
.dot { display: inline-block; width: 9px; height: 9px; border-radius: 50%; background: var(--fg-faint); vertical-align: middle; margin-right: 7px; }
|
||||
.dot.on { background: var(--ok); box-shadow: 0 0 0 3px var(--ok-soft); }
|
||||
.dot.off { background: var(--fg-faint); }
|
||||
.dot.disabled { background: var(--bad); }
|
||||
.dot.expired { background: var(--warn); }
|
||||
.status-text { white-space: nowrap; }
|
||||
|
||||
/* Badges */
|
||||
.badge { display: inline-block; padding: 1px 7px; border-radius: 999px; font-size: 11.5px; font-weight: 600; background: var(--bg-sunken); color: var(--fg-muted); white-space: nowrap; }
|
||||
.badge.ok { background: var(--ok-soft); color: var(--ok); }
|
||||
.badge.warn { background: var(--warn-soft); color: var(--warn); }
|
||||
.badge.bad { background: var(--bad-soft); color: var(--bad); }
|
||||
.badge.accent { background: var(--accent-soft); color: var(--accent); }
|
||||
|
||||
/* Buttons */
|
||||
.btn {
|
||||
display: inline-flex; align-items: center; gap: 6px;
|
||||
padding: 7px 12px; border-radius: 8px; border: 1px solid var(--line-strong);
|
||||
background: var(--bg-elev); color: var(--fg); cursor: pointer; font-weight: 500; line-height: 1.2;
|
||||
}
|
||||
.btn svg { width: 15px; height: 15px; }
|
||||
.btn:hover { background: var(--bg-sunken); }
|
||||
.btn:disabled { opacity: 0.55; cursor: not-allowed; }
|
||||
.btn.primary { background: var(--accent); border-color: var(--accent); color: var(--accent-fg); }
|
||||
.btn.primary:hover { filter: brightness(1.08); }
|
||||
.btn.danger { color: var(--bad); border-color: color-mix(in srgb, var(--bad) 40%, var(--line-strong)); }
|
||||
.btn.danger:hover { background: var(--bad-soft); }
|
||||
.btn.sm { padding: 4px 8px; font-size: 12.5px; }
|
||||
.btn.icon { padding: 5px; }
|
||||
.btn.ghost { border-color: transparent; background: transparent; }
|
||||
.btn.ghost:hover { background: var(--bg-sunken); }
|
||||
.btn-row { display: flex; gap: 8px; flex-wrap: wrap; align-items: center; }
|
||||
|
||||
/* Forms */
|
||||
.field { display: flex; flex-direction: column; gap: 4px; margin-bottom: 12px; }
|
||||
.field label { font-size: 12.5px; font-weight: 600; color: var(--fg-muted); }
|
||||
.field .hint { font-size: 12px; color: var(--fg-faint); }
|
||||
.input, textarea.input, select.input {
|
||||
width: 100%; padding: 8px 10px; border-radius: 8px; border: 1px solid var(--line-strong);
|
||||
background: var(--bg-elev); color: var(--fg); outline: none;
|
||||
}
|
||||
.input:focus { border-color: var(--accent); box-shadow: 0 0 0 3px var(--accent-soft); }
|
||||
textarea.input { min-height: 72px; resize: vertical; }
|
||||
.check { display: flex; align-items: flex-start; gap: 10px; margin-bottom: 12px; }
|
||||
.check input { margin-top: 3px; accent-color: var(--accent); }
|
||||
.check label { font-weight: 500; }
|
||||
.check .hint { display: block; font-size: 12px; color: var(--fg-faint); font-weight: 400; }
|
||||
.form-cols { display: grid; grid-template-columns: 1fr 1fr; gap: 0 16px; }
|
||||
@media (max-width: 640px) { .form-cols { grid-template-columns: 1fr; } }
|
||||
.error { color: var(--bad); background: var(--bad-soft); padding: 8px 10px; border-radius: 8px; margin-bottom: 12px; font-size: 13px; }
|
||||
.notice { color: var(--warn); background: var(--warn-soft); padding: 8px 10px; border-radius: 8px; margin-bottom: 12px; font-size: 13px; }
|
||||
.success { color: var(--ok); background: var(--ok-soft); padding: 8px 10px; border-radius: 8px; margin-bottom: 12px; font-size: 13px; }
|
||||
|
||||
/* Auth pages */
|
||||
.auth { min-height: 100%; display: grid; place-items: center; padding: 24px; }
|
||||
.auth .card { width: 100%; max-width: 420px; }
|
||||
.auth .brand { justify-content: center; padding-bottom: 4px; }
|
||||
.auth h1 { text-align: center; margin: 4px 0 16px; }
|
||||
|
||||
/* Modal */
|
||||
.modal-back { position: fixed; inset: 0; background: rgba(8, 12, 10, 0.5); display: grid; place-items: center; padding: 20px; z-index: 50; backdrop-filter: blur(2px); }
|
||||
.modal { width: 100%; max-width: 640px; max-height: calc(100vh - 40px); overflow: auto; }
|
||||
.modal.wide { max-width: 860px; }
|
||||
.modal .card-head h2 { font-size: 16px; }
|
||||
|
||||
/* Toasts */
|
||||
.toasts { position: fixed; right: 16px; bottom: 16px; display: flex; flex-direction: column; gap: 8px; z-index: 60; }
|
||||
.toast { background: var(--fg); color: var(--bg); padding: 10px 14px; border-radius: 8px; box-shadow: var(--shadow); font-size: 13px; max-width: 360px; }
|
||||
.toast.bad { background: var(--bad); color: #fff; }
|
||||
|
||||
/* Peer detail */
|
||||
.kv { display: grid; grid-template-columns: max-content 1fr; gap: 6px 16px; font-size: 13px; }
|
||||
.kv dt { color: var(--fg-muted); }
|
||||
.kv dd { margin: 0; word-break: break-all; }
|
||||
.qr { display: grid; grid-template-columns: 1fr; gap: 16px; }
|
||||
.qr img { width: 100%; max-width: 320px; image-rendering: pixelated; border-radius: 8px; border: 1px solid var(--line); background: #fff; justify-self: center; }
|
||||
pre.config { background: var(--bg-sunken); border: 1px solid var(--line); border-radius: 8px; padding: 12px; font-family: var(--mono); font-size: 12.5px; overflow: auto; margin: 0; white-space: pre; }
|
||||
.tabs { display: flex; gap: 2px; border-bottom: 1px solid var(--line); margin-bottom: 14px; }
|
||||
.tabs button { background: none; border: 0; padding: 8px 12px; cursor: pointer; color: var(--fg-muted); font-weight: 500; border-bottom: 2px solid transparent; margin-bottom: -1px; }
|
||||
.tabs button.active { color: var(--accent); border-bottom-color: var(--accent); }
|
||||
|
||||
/* Charts */
|
||||
.chart { width: 100%; height: 180px; display: block; }
|
||||
.legend { display: flex; gap: 14px; font-size: 12px; color: var(--fg-muted); }
|
||||
.legend i { display: inline-block; width: 10px; height: 10px; border-radius: 2px; margin-right: 5px; vertical-align: -1px; }
|
||||
.bar { height: 8px; border-radius: 4px; background: var(--bg-sunken); overflow: hidden; }
|
||||
.bar > i { display: block; height: 100%; background: var(--accent); }
|
||||
.sparkline { width: 110px; height: 26px; display: block; }
|
||||
|
||||
.muted { color: var(--fg-muted); }
|
||||
.faint { color: var(--fg-faint); }
|
||||
.small { font-size: 12px; }
|
||||
.right { text-align: right; }
|
||||
.nowrap { white-space: nowrap; }
|
||||
.mt { margin-top: 12px; }
|
||||
.recovery { columns: 2; font-family: var(--mono); font-size: 14px; }
|
||||
.recovery li { margin: 4px 0; }
|
||||
.search { max-width: 280px; }
|
||||
.segmented { display: inline-flex; border: 1px solid var(--line-strong); border-radius: 8px; overflow: hidden; }
|
||||
.segmented button { border: 0; background: var(--bg-elev); padding: 5px 10px; cursor: pointer; color: var(--fg-muted); font-size: 12.5px; }
|
||||
.segmented button + button { border-left: 1px solid var(--line); }
|
||||
.segmented button.active { background: var(--accent-soft); color: var(--accent); font-weight: 600; }
|
||||
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"jsx": "react-jsx",
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"skipLibCheck": true,
|
||||
"isolatedModules": true,
|
||||
"resolveJsonModule": true,
|
||||
"noEmit": true,
|
||||
"types": ["vite/client"]
|
||||
},
|
||||
"include": ["src", "vite.config.ts"]
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import { defineConfig } from "vite";
|
||||
import react from "@vitejs/plugin-react";
|
||||
|
||||
// The build lands inside the Go module so `go build` embeds it.
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
build: {
|
||||
outDir: "../internal/server/static/dist",
|
||||
emptyOutDir: true,
|
||||
sourcemap: false,
|
||||
},
|
||||
server: {
|
||||
port: 5173,
|
||||
proxy: {
|
||||
"/api": { target: "http://127.0.0.1:51821", changeOrigin: false },
|
||||
"/metrics": { target: "http://127.0.0.1:51821", changeOrigin: false },
|
||||
},
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user