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