Add host setup functions and client components
This commit is contained in:
@@ -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
@@ -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
|
||||
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -18,4 +18,5 @@ export const AppData = {
|
||||
PaystackEndpoint: "https://api.paystack.co",
|
||||
|
||||
WireguardHostID: 0,
|
||||
DefaultPrivateIP: `10.1.0.1`,
|
||||
} as const;
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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,21 +60,20 @@ 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>
|
||||
);
|
||||
}
|
||||
@@ -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,16 +1,49 @@
|
||||
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,14 +18,17 @@ 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">
|
||||
<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>
|
||||
</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"
|
||||
@@ -26,27 +36,36 @@ export default function PeersTableSection() {
|
||||
View all
|
||||
<ArrowUpRight size={12} className="-mt-0.5" />
|
||||
</Link>
|
||||
</div>
|
||||
</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>
|
||||
) : (
|
||||
<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>
|
||||
<AsidePanel />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
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);
|
||||
onCreated?.();
|
||||
}
|
||||
|
||||
return (
|
||||
<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)}>
|
||||
</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">
|
||||
Add client
|
||||
<AdminButton
|
||||
variant="primary"
|
||||
type="submit"
|
||||
disabled={busy}
|
||||
>
|
||||
{busy ? "Adding…" : "Add client"}
|
||||
</AdminButton>
|
||||
</div>
|
||||
</form>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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`,
|
||||
};
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
};
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
};
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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",
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user