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 (
);
}
function ExitActions({ exit, busy, onAction, onEdit, onDelete }) {
const disabled = busy;
return (
{exit.health_status === "disabled" ? (
) : (
)}
);
}
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 (
>
}
>
);
}
function ExitTable({
exits,
accountsByExit,
bindingsError,
busy,
onAction,
onEdit,
onDelete,
}) {
const columns = [
{
header: "出口",
width: "22%",
render: (exit) => (
{exit.id}
{exit.protocol}://{exit.host}:{exit.port}
),
},
{
header: "状态",
width: "9%",
render: (exit) => ,
},
{
header: "用户名",
width: "10%",
render: (exit) => (
{exit.username || "无"}
),
},
{
header: "密码",
width: "10%",
render: (exit) => (
{exit.password || "无"}
),
},
{
header: "出口IP",
width: "13%",
render: (exit) => (
{exit.observed_public_ip || "尚无观测"}
),
},
{
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 状态未知;
const accounts = accountsByExit.get(exit.id);
return (
{accounts?.length ? accounts.join("、") : "未绑定"}
);
},
},
{
header: "操作",
width: "8%",
align: "right",
render: (exit) => (
),
},
];
return (
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 (
{notice ? (
{notice.text}
) : null}
{error ? (
{error.message}
) : null}
{bindingsError ? (
环境不可用{bindingsError.status ? `(${bindingsError.status})` : ""}
:无法读取账号绑定,状态暂时未知。
) : null}
action === "disable" ? setDisabling(exit) : runAction(exit, action)
}
onEdit={(exit) => setEditing(exit)}
onDelete={(exit) => setDeleting(exit)}
/>
{
setCreateOpen(false);
setCreateError(null);
}}
onSubmit={createExit}
busy={busy === "create"}
error={createError}
/>
setEditing(null)}
onSubmit={(data) => updateExit(editing, data)}
busy={busy === `edit:${editing?.id}`}
error={null}
initialValue={editing || createInitial}
title="编辑网络出口"
submitLabel="保存网络出口"
/>
setDeleting(null)}
onConfirm={() => deleteExit(deleting)}
title="删除网络出口"
confirmLabel="确认删除"
busy={busy === `delete:${deleting?.id}`}
body={`删除网络出口 ${deleting?.id}?有绑定或运行实例时会拒绝。`}
/>
setDisabling(null)}
onConfirm={() => {
const exit = disabling;
setDisabling(null);
runAction(exit, "disable");
}}
title="停用网络出口"
confirmLabel="确认停用"
busy={busy === disabling?.id}
body={`停用网络出口 ${disabling?.id}?绑定账号将暂停,排队任务进入 hold。`}
/>
);
}
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 ;
if (error || !exit)
return (
{error?.message || "网络出口不存在"}
);
return (
{exit.protocol}://{exit.host}:{exit.port}
}
>
{exit.id}],
[
"健康 / 版本",
{healthText[exit.health_status] ?? exit.health_status} ·{" "}
{exit.version}
,
],
[
"用户名",
{exit.username || "无"},
],
[
"密码",
{exit.password || "无"},
],
[
"出口IP",
{exit.observed_public_ip || "尚无观测"}
,
],
[
"最近检测",
exit.last_checked_at
? new Date(exit.last_checked_at).toLocaleString("zh-CN")
: "未检测",
],
]}
/>
);
}