Add admin setup page

This commit is contained in:
2026-09-20 18:15:51 +01:00
parent 27676c5a3f
commit f05384d8fe
21 changed files with 1318 additions and 4 deletions
@@ -0,0 +1,114 @@
import AdminCard from "@/src/components/general/admin-card";
import Button from "@/src/components/twui/layout/Button";
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 useStatus from "@/src/components/twui/hooks/useStatus";
import { TriangleAlert } from "lucide-react";
import { useState, type ComponentProps, type ReactNode } from "react";
import SetupConsole from "./setup-console";
type Props = {
title: string;
description?: string | ReactNode;
icon?: ReactNode;
button_title: string;
buttonProps?: Omit<ComponentProps<typeof Button>, "title">;
on_run: () =>
| Promise<{ success: boolean; msg?: string } | void>
| { success: boolean; msg?: string }
| void;
children?: ReactNode;
};
export default function SetupActionCard({
title,
description,
icon,
button_title,
buttonProps,
on_run,
children,
}: Props) {
const { loading, setLoading, status, setStatus } = useStatus();
const [output, setOutput] = useState<string | null>(null);
const [is_error, setIsError] = useState(false);
const run = () => {
setLoading(true);
setStatus(undefined);
setOutput(null);
Promise.resolve()
.then(() => on_run())
.then((res) => {
if (res) {
setOutput(res.msg || null);
setIsError(!res.success);
}
})
.catch((error: any) => {
setStatus({
error: true,
msg: `Request failed — the wg-ui server may have restarted. ${
error?.message || ""
}`.trim(),
});
})
.finally(() => {
setLoading(false);
});
};
return (
<AdminCard className="w-full p-5 flex flex-col gap-4">
<Row className="gap-3 items-start">
{icon ? (
<Span className="p-2.5 rounded-lg bg-primary/10 dark:bg-primary-dark/10 text-primary dark:text-primary-dark shrink-0">
{icon}
</Span>
) : null}
<Stack className="gap-1 min-w-0">
<H3 className="text-[14px] font-semibold mb-0!">
{title}
</H3>
{description ? (
typeof description == "string" ? (
<P
noMargin
className="text-[12.5px] text-foreground-light/45 dark:text-foreground-dark/45"
>
{description}
</P>
) : (
description
)
) : null}
</Stack>
</Row>
{children}
<Button
title={button_title}
className="w-full"
loading={loading}
{...buttonProps}
onClick={run}
>
{button_title}
</Button>
{status?.error && status.msg ? (
<Row className="gap-2 items-center bg-error/5 rounded-lg px-3 py-2 text-error">
<TriangleAlert size={15} className="shrink-0" />
<Span className="text-[12.5px]">{status.msg}</Span>
</Row>
) : null}
<SetupConsole output={output} error={is_error} />
</AdminCard>
);
}
@@ -0,0 +1,64 @@
import Card from "@/src/components/twui/elements/Card";
import Tag from "@/src/components/twui/elements/Tag";
import Span from "@/src/components/twui/layout/Span";
import Stack from "@/src/components/twui/layout/Stack";
import { twMerge } from "tailwind-merge";
type Props = {
title: string;
subtitle?: string;
ok: boolean;
ok_label?: string;
fail_label?: string;
};
export default function SetupCheckStatusItem({
title,
subtitle,
ok,
ok_label = "Installed",
fail_label = "Missing",
}: Props) {
return (
<Card
noHover
className="w-full p-2.5 flex flex-row items-center gap-2.5"
title={subtitle}
>
<Span
aria-hidden="true"
className={twMerge(
"relative flex w-2.5 h-2.5 shrink-0 rounded-full",
ok ? "bg-success" : "bg-error",
)}
/>
<Stack className="gap-1 min-w-0 flex-1">
<Span
className={twMerge(
"text-[13px] font-semibold leading-none text-foreground-light",
"dark:text-foreground-dark truncate w-full line-clamp-1",
)}
>
{title}
</Span>
{subtitle ? (
<Span
className={twMerge(
"font-mono text-[11.5px] text-foreground-light/45 dark:text-foreground-dark/45 truncate",
"line-clamp-1 w-full",
)}
>
{subtitle}
</Span>
) : null}
</Stack>
<Tag
variant="outlined"
color={ok ? "success" : "error"}
className="shrink-0"
>
{ok ? ok_label : fail_label}
</Tag>
</Card>
);
}
@@ -0,0 +1,28 @@
import Span from "@/src/components/twui/layout/Span";
import { twMerge } from "tailwind-merge";
type Props = {
output?: string | null;
error?: boolean;
};
export default function SetupConsole({ output, error }: Props) {
if (!output) {
return null;
}
return (
<div
aria-live="polite"
className={twMerge(
"w-full max-h-64 overflow-y-auto overflow-x-hidden rounded-lg bg-slate-950 p-3",
"border border-solid",
error ? "border-error/40" : "border-slate-700/40",
)}
>
<pre className="text-[11.5px] leading-5 font-mono whitespace-pre-wrap break-words text-slate-300">
{output}
</pre>
</div>
);
}
@@ -0,0 +1,39 @@
import Span from "@/src/components/twui/layout/Span";
import Stack from "@/src/components/twui/layout/Stack";
import { twMerge } from "tailwind-merge";
export type SetupStatCellTone = "success" | "error" | "warning" | "muted";
const toneClassName: Record<SetupStatCellTone, string> = {
success: "text-success",
error: "text-error",
warning: "text-warning",
muted: "text-foreground-light/40 dark:text-foreground-dark/40",
};
type Props = {
label: string;
value: string;
tone?: SetupStatCellTone;
};
export default function SetupStatCell({ label, value, tone }: Props) {
return (
<Stack className="gap-1 min-w-0">
<Span className="text-[11.5px] font-semibold uppercase tracking-[0.08em] text-foreground-light/40 dark:text-foreground-dark/40">
{label}
</Span>
<Span
title={value}
className={twMerge(
"tabular text-[15px] font-semibold truncate line-clamp-1 w-full",
tone
? toneClassName[tone]
: "text-foreground-light/85 dark:text-foreground-dark/85",
)}
>
{value}
</Span>
</Stack>
);
}
@@ -0,0 +1,101 @@
import fetchApi from "@/src/components/twui/utils/fetch/fetchApi";
import Input from "@/src/components/twui/form/Input";
import Stack from "@/src/components/twui/layout/Stack";
import Span from "@/src/components/twui/layout/Span";
import type useSystemSetupStatus from "../(hooks)/use-system-setup-status";
import SetupActionCard from "./setup-action-card";
import type { ApiReqParams } from "@/src/types";
import type { APIResponseObject } from "@moduletrace/bunext/types";
import { CloudDownload } from "lucide-react";
import { useState } from "react";
import { SiteData } from "@/src/data/site-data";
type Props = {
setup: ReturnType<typeof useSystemSetupStatus>;
};
export default function SetupUpdateWgUiAction({ setup }: Props) {
const setup_status = setup.setup_status;
const [repoUrl, setRepoUrl] = useState<string>(
setup_status?.repo_url || "",
);
const [branch, setBranch] = useState<string>(
setup_status?.repo_branch || "main",
);
const needs_repo_url = !setup_status?.is_dev && !setup_status?.repo_url;
return (
<SetupActionCard
title="Update wg-ui"
description={
needs_repo_url ? (
<Span className="text-[12.5px] text-foreground-light/45 dark:text-foreground-dark/45">
wg-ui isn't installed yet — provide the git repository
URL so the installer can clone it, then run the update
again to pull newer versions.
</Span>
) : setup_status?.is_dev ? (
<Span className="text-[12.5px] text-foreground-light/45 dark:text-foreground-dark/45">
Development mode — uses this local checkout. Reinstalls
dependencies and syncs the wg-quick helper script. The
dev server is not restarted.
</Span>
) : (
<Span className="text-[12.5px] text-foreground-light/45 dark:text-foreground-dark/45">
Pulls the latest code, installs dependencies, syncs
helper scripts and restarts the{" "}
{setup_status?.service_name || "wgui"} service. The
server will briefly go offline.
</Span>
)
}
icon={<CloudDownload size={19} />}
button_title={
setup_status?.app_installed ? "Update wg-ui" : "Install wg-ui"
}
on_run={async () => {
const res = await fetchApi<ApiReqParams, APIResponseObject>(
`/api/admin/update-wg-ui`,
{
method: "POST",
body: {
repo_url: repoUrl,
branch,
},
},
);
setup.refresh();
return res;
}}
>
{needs_repo_url || !setup_status?.is_dev ? (
<Stack className="gap-2.5">
{needs_repo_url ? (
<Input<"repo_url">
label="Repository URL"
placeholder={SiteData["RepoURL"]}
value={repoUrl}
onChange={(e) => {
setRepoUrl(e.target.value);
}}
/>
) : null}
{!setup_status?.is_dev ? (
<Input<"branch">
label="Branch"
placeholder="main"
value={branch}
onChange={(e) => {
setBranch(e.target.value);
}}
/>
) : null}
</Stack>
) : null}
</SetupActionCard>
);
}