Add host setup functions and client components

This commit is contained in:
2026-09-13 10:52:30 +01:00
parent 76679aabe7
commit 86eb3a0b05
53 changed files with 1284 additions and 1325 deletions
+22
View File
@@ -186,6 +186,10 @@ const schema: BUN_SQLITE_DatabaseSchemaType = {
fieldName: "allowed_ips",
dataType: "TEXT",
},
{
fieldName: "notes",
dataType: "TEXT",
},
],
},
{
@@ -209,6 +213,24 @@ const schema: BUN_SQLITE_DatabaseSchemaType = {
},
],
},
{
tableName: "client_rules",
tableDescription: `IP table rules for this client`,
fields: [
{
fieldName: "user_id",
dataType: "INTEGER",
},
{
fieldName: "client_id",
dataType: "INTEGER",
},
{
fieldName: "rule",
dataType: "TEXT",
},
],
},
{
tableName: "variables",
fields: [
+22 -2
View File
@@ -5,6 +5,7 @@ export const BunSQLiteTables = [
"media_paradigms",
"clients",
"hosts",
"client_rules",
"variables",
] as const
@@ -113,6 +114,7 @@ export type BUN_SQLITE_WGUI_CLIENTS = {
private_key?: string;
public_key?: string;
allowed_ips?: string;
notes?: string;
}
export type BUN_SQLITE_WGUI_HOSTS = {
@@ -134,6 +136,24 @@ export type BUN_SQLITE_WGUI_HOSTS = {
public_key?: string;
}
export type BUN_SQLITE_WGUI_CLIENT_RULES = {
/**
* 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 | "";
client_id?: number | "";
rule?: string;
}
export type BUN_SQLITE_WGUI_VARIABLES = {
/**
* The unique identifier of the record.
@@ -147,8 +167,8 @@ export type BUN_SQLITE_WGUI_VARIABLES = {
* The time when the record was updated. (Unix Timestamp)
*/
updated_at?: number | "";
key?: "main_host_wg_ip_address" | "";
key?: "main_host_wg_ip_address" | "main_host_wg_public_key" | "main_host_wg_private_key" | "";
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
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_CLIENT_RULES & BUN_SQLITE_WGUI_VARIABLES
+1 -1
View File
@@ -10,7 +10,7 @@ if [ -z "${SOURCE_PATH:-}" ] || [ "$(realpath -m "$SOURCE_PATH")" = "/" ]; then
fi
SERVER_IP="$DEPLOY_SERVER_IP"
SERVER_IP="195.26.246.243"
SERVER_PATH="/docker/bunext-mariadb"
cd "$SOURCE_PATH"
+1 -2
View File
@@ -21,7 +21,7 @@ export default function AdminHero({ title, description, buttons }: Props) {
<Section>
<Stack className="w-full items-stretch">
<Row className="w-full justify-between items-start">
<Stack>
<Stack className="gap-3">
<H1>{title}</H1>
{description ? (
typeof description == "string" ? (
@@ -45,7 +45,6 @@ export default function AdminHero({ title, description, buttons }: Props) {
</Stack>
<Row>{buttons}</Row>
</Row>
<Divider className="my-4" />
</Stack>
</Section>
);
+35
View File
@@ -0,0 +1,35 @@
import Span from "@/src/components/twui/layout/Span";
import formatUnixTimestamp from "@/src/utils/format-unix-timestamp";
import type { BUN_SQLITE_WGUI_CLIENTS } from "@/db/types/db";
type Props = {
client: BUN_SQLITE_WGUI_CLIENTS;
};
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]">
<Span className="text-[13.5px] font-medium text-foreground-light dark:text-foreground-dark">
{client.name || "—"}
</Span>
</td>
<td className="px-4 py-[9px] tabular text-[13px] text-foreground-light/60 dark:text-foreground-dark/60 whitespace-nowrap">
{client.wg_ip_address || "—"}
</td>
<td className="px-4 py-[9px] tabular text-[13px] text-foreground-light/45 dark:text-foreground-dark/45 whitespace-nowrap">
{client.allowed_ips || "—"}
</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.public_key
? `${client.public_key.slice(0, 16)}…`
: "—"}
</Span>
</td>
<td className="px-4 py-[9px] tabular text-[12px] text-foreground-light/40 dark:text-foreground-dark/40 text-right whitespace-nowrap">
{formatUnixTimestamp({ timestamp: client.created_at })}
</td>
</tr>
);
}
+77
View File
@@ -0,0 +1,77 @@
import { useEffect, useState, type ComponentProps } from "react";
import Button from "../twui/layout/Button";
import useStatus from "../twui/hooks/useStatus";
import fetchApi from "../twui/utils/fetch/fetchApi";
import type { ApiReqParams } from "@/src/types";
import Row from "../twui/layout/Row";
import Input from "../twui/form/Input";
import Stack from "../twui/layout/Stack";
import { Network } from "lucide-react";
import Span from "../twui/layout/Span";
import { AppData } from "@/src/data/app-data";
type Props = {
button_props?: Omit<ComponentProps<typeof Button>, "title">;
};
export default function SetupMainHostButton({ button_props }: Props) {
const { loading, setLoading, ready, setReady } = useStatus();
const [wgIP, setWgIP] = useState<string>(AppData["DefaultPrivateIP"]);
useEffect(() => {
fetchApi<ApiReqParams>(
`/api/admin/check-private-ip-address-availability`,
{
method: "POST",
body: { ip_address: wgIP },
},
).then((res) => {
console.log(`res`, res);
});
}, [wgIP]);
return (
<Stack className="w-full items-stretch">
<Input
defaultValue={wgIP}
changeHandler={(v) => {
setWgIP(v);
}}
prefix={<Network size={17} opacity={0.5} />}
suffix={
<Span className="text-sm opacity-50 whitespace-nowrap">
Selected Wireguard IP Address
</Span>
}
autoFocus
/>
<Button
title="Setup Main Host"
{...button_props}
loading={loading}
onClick={() => {
setLoading(true);
fetchApi<ApiReqParams>(`/api/admin/setup-main-host`, {
method: "POST",
body: {
ip_address: ``,
},
})
.then((res) => {
console.log(`res`, res);
})
.finally(() => {
setTimeout(() => {
setLoading(false);
}, 4000);
});
}}
>
Setup Main Host
</Button>
</Stack>
);
}
-52
View File
@@ -1,52 +0,0 @@
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>
);
}
+1
View File
@@ -18,4 +18,5 @@ export const AppData = {
PaystackEndpoint: "https://api.paystack.co",
WireguardHostID: 0,
DefaultPrivateIP: `10.1.0.1`,
} as const;
+10
View File
@@ -4,4 +4,14 @@ export const Variables = [
value: "main_host_wg_ip_address",
description: `Private IP address to use for the main Wireguard host. Eg. 10.1.0.1`,
},
{
title: `Main Host Wireguard Public Key`,
value: "main_host_wg_public_key",
description: `Public Key for the main host`,
},
{
title: `Main Host Wireguard Private Key`,
value: "main_host_wg_private_key",
description: `Private Key for the main host`,
},
] as const;
@@ -0,0 +1,81 @@
import type { APIResponseObject } from "@moduletrace/bunext/types";
import { execSync } from "node:child_process";
type Params = {
ip_address: string;
};
/**
* Function to check the availability of a private
* IP address (like 10.1.0.1)
* @param param0
*/
export default async function checkPrivateIPAvailability({
ip_address,
}: Params): Promise<APIResponseObject> {
const ip_pattern = /^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/;
const matches = ip_address.match(ip_pattern);
if (!matches) {
return {
success: false,
msg: `Invalid IP address: ${ip_address}`,
};
}
const octets = matches.slice(1).map(Number);
if (octets.some((o) => o < 0 || o > 255)) {
return {
success: false,
msg: `Invalid IP address: ${ip_address}`,
};
}
if (octets[3] !== 1) {
return {
success: false,
msg: `IP address must end with 1: ${ip_address}`,
};
}
const [octet1, octet2, octet3] = octets;
try {
const addr_output = execSync(`ip -4 addr show`, {
encoding: "utf-8",
});
for (const match of addr_output.matchAll(
/inet\s+(\d+\.\d+\.\d+\.\d+)\/\d+/g,
)) {
if (!match[1]) continue;
const [existing1, existing2, existing3] = match[1]
.split(".")
.map(Number);
if (
existing1 === octet1 &&
existing2 === octet2 &&
existing3 === octet3
) {
return {
success: false,
msg: `IP address ${ip_address} is already in use`,
};
}
}
return {
success: true,
msg: `IP address ${ip_address} is available`,
};
} catch (error: any) {
return {
success: false,
msg: error.message,
};
}
}
@@ -0,0 +1,102 @@
import type {
BUN_SQLITE_WGUI_CLIENTS,
BUN_SQLITE_WGUI_HOSTS,
BUN_SQLITE_WGUI_VARIABLES,
} from "@/db/types/db";
import type { APIResponseObject } from "@moduletrace/bunext/types";
import BunSQLite from "@moduletrace/bun-sqlite";
import type { TableType } from "@/src/types";
type Params = {
host?: BUN_SQLITE_WGUI_HOSTS;
};
/**
* Function to grab the next available client
* private IP address within the host's subnet.
* Checks the clients records and increments
* the last octet of the last client
* @param param0
*/
export default async function grabNextAvailableClientIPAddress({
host,
}: Params): Promise<APIResponseObject> {
const variables_res = await BunSQLite.select<
BUN_SQLITE_WGUI_VARIABLES,
TableType
>({
table: "variables",
});
const variables = variables_res.payload;
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`,
};
}
const parts = HOST_WG_IP.split(".");
if (parts.length !== 4) {
return {
success: false,
msg: `Invalid Host Private IP address: ${HOST_WG_IP}`,
};
}
const host_last_octet = Number(parts[3]);
if (Number.isNaN(host_last_octet)) {
return {
success: false,
msg: `Invalid Host Private IP address: ${HOST_WG_IP}`,
};
}
const subnet = `${parts[0]}.${parts[1]}.${parts[2]}.`;
const clients_res = await BunSQLite.select<
BUN_SQLITE_WGUI_CLIENTS,
TableType
>({
table: "clients",
});
const clients = clients_res.payload || [];
let max_last_octet = host_last_octet;
for (const client of clients) {
const client_ip = client?.wg_ip_address;
if (!client_ip || !client_ip.startsWith(subnet)) continue;
const client_last_octet = Number(client_ip.split(".")[3]);
if (Number.isNaN(client_last_octet)) continue;
if (client_last_octet > max_last_octet) {
max_last_octet = client_last_octet;
}
}
const next_last_octet = max_last_octet + 1;
if (next_last_octet > 254) {
return {
success: false,
msg: `No available client IP address in subnet ${subnet}0/24`,
};
}
return {
success: true,
msg: `${subnet}${next_last_octet}`,
};
}
@@ -0,0 +1,47 @@
import { AppData } from "@/src/data/app-data";
import type { APIResponseObject } from "@moduletrace/bunext/types";
import checkPrivateIPAvailability from "./check-private-ip-availability";
/**
* Function to grab the next available private
* IP subnet. Beginning with `10.1.0.1`. If not
* available then `10.2.0.1`, etc.
* @param param0
*/
export default async function grabNextAvailablePrivateIPSubnet(): Promise<APIResponseObject> {
const default_ip = AppData["DefaultPrivateIP"];
const parts = default_ip.split(".");
if (parts.length !== 4) {
return {
success: false,
msg: `Invalid default private IP address: ${default_ip}`,
};
}
const [octet1, octet3, octet4] = [parts[0], parts[2], parts[3]];
const start_octet = Number(parts[1]);
const max_octet = Number.isNaN(start_octet) ? 254 : 255;
for (let i = start_octet; i <= max_octet; i++) {
const candidate = `${octet1}.${i}.${octet3}.${octet4}`;
const availability_res = await checkPrivateIPAvailability({
ip_address: candidate,
});
if (availability_res.success) {
return {
success: true,
msg: candidate,
};
}
}
return {
success: false,
msg: `No available private IP subnet found`,
};
}
@@ -25,7 +25,6 @@ const {
type Params = {
client: BUN_SQLITE_WGUI_CLIENTS;
host?: BUN_SQLITE_WGUI_HOSTS;
variables?: BUN_SQLITE_WGUI_VARIABLES[];
};
/**
@@ -38,10 +37,18 @@ type Params = {
export default async function setupWireguardClient({
client,
host,
variables,
}: Params): Promise<APIResponseObject> {
const host_id = host?.id || AppData["WireguardHostID"];
const variables_res = await BunSQLite.select<
BUN_SQLITE_WGUI_VARIABLES,
TableType
>({
table: "variables",
});
const variables = variables_res.payload;
const CLIENT_WG_IP = client?.wg_ip_address;
if (!CLIENT_WG_IP) {
@@ -161,7 +168,7 @@ EOF
encoding: "utf-8",
});
const host_setup_res = await setupWireguardHost({ host, variables });
const host_setup_res = await setupWireguardHost({ host });
if (!host_setup_res.success) {
return {
@@ -11,6 +11,7 @@ import grabHostNetworkInterface from "./grab-host-network-interface";
import type { APIResponseObject } from "@moduletrace/bunext/types";
import BunSQLite from "@moduletrace/bun-sqlite";
import type { TableType } from "@/src/types";
import checkPrivateIPAvailability from "./check-private-ip-availability";
const {
WGUI_LIB_IP_TABLES_DIR,
@@ -21,15 +22,24 @@ const {
type Params = {
host?: BUN_SQLITE_WGUI_HOSTS;
variables?: BUN_SQLITE_WGUI_VARIABLES[];
wg_subnet_ip?: string;
};
export default async function setupWireguardHost({
host,
variables,
wg_subnet_ip,
}: Params): Promise<APIResponseObject> {
const host_id = host?.id || AppData["WireguardHostID"];
const variables_res = await BunSQLite.select<
BUN_SQLITE_WGUI_VARIABLES,
TableType
>({
table: "variables",
});
const variables = variables_res.payload;
const host_clients_res = await BunSQLite.select<
BUN_SQLITE_WGUI_CLIENTS,
TableType
@@ -49,7 +59,8 @@ export default async function setupWireguardHost({
const TARGET_INTERFACE = await grabHostNetworkInterface();
const HOST_WG_IP =
host?.wg_ip_address ||
variables?.find((v) => v.key == "main_host_wg_ip_address")?.value;
variables?.find((v) => v.key == "main_host_wg_ip_address")?.value ||
wg_subnet_ip;
if (!HOST_WG_IP) {
return {
@@ -58,6 +69,17 @@ export default async function setupWireguardHost({
};
}
const is_ip_available = await checkPrivateIPAvailability({
ip_address: HOST_WG_IP,
});
if (!is_ip_available.success) {
return {
success: false,
msg: `IP not available`,
};
}
let pre_sh = ``;
pre_sh += `cd ${WIREGUARD_HOST_CONFIG_DIR}\n`;
@@ -67,6 +89,8 @@ export default async function setupWireguardHost({
try {
const exec_pre_setup = execSync(pre_sh, { encoding: "utf-8" });
console.log("exec_pre_setup", exec_pre_setup);
} catch (error: any) {
return {
success: false,
@@ -155,6 +179,11 @@ export default async function setupWireguardHost({
sh += `\n`;
const exec = execSync(sh, { encoding: "utf-8" });
return {
success: true,
msg: exec,
};
} catch (error: any) {
return {
success: false,
@@ -0,0 +1,20 @@
import type { BUN_SQLITE_WGUI_VARIABLES } from "@/db/types/db";
type Params = {
variables?: BUN_SQLITE_WGUI_VARIABLES[];
};
export default function checkIfMainHostIsSet({ variables }: Params) {
const is_main_host_set =
Boolean(
variables?.find((v) => v.key == "main_host_wg_ip_address")?.value,
) &&
Boolean(
variables?.find((v) => v.key == "main_host_wg_private_key")?.value,
) &&
Boolean(
variables?.find((v) => v.key == "main_host_wg_public_key")?.value,
);
return is_main_host_set;
}
@@ -43,16 +43,16 @@ export default function AdminAsideLinks() {
},
{ component: sectionLabel("WireGuard") },
{
title: "Clients",
url: "/admin/clients",
title: "Hosts",
url: "/admin/hosts",
icon: (
<Users size={16} strokeWidth={2} className={iconClassName} />
<Server size={16} strokeWidth={2} className={iconClassName} />
),
},
{
title: "Host",
url: "/admin/host",
icon: <Server size={16} strokeWidth={2} className={iconClassName} />,
title: "Clients",
url: "/admin/clients",
icon: <Users size={16} strokeWidth={2} className={iconClassName} />,
},
{ component: sectionLabel("Account") },
{
@@ -1,227 +0,0 @@
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,31 @@
import { useAdminCrudGet } from "@/src/hooks/use-admin-crud-get";
import type {
BUN_SQLITE_WGUI_CLIENTS,
BUN_SQLITE_WGUI_HOSTS,
BUN_SQLITE_WGUI_VARIABLES,
} from "@/db/types/db";
export default function useDashboardData() {
const clients_res = useAdminCrudGet<BUN_SQLITE_WGUI_CLIENTS>({
table: "clients",
sql_query: {
order: { field: "created_at", strategy: "DESC" },
limit: 100,
},
});
const hosts_res = useAdminCrudGet<BUN_SQLITE_WGUI_HOSTS>({
table: "hosts",
limit: 100,
});
const variables_res = useAdminCrudGet<BUN_SQLITE_WGUI_VARIABLES>({
table: "variables",
});
return {
clients: clients_res.res,
hosts: hosts_res.res,
variables: variables_res.res,
};
}
@@ -1,57 +0,0 @@
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>
);
}
@@ -1,38 +0,0 @@
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>
);
}
@@ -1,56 +0,0 @@
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
@@ -1,39 +0,0 @@
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
@@ -1,62 +0,0 @@
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>
);
}
+19 -17
View File
@@ -1,7 +1,10 @@
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";
import Span from "@/src/components/twui/layout/Span";
import Stack from "@/src/components/twui/layout/Stack";
type DeltaTone = "positive" | "negative" | "neutral";
type Props = {
label: string;
@@ -35,11 +38,11 @@ export default function StatCard({
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">
<Stack className="min-w-0 gap-1.5">
<Span className="text-[11.5px] font-semibold uppercase tracking-[0.08em] text-foreground-light/50 dark:text-foreground-dark/50">
{label}
</span>
<span
</Span>
<Span
className={twMerge(
"tabular text-[22px] font-semibold tracking-[-0.02em] leading-none",
"text-foreground-light dark:text-foreground-dark",
@@ -47,9 +50,9 @@ export default function StatCard({
)}
>
{value}
</span>
</Span>
{delta || subtext ? (
<span
<Span
className={twMerge(
"text-[12.5px] font-medium tabular",
delta ? DELTA_TONE_CLASS[deltaTone] : undefined,
@@ -57,22 +60,21 @@ export default function StatCard({
>
{delta}
{delta && subtext ? (
<span className="text-foreground-light/40 dark:text-foreground-dark/40">
{" "}
· {subtext}
</span>
<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">
<Span className="text-foreground-light/40 dark:text-foreground-dark/40">
{subtext}
</span>
</Span>
) : null}
</span>
</Span>
) : null}
</div>
</Stack>
{trailing ? (
<div className="shrink-0 self-center">{trailing}</div>
<Span className="shrink-0 self-center">{trailing}</Span>
) : null}
</AdminCard>
);
}
}
@@ -1,130 +0,0 @@
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>
);
}
@@ -1,20 +0,0 @@
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>
);
}
+10 -15
View File
@@ -1,23 +1,18 @@
import StatCard from "../(partials)/stat-card";
import Sparkline from "../(partials)/sparkline";
import { HERO_KPI } from "../(data)/dashboard-mock-data";
import type { BUN_SQLITE_WGUI_CLIENTS } from "@/db/types/db";
export default function KpiHeroSection() {
type Props = {
clients?: BUN_SQLITE_WGUI_CLIENTS[];
};
export default function KpiHeroSection({ clients }: Props) {
return (
<StatCard
tier="default"
label={HERO_KPI.label}
value={HERO_KPI.value}
delta={HERO_KPI.delta}
deltaTone={HERO_KPI.deltaTone}
subtext={HERO_KPI.subtext}
label="Total clients"
value={String(clients?.length || 0)}
subtext="WireGuard peers on this network"
valueClassName="text-4xl"
trailing={
<Sparkline
data={HERO_KPI.sparkline}
className="text-secondary dark:text-secondary"
/>
}
/>
);
}
}
@@ -1,18 +1,51 @@
import StatCard from "../(partials)/stat-card";
import { SECONDARY_KPIS } from "../(data)/dashboard-mock-data";
import type {
BUN_SQLITE_WGUI_CLIENTS,
BUN_SQLITE_WGUI_HOSTS,
BUN_SQLITE_WGUI_VARIABLES,
} from "@/db/types/db";
type Props = {
clients?: BUN_SQLITE_WGUI_CLIENTS[];
hosts?: BUN_SQLITE_WGUI_HOSTS[];
variables?: BUN_SQLITE_WGUI_VARIABLES[];
};
export default function KpiSecondarySection({
clients,
hosts,
variables,
}: Props) {
const kpis = [
{
key: "k-clients",
label: "Total clients",
value: String(clients?.length || 0),
},
{
key: "k-hosts",
label: "Total hosts",
value: String(hosts?.length || 0),
},
{
key: "k-host-address",
label: "Main host address",
value:
variables?.find(
(v) => v.key == "main_host_wg_ip_address",
)?.value || "—",
},
];
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) => (
{kpis.map((kpi) => (
<StatCard
key={kpi.id}
key={kpi.key}
label={kpi.label}
value={kpi.value}
delta={kpi.delta}
deltaTone={kpi.deltaTone}
/>
))}
</div>
);
}
}
@@ -1,9 +1,16 @@
import { ArrowUpRight } from "lucide-react";
import Link from "@/src/components/twui/layout/Link";
import H2 from "@/src/components/twui/layout/H2";
import Row from "@/src/components/twui/layout/Row";
import Stack from "@/src/components/twui/layout/Stack";
import Span from "@/src/components/twui/layout/Span";
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";
import ClientRow from "@/src/components/general/client-row";
import type { BUN_SQLITE_WGUI_CLIENTS } from "@/db/types/db";
type Props = {
clients?: BUN_SQLITE_WGUI_CLIENTS[];
};
const thClass =
"px-4 py-2 text-left text-[11px] font-semibold uppercase tracking-[0.08em] " +
@@ -11,42 +18,54 @@ const thClass =
const thRightClass = `${thClass} text-right`;
export default function PeersTableSection() {
const RECENT_LIMIT = 8;
export default function PeersTableSection({ clients }: Props) {
const recent = (clients || []).slice(0, RECENT_LIMIT);
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>
<AdminCard className="w-full overflow-hidden">
<Row className="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 mb-0!">
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>
</Row>
{recent.length ? (
<div className="overflow-x-auto">
<table className="w-full min-w-[680px]">
<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}>Peer</th>
<th className={thClass}>Client</th>
<th className={thClass}>Tunnel IP</th>
<th className={thClass}>Endpoint</th>
<th className={thRightClass}>Traffic</th>
<th className={thRightClass}>Last handshake</th>
<th className={thClass}>Allowed IPs</th>
<th className={thClass}>Public key</th>
<th className={thRightClass}>Created</th>
</tr>
</thead>
<tbody>
{PEERS.map((peer) => (
<PeerRow key={peer.id} peer={peer} />
{recent.map((client) => (
<ClientRow key={client.id} client={client} />
))}
</tbody>
</table>
</div>
</AdminCard>
<AsidePanel />
</div>
) : (
<Stack center className="w-full justify-center py-14 px-6 text-center gap-1">
<Span className="text-[13.5px] font-medium text-foreground-light/50 dark:text-foreground-dark/50">
No clients yet
</Span>
<Span className="text-[12.5px] text-foreground-light/35 dark:text-foreground-dark/35">
Manage peers from the Clients page
</Span>
</Stack>
)}
</AdminCard>
);
}
}
@@ -1,78 +0,0 @@
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>
);
}
@@ -1,122 +0,0 @@
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",
},
];
@@ -1,12 +1,20 @@
import type { Dispatch, ReactNode, SetStateAction } from "react";
import { useState, type Dispatch, type ReactNode, type 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";
import Form from "@/src/components/twui/form/Form";
import Stack from "@/src/components/twui/layout/Stack";
import Span from "@/src/components/twui/layout/Span";
import H3 from "@/src/components/twui/layout/H3";
import Row from "@/src/components/twui/layout/Row";
import adminCrudHandler from "@/src/functions/frontend/admin-crud-handler";
import type { BUN_SQLITE_WGUI_CLIENTS } from "@/db/types/db";
type Props = {
open: boolean;
setOpen: Dispatch<SetStateAction<boolean>>;
onCreated?: () => void;
};
type FormFieldProps = {
@@ -15,94 +23,104 @@ type FormFieldProps = {
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>
);
}
type FormData = {
client_name?: string;
tunnel_ip?: string;
allowed_ips?: string;
};
export default function ClientFormModal({ open, setOpen, onCreated }: Props) {
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string>();
async function handleSubmit(data: FormData) {
setBusy(true);
setError(undefined);
const res = await adminCrudHandler<BUN_SQLITE_WGUI_CLIENTS>({
action: "insert",
table: "clients",
insert_data: [
{
name: data.client_name || "",
wg_ip_address: data.tunnel_ip || "",
allowed_ips: data.allowed_ips || "",
},
],
});
setBusy(false);
if (!res.success) {
setError(res.msg || "Could not add client");
return;
}
setOpen(false);
onCreated?.();
}
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);
<Modal open={open} setOpen={setOpen} className="p-6">
<Stack className="gap-4 mb-6">
<H3 className="text-lg font-bold text-foreground-light dark:text-foreground-dark">
Add client
</H3>
<Span className="" variant="faded">
The client is added to your WireGuard network
</Span>
</Stack>
<Form
submitHandler={(e, data) => {
handleSubmit(data as FormData);
}}
>
<FormField label="Client name" htmlFor="client_name">
<Stack className="w-full items-stretch gap-6">
<Input
name="client_name"
id="client_name"
placeholder="e.g. MacBook Pro"
label="Client name"
autoFocus
showLabel
required
/>
</FormField>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<FormField label="Tunnel IP" htmlFor="tunnel_ip">
<Row className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<Input
name="tunnel_ip"
id="tunnel_ip"
placeholder="10.0.0.10 · auto-assigned"
placeholder="10.0.0.10"
label="Tunnel IP"
showLabel
/>
</FormField>
<FormField label="Allowed IPs" htmlFor="allowed_ips">
<Input
name="allowed_ips"
id="allowed_ips"
placeholder="10.0.0.10/32"
label="Allowed IPs"
showLabel
/>
</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>
</Row>
{error ? (
<Span className="text-[12.5px] text-error dark:text-error">
{error}
</Span>
) : null}
<Row className="justify-end gap-2 mt-2">
<AdminButton onClick={() => setOpen(false)} disabled={busy}>
Cancel
</AdminButton>
<AdminButton
variant="primary"
type="submit"
disabled={busy}
>
{busy ? "Adding…" : "Add client"}
</AdminButton>
</Row>
</Stack>
</Form>
</Modal>
);
}
}
@@ -1,47 +0,0 @@
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>
);
}
@@ -1,9 +1,15 @@
import { useMemo, useState, type Dispatch, type SetStateAction } from "react";
import Search from "@/src/components/twui/elements/Search";
import Loading from "@/src/components/twui/elements/Loading";
import AdminCard from "@/src/components/general/admin-card";
import ClientRow from "../(partials)/client-row";
import ClientRow from "@/src/components/general/client-row";
import H2 from "@/src/components/twui/layout/H2";
import Span from "@/src/components/twui/layout/Span";
import Row from "@/src/components/twui/layout/Row";
import Stack from "@/src/components/twui/layout/Stack";
import ClientFormModal from "../(partials)/client-form-modal";
import { CLIENTS } from "../(data)/clients-mock-data";
import { useAdminCrudGet } from "@/src/hooks/use-admin-crud-get";
import type { BUN_SQLITE_WGUI_CLIENTS } from "@/db/types/db";
type Props = {
addOpen: boolean;
@@ -19,25 +25,36 @@ const thRightClass = `${thClass} text-right`;
export default function ClientsTableSection({ addOpen, setAddOpen }: Props) {
const [query, setQuery] = useState("");
const { res, setRes } = useAdminCrudGet<BUN_SQLITE_WGUI_CLIENTS>({
table: "clients",
sql_query: {
order: { field: "created_at", strategy: "DESC" },
limit: 100,
},
});
const filtered = useMemo(() => {
const clients = res || [];
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),
if (!q) {
return clients;
}
return clients.filter((client) =>
[client.name, client.wg_ip_address, client.allowed_ips].some(
(value) => (value || "").toLowerCase().includes(q),
),
);
}, [query]);
}, [res, 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">
<Row className="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 mb-0!">
Clients
<span className="tabular text-foreground-light/40 dark:text-foreground-dark/40 ml-2">
{filtered.length}
</span>
</h2>
<Span className="tabular text-foreground-light/40 dark:text-foreground-dark/40 ml-2">
{String(filtered.length)}
</Span>
</H2>
<Search
no_search_button
placeholder="Search clients…"
@@ -50,8 +67,12 @@ export default function ClientsTableSection({ addOpen, setAddOpen }: Props) {
},
}}
/>
</div>
{filtered.length ? (
</Row>
{res === undefined ? (
<Stack center className="w-full py-10">
<Loading />
</Stack>
) : filtered.length ? (
<div className="overflow-x-auto">
<table className="w-full min-w-[860px]">
<thead>
@@ -60,8 +81,6 @@ export default function ClientsTableSection({ addOpen, setAddOpen }: Props) {
<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>
@@ -73,16 +92,22 @@ export default function ClientsTableSection({ addOpen, setAddOpen }: Props) {
</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>
<Stack center className="w-full justify-center py-14 px-6 text-center gap-1">
<Span className="text-[13.5px] font-medium text-foreground-light/50 dark:text-foreground-dark/50">
{query ? "No clients found" : "No clients yet"}
</Span>
<Span className="text-[12.5px] text-foreground-light/35 dark:text-foreground-dark/35">
{query
? "Try adjusting your search terms"
: "Add a client to get started"}
</Span>
</Stack>
)}
<ClientFormModal open={addOpen} setOpen={setAddOpen} />
<ClientFormModal
open={addOpen}
setOpen={setAddOpen}
onCreated={() => setRes(undefined)}
/>
</AdminCard>
);
}
}
@@ -1,33 +0,0 @@
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 },
];
@@ -1,17 +0,0 @@
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>
);
}
@@ -1,17 +0,0 @@
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>
);
}
@@ -1,46 +0,0 @@
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>
);
}
@@ -1,60 +0,0 @@
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
@@ -1,33 +0,0 @@
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`,
};
@@ -0,0 +1,42 @@
import { AppData } from "@/src/data/app-data";
import type {
BUN_SQLITE_WGUI_CLIENTS,
BUN_SQLITE_WGUI_HOSTS,
BUN_SQLITE_WGUI_VARIABLES,
} from "@/db/types/db";
const LISTEN_PORT = 51820;
const IP_TABLES_DIR = `/var/lib/wgui/iptables`;
type Params = {
host?: BUN_SQLITE_WGUI_HOSTS;
variables?: BUN_SQLITE_WGUI_VARIABLES[];
clients?: BUN_SQLITE_WGUI_CLIENTS[];
};
export default function deriveHostConfig({
host,
variables,
clients,
}: Params) {
const host_id = host?.id || AppData["WireguardHostID"];
const wg_ip_address =
host?.wg_ip_address ||
variables?.find((v) => v.key == "main_host_wg_ip_address")?.value;
return {
host_id,
interface_name: `wg${host_id}`,
config_path: `/etc/wireguard/wg${host_id}.conf`,
address: wg_ip_address ? `${wg_ip_address}/24` : undefined,
listen_port: LISTEN_PORT,
post_up: `${IP_TABLES_DIR}/${host_id}-up.sh`,
post_down: `${IP_TABLES_DIR}/${host_id}-down.sh`,
private_key: host?.private_key,
public_key: host?.public_key,
client_count: clients?.filter(
(client) => (client.host_id || 0) == host_id,
).length || 0,
};
}
@@ -0,0 +1,28 @@
import { useAdminCrudGet } from "@/src/hooks/use-admin-crud-get";
import type {
BUN_SQLITE_WGUI_CLIENTS,
BUN_SQLITE_WGUI_HOSTS,
BUN_SQLITE_WGUI_VARIABLES,
} from "@/db/types/db";
export default function useHostData() {
const hosts_res = useAdminCrudGet<BUN_SQLITE_WGUI_HOSTS>({
table: "hosts",
limit: 100,
});
const variables_res = useAdminCrudGet<BUN_SQLITE_WGUI_VARIABLES>({
table: "variables",
});
const clients_res = useAdminCrudGet<BUN_SQLITE_WGUI_CLIENTS>({
table: "clients",
limit: 100,
});
return {
hosts: hosts_res.res,
variables: variables_res.res,
clients: clients_res.res,
};
}
@@ -0,0 +1,20 @@
import Span from "@/src/components/twui/layout/Span";
import Stack from "@/src/components/twui/layout/Stack";
type Props = {
label: string;
value: string;
};
export default function HostStatCell({ label, value }: Props) {
return (
<Stack className="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>
</Stack>
);
}
@@ -0,0 +1,20 @@
import Row from "@/src/components/twui/layout/Row";
import Span from "@/src/components/twui/layout/Span";
type Props = {
keyName: string;
value: string;
};
export default function InterfaceConfigRow({ keyName, value }: Props) {
return (
<Row className="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>
</Row>
);
}
@@ -0,0 +1,97 @@
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 H2 from "@/src/components/twui/layout/H2";
import P from "@/src/components/twui/layout/P";
import Row from "@/src/components/twui/layout/Row";
import Span from "@/src/components/twui/layout/Span";
import Stack from "@/src/components/twui/layout/Stack";
import deriveHostConfig from "../(functions)/derive-host-config";
import type {
BUN_SQLITE_WGUI_CLIENTS,
BUN_SQLITE_WGUI_HOSTS,
BUN_SQLITE_WGUI_VARIABLES,
} from "@/db/types/db";
type Props = {
host?: BUN_SQLITE_WGUI_HOSTS;
variables?: BUN_SQLITE_WGUI_VARIABLES[];
clients?: BUN_SQLITE_WGUI_CLIENTS[];
};
export default function InterfaceConfigSection({
host,
variables,
clients,
}: Props) {
const config = deriveHostConfig({ host, variables, clients });
const rows = [
{ keyName: "Address", value: config.address || "—" },
{ keyName: "ListenPort", value: String(config.listen_port) },
{
keyName: "PrivateKey",
value: config.private_key
? "•••••••••••• (redacted)"
: "Not set",
},
{ keyName: "PostUp", value: config.post_up },
{ keyName: "PostDown", value: config.post_down },
];
function buildConfigText() {
const lines = ["[Interface]"];
for (const row of rows) {
lines.push(`${row.keyName} = ${row.value}`);
}
lines.push("");
lines.push(
`# Public key: ${config.public_key?.slice(0, 24) || "Not generated"}`,
);
return lines.join("\n");
}
return (
<AdminCard className="w-full overflow-hidden">
<Row className="justify-between gap-3 px-5 h-12 flex-wrap">
<Stack className="gap-0.5">
<H2 className="text-[13.5px] font-medium text-foreground-light/60 dark:text-foreground-dark/60 mb-0!">
Interface configuration
</H2>
<P noMargin className="font-mono text-[11.5px] text-foreground-light/40 dark:text-foreground-dark/40">
{config.config_path}
</P>
</Stack>
<AdminButton
Icon={Copy}
onClick={() =>
navigator.clipboard?.writeText(buildConfigText())
}
>
Copy config
</AdminButton>
</Row>
<div className="border-t border-slate-200 dark:border-white/10">
<Span className="px-5 py-3 font-mono text-[12.5px] font-semibold text-secondary dark:text-secondary block">
[Interface]
</Span>
{rows.map((row) => (
<InterfaceConfigRow
key={row.keyName}
keyName={row.keyName}
value={row.value}
/>
))}
<Row className="justify-between gap-4 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 ·{" "}
{config.public_key
? `${config.public_key.slice(0, 24)}…`
: "Not generated"}
</Span>
</Row>
</div>
</AdminCard>
);
}
@@ -0,0 +1,80 @@
import AdminCard from "@/src/components/general/admin-card";
import HostStatCell from "../(partials)/host-stat-cell";
import Row from "@/src/components/twui/layout/Row";
import Span from "@/src/components/twui/layout/Span";
import Stack from "@/src/components/twui/layout/Stack";
import deriveHostConfig from "../(functions)/derive-host-config";
import { twMerge } from "tailwind-merge";
import type {
BUN_SQLITE_WGUI_CLIENTS,
BUN_SQLITE_WGUI_VARIABLES,
} from "@/db/types/db";
type Props = {
variables?: BUN_SQLITE_WGUI_VARIABLES[];
clients?: BUN_SQLITE_WGUI_CLIENTS[];
};
export default function MainHostStatusSection({ variables, clients }: Props) {
const config = deriveHostConfig({ variables, clients });
const stats = [
{
label: "Address",
value: config.address || "—",
},
{
label: "Interface",
value: config.interface_name,
},
{
label: "Peers",
value: String(config.client_count),
},
{
label: "Public key",
value: config.public_key
? `${config.public_key.slice(0, 20)}…`
: "Not generated",
},
];
return (
<AdminCard className="w-full p-5 flex flex-col gap-5">
<Row className="justify-between flex-nowrap items-start gap-3">
<Row className="gap-3 items-start">
<Span
className={twMerge(
"relative flex w-2.5 h-2.5",
config.address
? "bg-success"
: "bg-slate-400 dark:bg-slate-600",
)}
aria-hidden="true"
/>
<Stack className="gap-1">
<Span className="text-[14px] font-semibold text-foreground-light dark:text-foreground-dark leading-none">
{config.address ? "Configured" : "Not configured"}
</Span>
<Span className="text-[12.5px] text-foreground-light/45 dark:text-foreground-dark/45">
{config.interface_name}
{config.address ? ` · ${config.address}` : ""}
</Span>
</Stack>
</Row>
<Span className="tabular text-[12px] text-foreground-light/40 dark:text-foreground-dark/40">
{config.config_path}
</Span>
</Row>
<div className="grid grid-cols-2 lg:grid-cols-4 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>
);
}
+78
View File
@@ -0,0 +1,78 @@
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 useHostData from "./(hooks)/use-host-data";
import InterfaceConfigSection from "./(sections)/interface-config-section";
import Button from "@/src/components/twui/layout/Button";
import MainHostStatusSection from "./(sections)/main-host-status-section";
import H2 from "@/src/components/twui/layout/H2";
import Divider from "@/src/components/twui/layout/Divider";
import EmptyContent from "@/src/components/twui/elements/EmptyContent";
import Span from "@/src/components/twui/layout/Span";
import checkIfMainHostIsSet from "@/src/functions/check-if-main-host-is-set";
import SetupMainHostButton from "@/src/components/general/setup-main-host-button";
export default function AdminHostPage() {
const { hosts, variables, clients } = useHostData();
const is_main_host_set = checkIfMainHostIsSet({ variables });
if (!is_main_host_set) {
return (
<>
<AdminHero
title="Hosts"
description="WireGuard server configuration and interface details"
/>
<Stack className="w-full px-6 pb-8 gap-5 items-stretch">
<Span variant="faded">Main Host Not Set up</Span>
<SetupMainHostButton />
</Stack>
</>
);
}
return (
<>
<AdminHero
title="Host"
description="WireGuard server configuration and interface details"
buttons={
<>
<Button title="Add New Host">Add New Host</Button>
</>
}
/>
<Divider className="mb-6" />
<Stack className="w-full px-6 pb-8 gap-5 items-stretch">
<H2>Main Host</H2>
<MainHostStatusSection
variables={variables}
clients={clients}
/>
<InterfaceConfigSection
variables={variables}
clients={clients}
/>
</Stack>
<Divider className="mb-6" />
<Stack className="w-full px-6 pb-8 gap-5 items-stretch">
<H2>Other Hosts</H2>
{hosts?.[0] ? (
<></>
) : (
<EmptyContent title="No other hosts found" />
)}
</Stack>
</>
);
}
export const meta: BunextPageModuleMeta = {
title: `Host | ${SiteData["SiteName"]}`,
description: `WireGuard host configuration`,
};
+11 -8
View File
@@ -2,13 +2,14 @@ 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 useDashboardData from "./(hooks)/use-dashboard-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() {
const { clients, hosts, variables } = useDashboardData();
return (
<>
<AdminHero
@@ -16,11 +17,13 @@ export default function AdminDashboardPage() {
description="Overview of your WireGuard network"
/>
<Stack className="w-full px-6 pb-8 gap-5 items-stretch">
<KpiHeroSection />
<KpiSecondarySection />
<TrafficChartSection />
<PeersTableSection />
<ActivitySection />
<KpiHeroSection clients={clients} />
<KpiSecondarySection
clients={clients}
hosts={hosts}
variables={variables}
/>
<PeersTableSection clients={clients} />
</Stack>
</>
);
@@ -29,4 +32,4 @@ export default function AdminDashboardPage() {
export const meta: BunextPageModuleMeta = {
title: `Admin Dashboard | ${SiteData["SiteName"]}`,
description: `Admin dashboard`,
};
};
@@ -0,0 +1,46 @@
import userAuth from "@/src/functions/backend/auth/user-auth";
import checkPrivateIPAvailability from "@/src/functions/backend/setup/check-private-ip-availability";
import type { ApiReqParams } from "@/src/types";
import type {
APIResponseObject,
BunextAPIRouteHandler,
} from "@moduletrace/bunext/types";
import _ from "lodash";
export const handler: BunextAPIRouteHandler<APIResponseObject> = async (
params,
) => {
const req = params.req;
const body = params.body as ApiReqParams | undefined;
if (req.method !== "POST") {
return {
success: false,
};
}
const { user, user_types } = await userAuth({ req });
if (!user?.logged_in_status) {
return {
success: false,
msg: `Unauthorized`,
logoutUser: true,
};
}
try {
const ip_address = body?.ip_address;
if (!ip_address) {
throw new Error(`No IP address passed!`);
}
return await checkPrivateIPAvailability({ ip_address });
} catch (error: any) {
return {
success: false,
msg: error.message,
};
}
};
@@ -0,0 +1,35 @@
import checkUserAccess from "@/src/functions/backend/auth/check-user-access";
import userAuth from "@/src/functions/backend/auth/user-auth";
import grabNextAvailablePrivateIPSubnet from "@/src/functions/backend/setup/grab-next-available-private-ip-subnet";
import setupWireguardHost from "@/src/functions/backend/setup/setup-wireguard-host";
import type { ApiReqParams } from "@/src/types";
import type {
APIResponseObject,
BunextAPIRouteHandler,
} from "@moduletrace/bunext/types";
import _ from "lodash";
export const handler: BunextAPIRouteHandler<APIResponseObject> = async (
params,
) => {
const req = params.req;
const { user, user_types } = await userAuth({ req });
if (!user?.logged_in_status) {
return {
success: false,
msg: `Unauthorized`,
logoutUser: true,
};
}
try {
return await grabNextAvailablePrivateIPSubnet();
} catch (error: any) {
return {
success: false,
msg: error.message,
};
}
};
+52
View File
@@ -0,0 +1,52 @@
import checkUserAccess from "@/src/functions/backend/auth/check-user-access";
import userAuth from "@/src/functions/backend/auth/user-auth";
import setupWireguardHost from "@/src/functions/backend/setup/setup-wireguard-host";
import type { ApiReqParams } from "@/src/types";
import type {
APIResponseObject,
BunextAPIRouteHandler,
} from "@moduletrace/bunext/types";
import _ from "lodash";
export const handler: BunextAPIRouteHandler<APIResponseObject> = async (
params,
) => {
const req = params.req;
const body = params.body as ApiReqParams;
if (req.method !== "POST") {
return {
success: false,
};
}
const { user, user_types } = await userAuth({ req });
if (!user?.logged_in_status) {
return {
success: false,
msg: `Unauthorized`,
logoutUser: true,
};
}
try {
const is_super_admin = checkUserAccess({ user_types });
if (!is_super_admin.success) {
return {
success: false,
msg: `Unauthorized`,
};
}
return await setupWireguardHost({
wg_subnet_ip: body.ip_address,
});
} catch (error: any) {
return {
success: false,
msg: error.message,
};
}
};
+2
View File
@@ -220,6 +220,8 @@ export type ApiReqParams<
text?: string;
return_media_text_content?: boolean;
media?: BUN_SQLITE_WGUI_MEDIA;
ip_address?: string;
};
export type UserAuthReturn = {
+17
View File
@@ -0,0 +1,17 @@
type Params = {
timestamp?: number | "";
};
export default function formatUnixTimestamp({ timestamp }: Params) {
if (!timestamp) {
return "—";
}
const date = new Date(timestamp * 1000);
return date.toLocaleDateString("en-US", {
month: "short",
day: "numeric",
year: "numeric",
});
}