Files
creator-hub/web/src/NetworkExitsPage.jsx
T

526 lines
15 KiB
React
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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>
);
}