This commit is contained in:
2026-09-20 10:49:14 +01:00
parent 3d122036ce
commit 6e3319b21c
23 changed files with 1331 additions and 223 deletions
@@ -0,0 +1,54 @@
import type { BUN_SQLITE_WGUI_CLIENTS } from "@/db/types/db";
import adminCrudHandler from "@/src/functions/frontend/admin-crud-handler";
import type { AddClientFormData } from "@/src/types";
import type { Dispatch, SetStateAction } from "react";
type Params = {
setBusy: Dispatch<SetStateAction<boolean>>;
busy: boolean;
setError: Dispatch<SetStateAction<string | undefined>>;
error?: string;
data: AddClientFormData;
open: boolean;
setOpen: Dispatch<SetStateAction<boolean>>;
onCreated?: () => void;
host_id: number;
};
export default async function submitClientForm({
busy,
setBusy,
setError,
error,
data,
open,
setOpen,
onCreated,
host_id,
}: Params) {
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 || "",
host_id,
},
],
});
setBusy(false);
if (!res.success) {
setError(res.msg || "Could not add client");
return;
}
setOpen(false);
onCreated?.();
}
@@ -1,10 +1,4 @@
import {
useState,
type Dispatch,
type ReactNode,
type SetStateAction,
} from "react";
import { X } from "lucide-react";
import { useState, type Dispatch, type SetStateAction } from "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";
@@ -13,8 +7,12 @@ 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";
import type { AddClientFormData } from "@/src/types";
import submitClientForm from "../(functions)/submit-client-form";
import { useAdminCrudGet } from "@/src/hooks/use-admin-crud-get";
import type { BUN_SQLITE_WGUI_HOSTS } from "@/db/types/db";
import Select from "@/src/components/twui/form/Select";
import { Globe } from "lucide-react";
type Props = {
open: boolean;
@@ -22,49 +20,27 @@ type Props = {
onCreated?: () => void;
};
type FormFieldProps = {
label: string;
htmlFor: string;
children: ReactNode;
};
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 [host_id, set_host_id] = useState(0);
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 || "",
},
],
});
const { res: additional_hosts } = useAdminCrudGet<BUN_SQLITE_WGUI_HOSTS>({
table: "hosts",
});
setBusy(false);
if (!res.success) {
setError(res.msg || "Could not add client");
return;
}
setOpen(false);
onCreated?.();
if (!additional_hosts) {
return null;
}
const hosts: BUN_SQLITE_WGUI_HOSTS[] = [
{
id: 0,
},
...additional_hosts,
];
return (
<Modal open={open} setOpen={setOpen} className="p-6">
<Stack className="gap-4 mb-6">
@@ -78,10 +54,35 @@ export default function ClientFormModal({ open, setOpen, onCreated }: Props) {
<Form
submitHandler={(e, data) => {
handleSubmit(data as FormData);
submitClientForm({
data: data as AddClientFormData,
busy,
open,
setBusy,
setError,
setOpen,
error,
onCreated,
host_id,
});
}}
>
<Stack className="w-full items-stretch gap-6">
<Select
options={hosts.map((h) => ({
value: (h.id || 0).toString(),
title: h.id == 0 ? `Main Host` : `Host ${h.id}`,
}))}
changeHandler={(v) => {
const selected_host = hosts.find(
(h) => h.id == Number(v),
);
if (typeof selected_host?.id == "number") {
set_host_id(selected_host.id);
}
}}
/>
<Input
name="client_name"
id="client_name"
@@ -0,0 +1,68 @@
import { Server } from "lucide-react";
import type { BUN_SQLITE_WGUI_HOSTS } from "@/db/types/db";
import AdminCard from "@/src/components/general/admin-card";
import Button from "@/src/components/twui/layout/Button";
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 Tag from "@/src/components/twui/elements/Tag";
type Props = {
host: BUN_SQLITE_WGUI_HOSTS;
client_count?: number;
};
export default function OtherHostCard({ host, client_count }: Props) {
const host_id = host.id || 0;
return (
<AdminCard className="w-full p-5 flex flex-col gap-4">
<Row className="justify-between items-center gap-3 flex-wrap">
<Stack className="gap-0.5">
<Row className="gap-2 items-center">
<Server size={15} className="opacity-50" />
<Span className="text-[14px] font-semibold leading-none">
Host #{host_id}
</Span>
</Row>
<Span className="font-mono text-[12.5px] text-foreground-light/45 dark:text-foreground-dark/45">
{host.wg_ip_address || "Not configured"}
</Span>
</Stack>
<Row className="gap-2">
<Button
title="View Host"
size="small"
variant="outlined"
href={`/admin/hosts/${host_id}`}
>
View
</Button>
<Button
title="Edit Host"
size="small"
variant="outlined"
href={`/admin/hosts/${host_id}/edit`}
>
Edit
</Button>
</Row>
</Row>
<Row className="gap-2 items-center flex-wrap">
<Tag variant="outlined" color="gray">
{client_count || 0}{" "}
{client_count == 1 ? "client" : "clients"}
</Tag>
{host.public_ip_address ? (
<Tag
variant="outlined"
color="success"
className="outline-success/50 bg-success/5!"
>
{host.public_ip_address}
</Tag>
) : null}
</Row>
</AdminCard>
);
}
@@ -104,7 +104,7 @@ export default function HostPublicIpSection({ host_id, current_ip }: Props) {
placeholder="e.g. 203.0.113.10"
label="New public IP"
showLabel
value={ip}
defaultValue={ip}
onChange={(e) => {
setTyped(true);
setIp(e.target.value);
@@ -9,14 +9,16 @@ import type {
BUN_SQLITE_WGUI_CLIENTS,
BUN_SQLITE_WGUI_VARIABLES,
} from "@/db/types/db";
import type { BUN_SQLITE_WGUI_HOSTS_JOIN } from "@/src/types/sql-joins";
type Props = {
host?: BUN_SQLITE_WGUI_HOSTS_JOIN;
variables?: BUN_SQLITE_WGUI_VARIABLES[];
clients?: BUN_SQLITE_WGUI_CLIENTS[];
};
export default function MainHostStatusSection({ variables, clients }: Props) {
const config = deriveHostConfig({ variables, clients });
export default function MainHostStatusSection({ host, variables, clients }: Props) {
const config = deriveHostConfig({ host, variables, clients });
const stats = [
{
@@ -16,11 +16,11 @@ export default function ClientRulePortsField({ draft, setDraft }: Props) {
title="Ports"
placeholder="80,443 or 8000:8080"
showLabel
value={draft.ports}
onChange={(e) => {
defaultValue={draft.ports}
changeHandler={(value) => {
setDraft({
...draft,
ports: e.target.value,
ports: value,
});
}}
info="Leave empty for any port."
@@ -44,14 +44,14 @@ export default function AddClientFormWgIpAddress({
id="add_client_wg_ip_address"
title="WireGuard IP Address"
placeholder="Eg. 10.0.0.2"
onChange={(e) => {
changeHandler={(v) => {
setForm((prev) => ({
...prev,
wg_ip_address: e.target.value,
allowed_ips: `${e.target.value.replace(/\.\d+$/, "")}.0/24`,
wg_ip_address: v,
allowed_ips: `${v.replace(/\.\d+$/, "")}.0/24`,
}));
}}
value={form.wg_ip_address}
defaultValue={form.wg_ip_address}
showLabel
/>
@@ -0,0 +1,142 @@
import { useContext, useState } from "react";
import { CircleCheck, TriangleAlert } from "lucide-react";
import AdminCard from "@/src/components/general/admin-card";
import HostWgIpField, {
type HostWgIpFieldStatus,
} from "@/src/components/general/host-wg-ip-field";
import useStatus from "@/src/components/twui/hooks/useStatus";
import fetchApi from "@/src/components/twui/utils/fetch/fetchApi";
import H3 from "@/src/components/twui/layout/H3";
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 Tag from "@/src/components/twui/elements/Tag";
import Button from "@/src/components/twui/layout/Button";
import { AppContext } from "@/src/pages/__root";
import HostPublicIpSection from "../../../(sections)/host-public-ip-section";
import type { ApiReqParams } from "@/src/types";
import type { APIResponseObject } from "@moduletrace/bunext/types";
export default function EditHostFormSection() {
const { pageProps, query } = useContext(AppContext);
const host_id = Number(query?.host_id || pageProps?.host?.id || 0);
const host = pageProps?.host;
const { loading, setLoading, status, setStatus } = useStatus();
const [wgIP, setWgIP] = useState<string>(host?.wg_ip_address || "");
const [ipStatus, setIpStatus] = useState<HostWgIpFieldStatus>("checking");
function handleSave() {
if (!window.confirm("Update this host configuration?")) return;
setStatus(undefined);
setLoading(true);
fetchApi<ApiReqParams, APIResponseObject>(
`/api/admin/update-host-wg-ip`,
{
method: "POST",
body: {
host_id,
wg_ip_address: wgIP,
},
},
)
.then((res) => {
if (res.success) {
window.location.pathname = `/admin/hosts/${host_id}`;
return;
}
setStatus({
error: true,
success: false,
msg: res.msg || "Could not update the host",
});
})
.catch((error: any) => {
setStatus({
error: true,
success: false,
msg: error.message || "Could not update the host",
});
})
.finally(() => {
setLoading(false);
});
}
return (
<>
<AdminCard className="w-full p-5 flex flex-col gap-4">
<Stack className="gap-1">
<H3 className="text-[14px] font-semibold mb-0!">
WireGuard subnet
</H3>
<P
noMargin
className="text-[12.5px] text-foreground-light/45 dark:text-foreground-dark/45"
>
The private subnet the host listens on (e.g.
10.1.0.1/24). Updating it rewrites the host config and
restarts the tunnel.
</P>
</Stack>
<HostWgIpField
value={wgIP}
onChange={setWgIP}
onStatus={setIpStatus}
current_value={host?.wg_ip_address || ""}
autoFocus
/>
{status?.error && status.msg ? (
<Tag
variant="outlined"
color="error"
className="w-full py-1.5 outline-error/50 bg-error/5!"
>
<Row>
<TriangleAlert size={15} />
<Span>{status.msg}</Span>
</Row>
</Tag>
) : null}
{status?.success && status.msg ? (
<Tag
variant="outlined"
color="success"
className="w-full py-1.5 outline-success/50 bg-success/5!"
>
<Row>
<CircleCheck size={15} />
<Span>{status.msg}</Span>
</Row>
</Tag>
) : null}
<Row className="w-full items-center gap-2">
<Button
title="Save Changes"
disabled={
ipStatus !== "available" && ipStatus !== "current"
}
loading={loading}
onClick={handleSave}
>
Save Changes
</Button>
</Row>
</AdminCard>
<HostPublicIpSection
host_id={host_id}
current_ip={host?.public_ip_address || ""}
/>
</>
);
}
@@ -0,0 +1,58 @@
import type {
BUN_SQLITE_WGUI_HOSTS,
BUN_SQLITE_WGUI_VARIABLES,
} from "@/db/types/db";
import { AppData } from "@/src/data/app-data";
import type { PagePropsType, PageQueryObject, TableType } from "@/src/types";
import BunSQLite from "@moduletrace/bun-sqlite";
import type { BunextPageServerFn } from "@moduletrace/bunext/types";
const server: BunextPageServerFn<PagePropsType> = async ({ query }) => {
const page_query = query as PageQueryObject;
const host_id = Number(
page_query.host_id || AppData["WireguardHostID"],
);
const host_res = await BunSQLite.select<
BUN_SQLITE_WGUI_HOSTS,
TableType
>({
table: "hosts",
targetId: host_id,
});
let host = host_res.singleRes || null;
if (!host && host_id == AppData["WireguardHostID"]) {
const variables_res = await BunSQLite.select<
BUN_SQLITE_WGUI_VARIABLES,
TableType
>({
table: "variables",
});
const variables = variables_res.payload || [];
host = {
id: AppData["WireguardHostID"],
wg_ip_address:
variables.find((v) => v.key == "main_host_wg_ip_address")
?.value || "",
public_ip_address:
variables.find((v) => v.key == "main_host_public_ip_address")
?.value || "",
public_key:
variables.find((v) => v.key == "main_host_wg_public_key")
?.value || "",
};
}
return {
props: {
host,
},
};
};
export default server;
@@ -0,0 +1,55 @@
import type { BunextPageModuleMeta } from "@moduletrace/bunext/types";
import AdminHero from "@/src/components/general/admin-hero";
import { SiteData } from "@/src/data/site-data";
import Button from "@/src/components/twui/layout/Button";
import Divider from "@/src/components/twui/layout/Divider";
import Stack from "@/src/components/twui/layout/Stack";
import EmptyContent from "@/src/components/twui/elements/EmptyContent";
import { ArrowLeft } from "lucide-react";
import { useContext } from "react";
import { AppContext } from "@/src/pages/__root";
import EditHostFormSection from "./(sections)/edit-host-form-section";
export default function AdminEditHostPage() {
const { pageProps, query } = useContext(AppContext);
const host_id = Number(query?.host_id || pageProps?.host?.id || 0);
return (
<>
<AdminHero
title="Edit Host"
description={
pageProps?.host?.wg_ip_address
? `Edit host ${host_id} configuration`
: "Edit host configuration"
}
buttons={
<Button
title="View host"
variant="outlined"
beforeIcon={<ArrowLeft />}
href={`/admin/hosts/${host_id}`}
>
View host
</Button>
}
/>
<Divider className="mb-6" />
<Stack className="w-full px-6 pb-8 gap-5 items-stretch">
{pageProps?.host ? (
<EditHostFormSection />
) : (
<EmptyContent title="Host not found" />
)}
</Stack>
</>
);
}
export const meta: BunextPageModuleMeta = {
title: `Edit Host | ${SiteData["SiteName"]}`,
description: `WireGuard edit host page`,
};
@@ -1,7 +1,9 @@
import type {
BUN_SQLITE_WGUI_CLIENTS,
BUN_SQLITE_WGUI_HOSTS,
BUN_SQLITE_WGUI_VARIABLES,
} from "@/db/types/db";
import grabHostDirnames from "@/src/functions/backend/setup/grab-host-dir-names";
import type { PagePropsType, PageQueryObject, TableType } from "@/src/types";
import BunSQLite from "@moduletrace/bun-sqlite";
import type { BunextPageServerFn } from "@moduletrace/bunext/types";
@@ -18,6 +20,15 @@ const server: BunextPageServerFn<PagePropsType> = async ({
TableType
>({ table: "hosts", targetId: page_query.host_id });
const host_record = host_record_res.singleRes || null;
const host = host_record
? {
...host_record,
dir_names: grabHostDirnames({ host: host_record }),
}
: null;
const host_clients = await BunSQLite.select<
BUN_SQLITE_WGUI_CLIENTS,
TableType
@@ -32,12 +43,18 @@ const server: BunextPageServerFn<PagePropsType> = async ({
},
});
const variables_res = await BunSQLite.select<
BUN_SQLITE_WGUI_VARIABLES,
TableType
>({ table: "variables" });
return {
props: {
host: host_record_res.singleRes || null,
host,
clients: host_clients.payload || null,
variables: variables_res.payload || null,
},
};
};
export default server;
export default server;
+72 -12
View File
@@ -1,26 +1,72 @@
import type { BunextPageModuleMeta } from "@moduletrace/bunext/types";
import { useContext } from "react";
import AdminHero from "@/src/components/general/admin-hero";
import { SiteData } from "@/src/data/site-data";
import Button from "@/src/components/twui/layout/Button";
import Divider from "@/src/components/twui/layout/Divider";
import EmptyContent from "@/src/components/twui/elements/EmptyContent";
import Stack from "@/src/components/twui/layout/Stack";
import HostPublicIpSection from "../(sections)/host-public-ip-section";
import { useContext } from "react";
import { AppContext } from "@/src/pages/__root";
import MainHostStatusSection from "../(sections)/main-host-status-section";
import HostPublicIpSection from "../(sections)/host-public-ip-section";
import InterfaceConfigSection from "../(sections)/interface-config-section";
export default function AdminSingleHostPage() {
const { pageProps } = useContext(AppContext);
const { pageProps, query } = useContext(AppContext);
const host_id = Number(pageProps?.host?.id || 0);
const host_id = Number(query?.host_id || pageProps?.host?.id || 0);
const host = pageProps?.host || undefined;
const variables = pageProps?.variables || [];
const clients = pageProps?.clients || [];
const is_main_host = !host || host_id == 0;
if (!pageProps?.host && host_id != 0) {
return (
<>
<AdminHero
title="Host"
description="WireGuard server configuration and interface details"
/>
<Divider className="mb-6" />
<Stack className="w-full px-6 pb-8 gap-5 items-stretch">
<EmptyContent title="Host not found" />
</Stack>
</>
);
}
return (
<>
<AdminHero
title="Host"
title={is_main_host ? "Host" : `Host #${host_id}`}
description="WireGuard server configuration and interface details"
buttons={
<>
<Button title="Add New Host" variant="outlined">
<Button
title="View Clients"
size="small"
variant="outlined"
color="primary"
href={`/admin/hosts/${host_id}/clients`}
>
View Clients
</Button>
<Button
title="Edit Host"
size="small"
variant="outlined"
href={`/admin/hosts/${host_id}/edit`}
>
Edit Host
</Button>
<Button
title="Add New Host"
size="small"
variant="outlined"
href="/admin/hosts/add"
>
Add New Host
</Button>
</>
@@ -30,12 +76,26 @@ export default function AdminSingleHostPage() {
<Divider className="mb-6" />
<Stack className="w-full px-6 pb-8 gap-5 items-stretch">
{pageProps?.host ? (
<HostPublicIpSection
host_id={host_id}
current_ip={pageProps.host.public_ip_address || ""}
/>
) : null}
<MainHostStatusSection
host={host}
variables={variables}
clients={clients}
/>
<HostPublicIpSection
host_id={host_id}
current_ip={
host?.public_ip_address ||
variables.find(
(v) => v.key == "main_host_public_ip_address",
)?.value ||
""
}
/>
<InterfaceConfigSection
host={host}
variables={variables}
clients={clients}
/>
</Stack>
</>
);
@@ -0,0 +1,126 @@
import { useState } from "react";
import { CircleCheck, TriangleAlert } from "lucide-react";
import AdminCard from "@/src/components/general/admin-card";
import GrabNextSubnetButton from "@/src/components/general/grab-next-subnet-button";
import HostWgIpField, {
type HostWgIpFieldStatus,
} from "@/src/components/general/host-wg-ip-field";
import useStatus from "@/src/components/twui/hooks/useStatus";
import fetchApi from "@/src/components/twui/utils/fetch/fetchApi";
import H3 from "@/src/components/twui/layout/H3";
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 Tag from "@/src/components/twui/elements/Tag";
import Button from "@/src/components/twui/layout/Button";
import { AppData } from "@/src/data/app-data";
import type { ApiReqParams } from "@/src/types";
import type { APIResponseObject } from "@moduletrace/bunext/types";
export default function AddHostFormSection() {
const { loading, setLoading, status, setStatus } = useStatus();
const [wgIP, setWgIP] = useState<string>(AppData["DefaultPrivateIP"]);
const [ipStatus, setIpStatus] = useState<HostWgIpFieldStatus>("checking");
function handleCreate() {
if (!window.confirm("Create this new host?")) return;
setStatus(undefined);
setLoading(true);
fetchApi<ApiReqParams, APIResponseObject>(
`/api/admin/add-host`,
{
method: "POST",
body: { wg_ip_address: wgIP },
},
)
.then((res) => {
if (res.success && res.numberRes) {
window.location.pathname = `/admin/hosts/${res.numberRes}`;
return;
}
setStatus({
error: true,
success: false,
msg: res.msg || "Could not create the host",
});
})
.catch((error: any) => {
setStatus({
error: true,
success: false,
msg: error.message || "Could not create the host",
});
})
.finally(() => {
setLoading(false);
});
}
return (
<AdminCard className="w-full p-5 flex flex-col gap-4">
<Stack className="gap-1">
<H3 className="text-[14px] font-semibold mb-0!">
WireGuard subnet
</H3>
<P
noMargin
className="text-[12.5px] text-foreground-light/45 dark:text-foreground-dark/45"
>
Each host runs on its own private subnet (e.g.
10.2.0.1/24). The tunnel is brought up and restarted as
part of creation.
</P>
</Stack>
<HostWgIpField
value={wgIP}
onChange={setWgIP}
onStatus={setIpStatus}
autoFocus
/>
{status?.error && status.msg ? (
<Tag
variant="outlined"
color="error"
className="w-full py-1.5 outline-error/50 bg-error/5!"
>
<Row>
<TriangleAlert size={15} />
<Span>{status.msg}</Span>
</Row>
</Tag>
) : null}
{status?.success && status.msg ? (
<Tag
variant="outlined"
color="success"
className="w-full py-1.5 outline-success/50 bg-success/5!"
>
<Row>
<CircleCheck size={15} />
<Span>{status.msg}</Span>
</Row>
</Tag>
) : null}
<Row className="w-full items-center gap-2">
<GrabNextSubnetButton onGrab={setWgIP} />
<Button
title="Create Host"
disabled={ipStatus !== "available"}
loading={loading}
onClick={handleCreate}
>
Create Host
</Button>
</Row>
</AdminCard>
);
}
+40
View File
@@ -0,0 +1,40 @@
import type { BunextPageModuleMeta } from "@moduletrace/bunext/types";
import AdminHero from "@/src/components/general/admin-hero";
import { SiteData } from "@/src/data/site-data";
import Button from "@/src/components/twui/layout/Button";
import Divider from "@/src/components/twui/layout/Divider";
import Stack from "@/src/components/twui/layout/Stack";
import { ArrowLeft } from "lucide-react";
import AddHostFormSection from "./(sections)/add-host-form-section";
export default function AdminAddHostPage() {
return (
<>
<AdminHero
title="Add New Host"
description="Create a new WireGuard host with a dedicated private subnet"
buttons={
<Button
title="Back to Hosts"
variant="outlined"
beforeIcon={<ArrowLeft />}
href="/admin/hosts"
>
Back to Hosts
</Button>
}
/>
<Divider className="mb-6" />
<Stack className="w-full px-6 pb-8 gap-5 items-stretch">
<AddHostFormSection />
</Stack>
</>
);
}
export const meta: BunextPageModuleMeta = {
title: `Add New Host | ${SiteData["SiteName"]}`,
description: `WireGuard add host page`,
};
+24 -3
View File
@@ -15,10 +15,13 @@ import SetupMainHostButton from "@/src/components/general/setup-main-host-button
import Row from "@/src/components/twui/layout/Row";
import { Plus } from "lucide-react";
import HostPublicIpSection from "./(sections)/host-public-ip-section";
import OtherHostCard from "./(partials)/other-host-card";
export default function AdminHostPage() {
const { hosts, variables, clients } = useHostData();
const other_hosts = (hosts || []).filter((host) => (host.id || 0) != 0);
const main_host_public_ip = variables?.find(
(v) => v.key == "main_host_public_ip_address",
)?.value;
@@ -48,7 +51,11 @@ export default function AdminHostPage() {
description="WireGuard server configuration and interface details"
buttons={
<>
<Button title="Add New Host" variant="outlined">
<Button
title="Add New Host"
variant="outlined"
href="/admin/hosts/add"
>
Add New Host
</Button>
</>
@@ -97,8 +104,22 @@ export default function AdminHostPage() {
<Divider className="mb-6" />
<Stack className="w-full px-6 pb-8 gap-5 items-stretch">
<H2>Other Hosts</H2>
{hosts?.[0] ? (
<></>
{other_hosts[0] ? (
<Stack className="w-full gap-4 items-stretch">
{other_hosts.map((host) => (
<OtherHostCard
key={host.id}
host={host}
client_count={
clients?.filter(
(client) =>
(client.host_id || 0) ==
(host.id || 0),
).length || 0
}
/>
))}
</Stack>
) : (
<EmptyContent title="No other hosts found" />
)}