688 lines
18 KiB
React
688 lines
18 KiB
React
import { useEffect, useMemo, useState } from "react";
|
||
import { Link, useParams } from "react-router";
|
||
import { useDataProvider, useList, useOne } from "@refinedev/core";
|
||
import {
|
||
Alert,
|
||
Button,
|
||
Card,
|
||
CardContent,
|
||
ConfirmDialog,
|
||
DetailList,
|
||
Field,
|
||
Input,
|
||
Modal,
|
||
PageHeader,
|
||
PageState,
|
||
Select,
|
||
StatusPill,
|
||
Table,
|
||
conflictMessage,
|
||
} from "./lib/ui.jsx";
|
||
import { useTitle } from "./lib/hooks.js";
|
||
|
||
const createInitial = {
|
||
protocol: "socks5",
|
||
host: "",
|
||
port: "",
|
||
username: "",
|
||
password: "",
|
||
expected_public_ip: "",
|
||
expected_region: "",
|
||
};
|
||
const healthText = {
|
||
unchecked: "未检测",
|
||
healthy: "健康",
|
||
unhealthy: "不健康",
|
||
disabled: "已停用",
|
||
};
|
||
const protocolOptions = ["http", "https", "socks4", "socks5"].map((value) => ({
|
||
value,
|
||
label: value,
|
||
}));
|
||
|
||
function ExitHealthPill({ exit }) {
|
||
const known = Object.hasOwn(healthText, exit.health_status);
|
||
const tone =
|
||
exit.health_status === "healthy"
|
||
? "success"
|
||
: exit.health_status === "disabled"
|
||
? "neutral"
|
||
: exit.health_status === "unchecked"
|
||
? "warning"
|
||
: "danger";
|
||
return (
|
||
<StatusPill
|
||
tone={tone}
|
||
label={known ? healthText[exit.health_status] : "未知状态"}
|
||
sub={known ? undefined : exit.health_status}
|
||
/>
|
||
);
|
||
}
|
||
|
||
function ExitActions({ exit, busy, onAction, onEdit, onDelete }) {
|
||
const disabled = busy;
|
||
return (
|
||
<div className="flex flex-wrap gap-2">
|
||
<Button
|
||
size="sm"
|
||
icon="ri-refresh-line"
|
||
disabled={disabled}
|
||
onClick={() => onAction(exit, "check")}
|
||
>
|
||
检测
|
||
</Button>
|
||
{exit.health_status === "disabled" ? (
|
||
<Button
|
||
size="sm"
|
||
icon="ri-checkbox-circle-line"
|
||
disabled={disabled}
|
||
onClick={() => onAction(exit, "enable")}
|
||
>
|
||
启用
|
||
</Button>
|
||
) : (
|
||
<Button
|
||
size="sm"
|
||
variant="danger"
|
||
icon="ri-forbid-line"
|
||
disabled={disabled}
|
||
onClick={() => onAction(exit, "disable")}
|
||
>
|
||
停用
|
||
</Button>
|
||
)}
|
||
<Button
|
||
size="sm"
|
||
variant="outline"
|
||
disabled={disabled}
|
||
onClick={() => onEdit(exit)}
|
||
>
|
||
编辑
|
||
</Button>
|
||
<Button
|
||
size="sm"
|
||
variant="outline"
|
||
disabled={disabled}
|
||
onClick={() => onDelete(exit)}
|
||
>
|
||
删除
|
||
</Button>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function ExitCreateModal({
|
||
open,
|
||
onClose,
|
||
onSubmit,
|
||
busy,
|
||
error,
|
||
initialValue = createInitial,
|
||
title = "创建网络出口",
|
||
submitLabel = "创建网络出口",
|
||
}) {
|
||
const [form, setForm] = useState({ ...createInitial, ...initialValue });
|
||
useEffect(() => {
|
||
if (open)
|
||
setForm({
|
||
...createInitial,
|
||
...initialValue,
|
||
port: initialValue.port ? String(initialValue.port) : "",
|
||
});
|
||
}, [open, initialValue]);
|
||
const update = (key, value) =>
|
||
setForm((current) => ({ ...current, [key]: value }));
|
||
const port = Number(form.port);
|
||
const credentialsValid = !form.password || !!form.username;
|
||
const valid =
|
||
form.host.trim() &&
|
||
Number.isInteger(port) &&
|
||
port > 0 &&
|
||
port <= 65535 &&
|
||
credentialsValid;
|
||
|
||
async function submit(event) {
|
||
event.preventDefault();
|
||
if (!valid) return;
|
||
const created = await onSubmit({
|
||
protocol: form.protocol,
|
||
host: form.host.trim(),
|
||
port,
|
||
username: form.username,
|
||
password: form.password,
|
||
expected_public_ip: form.expected_public_ip.trim(),
|
||
expected_region: form.expected_region.trim(),
|
||
});
|
||
if (created) {
|
||
setForm(createInitial);
|
||
onClose();
|
||
}
|
||
}
|
||
|
||
return (
|
||
<Modal
|
||
open={open}
|
||
onClose={onClose}
|
||
title={title}
|
||
labelledBy="exit-create-title"
|
||
size="lg"
|
||
footer={
|
||
<>
|
||
<Button onClick={onClose} disabled={busy}>
|
||
取消
|
||
</Button>
|
||
<Button
|
||
variant="primary"
|
||
type="submit"
|
||
form="exit-create-form"
|
||
busy={busy}
|
||
busyText="创建中…"
|
||
disabled={!valid}
|
||
>
|
||
{submitLabel}
|
||
</Button>
|
||
</>
|
||
}
|
||
>
|
||
<form id="exit-create-form" onSubmit={submit} noValidate>
|
||
{error ? (
|
||
<Alert variant="destructive" className="mb-4">
|
||
{conflictMessage(
|
||
error,
|
||
"出口地址或认证信息与现有资源冲突;表单内容已保留。",
|
||
)}
|
||
</Alert>
|
||
) : null}
|
||
<div className="grid gap-4 sm:grid-cols-3">
|
||
<Field id="exit-protocol" label="协议" required helper="代理协议">
|
||
<Select
|
||
id="exit-protocol"
|
||
value={form.protocol}
|
||
onChange={(event) => update("protocol", event.target.value)}
|
||
options={protocolOptions}
|
||
/>
|
||
</Field>
|
||
<Field id="exit-host" label="主机" required helper="代理服务器地址">
|
||
<Input
|
||
id="exit-host"
|
||
required
|
||
maxLength={253}
|
||
value={form.host}
|
||
onChange={(event) => update("host", event.target.value)}
|
||
placeholder="如 proxy.example"
|
||
/>
|
||
</Field>
|
||
<Field id="exit-port" label="端口" required helper="1..65535">
|
||
<Input
|
||
id="exit-port"
|
||
type="number"
|
||
min={1}
|
||
max={65535}
|
||
required
|
||
value={form.port}
|
||
onChange={(event) => update("port", event.target.value)}
|
||
/>
|
||
</Field>
|
||
<Field
|
||
id="exit-username"
|
||
label="用户名(可选)"
|
||
helper="直接保存到系统并用于代理认证"
|
||
>
|
||
<Input
|
||
id="exit-username"
|
||
maxLength={255}
|
||
value={form.username}
|
||
onChange={(event) => update("username", event.target.value)}
|
||
/>
|
||
</Field>
|
||
<Field
|
||
id="exit-password"
|
||
label="密码(可选)"
|
||
error={
|
||
credentialsValid ? undefined : "填写密码时必须同时填写用户名"
|
||
}
|
||
helper="明文保存并直接用于代理认证"
|
||
>
|
||
<Input
|
||
id="exit-password"
|
||
maxLength={255}
|
||
value={form.password}
|
||
onChange={(event) => update("password", event.target.value)}
|
||
invalid={!credentialsValid}
|
||
/>
|
||
</Field>
|
||
<Field
|
||
id="exit-ip"
|
||
label="出口IP(可选)"
|
||
helper="健康检测时比对的预期公网 IP"
|
||
>
|
||
<Input
|
||
id="exit-ip"
|
||
value={form.expected_public_ip}
|
||
onChange={(event) =>
|
||
update("expected_public_ip", event.target.value)
|
||
}
|
||
/>
|
||
</Field>
|
||
<Field
|
||
id="exit-region"
|
||
label="出口地区(可选)"
|
||
helper="如 CN-Shanghai"
|
||
>
|
||
<Input
|
||
id="exit-region"
|
||
maxLength={64}
|
||
value={form.expected_region}
|
||
onChange={(event) =>
|
||
update("expected_region", event.target.value)
|
||
}
|
||
/>
|
||
</Field>
|
||
</div>
|
||
</form>
|
||
</Modal>
|
||
);
|
||
}
|
||
|
||
function ExitTable({
|
||
exits,
|
||
accountsByExit,
|
||
bindingsError,
|
||
busy,
|
||
onAction,
|
||
onEdit,
|
||
onDelete,
|
||
}) {
|
||
const columns = [
|
||
{
|
||
header: "出口",
|
||
width: "22%",
|
||
render: (exit) => (
|
||
<div className="min-w-0">
|
||
<Link
|
||
to={`/network-exits/${encodeURIComponent(exit.id)}`}
|
||
className="anywhere font-semibold text-ink hover:text-primary"
|
||
>
|
||
{exit.id}
|
||
</Link>
|
||
<p className="anywhere text-xs text-muted">
|
||
{exit.protocol}://{exit.host}:{exit.port}
|
||
</p>
|
||
</div>
|
||
),
|
||
},
|
||
{
|
||
header: "状态",
|
||
width: "9%",
|
||
render: (exit) => <ExitHealthPill exit={exit} />,
|
||
},
|
||
{
|
||
header: "用户名",
|
||
width: "10%",
|
||
render: (exit) => (
|
||
<span className="anywhere">{exit.username || "无"}</span>
|
||
),
|
||
},
|
||
{
|
||
header: "密码",
|
||
width: "10%",
|
||
render: (exit) => (
|
||
<span className="anywhere">{exit.password || "无"}</span>
|
||
),
|
||
},
|
||
{
|
||
header: "出口IP",
|
||
width: "13%",
|
||
render: (exit) => (
|
||
<span className="anywhere">
|
||
{exit.observed_public_ip || "尚无观测"}
|
||
</span>
|
||
),
|
||
},
|
||
{
|
||
header: "最近检测",
|
||
width: "15%",
|
||
render: (exit) =>
|
||
exit.last_checked_at
|
||
? new Date(exit.last_checked_at).toLocaleString("zh-CN")
|
||
: "未检测",
|
||
},
|
||
{
|
||
header: "绑定账号",
|
||
width: "13%",
|
||
render: (exit) => {
|
||
if (bindingsError) return <span className="text-muted">状态未知</span>;
|
||
const accounts = accountsByExit.get(exit.id);
|
||
return (
|
||
<span className="anywhere">
|
||
{accounts?.length ? accounts.join("、") : "未绑定"}
|
||
</span>
|
||
);
|
||
},
|
||
},
|
||
{
|
||
header: "操作",
|
||
width: "8%",
|
||
align: "right",
|
||
render: (exit) => (
|
||
<ExitActions
|
||
exit={exit}
|
||
busy={busy === exit.id}
|
||
onAction={onAction}
|
||
onEdit={onEdit}
|
||
onDelete={onDelete}
|
||
/>
|
||
),
|
||
},
|
||
];
|
||
return (
|
||
<Table
|
||
columns={columns}
|
||
rows={exits}
|
||
rowKey={(exit) => exit.id}
|
||
minWidth="1100px"
|
||
/>
|
||
);
|
||
}
|
||
|
||
export function NetworkExitList() {
|
||
const dataProvider = useDataProvider()("default");
|
||
const [createOpen, setCreateOpen] = useState(false);
|
||
const [busy, setBusy] = useState("");
|
||
const [createError, setCreateError] = useState(null);
|
||
const [notice, setNotice] = useState(null);
|
||
const [disabling, setDisabling] = useState(null);
|
||
const [editing, setEditing] = useState(null);
|
||
const [deleting, setDeleting] = useState(null);
|
||
const { result, query } = useList({ resource: "network-exits" });
|
||
const error = query.error;
|
||
const isPending = query.isPending;
|
||
const exits = result.data ?? [];
|
||
const { result: bindingResult, query: browsersQuery } = useList({
|
||
resource: "browsers",
|
||
queryOptions: { retry: false },
|
||
});
|
||
const browsers = bindingResult.data ?? [];
|
||
const bindingsError = browsersQuery.error;
|
||
const refetchBrowsers = browsersQuery.refetch;
|
||
const accountsByExit = useMemo(() => {
|
||
const map = new Map();
|
||
for (const runtime of browsers) {
|
||
if (!runtime.network_exit_id || !runtime.account_id) continue;
|
||
map.set(runtime.network_exit_id, [
|
||
...(map.get(runtime.network_exit_id) || []),
|
||
runtime.account_id,
|
||
]);
|
||
}
|
||
return map;
|
||
}, [browsers]);
|
||
useTitle("CreatorHub · 网络出口");
|
||
|
||
async function createExit(data) {
|
||
setBusy("create");
|
||
setCreateError(null);
|
||
try {
|
||
await dataProvider.create({ resource: "network-exits", variables: data });
|
||
await query.refetch();
|
||
setNotice({
|
||
variant: "success",
|
||
text: "网络出口已创建;检测通过后才可用于账号绑定。",
|
||
});
|
||
return true;
|
||
} catch (reason) {
|
||
setCreateError(reason);
|
||
return false;
|
||
} finally {
|
||
setBusy("");
|
||
}
|
||
}
|
||
|
||
async function updateExit(exit, data) {
|
||
setBusy(`edit:${exit.id}`);
|
||
setNotice(null);
|
||
try {
|
||
await dataProvider.update({
|
||
resource: "network-exits",
|
||
id: exit.id,
|
||
variables: data,
|
||
});
|
||
await query.refetch();
|
||
setEditing(null);
|
||
setNotice({
|
||
variant: "success",
|
||
text: "网络出口已更新,需重新检测后才能使用。",
|
||
});
|
||
return true;
|
||
} catch (reason) {
|
||
setNotice({
|
||
variant: "destructive",
|
||
text: conflictMessage(reason, "出口当前不能修改。"),
|
||
});
|
||
return false;
|
||
} finally {
|
||
setBusy("");
|
||
}
|
||
}
|
||
|
||
async function deleteExit(exit) {
|
||
setBusy(`delete:${exit.id}`);
|
||
setNotice(null);
|
||
try {
|
||
await dataProvider.delete({ resource: "network-exits", id: exit.id });
|
||
await query.refetch();
|
||
setDeleting(null);
|
||
setNotice({ variant: "success", text: "网络出口已删除。" });
|
||
} catch (reason) {
|
||
setNotice({
|
||
variant: "destructive",
|
||
text: conflictMessage(reason, "出口仍有绑定或运行实例,不能删除。"),
|
||
});
|
||
} finally {
|
||
setBusy("");
|
||
}
|
||
}
|
||
|
||
async function runAction(exit, action) {
|
||
setBusy(exit.id);
|
||
setNotice(null);
|
||
try {
|
||
await dataProvider.networkExitAction(exit.id, action);
|
||
await Promise.all([query.refetch(), refetchBrowsers()]);
|
||
setNotice({
|
||
variant: "success",
|
||
text:
|
||
action === "check"
|
||
? "出口检测完成。"
|
||
: action === "enable"
|
||
? "出口已启用;重新检测通过后才能绑定。"
|
||
: "出口已停用;绑定账号不会自动恢复。",
|
||
});
|
||
} catch (reason) {
|
||
setNotice({
|
||
variant: "destructive",
|
||
text: conflictMessage(
|
||
reason,
|
||
action === "check" ? "已停用的出口不能检测。" : "出口当前不能停用。",
|
||
),
|
||
});
|
||
} finally {
|
||
setBusy("");
|
||
}
|
||
}
|
||
|
||
return (
|
||
<div>
|
||
<PageHeader
|
||
icon="ri-earth-line"
|
||
title="网络出口"
|
||
description="维护固定出口、健康观测与账号绑定"
|
||
>
|
||
<Button
|
||
variant="primary"
|
||
icon="ri-add-line"
|
||
onClick={() => setCreateOpen(true)}
|
||
>
|
||
创建网络出口
|
||
</Button>
|
||
</PageHeader>
|
||
{notice ? (
|
||
<Alert variant={notice.variant} className="mb-4">
|
||
{notice.text}
|
||
</Alert>
|
||
) : null}
|
||
{error ? (
|
||
<Alert variant="destructive" className="mb-4">
|
||
{error.message}
|
||
</Alert>
|
||
) : null}
|
||
{bindingsError ? (
|
||
<Alert variant="destructive" className="mb-4">
|
||
环境不可用{bindingsError.status ? `(${bindingsError.status})` : ""}
|
||
:无法读取账号绑定,状态暂时未知。
|
||
<Button size="sm" onClick={() => refetchBrowsers()}>
|
||
重试环境状态
|
||
</Button>
|
||
</Alert>
|
||
) : null}
|
||
<PageState
|
||
pending={isPending}
|
||
error={error}
|
||
empty={exits.length === 0}
|
||
emptyText="暂无网络出口。创建并检测健康后,才能绑定运行环境。"
|
||
>
|
||
<ExitTable
|
||
exits={exits}
|
||
accountsByExit={accountsByExit}
|
||
bindingsError={!!bindingsError}
|
||
busy={busy}
|
||
onAction={(exit, action) =>
|
||
action === "disable" ? setDisabling(exit) : runAction(exit, action)
|
||
}
|
||
onEdit={(exit) => setEditing(exit)}
|
||
onDelete={(exit) => setDeleting(exit)}
|
||
/>
|
||
</PageState>
|
||
<ExitCreateModal
|
||
open={createOpen}
|
||
onClose={() => {
|
||
setCreateOpen(false);
|
||
setCreateError(null);
|
||
}}
|
||
onSubmit={createExit}
|
||
busy={busy === "create"}
|
||
error={createError}
|
||
/>
|
||
<ExitCreateModal
|
||
open={editing !== null}
|
||
onClose={() => setEditing(null)}
|
||
onSubmit={(data) => updateExit(editing, data)}
|
||
busy={busy === `edit:${editing?.id}`}
|
||
error={null}
|
||
initialValue={editing || createInitial}
|
||
title="编辑网络出口"
|
||
submitLabel="保存网络出口"
|
||
/>
|
||
<ConfirmDialog
|
||
open={deleting !== null}
|
||
onClose={() => setDeleting(null)}
|
||
onConfirm={() => deleteExit(deleting)}
|
||
title="删除网络出口"
|
||
confirmLabel="确认删除"
|
||
busy={busy === `delete:${deleting?.id}`}
|
||
body={`删除网络出口 ${deleting?.id}?有绑定或运行实例时会拒绝。`}
|
||
/>
|
||
<ConfirmDialog
|
||
open={disabling !== null}
|
||
onClose={() => setDisabling(null)}
|
||
onConfirm={() => {
|
||
const exit = disabling;
|
||
setDisabling(null);
|
||
runAction(exit, "disable");
|
||
}}
|
||
title="停用网络出口"
|
||
confirmLabel="确认停用"
|
||
busy={busy === disabling?.id}
|
||
body={`停用网络出口 ${disabling?.id}?绑定账号将暂停,排队任务进入 hold。`}
|
||
/>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
export function NetworkExitDetail() {
|
||
const { id } = useParams();
|
||
const { result, error, isPending } = useOne({
|
||
resource: "network-exits",
|
||
id,
|
||
queryOptions: { retry: false },
|
||
});
|
||
const exit = result;
|
||
useTitle("CreatorHub · 出口详情");
|
||
if (isPending) return <PageState pending />;
|
||
if (error || !exit)
|
||
return (
|
||
<Alert variant="destructive">{error?.message || "网络出口不存在"}</Alert>
|
||
);
|
||
return (
|
||
<div>
|
||
<PageHeader
|
||
icon="ri-earth-line"
|
||
title="网络出口详情"
|
||
description={
|
||
<span className="anywhere">
|
||
{exit.protocol}://{exit.host}:{exit.port}
|
||
</span>
|
||
}
|
||
>
|
||
<Button icon="ri-arrow-left-line" as={Link} to="/network-exits">
|
||
返回出口列表
|
||
</Button>
|
||
</PageHeader>
|
||
<Card>
|
||
<CardContent className="space-y-4">
|
||
<ExitHealthPill exit={exit} />
|
||
<DetailList
|
||
rows={[
|
||
["ID", <span className="anywhere">{exit.id}</span>],
|
||
[
|
||
"健康 / 版本",
|
||
<span className="anywhere">
|
||
{healthText[exit.health_status] ?? exit.health_status} ·{" "}
|
||
{exit.version}
|
||
</span>,
|
||
],
|
||
[
|
||
"用户名",
|
||
<span className="anywhere">{exit.username || "无"}</span>,
|
||
],
|
||
[
|
||
"密码",
|
||
<span className="anywhere">{exit.password || "无"}</span>,
|
||
],
|
||
[
|
||
"出口IP",
|
||
<span className="anywhere">
|
||
{exit.observed_public_ip || "尚无观测"}
|
||
</span>,
|
||
],
|
||
[
|
||
"最近检测",
|
||
exit.last_checked_at
|
||
? new Date(exit.last_checked_at).toLocaleString("zh-CN")
|
||
: "未检测",
|
||
],
|
||
]}
|
||
/>
|
||
<div className="flex flex-wrap gap-3">
|
||
<Button
|
||
as={Link}
|
||
to={`/audit?network_exit_id=${encodeURIComponent(exit.id)}`}
|
||
>
|
||
查看审计
|
||
</Button>
|
||
</div>
|
||
</CardContent>
|
||
</Card>
|
||
</div>
|
||
);
|
||
}
|