`-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.
diff --git a/db/schema.ts b/db/schema.ts
index b8cdc72..daa4779 100644
--- a/db/schema.ts
+++ b/db/schema.ts
@@ -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",
},
],
diff --git a/db/types/db.ts b/db/types/db.ts
index d149d5f..3eb1053 100644
--- a/db/types/db.ts
+++ b/db/types/db.ts
@@ -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
\ No newline at end of file
+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
\ No newline at end of file
diff --git a/db/wgui b/db/wgui
index 96680d7..733412a 100644
Binary files a/db/wgui and b/db/wgui differ
diff --git a/src/components/general/admin-button.tsx b/src/components/general/admin-button.tsx
new file mode 100644
index 0000000..34b4f2a
--- /dev/null
+++ b/src/components/general/admin-button.tsx
@@ -0,0 +1,40 @@
+import type { ButtonHTMLAttributes } from "react";
+import type { LucideIcon } from "lucide-react";
+import { twMerge } from "tailwind-merge";
+
+type Props = ButtonHTMLAttributes
& {
+ 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 (
+
+ {Icon ? : null}
+ {children}
+
+ );
+}
diff --git a/src/components/general/admin-card.tsx b/src/components/general/admin-card.tsx
new file mode 100644
index 0000000..30ede84
--- /dev/null
+++ b/src/components/general/admin-card.tsx
@@ -0,0 +1,25 @@
+import type { HTMLAttributes } from "react";
+import { twMerge } from "tailwind-merge";
+
+type Props = HTMLAttributes & {
+ tier?: "default" | "secondary";
+};
+
+export default function AdminCard({
+ tier = "default",
+ className,
+ ...props
+}: Props) {
+ return (
+
+ );
+}
diff --git a/src/components/general/status-dot.tsx b/src/components/general/status-dot.tsx
new file mode 100644
index 0000000..c18d3c8
--- /dev/null
+++ b/src/components/general/status-dot.tsx
@@ -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 (
+
+
+ {showLabel ? (
+
+ {meta.label}
+
+ ) : null}
+
+ );
+}
diff --git a/src/data/app-data.ts b/src/data/app-data.ts
index 81abc2c..313f5e8 100644
--- a/src/data/app-data.ts
+++ b/src/data/app-data.ts
@@ -16,4 +16,6 @@ export const AppData = {
ImageQuality: 85,
ImageThumbnailQuality: 70,
PaystackEndpoint: "https://api.paystack.co",
+
+ WireguardHostID: 0,
} as const;
diff --git a/src/dict/variables-dict.ts b/src/dict/variables-dict.ts
new file mode 100644
index 0000000..9095e88
--- /dev/null
+++ b/src/dict/variables-dict.ts
@@ -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;
diff --git a/src/functions/backend/setup/grab-host-network-interface.ts b/src/functions/backend/setup/grab-host-network-interface.ts
new file mode 100644
index 0000000..a916dd7
--- /dev/null
+++ b/src/functions/backend/setup/grab-host-network-interface.ts
@@ -0,0 +1,9 @@
+/**
+ * Function to grab the host's network interface
+ * eg `eth0`
+ * @param param0
+ */
+export default async function grabHostNetworkInterface() {
+ // Placeholder
+ return `eth0`;
+}
diff --git a/src/functions/backend/setup/grab-host-public-ip-address.ts b/src/functions/backend/setup/grab-host-public-ip-address.ts
new file mode 100644
index 0000000..761f317
--- /dev/null
+++ b/src/functions/backend/setup/grab-host-public-ip-address.ts
@@ -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`;
+}
diff --git a/src/functions/backend/setup/setup-wireguard-host.ts b/src/functions/backend/setup/setup-wireguard-host.ts
new file mode 100644
index 0000000..7eee8ad
--- /dev/null
+++ b/src/functions/backend/setup/setup-wireguard-host.ts
@@ -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 {
+ 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,
+ };
+}
diff --git a/src/layouts/admin/(partials)/MobileNav.tsx b/src/layouts/admin/(partials)/MobileNav.tsx
index a25ea01..af838e1 100644
--- a/src/layouts/admin/(partials)/MobileNav.tsx
+++ b/src/layouts/admin/(partials)/MobileNav.tsx
@@ -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(null);
+ const panelRef = React.useRef(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 (
setOpen(false)}>
-
+
(
+
+ {label}
+
+);
+
+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: (
+
+ ),
},
+ { component: sectionLabel("WireGuard") },
{
title: "Clients",
url: "/admin/clients",
+ icon: (
+
+ ),
},
{
title: "Host",
url: "/admin/host",
+ icon: ,
},
- {
- component:
,
- },
+ { component: sectionLabel("Account") },
{
title: "Settings",
url: "/admin/settings",
+ icon: (
+
+ ),
},
{
title: "Logout",
url: "/auth/logout",
+ icon: (
+
+ ),
},
];
@@ -43,11 +75,8 @@ export default function AdminAsideLinks() {
);
diff --git a/src/layouts/admin/(partials)/admin-aside.tsx b/src/layouts/admin/(partials)/admin-aside.tsx
index ff6bb7e..1993439 100755
--- a/src/layouts/admin/(partials)/admin-aside.tsx
+++ b/src/layouts/admin/(partials)/admin-aside.tsx
@@ -11,15 +11,15 @@ type Props = {
export default function AdminAside({ className }: Props) {
return (
-
-
-
-
-
+
+
+
+
+
-
+
diff --git a/src/layouts/admin/(partials)/header-page-title.tsx b/src/layouts/admin/(partials)/header-page-title.tsx
new file mode 100644
index 0000000..7deaa01
--- /dev/null
+++ b/src/layouts/admin/(partials)/header-page-title.tsx
@@ -0,0 +1,29 @@
+import { AppContext } from "@/src/pages/__root";
+import { ChevronRight } from "lucide-react";
+import { useContext } from "react";
+
+const SECTION_TITLES: Record = {
+ "/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 (
+
+
+ Admin
+
+
+ {title}
+
+ );
+}
diff --git a/src/layouts/admin/(sections)/Header.tsx b/src/layouts/admin/(sections)/Header.tsx
index 384ce47..ebe78bb 100755
--- a/src/layouts/admin/(sections)/Header.tsx
+++ b/src/layouts/admin/(sections)/Header.tsx
@@ -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" }}
/>
+
diff --git a/src/pages/admin/(data)/dashboard-mock-data.ts b/src/pages/admin/(data)/dashboard-mock-data.ts
new file mode 100644
index 0000000..3ef070f
--- /dev/null
+++ b/src/pages/admin/(data)/dashboard-mock-data.ts
@@ -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: "admin@wgui.local",
+ 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",
+ },
+];
diff --git a/src/pages/admin/(partials)/activity-row.tsx b/src/pages/admin/(partials)/activity-row.tsx
new file mode 100644
index 0000000..57c5315
--- /dev/null
+++ b/src/pages/admin/(partials)/activity-row.tsx
@@ -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 (
+
+
+
+
+
+
+ {event.title}
+
+
+ {event.detail}
+
+
+
+ {event.time}
+
+
+ );
+}
diff --git a/src/pages/admin/(partials)/aside-panel.tsx b/src/pages/admin/(partials)/aside-panel.tsx
new file mode 100644
index 0000000..50f8bdf
--- /dev/null
+++ b/src/pages/admin/(partials)/aside-panel.tsx
@@ -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 (
+
+
+
+ Connection distribution
+
+
+ {DISTRIBUTION.map((d) => (
+
+ ))}
+
+
+
+
+ Quick actions
+
+
+ Add client
+
+
Generate config
+
Restart service
+
+
+ );
+}
diff --git a/src/pages/admin/(partials)/distribution-row.tsx b/src/pages/admin/(partials)/distribution-row.tsx
new file mode 100644
index 0000000..736a07c
--- /dev/null
+++ b/src/pages/admin/(partials)/distribution-row.tsx
@@ -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 = {
+ success: "bg-success",
+ warning: "bg-warning",
+ error: "bg-error",
+};
+
+const TEXT_TONE: Record = {
+ 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 (
+
+
+
+ {label}
+
+
+ {value}
+
+
+
+
+ );
+}
diff --git a/src/pages/admin/(partials)/peer-row.tsx b/src/pages/admin/(partials)/peer-row.tsx
new file mode 100644
index 0000000..4e510c6
--- /dev/null
+++ b/src/pages/admin/(partials)/peer-row.tsx
@@ -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 (
+
+
+
+
+
+ {peer.name}
+
+
+
+
+ {peer.tunnelIp}
+
+
+ {peer.endpoint}
+
+
+
+ ↓ {peer.received}
+
+
+ {" "}
+ ↑ {peer.sent}
+
+
+
+ {peer.lastHandshake}
+
+
+ );
+}
diff --git a/src/pages/admin/(partials)/sparkline.tsx b/src/pages/admin/(partials)/sparkline.tsx
new file mode 100644
index 0000000..07ac224
--- /dev/null
+++ b/src/pages/admin/(partials)/sparkline.tsx
@@ -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 (
+
+
+
+
+
+ );
+}
diff --git a/src/pages/admin/(partials)/stat-card.tsx b/src/pages/admin/(partials)/stat-card.tsx
new file mode 100644
index 0000000..5202cac
--- /dev/null
+++ b/src/pages/admin/(partials)/stat-card.tsx
@@ -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 = {
+ 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 (
+
+
+
+ {label}
+
+
+ {value}
+
+ {delta || subtext ? (
+
+ {delta}
+ {delta && subtext ? (
+
+ {" "}
+ · {subtext}
+
+ ) : null}
+ {!delta && subtext ? (
+
+ {subtext}
+
+ ) : null}
+
+ ) : null}
+
+ {trailing ? (
+ {trailing}
+ ) : null}
+
+ );
+}
diff --git a/src/pages/admin/(partials)/traffic-chart.tsx b/src/pages/admin/(partials)/traffic-chart.tsx
new file mode 100644
index 0000000..db136e3
--- /dev/null
+++ b/src/pages/admin/(partials)/traffic-chart.tsx
@@ -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 (
+
+
+
+
+
+
+
+
+
+
+
+
+ {yTicks.map((tick, i) => (
+
+
+
+ {tick.label}
+
+
+ ))}
+
+
+
+
+
+ {xTicks.map((tick, i) => (
+
+ {tick.label}
+
+ ))}
+
+ );
+}
diff --git a/src/pages/admin/(sections)/activity-section.tsx b/src/pages/admin/(sections)/activity-section.tsx
new file mode 100644
index 0000000..d6bf3f7
--- /dev/null
+++ b/src/pages/admin/(sections)/activity-section.tsx
@@ -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 (
+
+
+
+ Recent activity
+
+
+
+ {ACTIVITY.map((event) => (
+
+ ))}
+
+
+ );
+}
diff --git a/src/pages/admin/(sections)/kpi-hero-section.tsx b/src/pages/admin/(sections)/kpi-hero-section.tsx
new file mode 100644
index 0000000..0e57e73
--- /dev/null
+++ b/src/pages/admin/(sections)/kpi-hero-section.tsx
@@ -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 (
+
+ }
+ />
+ );
+}
diff --git a/src/pages/admin/(sections)/kpi-secondary-section.tsx b/src/pages/admin/(sections)/kpi-secondary-section.tsx
new file mode 100644
index 0000000..99e0568
--- /dev/null
+++ b/src/pages/admin/(sections)/kpi-secondary-section.tsx
@@ -0,0 +1,18 @@
+import StatCard from "../(partials)/stat-card";
+import { SECONDARY_KPIS } from "../(data)/dashboard-mock-data";
+
+export default function KpiSecondarySection() {
+ return (
+
+ {SECONDARY_KPIS.map((kpi) => (
+
+ ))}
+
+ );
+}
diff --git a/src/pages/admin/(sections)/peers-table-section.tsx b/src/pages/admin/(sections)/peers-table-section.tsx
new file mode 100644
index 0000000..a44d0fb
--- /dev/null
+++ b/src/pages/admin/(sections)/peers-table-section.tsx
@@ -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 (
+
+
+
+
+ Recent peers
+
+
+ View all
+
+
+
+
+
+
+
+ Peer
+ Tunnel IP
+ Endpoint
+ Traffic
+ Last handshake
+
+
+
+ {PEERS.map((peer) => (
+
+ ))}
+
+
+
+
+
+
+ );
+}
diff --git a/src/pages/admin/(sections)/traffic-chart-section.tsx b/src/pages/admin/(sections)/traffic-chart-section.tsx
new file mode 100644
index 0000000..18cf4c0
--- /dev/null
+++ b/src/pages/admin/(sections)/traffic-chart-section.tsx
@@ -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 (
+
+
+
+
+ Network traffic
+
+
+ Last 24 hours
+
+
+
+
+
+ Download
+
+
+
+ Upload
+
+
+
+
+
+
+
+ {stats.map((stat, i) => (
+
+ {i > 0 ? (
+
+ ) : null}
+
+
+ {stat.label}
+
+
+ {stat.value}
+
+
+
+ ))}
+
+
+ );
+}
diff --git a/src/pages/admin/clients/(data)/clients-mock-data.ts b/src/pages/admin/clients/(data)/clients-mock-data.ts
new file mode 100644
index 0000000..ee16ccd
--- /dev/null
+++ b/src/pages/admin/clients/(data)/clients-mock-data.ts
@@ -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",
+ },
+];
diff --git a/src/pages/admin/clients/(partials)/client-form-modal.tsx b/src/pages/admin/clients/(partials)/client-form-modal.tsx
new file mode 100644
index 0000000..ad137ad
--- /dev/null
+++ b/src/pages/admin/clients/(partials)/client-form-modal.tsx
@@ -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>;
+};
+
+type FormFieldProps = {
+ label: string;
+ htmlFor: string;
+ children: ReactNode;
+};
+
+function FormField({ label, htmlFor, children }: FormFieldProps) {
+ return (
+
+
+ {label}
+
+ {children}
+
+ );
+}
+
+export default function ClientFormModal({ open, setOpen }: Props) {
+ return (
+
+
+
+
+ Add client
+
+
+ A WireGuard config will be generated on save
+
+
+
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"
+ >
+
+
+
+
+
+ );
+}
diff --git a/src/pages/admin/clients/(partials)/client-row.tsx b/src/pages/admin/clients/(partials)/client-row.tsx
new file mode 100644
index 0000000..c74bcf7
--- /dev/null
+++ b/src/pages/admin/clients/(partials)/client-row.tsx
@@ -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 (
+
+
+
+
+
+ {client.name}
+
+
+
+
+ {client.tunnelIp}
+
+
+ {client.allowedIps}
+
+
+
+ {client.publicKey.slice(0, 16)}…
+
+
+
+
+ ↓ {client.received}
+
+
+ {" "}
+ ↑ {client.sent}
+
+
+
+ {client.lastHandshake}
+
+
+ {client.createdAt}
+
+
+ );
+}
diff --git a/src/pages/admin/clients/(sections)/clients-table-section.tsx b/src/pages/admin/clients/(sections)/clients-table-section.tsx
new file mode 100644
index 0000000..3cdcdd4
--- /dev/null
+++ b/src/pages/admin/clients/(sections)/clients-table-section.tsx
@@ -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>;
+};
+
+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 (
+
+
+
+ Clients
+
+ {filtered.length}
+
+
+ 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]",
+ },
+ }}
+ />
+
+ {filtered.length ? (
+
+
+
+
+ Client
+ Tunnel IP
+ Allowed IPs
+ Public key
+ Traffic
+ Last handshake
+ Created
+
+
+
+ {filtered.map((client) => (
+
+ ))}
+
+
+
+ ) : (
+
+
+ No clients found
+
+
+ Try adjusting your search terms
+
+
+ )}
+
+
+ );
+}
diff --git a/src/pages/admin/clients/index.tsx b/src/pages/admin/clients/index.tsx
new file mode 100644
index 0000000..651d617
--- /dev/null
+++ b/src/pages/admin/clients/index.tsx
@@ -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 (
+ <>
+ setAddOpen(true)}
+ >
+ Add client
+
+ }
+ />
+
+
+
+ >
+ );
+}
+
+export const meta: BunextPageModuleMeta = {
+ title: `Clients | ${SiteData["SiteName"]}`,
+ description: `Manage WireGuard clients`,
+};
diff --git a/src/pages/admin/host/(data)/host-mock-data.ts b/src/pages/admin/host/(data)/host-mock-data.ts
new file mode 100644
index 0000000..9c9a08d
--- /dev/null
+++ b/src/pages/admin/host/(data)/host-mock-data.ts
@@ -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 },
+];
diff --git a/src/pages/admin/host/(partials)/host-stat-cell.tsx b/src/pages/admin/host/(partials)/host-stat-cell.tsx
new file mode 100644
index 0000000..bde8826
--- /dev/null
+++ b/src/pages/admin/host/(partials)/host-stat-cell.tsx
@@ -0,0 +1,17 @@
+type Props = {
+ label: string;
+ value: string;
+};
+
+export default function HostStatCell({ label, value }: Props) {
+ return (
+
+
+ {label}
+
+
+ {value}
+
+
+ );
+}
diff --git a/src/pages/admin/host/(partials)/interface-config-row.tsx b/src/pages/admin/host/(partials)/interface-config-row.tsx
new file mode 100644
index 0000000..c057953
--- /dev/null
+++ b/src/pages/admin/host/(partials)/interface-config-row.tsx
@@ -0,0 +1,17 @@
+type Props = {
+ keyName: string;
+ value: string;
+};
+
+export default function InterfaceConfigRow({ keyName, value }: Props) {
+ return (
+
+
+ {keyName} =
+
+
+ {value}
+
+
+ );
+}
diff --git a/src/pages/admin/host/(sections)/host-status-section.tsx b/src/pages/admin/host/(sections)/host-status-section.tsx
new file mode 100644
index 0000000..7372142
--- /dev/null
+++ b/src/pages/admin/host/(sections)/host-status-section.tsx
@@ -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 (
+
+
+
+
+
+
+
+
+
+ Running
+
+
+ {HOST_INTERFACE.name} · {HOST_INTERFACE.endpoint}
+
+
+
+
+ wg-quick status · v{HOST_STATUS.version}
+
+
+
+ {STATS.map((stat) => (
+
+ ))}
+
+
+ );
+}
diff --git a/src/pages/admin/host/(sections)/interface-config-section.tsx b/src/pages/admin/host/(sections)/interface-config-section.tsx
new file mode 100644
index 0000000..5990383
--- /dev/null
+++ b/src/pages/admin/host/(sections)/interface-config-section.tsx
@@ -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 (
+
+
+
+
+ Interface configuration
+
+
+ {HOST_INTERFACE.configPath}
+
+
+
+ navigator.clipboard?.writeText(buildConfigText())
+ }
+ >
+ Copy config
+
+
+
+
+ [Interface]
+
+ {INTERFACE_CONFIG.map((row) => (
+
+ ))}
+
+
+ # Public key · {HOST_INTERFACE.publicKey.slice(0, 24)}…
+
+
+
+
+ );
+}
diff --git a/src/pages/admin/host/index.tsx b/src/pages/admin/host/index.tsx
new file mode 100644
index 0000000..1362759
--- /dev/null
+++ b/src/pages/admin/host/index.tsx
@@ -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 (
+ <>
+
+ Restart service
+
+ }
+ />
+
+
+
+
+ >
+ );
+}
+
+export const meta: BunextPageModuleMeta = {
+ title: `Host | ${SiteData["SiteName"]}`,
+ description: `WireGuard host configuration`,
+};
diff --git a/src/pages/admin/index.tsx b/src/pages/admin/index.tsx
index 7d37953..410180d 100644
--- a/src/pages/admin/index.tsx
+++ b/src/pages/admin/index.tsx
@@ -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 (
<>
-
+
+
+
+
+
+
+
+
>
);
}
diff --git a/src/scripts/setup-wireguard.sh b/src/scripts/setup-wireguard.sh
index 2d355e4..dacd7a4 100755
--- a/src/scripts/setup-wireguard.sh
+++ b/src/scripts/setup-wireguard.sh
@@ -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.
\ No newline at end of file
+# 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
\ No newline at end of file
diff --git a/src/styles/main.css b/src/styles/main.css
index a05993f..2fe4b7c 100755
--- a/src/styles/main.css
+++ b/src/styles/main.css
@@ -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 {
diff --git a/src/utils/grab-dir-names.ts b/src/utils/grab-dir-names.ts
index 4d447bf..f34ed17 100644
--- a/src/utils/grab-dir-names.ts
+++ b/src/utils/grab-dir-names.ts
@@ -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,
};
}