This commit is contained in:
2026-09-13 06:53:33 +01:00
parent 75670e4d5c
commit e7e63bc491
45 changed files with 2117 additions and 22 deletions
+8
View File
@@ -122,3 +122,11 @@ Server functions use `index.server.ts` alongside `index.tsx` (e.g. `single-docum
- `external/` binary directories (`**/models`, `**/bin`, `**/x-86`, `**/arm-64`) are gitignored.
- The `bunext.config.ts` excludes `onnxruntime-node` and `@xenova/transformers` from SSR/page compilation (native modules that must stay server-side).
- `tsconfig.json` excludes `src/components/twui/mdx`, `src/components/twui/elements/RemoteCodeBlock.tsx`, and `src/components/twui/mdx/markdown/MarkdownEditorPreviewComponent.tsx` from type-checking.
## Conventions
- Prefer one function/component per file over monolithic files with multiple exports.
- **Modularization is non-negotiable: prioritize modularization over monolithic functions/components.** Repeated UI blocks must be extracted into their own reusable components driven by props (e.g. an info row component taking `{ icon, text }`), never repeated inline markup.
- **UNCHANGEABLE RULE — twui components always win over raw JSX elements.** ALWAYS use twui components (`H1`–`H5`, `P`, `Span`, `List`, `Img`, `Row`, `Stack`, `Container`, `Section`, `Border`, `Card`, `Button`, `Tag`, `Link`, etc.) before basic JSX elements (`<h1>`–`<h6>`, `<p>`, `<span>`, `<ul>`, `<li>`, `<img>`, `<a>`, `<button>`, `<div>`-as-card, etc.). Never introduce or reintroduce a raw JSX element where a twui equivalent exists. Override twui defaults via `className` (e.g. `mb-0!`) rather than reaching for the raw element.
- **UNCHANGEABLE RULE — twui components are preferred over shadcn/ui components.** Use twui before `src/components/ui/*` (shadcn) whenever a twui equivalent exists. The long-term goal is to phase out shadcn/ui entirely — do not introduce new shadcn usage where twui covers the need, and prefer migrating existing shadcn call sites to twui when touching them anyway.
- **LIMIT EDITS TO `src/components/twui/`** — it is a general, publishable library shared across projects (its own `package.json`), not scoped to this repo. Do not modify its components unless strictly necessary. Customize/appearance overrides for this project belong in `src/styles/globals.css` (e.g. tweaking `.twui-card`, `--color-*` tokens, font/type styles) rather than editing twui source files.
+62 -1
View File
@@ -1,5 +1,6 @@
import { MediaParadigms, MediaTypes } from "@/src/dict/media-dict";
import { UserTypes } from "@/src/dict/user-types-dict";
import { Variables } from "@/src/dict/variables-dict";
import type { BUN_SQLITE_DatabaseSchemaType } from "@moduletrace/bun-sqlite/dist/types";
const schema: BUN_SQLITE_DatabaseSchemaType = {
@@ -157,7 +158,67 @@ const schema: BUN_SQLITE_DatabaseSchemaType = {
dataType: "INTEGER",
},
{
fieldName: "ip_address",
fieldName: "host_id",
dataType: "INTEGER",
defaultValue: 0,
},
{
fieldName: "name",
dataType: "TEXT",
},
{
fieldName: "wg_ip_address",
dataType: "TEXT",
},
{
fieldName: "public_ip_address",
dataType: "TEXT",
},
{
fieldName: "private_key",
dataType: "TEXT",
},
{
fieldName: "public_key",
dataType: "TEXT",
},
{
fieldName: "allowed_ips",
dataType: "TEXT",
},
],
},
{
tableName: "hosts",
fields: [
{
fieldName: "user_id",
dataType: "INTEGER",
},
{
fieldName: "wg_ip_address",
dataType: "TEXT",
},
{
fieldName: "private_key",
dataType: "TEXT",
},
{
fieldName: "public_key",
dataType: "TEXT",
},
],
},
{
tableName: "variables",
fields: [
{
fieldName: "key",
dataType: "TEXT",
options: Variables.map((v) => v.value),
},
{
fieldName: "value",
dataType: "TEXT",
},
],
+63 -1
View File
@@ -3,6 +3,9 @@ export const BunSQLiteTables = [
"user_types",
"media",
"media_paradigms",
"clients",
"hosts",
"variables",
] as const
export type BUN_SQLITE_WGUI_USERS = {
@@ -89,4 +92,63 @@ export type BUN_SQLITE_WGUI_MEDIA_PARADIGMS = {
media_paradigm?: "user-profile-image" | "generic" | "event" | "";
}
export type BUN_SQLITE_WGUI_ALL_TYPEDEFS = BUN_SQLITE_WGUI_USERS & BUN_SQLITE_WGUI_USER_TYPES & BUN_SQLITE_WGUI_MEDIA & BUN_SQLITE_WGUI_MEDIA_PARADIGMS
export type BUN_SQLITE_WGUI_CLIENTS = {
/**
* The unique identifier of the record.
*/
id?: number | "";
/**
* The time when the record was created. (Unix Timestamp)
*/
created_at?: number | "";
/**
* The time when the record was updated. (Unix Timestamp)
*/
updated_at?: number | "";
user_id?: number | "";
host_id?: number | "";
name?: string;
wg_ip_address?: string;
public_ip_address?: string;
private_key?: string;
public_key?: string;
allowed_ips?: string;
}
export type BUN_SQLITE_WGUI_HOSTS = {
/**
* The unique identifier of the record.
*/
id?: number | "";
/**
* The time when the record was created. (Unix Timestamp)
*/
created_at?: number | "";
/**
* The time when the record was updated. (Unix Timestamp)
*/
updated_at?: number | "";
user_id?: number | "";
wg_ip_address?: string;
private_key?: string;
public_key?: string;
}
export type BUN_SQLITE_WGUI_VARIABLES = {
/**
* The unique identifier of the record.
*/
id?: number | "";
/**
* The time when the record was created. (Unix Timestamp)
*/
created_at?: number | "";
/**
* The time when the record was updated. (Unix Timestamp)
*/
updated_at?: number | "";
key?: "main_host_wg_ip_address" | "";
value?: string;
}
export type BUN_SQLITE_WGUI_ALL_TYPEDEFS = BUN_SQLITE_WGUI_USERS & BUN_SQLITE_WGUI_USER_TYPES & BUN_SQLITE_WGUI_MEDIA & BUN_SQLITE_WGUI_MEDIA_PARADIGMS & BUN_SQLITE_WGUI_CLIENTS & BUN_SQLITE_WGUI_HOSTS & BUN_SQLITE_WGUI_VARIABLES
BIN
View File
Binary file not shown.
+40
View File
@@ -0,0 +1,40 @@
import type { ButtonHTMLAttributes } from "react";
import type { LucideIcon } from "lucide-react";
import { twMerge } from "tailwind-merge";
type Props = ButtonHTMLAttributes<HTMLButtonElement> & {
variant?: "primary" | "ghost";
Icon?: LucideIcon;
};
const baseClass =
"h-[30px] px-3 rounded-[5px] inline-flex items-center justify-center gap-2 " +
"text-[13px] font-medium cursor-pointer transition-colors whitespace-nowrap";
const variantClass = {
primary: "bg-primary text-white font-semibold hover:opacity-90",
ghost:
"text-foreground-light/70 dark:text-foreground-dark/70 " +
"hover:text-foreground-light dark:hover:text-foreground-dark " +
"border border-slate-200 dark:border-white/10 " +
"hover:border-secondary/60 dark:hover:border-secondary/60",
};
export default function AdminButton({
variant = "ghost",
Icon,
className,
children,
...props
}: Props) {
return (
<button
type="button"
{...props}
className={twMerge(baseClass, variantClass[variant], className)}
>
{Icon ? <Icon size={13} strokeWidth={2.25} /> : null}
{children}
</button>
);
}
+25
View File
@@ -0,0 +1,25 @@
import type { HTMLAttributes } from "react";
import { twMerge } from "tailwind-merge";
type Props = HTMLAttributes<HTMLDivElement> & {
tier?: "default" | "secondary";
};
export default function AdminCard({
tier = "default",
className,
...props
}: Props) {
return (
<div
{...props}
className={twMerge(
"rounded-default border border-slate-200 dark:border-white/10",
tier == "secondary"
? "bg-background-light dark:bg-[#0e0e10]"
: "bg-background-light dark:bg-background-dark",
className,
)}
/>
);
}
+52
View File
@@ -0,0 +1,52 @@
import { twMerge } from "tailwind-merge";
export type WgStatus = "connected" | "idle" | "error";
type Props = {
status: WgStatus;
showLabel?: boolean;
};
const STATUS_META: Record<
WgStatus,
{ label: string; dot: string; text: string }
> = {
connected: {
label: "Connected",
dot: "bg-success",
text: "text-success dark:text-success",
},
idle: {
label: "Idle",
dot: "bg-warning",
text: "text-warning dark:text-warning",
},
error: {
label: "Error",
dot: "bg-error",
text: "text-error dark:text-error",
},
};
export default function StatusDot({ status, showLabel }: Props) {
const meta = STATUS_META[status];
return (
<span className="inline-flex items-center gap-1.5 shrink-0">
<span
className={twMerge("w-[6px] h-[6px] rounded-full", meta.dot)}
aria-hidden="true"
/>
{showLabel ? (
<span
className={twMerge(
"text-[12px] font-medium",
meta.text,
)}
>
{meta.label}
</span>
) : null}
</span>
);
}
+2
View File
@@ -16,4 +16,6 @@ export const AppData = {
ImageQuality: 85,
ImageThumbnailQuality: 70,
PaystackEndpoint: "https://api.paystack.co",
WireguardHostID: 0,
} as const;
+7
View File
@@ -0,0 +1,7 @@
export const Variables = [
{
title: `Main Host Wireguard IP Address`,
value: "main_host_wg_ip_address",
description: `Private IP address to use for the main Wireguard host. Eg. 10.1.0.1`,
},
] as const;
@@ -0,0 +1,9 @@
/**
* Function to grab the host's network interface
* eg `eth0`
* @param param0
*/
export default async function grabHostNetworkInterface() {
// Placeholder
return `eth0`;
}
@@ -0,0 +1,9 @@
/**
* Function to grab the host's public
* IP address
* @param param0
*/
export default async function grabHostPublicIPAddress() {
// Placeholder
return `102.34.765.43`;
}
@@ -0,0 +1,147 @@
import type {
BUN_SQLITE_WGUI_CLIENTS,
BUN_SQLITE_WGUI_HOSTS,
BUN_SQLITE_WGUI_VARIABLES,
} from "@/db/types/db";
import { AppData } from "@/src/data/app-data";
import grabDirNames from "@/src/utils/grab-dir-names";
import { execSync } from "node:child_process";
import path from "node:path";
import grabHostNetworkInterface from "./grab-host-network-interface";
import type { APIResponseObject } from "@moduletrace/bunext/types";
const {
WGUI_LIB_IP_TABLES_DIR,
WIREGUARD_HOST_CONFIG_DIR,
WIREGUARD_PRIVATE_KEY_FILE_NAME,
WIREGUARD_PUBLIC_KEY_FILE_NAME,
} = grabDirNames();
type Params = {
clients: BUN_SQLITE_WGUI_CLIENTS[];
host?: BUN_SQLITE_WGUI_HOSTS;
variables?: BUN_SQLITE_WGUI_VARIABLES[];
};
export default async function setupWireguardHost({
clients,
host,
variables,
}: Params): Promise<APIResponseObject> {
const host_id = host?.id || AppData["WireguardHostID"];
const TARGET_INTERFACE = await grabHostNetworkInterface();
const HOST_WG_IP =
host?.wg_ip_address ||
variables?.find((v) => v.key == "main_host_wg_ip_address")?.value;
if (!HOST_WG_IP) {
return {
success: false,
msg: `No Host Private IP address provided`,
};
}
let pre_sh = ``;
pre_sh += `cd ${WIREGUARD_HOST_CONFIG_DIR}\n`;
pre_sh += `if [ ! -f ${WIREGUARD_PRIVATE_KEY_FILE_NAME} ]; then\n`;
pre_sh += ` wg genkey | tee ${WIREGUARD_PRIVATE_KEY_FILE_NAME} | wg pubkey > ${WIREGUARD_PUBLIC_KEY_FILE_NAME}\n`;
pre_sh += `fi\n`;
try {
const exec_pre_setup = execSync(pre_sh, { encoding: "utf-8" });
} catch (error: any) {
return {
success: false,
msg: error.message,
};
}
const HOST_PUBLIC_KEY = host?.id
? host.public_key
: execSync(
`cat ${path.join(WIREGUARD_HOST_CONFIG_DIR, WIREGUARD_PUBLIC_KEY_FILE_NAME)}`,
);
const HOST_PRIVATE_KEY = host?.id
? host.public_key
: execSync(
`cat ${path.join(WIREGUARD_HOST_CONFIG_DIR, WIREGUARD_PRIVATE_KEY_FILE_NAME)}`,
);
let sh = ``;
const POST_UP_PATH = path.join(WGUI_LIB_IP_TABLES_DIR, `${host_id}-up.sh`);
const POST_DOWN_PATH = path.join(
WGUI_LIB_IP_TABLES_DIR,
`${host_id}-down.sh`,
);
sh += `cat > ${POST_UP_PATH} << EOF\n`;
sh += `#!/bin/bash\n\n`;
sh += `# Allow WireGuard traffic to/from the server itself\n`;
sh += `iptables -I INPUT 1 -i wg${host_id} -j ACCEPT\n`;
sh += `iptables -I OUTPUT 1 -o wg${host_id} -j ACCEPT\n`;
sh += `\n`;
sh += `# Allow WireGuard traffic to be forwarded (insert above Docker rules)\n`;
sh += `iptables -I FORWARD 1 -i wg${host_id} -j ACCEPT\n`;
sh += `iptables -I FORWARD 1 -o wg${host_id} -j ACCEPT\n`;
sh += `\n`;
sh += `iptables -t nat -A POSTROUTING -o ${TARGET_INTERFACE} -j MASQUERADE\n`;
sh += `EOF\n`;
sh += `\n`;
sh += `cat > ${POST_DOWN_PATH} << EOF\n`;
sh += `#!/bin/bash\n\n`;
sh += `# Remove WireGuard INPUT/OUTPUT rules\n`;
sh += `iptables -D INPUT -i wg${host_id} -j ACCEPT\n`;
sh += `iptables -D OUTPUT -o wg${host_id} -j ACCEPT\n`;
sh += `\n`;
sh += `# Remove FORWARD rules\n`;
sh += `iptables -D FORWARD -i wg${host_id} -j ACCEPT\n`;
sh += `iptables -D FORWARD -o wg${host_id} -j ACCEPT\n`;
sh += `\n`;
sh += `iptables -t nat -D POSTROUTING -o ${TARGET_INTERFACE} -j MASQUERADE\n`;
sh += `EOF\n`;
sh += `\n`;
sh += `cat > wg${host_id}.conf << EOF\n`;
sh += `[Interface]\n`;
sh += `Address = ${HOST_WG_IP}/24\n`;
sh += `ListenPort = 51820\n`;
sh += `PrivateKey = ${HOST_PRIVATE_KEY}\n`;
sh += `PostUp = ${POST_UP_PATH}\n`;
sh += `PostDown = ${POST_DOWN_PATH}\n`;
sh += `\n`;
if (clients[0]) {
for (let i = 0; i < clients.length; i++) {
const client = clients[i];
if (!client?.id || !client.public_key || !client.private_key)
continue;
sh += `[Peer]\n`;
sh += `PublicKey = ${client.public_key}\n`;
sh += `\n`;
}
}
sh += `EOF\n`;
sh += `\n`;
try {
const exec = execSync(sh, { encoding: "utf-8" });
} catch (error: any) {
return {
success: false,
msg: error.message,
};
}
return {
success: true,
};
}
+9 -2
View File
@@ -9,6 +9,8 @@ import Stack from "@/src/components/twui/layout/Stack";
export default function MobileNav() {
const [open, setOpen] = React.useState(false);
const triggerRef = React.useRef<HTMLButtonElement>(null);
const panelRef = React.useRef<HTMLElement>(null);
React.useEffect(() => {
if (!open) return;
@@ -21,16 +23,19 @@ export default function MobileNav() {
};
document.addEventListener("keydown", onKey);
panelRef.current?.focus();
return () => {
document.body.style.overflow = prev;
document.removeEventListener("keydown", onKey);
triggerRef.current?.focus();
};
}, [open]);
return (
<div className="flex xl:hidden">
<button
ref={triggerRef}
type="button"
aria-label="Open menu"
aria-expanded={open}
@@ -48,10 +53,12 @@ export default function MobileNav() {
aria-hidden
/>
<aside
ref={panelRef}
tabIndex={-1}
className={twMerge(
"fixed top-0 left-0 bottom-0 z-210",
"w-[min(280px,85vw)] bg-background-light dark:bg-background-dark",
"flex flex-col shadow-xl",
"flex flex-col shadow-xl outline-none",
)}
role="dialog"
aria-modal="true"
@@ -59,7 +66,7 @@ export default function MobileNav() {
>
<Row className="w-full justify-between items-center py-2 px-4 h-20 flex-nowrap">
<a href="/" onClick={() => setOpen(false)}>
<LogoIcon icon_size={60} />
<LogoIcon icon_size={40} />
</a>
<button
type="button"
@@ -3,6 +3,21 @@ import LinkList from "@/src/components/twui/elements/LinkList";
import Stack from "@/src/components/twui/layout/Stack";
import { AppContext } from "@/src/pages/__root";
import { useContext } from "react";
import { LayoutDashboard, LogOut, Server, Settings, Users } from "lucide-react";
const sectionLabel = (label: string) => (
<div className="px-3 pt-6 pb-1.5 text-[11.5px] font-semibold uppercase tracking-[0.08em] text-foreground-light/40 dark:text-foreground-dark/40">
{label}
</div>
);
const linkClassName =
"px-3 py-2 w-full rounded-[5px] no-underline! border-b-0! " +
"text-[14px] font-medium text-foreground-light/70 dark:text-foreground-dark/70 " +
"hover:text-foreground-light! dark:hover:text-foreground-dark! " +
"hover:bg-foreground-light/5 dark:hover:bg-foreground-dark/5 transition-colors";
const iconClassName = "shrink-0 opacity-60";
export default function AdminAsideLinks() {
const { pageProps } = useContext(AppContext);
@@ -13,29 +28,46 @@ export default function AdminAsideLinks() {
}
const links: TWUILink[] = [
{ component: sectionLabel("Overview") },
{
title: "Dashboard",
url: "/admin",
strict: true,
icon: (
<LayoutDashboard
size={16}
strokeWidth={2}
className={iconClassName}
/>
),
},
{ component: sectionLabel("WireGuard") },
{
title: "Clients",
url: "/admin/clients",
icon: (
<Users size={16} strokeWidth={2} className={iconClassName} />
),
},
{
title: "Host",
url: "/admin/host",
icon: <Server size={16} strokeWidth={2} className={iconClassName} />,
},
{
component: <div className="h-10"></div>,
},
{ component: sectionLabel("Account") },
{
title: "Settings",
url: "/admin/settings",
icon: (
<Settings size={16} strokeWidth={2} className={iconClassName} />
),
},
{
title: "Logout",
url: "/auth/logout",
icon: (
<LogOut size={16} strokeWidth={2} className={iconClassName} />
),
},
];
@@ -43,11 +75,8 @@ export default function AdminAsideLinks() {
<Stack className="w-full items-stretch">
<LinkList
links={links}
className="gap-1 flex-col items-start"
linkProps={{
className: "px-4 py-3 w-full",
}}
divider
className="gap-0.5 flex-col items-start pb-6"
linkProps={{ className: linkClassName }}
/>
</Stack>
);
+6 -6
View File
@@ -11,15 +11,15 @@ type Props = {
export default function AdminAside({ className }: Props) {
return (
<aside className={twMerge("min-w-[180px] mb-20", className)}>
<Stack className="w-full items-stretch gap-0!">
<Row className="py-2 px-4 h-20">
<a href="/">
<LogoIcon icon_size={60} />
<aside className={twMerge("w-[220px] shrink-0", className)}>
<Stack className="w-full items-stretch gap-0! sticky top-0 h-screen overflow-y-auto">
<Row className="px-4 h-20 flex items-center">
<a href="/" className="no-underline! border-b-0!">
<LogoIcon icon_size={40} />
</a>
</Row>
<Divider />
<nav className="w-full">
<nav className="w-full grow">
<AdminAsideLinks />
</nav>
<Divider />
@@ -0,0 +1,29 @@
import { AppContext } from "@/src/pages/__root";
import { ChevronRight } from "lucide-react";
import { useContext } from "react";
const SECTION_TITLES: Record<string, string> = {
"/admin": "Dashboard",
"/admin/clients": "Clients",
"/admin/host": "Host",
"/admin/settings": "Settings",
};
export default function HeaderPageTitle() {
const { pageProps } = useContext(AppContext);
const pathname = pageProps?.url?.pathname || "/admin";
const title = SECTION_TITLES[pathname] || "Admin";
return (
<span className="hidden xl:flex items-center gap-2 text-[14px] font-semibold text-foreground-light dark:text-foreground-dark">
<span className="font-medium text-foreground-light/50 dark:text-foreground-dark/50">
Admin
</span>
<ChevronRight
size={14}
className="text-foreground-light/30 dark:text-foreground-dark/30"
/>
<span>{title}</span>
</span>
);
}
+2
View File
@@ -2,6 +2,7 @@ import Row from "@/src/components/twui/layout/Row";
import HeaderUser from "../../main/(partials)/header-user";
import LogoIcon from "@/src/components/general/logo-icon";
import MobileNav from "../(partials)/MobileNav";
import HeaderPageTitle from "../(partials)/header-page-title";
import { twMerge } from "tailwind-merge";
type Props = {};
@@ -24,6 +25,7 @@ export default function Header({}: Props) {
img_props={{ className: "flex xl:hidden" }}
/>
<MobileNav />
<HeaderPageTitle />
</Row>
<Row className="flex-nowrap">
<HeaderUser />
@@ -0,0 +1,227 @@
export type DeltaTone = "positive" | "negative" | "neutral";
export type PeerStatus = "connected" | "idle" | "error";
export type DashboardPeer = {
id: string;
name: string;
tunnelIp: string;
endpoint: string;
sent: string;
received: string;
lastHandshake: string;
status: PeerStatus;
};
export type TrafficPoint = {
label: string;
up: number;
down: number;
};
export type ActivityKind = "peer" | "client" | "config" | "auth" | "system";
export type ActivityEvent = {
id: string;
kind: ActivityKind;
title: string;
detail: string;
time: string;
};
export type SecondaryKpi = {
id: string;
label: string;
value: string;
delta: string;
deltaTone: DeltaTone;
};
export const HERO_KPI = {
label: "Connected peers",
value: "42",
delta: "+6",
deltaTone: "positive" as DeltaTone,
subtext: "since yesterday",
sparkline: [
24, 26, 25, 28, 31, 30, 33, 36, 35, 38, 37, 39, 41, 40, 42, 44, 43, 41,
42, 40, 41, 42, 42, 42,
],
};
export const SECONDARY_KPIS: SecondaryKpi[] = [
{
id: "k-total",
label: "Total clients",
value: "54",
delta: "+3 this week",
deltaTone: "positive",
},
{
id: "k-traffic",
label: "Data transferred · 24h",
value: "61.4 GB",
delta: "+12.4%",
deltaTone: "positive",
},
{
id: "k-status",
label: "Server status",
value: "Operational",
delta: "Uptime 99.98%",
deltaTone: "neutral",
},
];
export const TRAFFIC_SERIES: TrafficPoint[] = [
{ label: "00", up: 0.3, down: 0.9 },
{ label: "01", up: 0.2, down: 0.7 },
{ label: "02", up: 0.2, down: 0.6 },
{ label: "03", up: 0.2, down: 0.5 },
{ label: "04", up: 0.3, down: 0.8 },
{ label: "05", up: 0.5, down: 1.2 },
{ label: "06", up: 0.8, down: 1.9 },
{ label: "07", up: 1.1, down: 2.6 },
{ label: "08", up: 1.3, down: 3.1 },
{ label: "09", up: 1.2, down: 2.9 },
{ label: "10", up: 1.1, down: 2.7 },
{ label: "11", up: 1.2, down: 2.8 },
{ label: "12", up: 1.4, down: 3.2 },
{ label: "13", up: 1.3, down: 3.0 },
{ label: "14", up: 1.2, down: 2.8 },
{ label: "15", up: 1.3, down: 3.1 },
{ label: "16", up: 1.5, down: 3.4 },
{ label: "17", up: 1.7, down: 3.8 },
{ label: "18", up: 1.8, down: 4.0 },
{ label: "19", up: 1.6, down: 3.6 },
{ label: "20", up: 1.4, down: 3.3 },
{ label: "21", up: 1.2, down: 2.9 },
{ label: "22", up: 0.9, down: 2.2 },
{ label: "23", up: 0.5, down: 1.4 },
];
export const PEERS: DashboardPeer[] = [
{
id: "p-01",
name: "MacBook Pro",
tunnelIp: "10.0.0.2",
endpoint: "81.2.69.142:51820",
sent: "12.4 GB",
received: "48.2 GB",
lastHandshake: "just now",
status: "connected",
},
{
id: "p-02",
name: "iPhone 15",
tunnelIp: "10.0.0.3",
endpoint: "92.28.211.234:51820",
sent: "3.1 GB",
received: "11.7 GB",
lastHandshake: "1m ago",
status: "connected",
},
{
id: "p-03",
name: "Home Server",
tunnelIp: "10.0.0.4",
endpoint: "10.0.0.4:51820",
sent: "220.8 GB",
received: "84.3 GB",
lastHandshake: "4m ago",
status: "connected",
},
{
id: "p-04",
name: "Office Desktop",
tunnelIp: "10.0.0.5",
endpoint: "77.111.247.28:51820",
sent: "18.9 GB",
received: "32.5 GB",
lastHandshake: "22m ago",
status: "connected",
},
{
id: "p-05",
name: "Galaxy S24",
tunnelIp: "10.0.0.6",
endpoint: "151.101.1.69:51820",
sent: "1.2 GB",
received: "6.8 GB",
lastHandshake: "1h ago",
status: "idle",
},
{
id: "p-06",
name: "iPad",
tunnelIp: "10.0.0.7",
endpoint: "89.187.168.36:51820",
sent: "0.9 GB",
received: "4.1 GB",
lastHandshake: "3h ago",
status: "idle",
},
{
id: "p-07",
name: "Old Laptop",
tunnelIp: "10.0.0.8",
endpoint: "192.168.1.23:51820",
sent: "0.0 GB",
received: "0.0 GB",
lastHandshake: "3d ago",
status: "error",
},
];
export const DISTRIBUTION: { label: string; value: number; tone: "success" | "warning" | "error" }[] = [
{ label: "Connected", value: 42, tone: "success" },
{ label: "Idle", value: 9, tone: "warning" },
{ label: "Error", value: 3, tone: "error" },
];
export const DISTRIBUTION_TOTAL = 54;
export const ACTIVITY: ActivityEvent[] = [
{
id: "a-01",
kind: "peer",
title: "MacBook Pro connected",
detail: "10.0.0.2 · handshake ok",
time: "2m ago",
},
{
id: "a-02",
kind: "client",
title: "New client added",
detail: "Galaxy S24 · 10.0.0.6",
time: "1h ago",
},
{
id: "a-03",
kind: "config",
title: "Config generated",
detail: "galaxy-s24.conf · sent to owner",
time: "1h ago",
},
{
id: "a-04",
kind: "auth",
title: "Admin sign-in",
detail: "[email protected]",
time: "3h ago",
},
{
id: "a-05",
kind: "system",
title: "Service restarted",
detail: "wg0 interface · took 0.8s",
time: "5h ago",
},
{
id: "a-06",
kind: "peer",
title: "Old Laptop handshake failed",
detail: "10.0.0.8 · key mismatch",
time: "3d ago",
},
];
@@ -0,0 +1,57 @@
import { twMerge } from "tailwind-merge";
import type { ActivityEvent } from "../(data)/dashboard-mock-data";
import type { LucideIcon } from "lucide-react";
import { Cable, FileDown, LogIn, RefreshCw, UserPlus } from "lucide-react";
type Props = {
event: ActivityEvent;
};
const KIND_META: Record<
ActivityEvent["kind"],
{ Icon: LucideIcon; className: string }
> = {
peer: {
Icon: Cable,
className: "text-secondary dark:text-secondary",
},
client: { Icon: UserPlus, className: "text-primary" },
config: {
Icon: FileDown,
className: "text-link dark:text-link-dark",
},
auth: {
Icon: LogIn,
className: "text-foreground-light/50 dark:text-foreground-dark/50",
},
system: { Icon: RefreshCw, className: "text-warning dark:text-warning" },
};
export default function ActivityRow({ event }: Props) {
const meta = KIND_META[event.kind];
return (
<li className="flex items-center gap-3 px-5 py-2.5">
<span
className={twMerge(
"w-7 h-7 rounded-[5px] flex items-center justify-center shrink-0",
"bg-foreground-light/5 dark:bg-foreground-dark/5",
meta.className,
)}
>
<meta.Icon size={14} />
</span>
<div className="min-w-0 grow">
<p className="text-[13.5px] font-medium text-foreground-light dark:text-foreground-dark truncate">
{event.title}
</p>
<p className="text-[12px] text-foreground-light/45 dark:text-foreground-dark/45 truncate">
{event.detail}
</p>
</div>
<span className="shrink-0 tabular text-[12px] text-foreground-light/40 dark:text-foreground-dark/40">
{event.time}
</span>
</li>
);
}
@@ -0,0 +1,38 @@
import { FileDown, Plus, RefreshCw } from "lucide-react";
import AdminButton from "@/src/components/general/admin-button";
import AdminCard from "@/src/components/general/admin-card";
import DistributionRow from "./distribution-row";
import { DISTRIBUTION, DISTRIBUTION_TOTAL } from "../(data)/dashboard-mock-data";
export default function AsidePanel() {
return (
<AdminCard tier="secondary" className="p-4 flex flex-col gap-5">
<div className="w-full">
<h2 className="text-[13.5px] font-medium text-foreground-light/60 dark:text-foreground-dark/60 mb-3">
Connection distribution
</h2>
<div className="flex flex-col gap-3">
{DISTRIBUTION.map((d) => (
<DistributionRow
key={d.label}
label={d.label}
value={d.value}
total={DISTRIBUTION_TOTAL}
tone={d.tone}
/>
))}
</div>
</div>
<div className="w-full pt-4 border-t border-slate-200 dark:border-white/10 flex flex-col gap-2">
<h2 className="text-[13.5px] font-medium text-foreground-light/60 dark:text-foreground-dark/60 mb-1">
Quick actions
</h2>
<AdminButton variant="primary" Icon={Plus}>
Add client
</AdminButton>
<AdminButton Icon={FileDown}>Generate config</AdminButton>
<AdminButton Icon={RefreshCw}>Restart service</AdminButton>
</div>
</AdminCard>
);
}
@@ -0,0 +1,56 @@
import { twMerge } from "tailwind-merge";
type Props = {
label: string;
value: number;
total: number;
tone: "success" | "warning" | "error";
};
const BAR_TONE: Record<Props["tone"], string> = {
success: "bg-success",
warning: "bg-warning",
error: "bg-error",
};
const TEXT_TONE: Record<Props["tone"], string> = {
success: "text-success dark:text-success",
warning: "text-warning dark:text-warning",
error: "text-error dark:text-error",
};
export default function DistributionRow({
label,
value,
total,
tone,
}: Props) {
const pct = Math.round((value / total) * 100);
return (
<div>
<div className="flex items-center justify-between mb-1">
<span className="text-[12.5px] text-foreground-light/60 dark:text-foreground-dark/60">
{label}
</span>
<span
className={twMerge(
"tabular text-[12.5px] font-medium",
TEXT_TONE[tone],
)}
>
{value}
</span>
</div>
<div className="w-full h-[3px] rounded-full bg-slate-200 dark:bg-white/10">
<div
className={twMerge(
"h-full rounded-full",
BAR_TONE[tone],
)}
style={{ width: `${pct}%` }}
/>
</div>
</div>
);
}
+39
View File
@@ -0,0 +1,39 @@
import StatusDot from "@/src/components/general/status-dot";
import type { DashboardPeer } from "../(data)/dashboard-mock-data";
type Props = {
peer: DashboardPeer;
};
export default function PeerRow({ peer }: Props) {
return (
<tr className="border-b border-slate-200/60 dark:border-white/5 last:border-b-0 hover:bg-foreground-light/[0.02] dark:hover:bg-foreground-dark/[0.02] transition-colors">
<td className="px-4 py-[9px]">
<div className="flex items-center gap-2.5">
<StatusDot status={peer.status} />
<span className="text-[13.5px] font-medium text-foreground-light dark:text-foreground-dark">
{peer.name}
</span>
</div>
</td>
<td className="px-4 py-[9px] tabular text-[13px] text-foreground-light/60 dark:text-foreground-dark/60 whitespace-nowrap">
{peer.tunnelIp}
</td>
<td className="px-4 py-[9px] tabular text-[13px] text-foreground-light/60 dark:text-foreground-dark/60 whitespace-nowrap">
{peer.endpoint}
</td>
<td className="px-4 py-[9px] text-right whitespace-nowrap">
<span className="tabular text-[13px] font-semibold text-foreground-light dark:text-foreground-dark">
↓ {peer.received}
</span>
<span className="tabular text-[12px] text-foreground-light/40 dark:text-foreground-dark/40">
{" "}
↑ {peer.sent}
</span>
</td>
<td className="px-4 py-[9px] tabular text-[12px] text-foreground-light/40 dark:text-foreground-dark/40 text-right whitespace-nowrap">
{peer.lastHandshake}
</td>
</tr>
);
}
+62
View File
@@ -0,0 +1,62 @@
import { twMerge } from "tailwind-merge";
type Props = {
data: number[];
width?: number;
height?: number;
className?: string;
};
export default function Sparkline({
data,
width = 120,
height = 44,
className,
}: Props) {
const min = Math.min(...data);
const max = Math.max(...data);
const range = max - min || 1;
const stepX = width / (data.length - 1);
const pad = 3;
const points: [number, number][] = data.map((value, index) => [
index * stepX,
height - pad - ((value - min) / range) * (height - pad * 2),
]);
const linePath = points
.map(
([x, y], index) =>
`${index == 0 ? "M" : "L"}${x.toFixed(2)},${y.toFixed(2)}`,
)
.join(" ");
const areaPath = `${linePath} L${width},${height} L0,${height} Z`;
const last = points[points.length - 1]!;
return (
<svg
width={width}
height={height}
viewBox={`0 0 ${width} ${height}`}
className={twMerge("overflow-visible", className)}
aria-hidden="true"
>
<path d={areaPath} fill="currentColor" opacity="0.08" />
<path
d={linePath}
fill="none"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
/>
<circle
cx={last[0]}
cy={last[1]}
r="2.5"
fill="currentColor"
/>
</svg>
);
}
+78
View File
@@ -0,0 +1,78 @@
import type { ReactNode } from "react";
import { twMerge } from "tailwind-merge";
import AdminCard from "@/src/components/general/admin-card";
import type { DeltaTone } from "../(data)/dashboard-mock-data";
type Props = {
label: string;
value: string;
delta?: string;
deltaTone?: DeltaTone;
subtext?: string;
trailing?: ReactNode;
tier?: "default" | "secondary";
valueClassName?: string;
};
const DELTA_TONE_CLASS: Record<DeltaTone, string> = {
positive: "text-success dark:text-success",
negative: "text-error dark:text-error",
neutral: "text-foreground-light/50 dark:text-foreground-dark/50",
};
export default function StatCard({
label,
value,
delta,
deltaTone = "neutral",
subtext,
trailing,
tier = "secondary",
valueClassName,
}: Props) {
return (
<AdminCard
tier={tier}
className="p-4 flex items-start justify-between gap-4"
>
<div className="flex flex-col items-start gap-1.5 min-w-0">
<span className="text-[11.5px] font-semibold uppercase tracking-[0.08em] text-foreground-light/50 dark:text-foreground-dark/50">
{label}
</span>
<span
className={twMerge(
"tabular text-[22px] font-semibold tracking-[-0.02em] leading-none",
"text-foreground-light dark:text-foreground-dark",
valueClassName,
)}
>
{value}
</span>
{delta || subtext ? (
<span
className={twMerge(
"text-[12.5px] font-medium tabular",
delta ? DELTA_TONE_CLASS[deltaTone] : undefined,
)}
>
{delta}
{delta && subtext ? (
<span className="text-foreground-light/40 dark:text-foreground-dark/40">
{" "}
· {subtext}
</span>
) : null}
{!delta && subtext ? (
<span className="text-foreground-light/40 dark:text-foreground-dark/40">
{subtext}
</span>
) : null}
</span>
) : null}
</div>
{trailing ? (
<div className="shrink-0 self-center">{trailing}</div>
) : null}
</AdminCard>
);
}
@@ -0,0 +1,130 @@
import { useMemo } from "react";
import type { TrafficPoint } from "../(data)/dashboard-mock-data";
type Props = {
data: TrafficPoint[];
};
const W = 800;
const H = 240;
const PAD_L = 46;
const PAD_R = 12;
const PAD_T = 12;
const PAD_B = 28;
function formatAxis(value: number) {
return value >= 1 ? `${value}G` : `${Math.round(value * 1000)}M`;
}
export default function TrafficChart({ data }: Props) {
const { max, downPath, downArea, upPath, yTicks, xTicks } = useMemo(() => {
const plotW = W - PAD_L - PAD_R;
const plotH = H - PAD_T - PAD_B;
const max = Math.ceil(
Math.max(...data.flatMap((p) => [p.up, p.down])),
);
const x = (i: number) => PAD_L + (i / (data.length - 1)) * plotW;
const y = (v: number) => PAD_T + (1 - v / max) * plotH;
const toPath = (key: "up" | "down") =>
data
.map(
(p, i) =>
`${i == 0 ? "M" : "L"}${x(i).toFixed(2)},${y(
p[key],
).toFixed(2)}`,
)
.join(" ");
const downPath = toPath("down");
const upPath = toPath("up");
const baseY = (H - PAD_B).toFixed(2);
const downArea = `${downPath} L${x(data.length - 1).toFixed(
2,
)},${baseY} L${PAD_L},${baseY} Z`;
const yTicks = Array.from({ length: 5 }, (_, i) => {
const v = (max / 4) * i;
return { v, label: formatAxis(v), y: y(v) };
});
const xTicks: { label: string; x: number }[] = [];
for (let i = 0; i < data.length; i += 4) {
xTicks.push({ label: data[i]!.label, x: x(i) });
}
return { max, downPath, downArea, upPath, yTicks, xTicks };
}, [data]);
return (
<svg
viewBox={`0 0 ${W} ${H}`}
className="w-full h-auto text-secondary"
role="img"
aria-label="Network traffic over the last 24 hours"
>
<defs>
<linearGradient id="traffic-down" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stopColor="currentColor" stopOpacity="0.14" />
<stop offset="100%" stopColor="currentColor" stopOpacity="0" />
</linearGradient>
<linearGradient id="traffic-up" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stopColor="#94a3b8" stopOpacity="0.1" />
<stop offset="100%" stopColor="#94a3b8" stopOpacity="0" />
</linearGradient>
</defs>
{yTicks.map((tick, i) => (
<g key={i}>
<line
x1={PAD_L}
y1={tick.y}
x2={W - PAD_R}
y2={tick.y}
stroke="#94a3b8"
strokeOpacity="0.18"
/>
<text
x={PAD_L - 8}
y={tick.y + 3}
textAnchor="end"
className="fill-current text-zinc-500 dark:text-zinc-600 text-[10.5px] tabular"
>
{tick.label}
</text>
</g>
))}
<path d={downArea} fill="url(#traffic-down)" />
<path
d={upPath}
fill="none"
stroke="#94a3b8"
strokeWidth="1.25"
strokeLinecap="round"
strokeLinejoin="round"
/>
<path
d={downPath}
fill="none"
stroke="currentColor"
strokeWidth="1.75"
strokeLinecap="round"
strokeLinejoin="round"
/>
{xTicks.map((tick, i) => (
<text
key={i}
x={tick.x}
y={H - PAD_B + 16}
textAnchor="middle"
className="fill-current text-zinc-500 dark:text-zinc-600 text-[10.5px] tabular"
>
{tick.label}
</text>
))}
</svg>
);
}
@@ -0,0 +1,20 @@
import AdminCard from "@/src/components/general/admin-card";
import ActivityRow from "../(partials)/activity-row";
import { ACTIVITY } from "../(data)/dashboard-mock-data";
export default function ActivitySection() {
return (
<AdminCard tier="secondary" className="w-full">
<div className="px-5 pt-4 pb-1">
<h2 className="text-[13.5px] font-medium text-foreground-light/60 dark:text-foreground-dark/60">
Recent activity
</h2>
</div>
<ul className="divide-y divide-slate-200/60 dark:divide-white/5">
{ACTIVITY.map((event) => (
<ActivityRow key={event.id} event={event} />
))}
</ul>
</AdminCard>
);
}
@@ -0,0 +1,23 @@
import StatCard from "../(partials)/stat-card";
import Sparkline from "../(partials)/sparkline";
import { HERO_KPI } from "../(data)/dashboard-mock-data";
export default function KpiHeroSection() {
return (
<StatCard
tier="default"
label={HERO_KPI.label}
value={HERO_KPI.value}
delta={HERO_KPI.delta}
deltaTone={HERO_KPI.deltaTone}
subtext={HERO_KPI.subtext}
valueClassName="text-4xl"
trailing={
<Sparkline
data={HERO_KPI.sparkline}
className="text-secondary dark:text-secondary"
/>
}
/>
);
}
@@ -0,0 +1,18 @@
import StatCard from "../(partials)/stat-card";
import { SECONDARY_KPIS } from "../(data)/dashboard-mock-data";
export default function KpiSecondarySection() {
return (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3 w-full">
{SECONDARY_KPIS.map((kpi) => (
<StatCard
key={kpi.id}
label={kpi.label}
value={kpi.value}
delta={kpi.delta}
deltaTone={kpi.deltaTone}
/>
))}
</div>
);
}
@@ -0,0 +1,52 @@
import { ArrowUpRight } from "lucide-react";
import Link from "@/src/components/twui/layout/Link";
import AdminCard from "@/src/components/general/admin-card";
import PeerRow from "../(partials)/peer-row";
import AsidePanel from "../(partials)/aside-panel";
import { PEERS } from "../(data)/dashboard-mock-data";
const thClass =
"px-4 py-2 text-left text-[11px] font-semibold uppercase tracking-[0.08em] " +
"text-foreground-light/40 dark:text-foreground-dark/40 whitespace-nowrap";
const thRightClass = `${thClass} text-right`;
export default function PeersTableSection() {
return (
<div className="grid grid-cols-1 lg:grid-cols-[1fr_260px] gap-4 w-full items-start">
<AdminCard className="overflow-hidden">
<div className="flex items-center justify-between px-5 h-11">
<h2 className="text-[13.5px] font-medium text-foreground-light/60 dark:text-foreground-dark/60">
Recent peers
</h2>
<Link
href="/admin/clients"
className="inline-flex items-center gap-0.5 no-underline! border-b-0! text-[12.5px] text-foreground-light/50 dark:text-foreground-dark/50 hover:text-foreground-light dark:hover:text-foreground-dark transition-colors"
>
View all
<ArrowUpRight size={12} className="-mt-0.5" />
</Link>
</div>
<div className="overflow-x-auto">
<table className="w-full min-w-[680px]">
<thead>
<tr className="border-y border-slate-200 dark:border-white/10 bg-foreground-light/[0.02] dark:bg-foreground-dark/[0.03]">
<th className={thClass}>Peer</th>
<th className={thClass}>Tunnel IP</th>
<th className={thClass}>Endpoint</th>
<th className={thRightClass}>Traffic</th>
<th className={thRightClass}>Last handshake</th>
</tr>
</thead>
<tbody>
{PEERS.map((peer) => (
<PeerRow key={peer.id} peer={peer} />
))}
</tbody>
</table>
</div>
</AdminCard>
<AsidePanel />
</div>
);
}
@@ -0,0 +1,78 @@
import { useMemo } from "react";
import AdminCard from "@/src/components/general/admin-card";
import TrafficChart from "../(partials)/traffic-chart";
import { TRAFFIC_SERIES } from "../(data)/dashboard-mock-data";
function formatGb(value: number) {
return `${value.toFixed(1)} GB`;
}
export default function TrafficChartSection() {
const footer = useMemo(() => {
const maxDown = Math.max(...TRAFFIC_SERIES.map((p) => p.down));
const maxUp = Math.max(...TRAFFIC_SERIES.map((p) => p.up));
const avgDown =
TRAFFIC_SERIES.reduce((sum, p) => sum + p.down, 0) /
TRAFFIC_SERIES.length;
const total =
TRAFFIC_SERIES.reduce((sum, p) => sum + p.down + p.up, 0);
return {
maxDown: formatGb(maxDown),
maxUp: formatGb(maxUp),
avgDown: formatGb(avgDown),
total: formatGb(total),
};
}, []);
const stats = [
{ label: "Peak download", value: footer.maxDown },
{ label: "Peak upload", value: footer.maxUp },
{ label: "Avg download", value: footer.avgDown },
{ label: "Total · 24h", value: footer.total },
];
return (
<AdminCard className="w-full">
<div className="flex items-center justify-between px-5 pt-4 flex-wrap gap-2">
<div>
<h2 className="text-[13.5px] font-medium text-foreground-light/60 dark:text-foreground-dark/60">
Network traffic
</h2>
<p className="text-[12px] text-foreground-light/40 dark:text-foreground-dark/40">
Last 24 hours
</p>
</div>
<div className="flex items-center gap-4">
<span className="inline-flex items-center gap-1.5 text-[12px] text-foreground-light/60 dark:text-foreground-dark/60">
<span className="w-[7px] h-[7px] rounded-full bg-secondary" />
Download
</span>
<span className="inline-flex items-center gap-1.5 text-[12px] text-foreground-light/60 dark:text-foreground-dark/60">
<span className="w-[7px] h-[7px] rounded-full bg-slate-400" />
Upload
</span>
</div>
</div>
<div className="px-5 pt-3 pb-1">
<TrafficChart data={TRAFFIC_SERIES} />
</div>
<div className="mx-5 mt-3 mb-4 pt-3 border-t border-slate-200 dark:border-white/10 flex items-center gap-5">
{stats.map((stat, i) => (
<div key={stat.label} className="flex items-center gap-5">
{i > 0 ? (
<span className="w-px h-4 bg-slate-200 dark:bg-white/10" />
) : null}
<div className="flex items-baseline gap-1.5">
<span className="text-[11.5px] text-foreground-light/45 dark:text-foreground-dark/45">
{stat.label}
</span>
<span className="tabular text-[13px] font-medium text-foreground-light/80 dark:text-foreground-dark/80">
{stat.value}
</span>
</div>
</div>
))}
</div>
</AdminCard>
);
}
@@ -0,0 +1,122 @@
import type { WgStatus } from "@/src/components/general/status-dot";
export type ClientRecord = {
id: string;
name: string;
tunnelIp: string;
allowedIps: string;
publicKey: string;
endpoint: string;
sent: string;
received: string;
lastHandshake: string;
status: WgStatus;
createdAt: string;
};
export const CLIENTS: ClientRecord[] = [
{
id: "c-01",
name: "MacBook Pro",
tunnelIp: "10.0.0.2",
allowedIps: "10.0.0.2/32",
publicKey: "gQ1k4Lv9MxWn7Vp2RzHsTb8FcJdKqY3eWa",
endpoint: "81.2.69.142:51820",
sent: "12.4 GB",
received: "48.2 GB",
lastHandshake: "just now",
status: "connected",
createdAt: "Jan 12, 2026",
},
{
id: "c-02",
name: "iPhone 15",
tunnelIp: "10.0.0.3",
allowedIps: "10.0.0.3/32",
publicKey: "aZ9xN4cM6vBq2wEs8rT7yU1iO5pLkDfGhJ",
endpoint: "92.28.211.234:51820",
sent: "3.1 GB",
received: "11.7 GB",
lastHandshake: "1m ago",
status: "connected",
createdAt: "Feb 3, 2026",
},
{
id: "c-03",
name: "Home Server",
tunnelIp: "10.0.0.4",
allowedIps: "10.0.0.4/32, 10.0.0.0/24",
publicKey: "qW5eRt7yU8iO9pL0kM2nB3vC4xD5fG6hJ7k",
endpoint: "10.0.0.4:51820",
sent: "220.8 GB",
received: "84.3 GB",
lastHandshake: "4m ago",
status: "connected",
createdAt: "Nov 21, 2025",
},
{
id: "c-04",
name: "Office Desktop",
tunnelIp: "10.0.0.5",
allowedIps: "10.0.0.5/32",
publicKey: "zC1vB2nM3kL4jH5gF6dS7aA8sD9fG1hJ2kL",
endpoint: "77.111.247.28:51820",
sent: "18.9 GB",
received: "32.5 GB",
lastHandshake: "22m ago",
status: "connected",
createdAt: "Dec 9, 2025",
},
{
id: "c-05",
name: "Galaxy S24",
tunnelIp: "10.0.0.6",
allowedIps: "10.0.0.6/32",
publicKey: "pL0kM2nB3vC4xD5fG6hJ7kQ8wE9rT1yU2iO",
endpoint: "151.101.1.69:51820",
sent: "1.2 GB",
received: "6.8 GB",
lastHandshake: "1h ago",
status: "idle",
createdAt: "Feb 20, 2026",
},
{
id: "c-06",
name: "iPad",
tunnelIp: "10.0.0.7",
allowedIps: "10.0.0.7/32",
publicKey: "vB2nM3kL4jH5gF6dS7aA8sD9fG1hJ2kLqW",
endpoint: "89.187.168.36:51820",
sent: "0.9 GB",
received: "4.1 GB",
lastHandshake: "3h ago",
status: "idle",
createdAt: "Mar 2, 2026",
},
{
id: "c-07",
name: "Old Laptop",
tunnelIp: "10.0.0.8",
allowedIps: "10.0.0.8/32",
publicKey: "nM3kL4jH5gF6dS7aA8sD9fG1hJ2kLqW5eR",
endpoint: "192.168.1.23:51820",
sent: "0.0 GB",
received: "0.0 GB",
lastHandshake: "3d ago",
status: "error",
createdAt: "Aug 17, 2025",
},
{
id: "c-08",
name: "Travel Router",
tunnelIp: "10.0.0.9",
allowedIps: "10.0.0.9/32",
publicKey: "bV3cX4dZ5eA6sD7fG8hJ9kQ0wE1rT2yU3iO",
endpoint: "203.0.113.19:51820",
sent: "6.7 GB",
received: "21.9 GB",
lastHandshake: "2h ago",
status: "connected",
createdAt: "Jan 30, 2026",
},
];
@@ -0,0 +1,108 @@
import type { Dispatch, ReactNode, SetStateAction } from "react";
import { X } from "lucide-react";
import Modal from "@/src/components/twui/elements/Modal";
import Input from "@/src/components/twui/form/Input";
import AdminButton from "@/src/components/general/admin-button";
type Props = {
open: boolean;
setOpen: Dispatch<SetStateAction<boolean>>;
};
type FormFieldProps = {
label: string;
htmlFor: string;
children: ReactNode;
};
function FormField({ label, htmlFor, children }: FormFieldProps) {
return (
<div className="flex flex-col gap-1.5">
<label
htmlFor={htmlFor}
className="text-[11.5px] font-semibold uppercase tracking-[0.08em] text-foreground-light/50 dark:text-foreground-dark/50"
>
{label}
</label>
{children}
</div>
);
}
export default function ClientFormModal({ open, setOpen }: Props) {
return (
<Modal
open={open}
setOpen={setOpen}
no_cancel_button
className="p-6"
>
<div className="flex items-start justify-between gap-4 mb-6">
<div>
<h3 className="text-lg font-bold text-foreground-light dark:text-foreground-dark">
Add client
</h3>
<p className="text-xs text-foreground-light/50 dark:text-foreground-dark/50 mt-1">
A WireGuard config will be generated on save
</p>
</div>
<button
type="button"
aria-label="Close"
onClick={() => setOpen(false)}
className="p-1 cursor-pointer text-foreground-light/60 dark:text-foreground-dark/60 hover:text-foreground-light dark:hover:text-foreground-dark transition-colors"
>
<X size={18} />
</button>
</div>
<form
className="flex flex-col gap-4"
onSubmit={(e) => {
e.preventDefault();
setOpen(false);
}}
>
<FormField label="Client name" htmlFor="client_name">
<Input
name="client_name"
id="client_name"
placeholder="e.g. MacBook Pro"
required
/>
</FormField>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<FormField label="Tunnel IP" htmlFor="tunnel_ip">
<Input
name="tunnel_ip"
id="tunnel_ip"
placeholder="10.0.0.10 · auto-assigned"
/>
</FormField>
<FormField label="Allowed IPs" htmlFor="allowed_ips">
<Input
name="allowed_ips"
id="allowed_ips"
placeholder="10.0.0.10/32"
/>
</FormField>
</div>
<FormField label="Notes" htmlFor="client_notes">
<Input
name="client_notes"
id="client_notes"
istextarea
placeholder="Optional description for this client"
/>
</FormField>
<div className="flex justify-end gap-2 mt-2">
<AdminButton onClick={() => setOpen(false)}>
Cancel
</AdminButton>
<AdminButton variant="primary" type="submit">
Add client
</AdminButton>
</div>
</form>
</Modal>
);
}
@@ -0,0 +1,47 @@
import StatusDot from "@/src/components/general/status-dot";
import type { ClientRecord } from "../(data)/clients-mock-data";
type Props = {
client: ClientRecord;
};
export default function ClientRow({ client }: Props) {
return (
<tr className="border-b border-slate-200/60 dark:border-white/5 last:border-b-0 hover:bg-foreground-light/[0.02] dark:hover:bg-foreground-dark/[0.02] transition-colors">
<td className="px-4 py-[9px]">
<div className="flex items-center gap-2.5">
<StatusDot status={client.status} />
<span className="text-[13.5px] font-medium text-foreground-light dark:text-foreground-dark">
{client.name}
</span>
</div>
</td>
<td className="px-4 py-[9px] tabular text-[13px] text-foreground-light/60 dark:text-foreground-dark/60 whitespace-nowrap">
{client.tunnelIp}
</td>
<td className="px-4 py-[9px] tabular text-[13px] text-foreground-light/45 dark:text-foreground-dark/45 whitespace-nowrap">
{client.allowedIps}
</td>
<td className="px-4 py-[9px]">
<span className="font-mono text-[12px] text-foreground-light/45 dark:text-foreground-dark/45 whitespace-nowrap">
{client.publicKey.slice(0, 16)}…
</span>
</td>
<td className="px-4 py-[9px] text-right whitespace-nowrap">
<span className="tabular text-[13px] font-semibold text-foreground-light dark:text-foreground-dark">
↓ {client.received}
</span>
<span className="tabular text-[12px] text-foreground-light/40 dark:text-foreground-dark/40">
{" "}
↑ {client.sent}
</span>
</td>
<td className="px-4 py-[9px] tabular text-[12px] text-foreground-light/40 dark:text-foreground-dark/40 text-right whitespace-nowrap">
{client.lastHandshake}
</td>
<td className="px-4 py-[9px] tabular text-[12px] text-foreground-light/40 dark:text-foreground-dark/40 text-right whitespace-nowrap">
{client.createdAt}
</td>
</tr>
);
}
@@ -0,0 +1,88 @@
import { useMemo, useState, type Dispatch, type SetStateAction } from "react";
import Search from "@/src/components/twui/elements/Search";
import AdminCard from "@/src/components/general/admin-card";
import ClientRow from "../(partials)/client-row";
import ClientFormModal from "../(partials)/client-form-modal";
import { CLIENTS } from "../(data)/clients-mock-data";
type Props = {
addOpen: boolean;
setAddOpen: Dispatch<SetStateAction<boolean>>;
};
const thClass =
"px-4 py-2 text-left text-[11px] font-semibold uppercase tracking-[0.08em] " +
"text-foreground-light/40 dark:text-foreground-dark/40 whitespace-nowrap";
const thRightClass = `${thClass} text-right`;
export default function ClientsTableSection({ addOpen, setAddOpen }: Props) {
const [query, setQuery] = useState("");
const filtered = useMemo(() => {
const q = query.trim().toLowerCase();
if (!q) return CLIENTS;
return CLIENTS.filter((client) =>
[client.name, client.tunnelIp, client.allowedIps].some((value) =>
value.toLowerCase().includes(q),
),
);
}, [query]);
return (
<AdminCard className="w-full overflow-hidden">
<div className="flex items-center justify-between gap-3 px-5 h-12 flex-wrap">
<h2 className="text-[13.5px] font-medium text-foreground-light/60 dark:text-foreground-dark/60">
Clients
<span className="tabular text-foreground-light/40 dark:text-foreground-dark/40 ml-2">
{filtered.length}
</span>
</h2>
<Search
no_search_button
placeholder="Search clients…"
changeHandler={(value) => setQuery(value || "")}
inputProps={{
className: "!text-[13.5px]",
wrapperProps: {
className:
"!py-[5px] !min-h-[30px] w-[220px] bg-foreground-light/[0.02] dark:bg-foreground-dark/[0.03]",
},
}}
/>
</div>
{filtered.length ? (
<div className="overflow-x-auto">
<table className="w-full min-w-[860px]">
<thead>
<tr className="border-y border-slate-200 dark:border-white/10 bg-foreground-light/[0.02] dark:bg-foreground-dark/[0.03]">
<th className={thClass}>Client</th>
<th className={thClass}>Tunnel IP</th>
<th className={thClass}>Allowed IPs</th>
<th className={thClass}>Public key</th>
<th className={thRightClass}>Traffic</th>
<th className={thRightClass}>Last handshake</th>
<th className={thRightClass}>Created</th>
</tr>
</thead>
<tbody>
{filtered.map((client) => (
<ClientRow key={client.id} client={client} />
))}
</tbody>
</table>
</div>
) : (
<div className="flex flex-col items-center justify-center gap-1 py-14 px-6 text-center">
<span className="text-[13.5px] font-medium text-foreground-light/50 dark:text-foreground-dark/50">
No clients found
</span>
<span className="text-[12.5px] text-foreground-light/35 dark:text-foreground-dark/35">
Try adjusting your search terms
</span>
</div>
)}
<ClientFormModal open={addOpen} setOpen={setAddOpen} />
</AdminCard>
);
}
+41
View File
@@ -0,0 +1,41 @@
import { useState } from "react";
import { Plus } from "lucide-react";
import type { BunextPageModuleMeta } from "@moduletrace/bunext/types";
import AdminHero from "@/src/components/general/admin-hero";
import AdminButton from "@/src/components/general/admin-button";
import Stack from "@/src/components/twui/layout/Stack";
import { SiteData } from "@/src/data/site-data";
import ClientsTableSection from "./(sections)/clients-table-section";
export default function AdminClientsPage() {
const [addOpen, setAddOpen] = useState(false);
return (
<>
<AdminHero
title="Clients"
description="Manage WireGuard peers and their configurations"
buttons={
<AdminButton
variant="primary"
Icon={Plus}
onClick={() => setAddOpen(true)}
>
Add client
</AdminButton>
}
/>
<Stack className="w-full px-6 pb-8 gap-5 items-stretch">
<ClientsTableSection
addOpen={addOpen}
setAddOpen={setAddOpen}
/>
</Stack>
</>
);
}
export const meta: BunextPageModuleMeta = {
title: `Clients | ${SiteData["SiteName"]}`,
description: `Manage WireGuard clients`,
};
@@ -0,0 +1,33 @@
export type InterfaceConfigRow = {
keyName: string;
value: string;
};
export const HOST_STATUS = {
state: "running" as const,
version: "v0.2.0",
uptime: "37d 14h 22m",
activePeers: 42,
handshakesPerSec: 0.4,
rxBytes: "1.2 TB",
txBytes: "420 GB",
};
export const HOST_INTERFACE = {
name: "wg0",
address: "10.0.0.1/24",
listenPort: 51820,
mtu: 1420,
dns: "1.1.1.1, 1.0.0.1",
publicKey: "sH8pZ0vN3mQ6wE9rT2yU5iO8pL1kM4nB7vC0xD3fG6hJ9kL",
endpoint: "203.0.113.10:51820",
configPath: "/etc/wireguard/wg0.conf",
};
export const INTERFACE_CONFIG: InterfaceConfigRow[] = [
{ keyName: "Address", value: HOST_INTERFACE.address },
{ keyName: "ListenPort", value: String(HOST_INTERFACE.listenPort) },
{ keyName: "PrivateKey", value: "•••••••••••• (redacted)" },
{ keyName: "MTU", value: String(HOST_INTERFACE.mtu) },
{ keyName: "DNS", value: HOST_INTERFACE.dns },
];
@@ -0,0 +1,17 @@
type Props = {
label: string;
value: string;
};
export default function HostStatCell({ label, value }: Props) {
return (
<div className="flex flex-col gap-1">
<span className="text-[11.5px] font-semibold uppercase tracking-[0.08em] text-foreground-light/40 dark:text-foreground-dark/40">
{label}
</span>
<span className="tabular text-[15px] font-semibold text-foreground-light/85 dark:text-foreground-dark/85">
{value}
</span>
</div>
);
}
@@ -0,0 +1,17 @@
type Props = {
keyName: string;
value: string;
};
export default function InterfaceConfigRow({ keyName, value }: Props) {
return (
<div className="flex items-center justify-between gap-4 px-5 py-2.5 border-t border-slate-200/60 dark:border-white/5">
<span className="font-mono text-[12.5px] text-foreground-light/45 dark:text-foreground-dark/45 whitespace-nowrap">
{keyName} =
</span>
<span className="font-mono text-[12.5px] text-foreground-light/70 dark:text-foreground-dark/70 text-right break-all">
{value}
</span>
</div>
);
}
@@ -0,0 +1,46 @@
import AdminCard from "@/src/components/general/admin-card";
import HostStatCell from "../(partials)/host-stat-cell";
import {
HOST_INTERFACE,
HOST_STATUS,
} from "../(data)/host-mock-data";
const STATS = [
{ label: "Uptime", value: HOST_STATUS.uptime },
{ label: "Active peers", value: String(HOST_STATUS.activePeers) },
{ label: "Handshakes / s", value: HOST_STATUS.handshakesPerSec.toFixed(1) },
{ label: "Received", value: HOST_STATUS.rxBytes },
{ label: "Transmitted", value: HOST_STATUS.txBytes },
{ label: "Version", value: HOST_STATUS.version },
];
export default function HostStatusSection() {
return (
<AdminCard className="w-full p-5 flex flex-col gap-5">
<div className="flex items-center justify-between flex-wrap gap-3">
<div className="flex items-center gap-3">
<span className="relative flex w-2.5 h-2.5">
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-success opacity-40" />
<span className="relative inline-flex rounded-full w-2.5 h-2.5 bg-success" />
</span>
<div>
<p className="text-[14px] font-semibold text-foreground-light dark:text-foreground-dark leading-none">
Running
</p>
<p className="text-[12.5px] text-foreground-light/45 dark:text-foreground-dark/45 mt-1">
{HOST_INTERFACE.name} · {HOST_INTERFACE.endpoint}
</p>
</div>
</div>
<span className="tabular text-[12px] text-foreground-light/40 dark:text-foreground-dark/40">
wg-quick status · v{HOST_STATUS.version}
</span>
</div>
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-6 gap-x-4 gap-y-5 pt-4 border-t border-slate-200 dark:border-white/10">
{STATS.map((stat) => (
<HostStatCell key={stat.label} label={stat.label} value={stat.value} />
))}
</div>
</AdminCard>
);
}
@@ -0,0 +1,60 @@
import { Copy } from "lucide-react";
import AdminCard from "@/src/components/general/admin-card";
import AdminButton from "@/src/components/general/admin-button";
import InterfaceConfigRow from "../(partials)/interface-config-row";
import {
HOST_INTERFACE,
INTERFACE_CONFIG,
} from "../(data)/host-mock-data";
function buildConfigText() {
const lines = ["[Interface]"];
for (const row of INTERFACE_CONFIG) {
lines.push(`${row.keyName} = ${row.value}`);
}
lines.push("");
lines.push(`# Public key: ${HOST_INTERFACE.publicKey}`);
return lines.join("\n");
}
export default function InterfaceConfigSection() {
return (
<AdminCard className="w-full overflow-hidden">
<div className="flex items-center justify-between gap-3 px-5 h-12 flex-wrap">
<div>
<h2 className="text-[13.5px] font-medium text-foreground-light/60 dark:text-foreground-dark/60">
Interface configuration
</h2>
<p className="font-mono text-[11.5px] text-foreground-light/40 dark:text-foreground-dark/40">
{HOST_INTERFACE.configPath}
</p>
</div>
<AdminButton
Icon={Copy}
onClick={() =>
navigator.clipboard?.writeText(buildConfigText())
}
>
Copy config
</AdminButton>
</div>
<div className="border-t border-slate-200 dark:border-white/10">
<div className="px-5 py-3 font-mono text-[12.5px] font-semibold text-secondary dark:text-secondary">
[Interface]
</div>
{INTERFACE_CONFIG.map((row) => (
<InterfaceConfigRow
key={row.keyName}
keyName={row.keyName}
value={row.value}
/>
))}
<div className="px-5 py-2.5 border-t border-slate-200/60 dark:border-white/5">
<span className="font-mono text-[12px] text-foreground-light/35 dark:text-foreground-dark/35">
# Public key · {HOST_INTERFACE.publicKey.slice(0, 24)}…
</span>
</div>
</div>
</AdminCard>
);
}
+33
View File
@@ -0,0 +1,33 @@
import { RefreshCw } from "lucide-react";
import type { BunextPageModuleMeta } from "@moduletrace/bunext/types";
import AdminHero from "@/src/components/general/admin-hero";
import AdminButton from "@/src/components/general/admin-button";
import Stack from "@/src/components/twui/layout/Stack";
import { SiteData } from "@/src/data/site-data";
import HostStatusSection from "./(sections)/host-status-section";
import InterfaceConfigSection from "./(sections)/interface-config-section";
export default function AdminHostPage() {
return (
<>
<AdminHero
title="Host"
description="WireGuard server status and interface configuration"
buttons={
<AdminButton Icon={RefreshCw}>
Restart service
</AdminButton>
}
/>
<Stack className="w-full px-6 pb-8 gap-5 items-stretch">
<HostStatusSection />
<InterfaceConfigSection />
</Stack>
</>
);
}
export const meta: BunextPageModuleMeta = {
title: `Host | ${SiteData["SiteName"]}`,
description: `WireGuard host configuration`,
};
+17 -1
View File
@@ -1,11 +1,27 @@
import type { BunextPageModuleMeta } from "@moduletrace/bunext/types";
import AdminHero from "@/src/components/general/admin-hero";
import Stack from "@/src/components/twui/layout/Stack";
import { SiteData } from "@/src/data/site-data";
import KpiHeroSection from "./(sections)/kpi-hero-section";
import KpiSecondarySection from "./(sections)/kpi-secondary-section";
import TrafficChartSection from "./(sections)/traffic-chart-section";
import PeersTableSection from "./(sections)/peers-table-section";
import ActivitySection from "./(sections)/activity-section";
export default function AdminDashboardPage() {
return (
<>
<AdminHero title="Dashboard" />
<AdminHero
title="Dashboard"
description="Overview of your WireGuard network"
/>
<Stack className="w-full px-6 pb-8 gap-5 items-stretch">
<KpiHeroSection />
<KpiSecondarySection />
<TrafficChartSection />
<PeersTableSection />
<ActivitySection />
</Stack>
</>
);
}
+71 -2
View File
@@ -7,5 +7,74 @@
# This script will run as root, to have full access needed
# to setup wireguard
# This script will also be idempotent, so that it doesn't
# have to install repeatedly if the required tools and
# packages are alread installed.
# have to install repeatedly if the required tools and
# packages are alread installed.
set -euo pipefail
REQUIRED_TOOLS=(wg wg-quick)
HAVE_ALL_TOOLS=true
for tool in "${REQUIRED_TOOLS[@]}"; do
if ! command -v "$tool" >/dev/null 2>&1; then
HAVE_ALL_TOOLS=false
break
fi
done
if [ "$HAVE_ALL_TOOLS" = true ]; then
echo "wireguard tools already installed — nothing to do."
exit 0
fi
if [ "$(id -u)" -ne 0 ]; then
echo "error: this script must be run as root" >&2
exit 1
fi
detect_distro() {
if [ -f /etc/os-release ]; then
# shellcheck disable=SC1091
. /etc/os-release
echo "$ID"
else
echo "unknown"
fi
}
DISTRO="$(detect_distro)"
case "$DISTRO" in
debian | ubuntu | linuxmint | raspbian)
echo "detected Debian-family distro: $DISTRO"
export DEBIAN_FRONTEND=noninteractive
apt-get update -y
apt-get install -y wireguard
;;
fedora | rhel | centos | rocky | almalinux)
echo "detected Fedora-family distro: $DISTRO"
dnf install -y wireguard-tools
;;
arch | manjaro | endeavouros)
echo "detected Arch-family distro: $DISTRO"
pacman -Syu --noconfirm --needed wireguard-tools
;;
*)
echo "error: unsupported distro: $DISTRO" >&2
echo "install wireguard manually (kernel module + wg/wg-quick tools)" >&2
exit 1
;;
esac
for tool in "${REQUIRED_TOOLS[@]}"; do
if ! command -v "$tool" >/dev/null 2>&1; then
echo "error: '$tool' still missing after install" >&2
exit 1
fi
done
mkdir -p /etc/wireguard
chmod 700 /etc/wireguard
echo "wireguard setup complete."
wg --version
+14 -1
View File
@@ -57,6 +57,19 @@
body {
font-family: "Inter", Arial, Helvetica, sans-serif;
font-optical-sizing: auto;
font-feature-settings:
"cv02" 1,
"cv03" 1,
"cv11" 1;
}
.tabular {
font-variant-numeric: tabular-nums;
font-feature-settings:
"tnum" 1,
"cv02" 1,
"cv03" 1,
"cv11" 1;
}
h1,
@@ -165,7 +178,7 @@ header nav a:hover > * {
}
.twui-anchor {
@apply text-[#1f62f1];
@apply text-secondary;
}
.twui-anchor.active {
+18
View File
@@ -29,6 +29,15 @@ export default function grabDirNames(params?: Params) {
? path.join(MEDIA_RELATIVE_DIR, String(params.user.id), "private")
: undefined;
const WGUI_LIB_DIR = `/var/lib/wgui`;
const WGUI_LIB_IP_TABLES_DIR = path.join(WGUI_LIB_DIR, `iptables`);
const WGUI_LIB_KEYS_DIR = path.join(WGUI_LIB_DIR, `keys`);
const WGUI_LIB_CLIENTS_CONFIGS_DIR = path.join(WGUI_LIB_DIR, `clients`);
const WIREGUARD_HOST_CONFIG_DIR = `/etc/wireguard`;
const WIREGUARD_PRIVATE_KEY_FILE_NAME = `private.key`;
const WIREGUARD_PUBLIC_KEY_FILE_NAME = `public.key`;
return {
ROOT_DIR,
MEDIA_DIR,
@@ -37,5 +46,14 @@ export default function grabDirNames(params?: Params) {
DATA_DIR,
USER_MEDIA_PUBLIC_RELATIVE_DIR,
USER_MEDIA_PRIVATE_RELATIVE_DIR,
WGUI_LIB_DIR,
WGUI_LIB_IP_TABLES_DIR,
WGUI_LIB_KEYS_DIR,
WGUI_LIB_CLIENTS_CONFIGS_DIR,
WIREGUARD_HOST_CONFIG_DIR,
WIREGUARD_PRIVATE_KEY_FILE_NAME,
WIREGUARD_PUBLIC_KEY_FILE_NAME,
};
}