341 lines
12 KiB
React
341 lines
12 KiB
React
import { useEffect, useState } from "react";
|
|
import { useDataProvider, useList } from "../../shared/hooks/dataHooks.js";
|
|
import { Alert } from "../../components/ui/alert";
|
|
import { Button } from "../../components/ui/button";
|
|
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "../../components/ui/dialog";
|
|
import { Input } from "../../components/ui/input";
|
|
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "../../components/ui/table";
|
|
import { ConfirmDialog, Field, PageHeader, PageState } from "../../shared/ui/ui.jsx";
|
|
import { useTitle } from "../../shared/hooks/hooks.js";
|
|
|
|
const namePattern = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/;
|
|
const tokenPattern = /^[A-Za-z0-9][A-Za-z0-9._-]{15,127}$/;
|
|
function TokenCell({ token, name }) {
|
|
const [visible, setVisible] = useState(false);
|
|
return (
|
|
<div className="flex min-w-0 items-center gap-1">
|
|
<code className="anywhere font-mono text-[13px]">
|
|
{visible ? token : "••••••••••••"}
|
|
</code>
|
|
<Button
|
|
type="button"
|
|
variant="ghost"
|
|
size="icon-sm"
|
|
aria-label={visible ? `隐藏 ${name} 的令牌` : `显示 ${name} 的令牌`}
|
|
onClick={() => setVisible((current) => !current)}
|
|
>
|
|
<i className={visible ? "ri-eye-off-line" : "ri-eye-line"} aria-hidden="true" />
|
|
</Button>
|
|
{visible ? (
|
|
<Button
|
|
type="button"
|
|
variant="ghost"
|
|
size="icon-sm"
|
|
aria-label={`复制 ${token}`}
|
|
onClick={() => navigator.clipboard?.writeText(token)}
|
|
>
|
|
<i className="ri-file-copy-line" aria-hidden="true" />
|
|
</Button>
|
|
) : null}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function GatewayFormModal({ open, onClose, onSubmit, busy, error, initial }) {
|
|
const editing = initial !== null;
|
|
const [form, setForm] = useState({ name: "", endpoint: "", token: "" });
|
|
useEffect(() => {
|
|
if (!open) return;
|
|
setForm(
|
|
editing
|
|
? { name: initial.name, endpoint: initial.endpoint, token: "" }
|
|
: { name: "", endpoint: "", token: "" },
|
|
);
|
|
}, [editing, initial, open]);
|
|
const update = (key, value) =>
|
|
setForm((current) => ({ ...current, [key]: value }));
|
|
const token = form.token.trim();
|
|
const valid =
|
|
namePattern.test(form.name) &&
|
|
/^https?:\/\/\S+$/.test(form.endpoint) &&
|
|
(token === "" || tokenPattern.test(token));
|
|
const nameInvalid = form.name !== "" && !namePattern.test(form.name);
|
|
const endpointInvalid =
|
|
form.endpoint !== "" && !/^https?:\/\/\S+$/.test(form.endpoint);
|
|
const tokenInvalid = token !== "" && !tokenPattern.test(token);
|
|
const mode = editing ? "edit" : "create";
|
|
|
|
async function submit(event) {
|
|
event.preventDefault();
|
|
if (!valid) return;
|
|
if (
|
|
await onSubmit({
|
|
name: form.name,
|
|
endpoint: form.endpoint,
|
|
token,
|
|
})
|
|
) {
|
|
setForm({ name: "", endpoint: "", token: "" });
|
|
onClose();
|
|
}
|
|
}
|
|
|
|
return (
|
|
<Dialog open={open} onOpenChange={(nextOpen) => { if (!nextOpen) onClose() }}>
|
|
<DialogContent>
|
|
<DialogHeader><DialogTitle>{editing ? "编辑网关" : "注册网关"}</DialogTitle></DialogHeader>
|
|
<form id={`gateway-${mode}-form`} onSubmit={submit} noValidate>
|
|
{error ? (
|
|
<Alert variant="destructive" className="mb-4">
|
|
{error.message}
|
|
</Alert>
|
|
) : null}
|
|
<div className="space-y-4">
|
|
<Field
|
|
id={`gateway-${mode}-name`}
|
|
label="名称"
|
|
required
|
|
error={nameInvalid ? "字母、数字与 . _ -,最长 64 字符" : undefined}
|
|
helper={
|
|
editing
|
|
? "改名会同步更新引用该网关的环境"
|
|
: "网关唯一标识,如 gw-main"
|
|
}
|
|
>
|
|
<Input
|
|
id={`gateway-${mode}-name`}
|
|
required
|
|
maxLength={64}
|
|
value={form.name}
|
|
onChange={(event) => update("name", event.target.value)}
|
|
placeholder="如 gw-main"
|
|
aria-invalid={nameInvalid || undefined}
|
|
/>
|
|
</Field>
|
|
<Field
|
|
id={`gateway-${mode}-endpoint`}
|
|
label="Endpoint"
|
|
required
|
|
error={endpointInvalid ? "须为 http(s) URL" : undefined}
|
|
helper="网关进程的可访问地址"
|
|
>
|
|
<Input
|
|
id={`gateway-${mode}-endpoint`}
|
|
required
|
|
value={form.endpoint}
|
|
onChange={(event) => update("endpoint", event.target.value)}
|
|
placeholder="http://127.0.0.1:8081"
|
|
aria-invalid={endpointInvalid || undefined}
|
|
/>
|
|
</Field>
|
|
<Field
|
|
id={`gateway-${mode}-token`}
|
|
label={editing ? "令牌(留空保持不变)" : "令牌(可选)"}
|
|
error={
|
|
tokenInvalid ? "至少 16 个字符,字母、数字与 . _ -" : undefined
|
|
}
|
|
helper={
|
|
editing
|
|
? "填写时须与网关 GATEWAY_TOKEN 一致"
|
|
: "填写则须与网关 GATEWAY_TOKEN 一致;留空由平台生成"
|
|
}
|
|
>
|
|
<Input
|
|
id={`gateway-${mode}-token`}
|
|
maxLength={128}
|
|
value={form.token}
|
|
onChange={(event) => update("token", event.target.value)}
|
|
placeholder={editing ? "留空保持现有令牌" : "留空由平台生成"}
|
|
aria-invalid={tokenInvalid || undefined}
|
|
/>
|
|
</Field>
|
|
</div>
|
|
<DialogFooter>
|
|
<Button type="button" variant="outline" onClick={onClose} disabled={busy}>取消</Button>
|
|
<Button variant="default" type="submit" form={`gateway-${mode}-form`} disabled={!valid || busy}>
|
|
{busy ? (editing ? "保存中…" : "注册中…") : editing ? "保存" : "注册网关"}
|
|
</Button>
|
|
</DialogFooter>
|
|
</form>
|
|
</DialogContent>
|
|
</Dialog>
|
|
);
|
|
}
|
|
|
|
export function GatewayList() {
|
|
const dataProvider = useDataProvider()("default");
|
|
const { result, query } = useList({ resource: "gateways" });
|
|
const error = query.error;
|
|
const isPending = query.isPending;
|
|
const gateways = result.data ?? [];
|
|
const [createOpen, setCreateOpen] = useState(false);
|
|
const [busy, setBusy] = useState("");
|
|
const [createError, setCreateError] = useState(null);
|
|
const [created, setCreated] = useState(null);
|
|
const [editing, setEditing] = useState(null);
|
|
const [editError, setEditError] = useState(null);
|
|
const [deleting, setDeleting] = useState(null);
|
|
useTitle("CreatorHub · 网关管理");
|
|
|
|
async function createGateway(data) {
|
|
setBusy("create");
|
|
setCreateError(null);
|
|
try {
|
|
const { data: record } = await dataProvider.create({
|
|
resource: "gateways",
|
|
variables: data,
|
|
});
|
|
setCreated(record);
|
|
await query.refetch();
|
|
return true;
|
|
} catch (reason) {
|
|
setCreateError(reason);
|
|
return false;
|
|
} finally {
|
|
setBusy("");
|
|
}
|
|
}
|
|
|
|
async function updateGateway(data) {
|
|
const currentName = editing.name;
|
|
setBusy("edit");
|
|
setEditError(null);
|
|
try {
|
|
await dataProvider.update({
|
|
resource: "gateways",
|
|
id: currentName,
|
|
variables: data,
|
|
});
|
|
await query.refetch();
|
|
setEditing(null);
|
|
return true;
|
|
} catch (reason) {
|
|
setEditError(reason);
|
|
return false;
|
|
} finally {
|
|
setBusy("");
|
|
}
|
|
}
|
|
|
|
async function removeGateway() {
|
|
const gateway = deleting;
|
|
setBusy(gateway.name);
|
|
try {
|
|
await dataProvider.deleteOne({ resource: "gateways", id: gateway.name });
|
|
await query.refetch();
|
|
setDeleting(null);
|
|
} finally {
|
|
setBusy("");
|
|
}
|
|
}
|
|
|
|
return (
|
|
<div>
|
|
<PageHeader
|
|
icon="ri-git-branch-line"
|
|
title="网关管理"
|
|
description="注册宿主机 native browser gateway;令牌须与 gateway 进程的 GATEWAY_TOKEN 一致"
|
|
>
|
|
<Button variant="default" onClick={() => setCreateOpen(true)}>
|
|
<i className="ri-add-line" aria-hidden="true" />
|
|
注册网关
|
|
</Button>
|
|
</PageHeader>
|
|
{created ? (
|
|
<Alert variant="default" className="mb-4">
|
|
网关 {created.name} 已注册。
|
|
<span className="ml-1 inline-flex items-center gap-1">
|
|
令牌 <code className="font-mono">{created.token}</code>
|
|
<Button
|
|
type="button"
|
|
variant="ghost"
|
|
size="icon-sm"
|
|
aria-label={`复制 ${created.token}`}
|
|
onClick={() => navigator.clipboard?.writeText(created.token)}
|
|
>
|
|
<i className="ri-file-copy-line" aria-hidden="true" />
|
|
</Button>
|
|
</span>
|
|
<Button size="sm" onClick={() => setCreated(null)}>
|
|
知道了
|
|
</Button>
|
|
</Alert>
|
|
) : null}
|
|
{error ? (
|
|
<Alert variant="destructive" className="mb-4">
|
|
{error.message}
|
|
</Alert>
|
|
) : null}
|
|
<PageState
|
|
pending={isPending}
|
|
error={error}
|
|
empty={gateways.length === 0}
|
|
emptyText="暂无网关;注册后环境即可调度到该网关。"
|
|
>
|
|
<Table>
|
|
<TableHeader>
|
|
<TableRow>
|
|
<TableHead>名称</TableHead>
|
|
<TableHead>Endpoint</TableHead>
|
|
<TableHead>令牌</TableHead>
|
|
<TableHead className="text-right">操作</TableHead>
|
|
</TableRow>
|
|
</TableHeader>
|
|
<TableBody>
|
|
{gateways.map((gateway) => (
|
|
<TableRow key={gateway.name}>
|
|
<TableCell><span className="font-semibold">{gateway.name}</span></TableCell>
|
|
<TableCell><code className="anywhere font-mono text-[13px]">{gateway.endpoint}</code></TableCell>
|
|
<TableCell><TokenCell token={gateway.token} name={gateway.name} /></TableCell>
|
|
<TableCell className="text-right">
|
|
<div className="flex justify-end gap-2">
|
|
<Button variant="ghost" size="sm" aria-label={`编辑 ${gateway.name}`} disabled={busy === gateway.name || busy === "edit"} onClick={() => { setEditing(gateway); setEditError(null); }}>
|
|
<i className="ri-edit-line" aria-hidden="true" />
|
|
编辑
|
|
</Button>
|
|
<Button variant="destructive" size="sm" aria-label={`删除 ${gateway.name}`} disabled={busy === gateway.name || busy === "edit"} onClick={() => setDeleting(gateway)}>
|
|
<i className="ri-delete-bin-line" aria-hidden="true" />
|
|
删除
|
|
</Button>
|
|
</div>
|
|
</TableCell>
|
|
</TableRow>
|
|
))}
|
|
</TableBody>
|
|
</Table>
|
|
</PageState>
|
|
<GatewayFormModal
|
|
open={createOpen}
|
|
initial={null}
|
|
onClose={() => {
|
|
setCreateOpen(false);
|
|
setCreateError(null);
|
|
}}
|
|
onSubmit={createGateway}
|
|
busy={busy === "create"}
|
|
error={createError}
|
|
/>
|
|
<GatewayFormModal
|
|
open={editing !== null}
|
|
initial={editing}
|
|
onClose={() => {
|
|
setEditing(null);
|
|
setEditError(null);
|
|
}}
|
|
onSubmit={updateGateway}
|
|
busy={busy === "edit"}
|
|
error={editError}
|
|
/>
|
|
<ConfirmDialog
|
|
open={deleting !== null}
|
|
onClose={() => setDeleting(null)}
|
|
onConfirm={removeGateway}
|
|
title="删除网关"
|
|
confirmLabel="确认删除"
|
|
busy={busy === deleting?.name}
|
|
body={`删除网关 ${deleting?.name}?仍被环境引用时删除会被拒绝。`}
|
|
/>
|
|
</div>
|
|
);
|
|
}
|