Add host setup functions and client components

This commit is contained in:
2026-09-13 10:52:30 +01:00
parent 76679aabe7
commit 86eb3a0b05
53 changed files with 1284 additions and 1325 deletions
@@ -1,122 +0,0 @@
import type { WgStatus } from "@/src/components/general/status-dot";
export type ClientRecord = {
id: string;
name: string;
tunnelIp: string;
allowedIps: string;
publicKey: string;
endpoint: string;
sent: string;
received: string;
lastHandshake: string;
status: WgStatus;
createdAt: string;
};
export const CLIENTS: ClientRecord[] = [
{
id: "c-01",
name: "MacBook Pro",
tunnelIp: "10.0.0.2",
allowedIps: "10.0.0.2/32",
publicKey: "gQ1k4Lv9MxWn7Vp2RzHsTb8FcJdKqY3eWa",
endpoint: "81.2.69.142:51820",
sent: "12.4 GB",
received: "48.2 GB",
lastHandshake: "just now",
status: "connected",
createdAt: "Jan 12, 2026",
},
{
id: "c-02",
name: "iPhone 15",
tunnelIp: "10.0.0.3",
allowedIps: "10.0.0.3/32",
publicKey: "aZ9xN4cM6vBq2wEs8rT7yU1iO5pLkDfGhJ",
endpoint: "92.28.211.234:51820",
sent: "3.1 GB",
received: "11.7 GB",
lastHandshake: "1m ago",
status: "connected",
createdAt: "Feb 3, 2026",
},
{
id: "c-03",
name: "Home Server",
tunnelIp: "10.0.0.4",
allowedIps: "10.0.0.4/32, 10.0.0.0/24",
publicKey: "qW5eRt7yU8iO9pL0kM2nB3vC4xD5fG6hJ7k",
endpoint: "10.0.0.4:51820",
sent: "220.8 GB",
received: "84.3 GB",
lastHandshake: "4m ago",
status: "connected",
createdAt: "Nov 21, 2025",
},
{
id: "c-04",
name: "Office Desktop",
tunnelIp: "10.0.0.5",
allowedIps: "10.0.0.5/32",
publicKey: "zC1vB2nM3kL4jH5gF6dS7aA8sD9fG1hJ2kL",
endpoint: "77.111.247.28:51820",
sent: "18.9 GB",
received: "32.5 GB",
lastHandshake: "22m ago",
status: "connected",
createdAt: "Dec 9, 2025",
},
{
id: "c-05",
name: "Galaxy S24",
tunnelIp: "10.0.0.6",
allowedIps: "10.0.0.6/32",
publicKey: "pL0kM2nB3vC4xD5fG6hJ7kQ8wE9rT1yU2iO",
endpoint: "151.101.1.69:51820",
sent: "1.2 GB",
received: "6.8 GB",
lastHandshake: "1h ago",
status: "idle",
createdAt: "Feb 20, 2026",
},
{
id: "c-06",
name: "iPad",
tunnelIp: "10.0.0.7",
allowedIps: "10.0.0.7/32",
publicKey: "vB2nM3kL4jH5gF6dS7aA8sD9fG1hJ2kLqW",
endpoint: "89.187.168.36:51820",
sent: "0.9 GB",
received: "4.1 GB",
lastHandshake: "3h ago",
status: "idle",
createdAt: "Mar 2, 2026",
},
{
id: "c-07",
name: "Old Laptop",
tunnelIp: "10.0.0.8",
allowedIps: "10.0.0.8/32",
publicKey: "nM3kL4jH5gF6dS7aA8sD9fG1hJ2kLqW5eR",
endpoint: "192.168.1.23:51820",
sent: "0.0 GB",
received: "0.0 GB",
lastHandshake: "3d ago",
status: "error",
createdAt: "Aug 17, 2025",
},
{
id: "c-08",
name: "Travel Router",
tunnelIp: "10.0.0.9",
allowedIps: "10.0.0.9/32",
publicKey: "bV3cX4dZ5eA6sD7fG8hJ9kQ0wE1rT2yU3iO",
endpoint: "203.0.113.19:51820",
sent: "6.7 GB",
received: "21.9 GB",
lastHandshake: "2h ago",
status: "connected",
createdAt: "Jan 30, 2026",
},
];
@@ -1,12 +1,20 @@
import type { Dispatch, ReactNode, SetStateAction } from "react";
import { useState, type Dispatch, type ReactNode, type SetStateAction } from "react";
import { X } from "lucide-react";
import Modal from "@/src/components/twui/elements/Modal";
import Input from "@/src/components/twui/form/Input";
import AdminButton from "@/src/components/general/admin-button";
import Form from "@/src/components/twui/form/Form";
import Stack from "@/src/components/twui/layout/Stack";
import Span from "@/src/components/twui/layout/Span";
import H3 from "@/src/components/twui/layout/H3";
import Row from "@/src/components/twui/layout/Row";
import adminCrudHandler from "@/src/functions/frontend/admin-crud-handler";
import type { BUN_SQLITE_WGUI_CLIENTS } from "@/db/types/db";
type Props = {
open: boolean;
setOpen: Dispatch<SetStateAction<boolean>>;
onCreated?: () => void;
};
type FormFieldProps = {
@@ -15,94 +23,104 @@ type FormFieldProps = {
children: ReactNode;
};
function FormField({ label, htmlFor, children }: FormFieldProps) {
return (
<div className="flex flex-col gap-1.5">
<label
htmlFor={htmlFor}
className="text-[11.5px] font-semibold uppercase tracking-[0.08em] text-foreground-light/50 dark:text-foreground-dark/50"
>
{label}
</label>
{children}
</div>
);
}
type FormData = {
client_name?: string;
tunnel_ip?: string;
allowed_ips?: string;
};
export default function ClientFormModal({ open, setOpen, onCreated }: Props) {
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string>();
async function handleSubmit(data: FormData) {
setBusy(true);
setError(undefined);
const res = await adminCrudHandler<BUN_SQLITE_WGUI_CLIENTS>({
action: "insert",
table: "clients",
insert_data: [
{
name: data.client_name || "",
wg_ip_address: data.tunnel_ip || "",
allowed_ips: data.allowed_ips || "",
},
],
});
setBusy(false);
if (!res.success) {
setError(res.msg || "Could not add client");
return;
}
setOpen(false);
onCreated?.();
}
export default function ClientFormModal({ open, setOpen }: Props) {
return (
<Modal
open={open}
setOpen={setOpen}
no_cancel_button
className="p-6"
>
<div className="flex items-start justify-between gap-4 mb-6">
<div>
<h3 className="text-lg font-bold text-foreground-light dark:text-foreground-dark">
Add client
</h3>
<p className="text-xs text-foreground-light/50 dark:text-foreground-dark/50 mt-1">
A WireGuard config will be generated on save
</p>
</div>
<button
type="button"
aria-label="Close"
onClick={() => setOpen(false)}
className="p-1 cursor-pointer text-foreground-light/60 dark:text-foreground-dark/60 hover:text-foreground-light dark:hover:text-foreground-dark transition-colors"
>
<X size={18} />
</button>
</div>
<form
className="flex flex-col gap-4"
onSubmit={(e) => {
e.preventDefault();
setOpen(false);
<Modal open={open} setOpen={setOpen} className="p-6">
<Stack className="gap-4 mb-6">
<H3 className="text-lg font-bold text-foreground-light dark:text-foreground-dark">
Add client
</H3>
<Span className="" variant="faded">
The client is added to your WireGuard network
</Span>
</Stack>
<Form
submitHandler={(e, data) => {
handleSubmit(data as FormData);
}}
>
<FormField label="Client name" htmlFor="client_name">
<Stack className="w-full items-stretch gap-6">
<Input
name="client_name"
id="client_name"
placeholder="e.g. MacBook Pro"
label="Client name"
autoFocus
showLabel
required
/>
</FormField>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<FormField label="Tunnel IP" htmlFor="tunnel_ip">
<Row className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<Input
name="tunnel_ip"
id="tunnel_ip"
placeholder="10.0.0.10 · auto-assigned"
placeholder="10.0.0.10"
label="Tunnel IP"
showLabel
/>
</FormField>
<FormField label="Allowed IPs" htmlFor="allowed_ips">
<Input
name="allowed_ips"
id="allowed_ips"
placeholder="10.0.0.10/32"
label="Allowed IPs"
showLabel
/>
</FormField>
</div>
<FormField label="Notes" htmlFor="client_notes">
<Input
name="client_notes"
id="client_notes"
istextarea
placeholder="Optional description for this client"
/>
</FormField>
<div className="flex justify-end gap-2 mt-2">
<AdminButton onClick={() => setOpen(false)}>
Cancel
</AdminButton>
<AdminButton variant="primary" type="submit">
Add client
</AdminButton>
</div>
</form>
</Row>
{error ? (
<Span className="text-[12.5px] text-error dark:text-error">
{error}
</Span>
) : null}
<Row className="justify-end gap-2 mt-2">
<AdminButton onClick={() => setOpen(false)} disabled={busy}>
Cancel
</AdminButton>
<AdminButton
variant="primary"
type="submit"
disabled={busy}
>
{busy ? "Adding…" : "Add client"}
</AdminButton>
</Row>
</Stack>
</Form>
</Modal>
);
}
}
@@ -1,47 +0,0 @@
import StatusDot from "@/src/components/general/status-dot";
import type { ClientRecord } from "../(data)/clients-mock-data";
type Props = {
client: ClientRecord;
};
export default function ClientRow({ client }: Props) {
return (
<tr className="border-b border-slate-200/60 dark:border-white/5 last:border-b-0 hover:bg-foreground-light/[0.02] dark:hover:bg-foreground-dark/[0.02] transition-colors">
<td className="px-4 py-[9px]">
<div className="flex items-center gap-2.5">
<StatusDot status={client.status} />
<span className="text-[13.5px] font-medium text-foreground-light dark:text-foreground-dark">
{client.name}
</span>
</div>
</td>
<td className="px-4 py-[9px] tabular text-[13px] text-foreground-light/60 dark:text-foreground-dark/60 whitespace-nowrap">
{client.tunnelIp}
</td>
<td className="px-4 py-[9px] tabular text-[13px] text-foreground-light/45 dark:text-foreground-dark/45 whitespace-nowrap">
{client.allowedIps}
</td>
<td className="px-4 py-[9px]">
<span className="font-mono text-[12px] text-foreground-light/45 dark:text-foreground-dark/45 whitespace-nowrap">
{client.publicKey.slice(0, 16)}…
</span>
</td>
<td className="px-4 py-[9px] text-right whitespace-nowrap">
<span className="tabular text-[13px] font-semibold text-foreground-light dark:text-foreground-dark">
↓ {client.received}
</span>
<span className="tabular text-[12px] text-foreground-light/40 dark:text-foreground-dark/40">
{" "}
↑ {client.sent}
</span>
</td>
<td className="px-4 py-[9px] tabular text-[12px] text-foreground-light/40 dark:text-foreground-dark/40 text-right whitespace-nowrap">
{client.lastHandshake}
</td>
<td className="px-4 py-[9px] tabular text-[12px] text-foreground-light/40 dark:text-foreground-dark/40 text-right whitespace-nowrap">
{client.createdAt}
</td>
</tr>
);
}
@@ -1,9 +1,15 @@
import { useMemo, useState, type Dispatch, type SetStateAction } from "react";
import Search from "@/src/components/twui/elements/Search";
import Loading from "@/src/components/twui/elements/Loading";
import AdminCard from "@/src/components/general/admin-card";
import ClientRow from "../(partials)/client-row";
import ClientRow from "@/src/components/general/client-row";
import H2 from "@/src/components/twui/layout/H2";
import Span from "@/src/components/twui/layout/Span";
import Row from "@/src/components/twui/layout/Row";
import Stack from "@/src/components/twui/layout/Stack";
import ClientFormModal from "../(partials)/client-form-modal";
import { CLIENTS } from "../(data)/clients-mock-data";
import { useAdminCrudGet } from "@/src/hooks/use-admin-crud-get";
import type { BUN_SQLITE_WGUI_CLIENTS } from "@/db/types/db";
type Props = {
addOpen: boolean;
@@ -19,25 +25,36 @@ const thRightClass = `${thClass} text-right`;
export default function ClientsTableSection({ addOpen, setAddOpen }: Props) {
const [query, setQuery] = useState("");
const { res, setRes } = useAdminCrudGet<BUN_SQLITE_WGUI_CLIENTS>({
table: "clients",
sql_query: {
order: { field: "created_at", strategy: "DESC" },
limit: 100,
},
});
const filtered = useMemo(() => {
const clients = res || [];
const q = query.trim().toLowerCase();
if (!q) return CLIENTS;
return CLIENTS.filter((client) =>
[client.name, client.tunnelIp, client.allowedIps].some((value) =>
value.toLowerCase().includes(q),
if (!q) {
return clients;
}
return clients.filter((client) =>
[client.name, client.wg_ip_address, client.allowed_ips].some(
(value) => (value || "").toLowerCase().includes(q),
),
);
}, [query]);
}, [res, query]);
return (
<AdminCard className="w-full overflow-hidden">
<div className="flex items-center justify-between gap-3 px-5 h-12 flex-wrap">
<h2 className="text-[13.5px] font-medium text-foreground-light/60 dark:text-foreground-dark/60">
<Row className="justify-between gap-3 px-5 h-12 flex-wrap">
<H2 className="text-[13.5px] font-medium text-foreground-light/60 dark:text-foreground-dark/60 mb-0!">
Clients
<span className="tabular text-foreground-light/40 dark:text-foreground-dark/40 ml-2">
{filtered.length}
</span>
</h2>
<Span className="tabular text-foreground-light/40 dark:text-foreground-dark/40 ml-2">
{String(filtered.length)}
</Span>
</H2>
<Search
no_search_button
placeholder="Search clients…"
@@ -50,8 +67,12 @@ export default function ClientsTableSection({ addOpen, setAddOpen }: Props) {
},
}}
/>
</div>
{filtered.length ? (
</Row>
{res === undefined ? (
<Stack center className="w-full py-10">
<Loading />
</Stack>
) : filtered.length ? (
<div className="overflow-x-auto">
<table className="w-full min-w-[860px]">
<thead>
@@ -60,8 +81,6 @@ export default function ClientsTableSection({ addOpen, setAddOpen }: Props) {
<th className={thClass}>Tunnel IP</th>
<th className={thClass}>Allowed IPs</th>
<th className={thClass}>Public key</th>
<th className={thRightClass}>Traffic</th>
<th className={thRightClass}>Last handshake</th>
<th className={thRightClass}>Created</th>
</tr>
</thead>
@@ -73,16 +92,22 @@ export default function ClientsTableSection({ addOpen, setAddOpen }: Props) {
</table>
</div>
) : (
<div className="flex flex-col items-center justify-center gap-1 py-14 px-6 text-center">
<span className="text-[13.5px] font-medium text-foreground-light/50 dark:text-foreground-dark/50">
No clients found
</span>
<span className="text-[12.5px] text-foreground-light/35 dark:text-foreground-dark/35">
Try adjusting your search terms
</span>
</div>
<Stack center className="w-full justify-center py-14 px-6 text-center gap-1">
<Span className="text-[13.5px] font-medium text-foreground-light/50 dark:text-foreground-dark/50">
{query ? "No clients found" : "No clients yet"}
</Span>
<Span className="text-[12.5px] text-foreground-light/35 dark:text-foreground-dark/35">
{query
? "Try adjusting your search terms"
: "Add a client to get started"}
</Span>
</Stack>
)}
<ClientFormModal open={addOpen} setOpen={setAddOpen} />
<ClientFormModal
open={addOpen}
setOpen={setAddOpen}
onCreated={() => setRes(undefined)}
/>
</AdminCard>
);
}
}