526 lines
15 KiB
React
526 lines
15 KiB
React
import { 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,
|
||
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 }) {
|
||
const disabled = busy || exit.health_status === "disabled";
|
||
return (
|
||
<div className="flex flex-wrap gap-2">
|
||
<Button
|
||
size="sm"
|
||
icon="ri-refresh-line"
|
||
disabled={disabled}
|
||
onClick={() => onAction(exit, "check")}
|
||
>
|
||
检测
|
||
</Button>
|
||
<Button
|
||
size="sm"
|
||
variant="danger"
|
||
icon="ri-forbid-line"
|
||
disabled={disabled}
|
||
onClick={() => onAction(exit, "disable")}
|
||
>
|
||
停用
|
||
</Button>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function ExitCreateModal({ open, onClose, onSubmit, busy, error }) {
|
||
const [form, setForm] = useState(createInitial);
|
||
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="创建网络出口"
|
||
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}
|
||
>
|
||
创建网络出口
|
||
</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 ExitCard({ exit, boundAccounts, bindingsError, busy, onAction }) {
|
||
return (
|
||
<Card>
|
||
<CardContent className="space-y-3">
|
||
<div className="flex min-w-0 items-start justify-between gap-3">
|
||
<div className="min-w-0">
|
||
<Link
|
||
to={`/network-exits/${encodeURIComponent(exit.id)}`}
|
||
className="anywhere font-semibold text-ink hover:text-primary"
|
||
>
|
||
{exit.protocol}://{exit.host}:{exit.port}
|
||
</Link>
|
||
<p className="anywhere text-xs text-muted">
|
||
{exit.id} · 用户名 {exit.username || "无"} · 密码{" "}
|
||
{exit.password || "无"}
|
||
</p>
|
||
</div>
|
||
<ExitHealthPill exit={exit} />
|
||
</div>
|
||
<div className="text-sm">
|
||
<span className="text-muted">出口IP:</span>
|
||
<span className="anywhere">
|
||
{exit.observed_public_ip || "尚无观测"}
|
||
</span>
|
||
</div>
|
||
<p className="text-xs text-muted">
|
||
最近检测:
|
||
{exit.last_checked_at
|
||
? new Date(exit.last_checked_at).toLocaleString("zh-CN")
|
||
: "未检测"}
|
||
</p>
|
||
<div className="text-sm">
|
||
<span className="text-muted">绑定账号:</span>
|
||
{bindingsError ? (
|
||
"状态未知"
|
||
) : (
|
||
<span className="anywhere">
|
||
{boundAccounts?.length ? boundAccounts.join("、") : "未绑定"}
|
||
</span>
|
||
)}
|
||
</div>
|
||
<ExitActions exit={exit} busy={busy} onAction={onAction} />
|
||
</CardContent>
|
||
</Card>
|
||
);
|
||
}
|
||
|
||
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 { 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 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"
|
||
? "出口检测完成。"
|
||
: "出口已停用;绑定账号不会自动恢复。",
|
||
});
|
||
} 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="暂无网络出口。创建并检测健康后,才能绑定运行环境。"
|
||
>
|
||
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-3">
|
||
{exits.map((exit) => (
|
||
<ExitCard
|
||
key={exit.id}
|
||
exit={exit}
|
||
boundAccounts={accountsByExit.get(exit.id)}
|
||
bindingsError={!!bindingsError}
|
||
busy={busy === exit.id}
|
||
onAction={(exit, action) =>
|
||
action === "disable"
|
||
? setDisabling(exit)
|
||
: runAction(exit, action)
|
||
}
|
||
/>
|
||
))}
|
||
</div>
|
||
</PageState>
|
||
<ExitCreateModal
|
||
open={createOpen}
|
||
onClose={() => {
|
||
setCreateOpen(false);
|
||
setCreateError(null);
|
||
}}
|
||
onSubmit={createExit}
|
||
busy={busy === "create"}
|
||
error={createError}
|
||
/>
|
||
<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>
|
||
);
|
||
}
|