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,43 @@
import { useState } from "react";
import { Wand2 } from "lucide-react";
import Button from "../twui/layout/Button";
import fetchApi from "../twui/utils/fetch/fetchApi";
import type { ApiReqParams } from "@/src/types";
type Props = {
onGrab?: (ip: string) => void;
};
export default function GrabNextSubnetButton({ onGrab }: Props) {
const [grabbing, setGrabbing] = useState(false);
function grabNextSubnet() {
setGrabbing(true);
fetchApi<ApiReqParams, { success: boolean; msg?: string }>(
`/api/admin/grab-next-available-private-ip`,
{ method: "POST" },
)
.then((res) => {
if (res?.success && res.msg) {
onGrab?.(res.msg);
}
})
.finally(() => {
setGrabbing(false);
});
}
return (
<Button
title="Grab Next Available Subnet"
variant="outlined"
color="primary"
beforeIcon={<Wand2 size={16} />}
loading={grabbing}
onClick={grabNextSubnet}
>
Grab Next Subnet
</Button>
);
}
+158
View File
@@ -0,0 +1,158 @@
import { useEffect, useState, type RefObject } from "react";
import { CircleCheck, Loader2, Network, TriangleAlert } from "lucide-react";
import Input from "../twui/form/Input";
import Row from "../twui/layout/Row";
import Span from "../twui/layout/Span";
import Tag from "../twui/elements/Tag";
import Stack from "../twui/layout/Stack";
import fetchApi from "../twui/utils/fetch/fetchApi";
import type { ApiReqParams } from "@/src/types";
export type HostWgIpFieldStatus =
| "checking"
| "available"
| "not_available"
| "invalid"
| "current";
type Props = {
value: string;
onChange?: (value: string) => void;
onStatus?: (status: HostWgIpFieldStatus) => void;
inputRef?: RefObject<HTMLInputElement>;
current_value?: string;
autoFocus?: boolean;
};
const subnet_ip_pattern = /^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.1$/;
type AvailabilityType = {
success: boolean;
};
export default function HostWgIpField({
value,
onChange,
onStatus,
inputRef,
current_value,
autoFocus,
}: Props) {
const [availability, setAvailability] = useState<AvailabilityType>();
useEffect(() => {
setAvailability(undefined);
if (!subnet_ip_pattern.test(value)) return;
if (current_value && value == current_value) return;
let cancelled = false;
fetchApi<ApiReqParams, AvailabilityType>(
`/api/admin/check-private-ip-address-availability`,
{
method: "POST",
body: { ip_address: value },
},
).then((res) => {
if (cancelled) return;
setAvailability(res);
});
return () => {
cancelled = true;
};
}, [value, current_value]);
const is_current = Boolean(current_value && value == current_value);
const ip_status: HostWgIpFieldStatus = is_current
? "current"
: subnet_ip_pattern.test(value)
? availability
? availability.success
? "available"
: "not_available"
: "checking"
: "invalid";
useEffect(() => {
onStatus?.(ip_status);
}, [ip_status, onStatus]);
return (
<Stack className="w-full gap-2">
<Input
defaultValue={value}
changeHandler={(v) => {
onChange?.(v);
}}
prefix={<Network size={17} opacity={0.5} />}
suffix={
<Span className="text-sm opacity-50 whitespace-nowrap">
WireGuard IP Address
</Span>
}
componentRef={inputRef}
autoFocus={autoFocus}
/>
{ip_status === "invalid" ? (
<Tag
variant="outlined"
color="error"
className="w-full py-1.5 outline-error/50 bg-error/5!"
>
<Row>
<TriangleAlert size={15} />
<Span>Invalid IP</Span>
</Row>
</Tag>
) : ip_status === "current" ? (
<Tag
variant="outlined"
color="gray"
className="w-full py-1.5 outline-gray/50 bg-gray/5!"
>
<Row>
<CircleCheck size={15} />
<Span>Current value</Span>
</Row>
</Tag>
) : ip_status === "not_available" ? (
<Tag
variant="outlined"
color="error"
className="w-full py-1.5 outline-error/50 bg-error/5!"
>
<Row>
<TriangleAlert size={15} />
<Span>Not available</Span>
</Row>
</Tag>
) : ip_status === "available" ? (
<Tag
variant="outlined"
color="success"
className="w-full py-1.5 outline-success/50 bg-success/5!"
>
<Row>
<CircleCheck size={15} />
<Span>Available</Span>
</Row>
</Tag>
) : (
<Tag
variant="outlined"
color="gray"
className="w-full py-1.5 outline-gray/50 bg-gray/5!"
>
<Row>
<Loader2 size={15} className="animate-spin" />
<Span>Checking availability…</Span>
</Row>
</Tag>
)}
</Stack>
);
}
+17 -149
View File
@@ -1,164 +1,41 @@
import { useEffect, useRef, useState, type ComponentProps } from "react";
import { useState, type ComponentProps } from "react";
import Button from "../twui/layout/Button";
import useStatus from "../twui/hooks/useStatus";
import fetchApi from "../twui/utils/fetch/fetchApi";
import type { ApiReqParams } from "@/src/types";
import Row from "../twui/layout/Row";
import Input from "../twui/form/Input";
import Stack from "../twui/layout/Stack";
import {
CircleCheck,
Loader2,
Network,
TriangleAlert,
Wand2,
} from "lucide-react";
import Span from "../twui/layout/Span";
import Tag from "../twui/elements/Tag";
import { AppData } from "@/src/data/app-data";
import type { APIResponseObject } from "@moduletrace/bunext/types";
import HostWgIpField, {
type HostWgIpFieldStatus,
} from "./host-wg-ip-field";
import GrabNextSubnetButton from "./grab-next-subnet-button";
type Props = {
button_props?: Omit<ComponentProps<typeof Button>, "title">;
};
type AvailabilityType = {
success: boolean;
};
type GrabSubnetType = {
success: boolean;
msg?: string;
};
const subnet_ip_pattern = /^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.1$/;
type IPStatusType = "checking" | "available" | "not_available" | "invalid";
export default function SetupMainHostButton({ button_props }: Props) {
const { loading, setLoading, status, setStatus } = useStatus();
const [wgIP, setWgIP] = useState<string>(AppData["DefaultPrivateIP"]);
const [availability, setAvailability] = useState<AvailabilityType>();
const [grabbing, setGrabbing] = useState(false);
const input_ref = useRef<HTMLInputElement>(null);
useEffect(() => {
setAvailability(undefined);
if (!subnet_ip_pattern.test(wgIP)) return;
let cancelled = false;
fetchApi<ApiReqParams, AvailabilityType>(
`/api/admin/check-private-ip-address-availability`,
{
method: "POST",
body: { ip_address: wgIP },
},
).then((res) => {
if (cancelled) return;
setAvailability(res);
});
return () => {
cancelled = true;
};
}, [wgIP]);
const ip_status: IPStatusType = subnet_ip_pattern.test(wgIP)
? availability
? availability.success
? "available"
: "not_available"
: "checking"
: "invalid";
function grabNextSubnet() {
setGrabbing(true);
fetchApi<ApiReqParams, GrabSubnetType>(
`/api/admin/grab-next-available-private-ip`,
{ method: "POST" },
)
.then((res) => {
if (res?.success && res.msg) {
setWgIP(res.msg);
if (input_ref.current) {
input_ref.current.value = res.msg;
}
}
})
.finally(() => {
setGrabbing(false);
});
}
const [ipStatus, setIpStatus] = useState<HostWgIpFieldStatus>("checking");
return (
<Stack className="w-full items-stretch gap-3">
<Stack className="w-full gap-2">
<Input
defaultValue={wgIP}
changeHandler={(v) => {
setWgIP(v);
}}
prefix={<Network size={17} opacity={0.5} />}
suffix={
<Span className="text-sm opacity-50 whitespace-nowrap">
Selected Wireguard IP Address
</Span>
}
componentRef={input_ref}
autoFocus
/>
{ip_status === "invalid" ? (
<Tag
variant="outlined"
color="error"
className="w-full py-1.5 outline-error/50 bg-error/5!"
>
<Row>
<TriangleAlert size={15} />
<span>Invalid IP</span>
</Row>
</Tag>
) : ip_status === "not_available" ? (
<Tag
variant="outlined"
color="error"
className="w-full py-1.5 outline-error/50 bg-error/5!"
>
<Row>
<TriangleAlert size={15} />
<span>Not available</span>
</Row>
</Tag>
) : ip_status === "available" ? (
<Tag
variant="outlined"
color="success"
className="w-full py-1.5 outline-success/50 bg-success/5!"
>
<Row>
<CircleCheck size={15} />
<span>Available</span>
</Row>
</Tag>
) : (
<Tag
variant="outlined"
color="gray"
className="w-full py-1.5 outline-gray/50 bg-gray/5!"
>
<Row>
<Loader2 size={15} className="animate-spin" />
<span>Checking availability…</span>
</Row>
</Tag>
)}
</Stack>
<HostWgIpField
value={wgIP}
onChange={setWgIP}
onStatus={setIpStatus}
autoFocus
/>
{status?.error && status.msg ? (
<Tag
@@ -168,7 +45,7 @@ export default function SetupMainHostButton({ button_props }: Props) {
>
<Row>
<TriangleAlert size={15} />
<span>{status.msg}</span>
<Span>{status.msg}</Span>
</Row>
</Tag>
) : null}
@@ -180,27 +57,18 @@ export default function SetupMainHostButton({ button_props }: Props) {
>
<Row>
<CircleCheck size={15} />
<span>{status.msg}</span>
<Span>{status.msg}</Span>
</Row>
</Tag>
) : null}
<Row className="w-full items-center gap-2">
<Button
title="Grab Next Available Subnet"
variant="outlined"
color="primary"
beforeIcon={<Wand2 size={16} />}
loading={grabbing}
onClick={grabNextSubnet}
>
Grab Next Subnet
</Button>
<GrabNextSubnetButton onGrab={setWgIP} />
<Button
title="Setup Main Host"
{...button_props}
disabled={ip_status !== "available"}
disabled={ipStatus !== "available"}
loading={loading}
onClick={() => {
setLoading(true);
@@ -234,4 +102,4 @@ export default function SetupMainHostButton({ button_props }: Props) {
</Row>
</Stack>
);
}
}