Files
creator-hub/web/src/features/environments/GatewaysPage.jsx
T

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>
);
}