feat(web): task-5 browsers 模块迁移(运行环境列表/创建/详情、网络出口列表/详情、网关管理,显式动词 API)
This commit is contained in:
@@ -1,4 +1,111 @@
|
||||
// TODO: 从 web.archived 迁移 browsers/$id
|
||||
// 运行环境详情:语义对齐 web.archived BrowsersPage.jsx 的 BrowserDetail。
|
||||
import { useEffect, useState } from 'react';
|
||||
import { history, useParams } from '@umijs/max';
|
||||
import { Alert, Button, Card, Descriptions, Space, Tag, Typography, message } from 'antd';
|
||||
import { CopyOutlined } from '@ant-design/icons';
|
||||
import { getOne } from '@/services/api';
|
||||
|
||||
const stateText: Record<string, string> = {
|
||||
created: '已创建',
|
||||
running: '运行中',
|
||||
stopped: '已停止',
|
||||
degraded: '异常',
|
||||
failed: '失败待清理',
|
||||
cleanup_pending: '待清理',
|
||||
gateway_unreachable: '网关不可达',
|
||||
runtime_missing: '运行实例缺失',
|
||||
recorded: '已记录',
|
||||
missing: '未部署',
|
||||
};
|
||||
|
||||
export default function Page() {
|
||||
return null;
|
||||
const { id = '' } = useParams<{ id: string }>();
|
||||
const [runtime, setRuntime] = useState<any>(null);
|
||||
const [pending, setPending] = useState(true);
|
||||
const [error, setError] = useState<any>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
setPending(true);
|
||||
setError(null);
|
||||
getOne('browsers', id)
|
||||
.then((result) => {
|
||||
if (!cancelled) setRuntime(result);
|
||||
})
|
||||
.catch((loadError) => {
|
||||
if (!cancelled) setError(loadError);
|
||||
})
|
||||
.finally(() => {
|
||||
if (!cancelled) setPending(false);
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [id]);
|
||||
|
||||
if (pending) return <Card loading />;
|
||||
if (error || !runtime) return <Alert type="error" showIcon message={error?.message || '运行环境不存在'} />;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div style={{ marginBottom: 16, display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<Typography.Title level={4} style={{ marginBottom: 0 }}>
|
||||
{`${runtime.name}(${runtime.alias})`}
|
||||
</Typography.Title>
|
||||
<Button onClick={() => history.push('/browsers')}>返回运行环境</Button>
|
||||
</div>
|
||||
<Card>
|
||||
<Space direction="vertical" style={{ width: '100%' }} size={16}>
|
||||
<Tag color={runtime.state === 'running' ? 'success' : runtime.state === 'stopped' ? 'warning' : 'default'}>
|
||||
{stateText[runtime.state] ?? `未知状态:${runtime.state}`}
|
||||
</Tag>
|
||||
<Descriptions column={1} bordered size="small">
|
||||
<Descriptions.Item label="账号与指纹">
|
||||
{runtime.account_id} · seed {runtime.fingerprint?.seed}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="固定出口">
|
||||
{`${runtime.network_exit_id || '未绑定'} · 健康:${runtime.network_exit_health || '未知'}`}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="绑定版本 / 运行实例">
|
||||
{`${runtime.binding_version} · ${runtime.runtime_instance_id || '无'}`}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="网关">{runtime.gateway_reachable ? '可达' : '不可达'}</Descriptions.Item>
|
||||
{runtime.runtime_node_id ? <Descriptions.Item label="节点">{runtime.runtime_node_id}</Descriptions.Item> : null}
|
||||
{runtime.runtime_cleanup_state === 'pending' ? (
|
||||
<Descriptions.Item label="资源待清理">
|
||||
<Typography.Text type="danger">{runtime.runtime_cleanup_error || '原因未返回'}</Typography.Text>
|
||||
</Descriptions.Item>
|
||||
) : null}
|
||||
{runtime.endpoint ? (
|
||||
<Descriptions.Item label="CDP">
|
||||
<Space size={4}>
|
||||
<Typography.Text code style={{ fontSize: 12 }} ellipsis>
|
||||
{runtime.endpoint}
|
||||
</Typography.Text>
|
||||
<Button
|
||||
type="text"
|
||||
size="small"
|
||||
icon={<CopyOutlined />}
|
||||
aria-label="复制 CDP 地址"
|
||||
onClick={() => {
|
||||
navigator.clipboard?.writeText(runtime.endpoint);
|
||||
message.success('已复制 CDP 地址');
|
||||
}}
|
||||
/>
|
||||
</Space>
|
||||
</Descriptions.Item>
|
||||
) : null}
|
||||
</Descriptions>
|
||||
<Space wrap>
|
||||
<Button type="primary" onClick={() => history.push(`/accounts/${encodeURIComponent(runtime.account_id)}`)}>
|
||||
查看账号
|
||||
</Button>
|
||||
{runtime.network_exit_id ? (
|
||||
<Button onClick={() => history.push(`/network-exits/${encodeURIComponent(runtime.network_exit_id)}`)}>查看出口</Button>
|
||||
) : null}
|
||||
</Space>
|
||||
</Space>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,184 @@
|
||||
// TODO: 从 web.archived 迁移 browsers/index
|
||||
// 运行环境列表:语义对齐 web.archived BrowsersPage.jsx 的 BrowserList。
|
||||
// 动作保持显式动词(start/stop/recycle),启停禁用条件与归档版一致。
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { history } from '@umijs/max';
|
||||
import { Alert, Button, Popconfirm, Space, Table, Tag, Typography, message } from 'antd';
|
||||
import { CopyOutlined, PlusOutlined } from '@ant-design/icons';
|
||||
import type { ColumnsType } from 'antd/es/table';
|
||||
import { browserAction, getList } from '@/services/api';
|
||||
|
||||
const stateText: Record<string, string> = {
|
||||
created: '已创建',
|
||||
running: '运行中',
|
||||
stopped: '已停止',
|
||||
degraded: '异常',
|
||||
failed: '失败待清理',
|
||||
cleanup_pending: '待清理',
|
||||
gateway_unreachable: '网关不可达',
|
||||
runtime_missing: '运行实例缺失',
|
||||
recorded: '已记录',
|
||||
missing: '未部署',
|
||||
};
|
||||
const blockText: Record<string, string> = {
|
||||
account_revoked: '授权已撤销',
|
||||
account_paused: '账号已暂停',
|
||||
binding_missing: '绑定缺失',
|
||||
network_exit_missing: '固定出口缺失',
|
||||
network_exit_unhealthy: '固定出口不健康',
|
||||
runtime_stop_pending: '停止结果待确认',
|
||||
runtime_missing: '运行实例缺失',
|
||||
};
|
||||
|
||||
const scheduleBlock = (runtime: any) =>
|
||||
runtime.schedule_status === 'ready' ? '' : blockText[runtime.schedule_block_reason] || runtime.schedule_block_reason || '不可调度';
|
||||
|
||||
export default function Page() {
|
||||
return null;
|
||||
const [runtimes, setRuntimes] = useState<any[]>([]);
|
||||
const [pending, setPending] = useState(true);
|
||||
const [error, setError] = useState<any>(null);
|
||||
const [busy, setBusy] = useState('');
|
||||
const [messageApi, contextHolder] = message.useMessage();
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setPending(true);
|
||||
setError(null);
|
||||
try {
|
||||
const result = await getList({ resource: 'browsers' });
|
||||
setRuntimes(result.data ?? []);
|
||||
} catch (loadError) {
|
||||
setError(loadError);
|
||||
} finally {
|
||||
setPending(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
load();
|
||||
}, [load]);
|
||||
|
||||
async function runAction(runtime: any, action: 'start' | 'stop' | 'recycle') {
|
||||
setBusy(runtime.alias);
|
||||
try {
|
||||
await browserAction(runtime.alias, action);
|
||||
await load();
|
||||
} catch (reason: any) {
|
||||
messageApi.error(reason.message || '操作失败');
|
||||
} finally {
|
||||
setBusy('');
|
||||
}
|
||||
}
|
||||
|
||||
async function handleRecycle(runtime: any) {
|
||||
await runAction(runtime, 'recycle');
|
||||
}
|
||||
|
||||
const columns: ColumnsType<any> = [
|
||||
{
|
||||
title: '环境',
|
||||
dataIndex: 'name',
|
||||
render: (_, runtime) => (
|
||||
<div style={{ cursor: 'pointer' }} onClick={() => history.push(`/browsers/${encodeURIComponent(runtime.alias)}`)}>
|
||||
<Typography.Text strong>{runtime.name}</Typography.Text>
|
||||
<Typography.Paragraph type="secondary" style={{ marginBottom: 0, fontSize: 12 }} ellipsis>
|
||||
{runtime.alias} · seed {runtime.fingerprint?.seed}
|
||||
</Typography.Paragraph>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '状态',
|
||||
dataIndex: 'state',
|
||||
render: (state: string) => {
|
||||
const known = Object.hasOwn(stateText, state);
|
||||
return (
|
||||
<Tag color={state === 'running' ? 'success' : state === 'stopped' ? 'warning' : 'default'}>{known ? stateText[state] : `未知状态:${state}`}</Tag>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
title: '固定绑定',
|
||||
dataIndex: 'account_id',
|
||||
render: (_, runtime) => {
|
||||
const block = runtime.schedule_block_reason === 'runtime_missing' ? '' : scheduleBlock(runtime);
|
||||
return (
|
||||
<div>
|
||||
<Typography.Paragraph style={{ marginBottom: 0 }}>{runtime.account_id || '未绑定账号'}</Typography.Paragraph>
|
||||
<Typography.Paragraph type="secondary" style={{ marginBottom: 4, fontSize: 12 }} ellipsis>
|
||||
{runtime.network_exit_id || '当前机器直连'}
|
||||
</Typography.Paragraph>
|
||||
<Tag color={block ? 'warning' : 'success'}>{block || '可调度'}</Tag>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
title: 'CDP',
|
||||
dataIndex: 'endpoint',
|
||||
render: (endpoint?: string) =>
|
||||
endpoint ? (
|
||||
<Space size={4}>
|
||||
<Typography.Text code style={{ fontSize: 12 }} ellipsis>
|
||||
{endpoint}
|
||||
</Typography.Text>
|
||||
<Button type="text" size="small" icon={<CopyOutlined />} aria-label="复制 CDP 地址" onClick={() => navigator.clipboard?.writeText(endpoint)} />
|
||||
</Space>
|
||||
) : (
|
||||
<Typography.Text type="secondary">—</Typography.Text>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
key: 'actions',
|
||||
align: 'right',
|
||||
render: (_, runtime) => {
|
||||
const running = runtime.state === 'running';
|
||||
const stopping = ['running', 'degraded', 'starting', 'failed', 'cleanup_pending'].includes(runtime.state);
|
||||
const gatewayUnavailable = runtime.gateway_reachable === false;
|
||||
const block = runtime.schedule_block_reason === 'runtime_missing' ? '' : scheduleBlock(runtime);
|
||||
return (
|
||||
<Space wrap style={{ justifyContent: 'flex-end' }}>
|
||||
<Button
|
||||
size="small"
|
||||
title={`启动 ${runtime.name}${block ? `(${block})` : ''}`}
|
||||
disabled={busy === runtime.alias || running || gatewayUnavailable || block !== ''}
|
||||
onClick={() => runAction(runtime, 'start')}
|
||||
>
|
||||
启动
|
||||
</Button>
|
||||
<Button size="small" disabled={busy === runtime.alias || !stopping || gatewayUnavailable} onClick={() => runAction(runtime, 'stop')}>
|
||||
停止
|
||||
</Button>
|
||||
<Popconfirm title={`回收 ${runtime.name}(${runtime.alias})?运行资源将被回收,Profile 目录保留。`} onConfirm={() => handleRecycle(runtime)}>
|
||||
<Button size="small" danger disabled={busy === runtime.alias || gatewayUnavailable}>
|
||||
回收
|
||||
</Button>
|
||||
</Popconfirm>
|
||||
</Space>
|
||||
);
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div>
|
||||
{contextHolder}
|
||||
<div style={{ marginBottom: 16, display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<Typography.Text type="secondary">启动、停止并回收隔离的指纹浏览器运行环境</Typography.Text>
|
||||
<Button type="primary" icon={<PlusOutlined />} onClick={() => history.push('/browsers/new')}>
|
||||
创建环境
|
||||
</Button>
|
||||
</div>
|
||||
{error ? (
|
||||
<Alert type="error" showIcon style={{ marginBottom: 16 }} message={error.message} action={<Button size="small" onClick={load}>重试</Button>} />
|
||||
) : null}
|
||||
<Table
|
||||
rowKey="alias"
|
||||
columns={columns}
|
||||
dataSource={runtimes}
|
||||
loading={pending}
|
||||
pagination={false}
|
||||
locale={{ emptyText: '暂无运行环境,创建第一个隔离 Profile。' }}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,206 @@
|
||||
// TODO: 从 web.archived 迁移 browsers/new
|
||||
// 创建运行环境:语义对齐 web.archived BrowsersPage.jsx 的 BrowserCreatePage。
|
||||
// 高级指纹参数用 antd Collapse 收纳;空值不提交,收敛到 seed 驱动。
|
||||
import { useEffect, useState } from 'react';
|
||||
import { history } from '@umijs/max';
|
||||
import { Alert, Button, Card, Checkbox, Collapse, Form, Input, InputNumber, Select, Space, Typography, message } from 'antd';
|
||||
import { create, getList } from '@/services/api';
|
||||
import { conflictMessage } from '@/utils/helpers';
|
||||
|
||||
const aliasPattern = /^[a-z0-9][a-z0-9-]{0,31}$/;
|
||||
const platformOptions = ['windows', 'linux', 'macos'].map((value) => ({ value, label: value }));
|
||||
const brandOptions = ['Chrome', 'Edge', 'Opera', 'Vivaldi'].map((value) => ({ value, label: value }));
|
||||
const spoofingFields = ['font', 'audio', 'canvas', 'clientrects', 'gpu'];
|
||||
|
||||
// 高级字段空值不提交,收敛到 seed 驱动(对齐归档版 fingerprintPayload)。
|
||||
function fingerprintPayload(form: any) {
|
||||
const payload: Record<string, unknown> = { seed: Number(form.seed) };
|
||||
const advanced = {
|
||||
platform: form.platform,
|
||||
platform_version: form.platform_version,
|
||||
brand: form.brand,
|
||||
brand_version: form.brand_version,
|
||||
lang: form.lang,
|
||||
accept_lang: form.accept_lang,
|
||||
timezone: form.timezone,
|
||||
};
|
||||
for (const [key, value] of Object.entries(advanced)) if (value) payload[key] = value;
|
||||
if (form.hardware_concurrency) payload.hardware_concurrency = Number(form.hardware_concurrency);
|
||||
if (form.disable_spoofing?.length > 0) payload.disable_spoofing = form.disable_spoofing.join(',');
|
||||
return payload;
|
||||
}
|
||||
|
||||
// 已用 seed 之外的最小可用 seed(对齐归档版 nextFingerprintSeed)。
|
||||
function nextFingerprintSeed(runtimes: any[]): string {
|
||||
const used = new Set(runtimes.map((runtime) => Number(runtime.fingerprint?.seed)).filter((seed) => Number.isInteger(seed) && seed > 0));
|
||||
let seed = 1000;
|
||||
while (used.has(seed)) seed += 1;
|
||||
return String(seed);
|
||||
}
|
||||
|
||||
export default function Page() {
|
||||
return null;
|
||||
const [form] = Form.useForm();
|
||||
const [runtimes, setRuntimes] = useState<any[]>([]);
|
||||
const [gateways, setGateways] = useState<any[]>([]);
|
||||
const [accounts, setAccounts] = useState<any[]>([]);
|
||||
const [networkExits, setNetworkExits] = useState<any[]>([]);
|
||||
const [submitBusy, setSubmitBusy] = useState(false);
|
||||
const [messageApi, contextHolder] = message.useMessage();
|
||||
|
||||
useEffect(() => {
|
||||
Promise.allSettled([
|
||||
getList({ resource: 'browsers' }),
|
||||
getList({ resource: 'gateways' }),
|
||||
getList({ resource: 'accounts' }),
|
||||
getList({ resource: 'network-exits' }),
|
||||
]).then(([browsers, gatewaysResult, accountsResult, exitsResult]) => {
|
||||
if (browsers.status === 'fulfilled') {
|
||||
setRuntimes(browsers.value.data ?? []);
|
||||
form.setFieldsValue({ seed: nextFingerprintSeed(browsers.value.data ?? []) });
|
||||
}
|
||||
if (gatewaysResult.status === 'fulfilled') {
|
||||
setGateways(gatewaysResult.value.data ?? []);
|
||||
const first = (gatewaysResult.value.data ?? [])[0];
|
||||
if (first) form.setFieldsValue({ gateway: first.name });
|
||||
}
|
||||
if (accountsResult.status === 'fulfilled') {
|
||||
const paused = (accountsResult.value.data ?? []).filter(
|
||||
(account: any) => account.authorization_status === 'authorized' && account.runtime_status === 'paused',
|
||||
);
|
||||
setAccounts(paused);
|
||||
if (paused[0]?.id) form.setFieldsValue({ account_id: paused[0].id });
|
||||
}
|
||||
if (exitsResult.status === 'fulfilled') {
|
||||
setNetworkExits((exitsResult.value.data ?? []).filter((item: any) => item.health_status === 'healthy'));
|
||||
}
|
||||
});
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
const pausedAccounts = accounts;
|
||||
const healthyExits = networkExits;
|
||||
|
||||
async function submit(values: any) {
|
||||
setSubmitBusy(true);
|
||||
try {
|
||||
await create('browsers', {
|
||||
alias: values.alias,
|
||||
name: values.name.trim(),
|
||||
gateway: values.gateway,
|
||||
account_id: values.account_id,
|
||||
network_exit_id: values.network_exit_id || '',
|
||||
fingerprint: fingerprintPayload(values),
|
||||
});
|
||||
messageApi.success('运行环境已创建。');
|
||||
history.push('/browsers');
|
||||
} catch (reason: any) {
|
||||
messageApi.error(
|
||||
reason.status === 503 ? `环境不可用(503):${reason.message}` : conflictMessage(reason, '账号或运行环境已有活动绑定;表单内容已保留。'),
|
||||
);
|
||||
} finally {
|
||||
setSubmitBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
{contextHolder}
|
||||
<div style={{ marginBottom: 16, display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<Typography.Text type="secondary">为已授权且暂停的账号绑定网关、指纹与网络出口</Typography.Text>
|
||||
<Button onClick={() => history.push('/browsers')}>返回运行环境</Button>
|
||||
</div>
|
||||
<Card>
|
||||
<Form form={form} layout="vertical" onFinish={submit}>
|
||||
<Space size={16} wrap style={{ display: 'flex' }}>
|
||||
<Form.Item name="name" label="环境名称" required rules={[{ required: true, message: '请输入环境名称' }]} extra="展示名称,支持中文,如:店铺一号">
|
||||
<Input maxLength={64} />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
name="alias"
|
||||
label="别名"
|
||||
required
|
||||
rules={[
|
||||
{ required: true, message: '请输入别名' },
|
||||
{ pattern: aliasPattern, message: '小写字母、数字与中划线,最长 32 字符' },
|
||||
]}
|
||||
extra="用于 Profile 标识与 API 路径"
|
||||
>
|
||||
<Input placeholder="如:shop-01" />
|
||||
</Form.Item>
|
||||
<Form.Item name="gateway" label="网关" required rules={[{ required: true, message: '请选择网关' }]} extra={gateways.length === 0 ? '请先在「网关管理」注册网关' : '环境由该 native gateway 管理'}>
|
||||
<Select style={{ width: 200 }} placeholder="选择网关" disabled={gateways.length === 0} options={gateways.map((item) => ({ value: item.name, label: item.name }))} />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
name="seed"
|
||||
label="Fingerprint Seed"
|
||||
required
|
||||
rules={[
|
||||
{ required: true, message: '请输入 seed' },
|
||||
{ type: 'integer', min: 1, max: 2147483647, message: '1..2147483647' },
|
||||
]}
|
||||
extra="1..2147483647,派生指纹参数"
|
||||
>
|
||||
<InputNumber min={1} max={2147483647} style={{ width: 180 }} />
|
||||
</Form.Item>
|
||||
<Form.Item name="account_id" label="社媒账号" required rules={[{ required: true, message: '请选择账号' }]} extra={pausedAccounts.length === 0 ? '请先准备已授权且暂停的账号' : '绑定后该账号固定使用此环境'}>
|
||||
<Select style={{ width: 240 }} placeholder="选择账号" disabled={pausedAccounts.length === 0} options={pausedAccounts.map((item) => ({ value: item.id, label: `${item.id} · ${item.platform}` }))} />
|
||||
</Form.Item>
|
||||
<Form.Item name="network_exit_id" label="网络出口" extra="留空时使用当前机器网络出口直连">
|
||||
<Select
|
||||
style={{ width: 240 }}
|
||||
allowClear
|
||||
placeholder="当前机器直连"
|
||||
options={healthyExits.map((item) => ({ value: item.id, label: `${item.id} · ${item.protocol}://${item.host}:${item.port}` }))}
|
||||
/>
|
||||
</Form.Item>
|
||||
</Space>
|
||||
<Collapse
|
||||
ghost
|
||||
items={[
|
||||
{
|
||||
key: 'advanced',
|
||||
label: '高级指纹参数',
|
||||
children: (
|
||||
<Space size={16} wrap style={{ display: 'flex' }}>
|
||||
<Form.Item name="platform" label="操作系统 platform">
|
||||
<Select style={{ width: 140 }} allowClear placeholder="默认" options={platformOptions} />
|
||||
</Form.Item>
|
||||
<Form.Item name="platform_version" label="系统版本 platform-version" extra="如 11.0.0">
|
||||
<Input style={{ width: 140 }} />
|
||||
</Form.Item>
|
||||
<Form.Item name="brand" label="浏览器品牌 brand">
|
||||
<Select style={{ width: 140 }} allowClear placeholder="默认 Chromium" options={brandOptions} />
|
||||
</Form.Item>
|
||||
<Form.Item name="brand_version" label="品牌版本 brand-version" extra="如 132.0.6834.159">
|
||||
<Input style={{ width: 160 }} />
|
||||
</Form.Item>
|
||||
<Form.Item name="hardware_concurrency" label="CPU 核心数 hardware-concurrency" extra="留空由种子生成">
|
||||
<InputNumber min={1} max={128} style={{ width: 100 }} />
|
||||
</Form.Item>
|
||||
<Form.Item name="lang" label="语言 lang" extra="如 zh-CN">
|
||||
<Input style={{ width: 120 }} />
|
||||
</Form.Item>
|
||||
<Form.Item name="accept_lang" label="接受语言 accept-lang" extra="如 zh-CN,en-US">
|
||||
<Input style={{ width: 160 }} />
|
||||
</Form.Item>
|
||||
<Form.Item name="timezone" label="时区 timezone" extra="如 Asia/Shanghai">
|
||||
<Input style={{ width: 160 }} />
|
||||
</Form.Item>
|
||||
<Form.Item name="disable_spoofing" label="禁用指纹伪装 disable-spoofing">
|
||||
<Checkbox.Group options={spoofingFields.map((field) => ({ value: field, label: field }))} />
|
||||
</Form.Item>
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
<Space>
|
||||
<Button type="primary" htmlType="submit" loading={submitBusy}>
|
||||
{submitBusy ? '创建中…' : '创建环境'}
|
||||
</Button>
|
||||
<Button onClick={() => history.push('/browsers')}>取消</Button>
|
||||
</Space>
|
||||
</Form>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,255 @@
|
||||
// TODO: task-5 迁移网关管理页(web.archived/src/features/environments/GatewaysPage.jsx)
|
||||
// 网关管理:语义对齐 web.archived GatewaysPage.jsx(注册/编辑 Modal、令牌显隐复制、删除)。
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { Alert, Button, Form, Input, Modal, Popconfirm, Space, Table, Typography, message } from 'antd';
|
||||
import { CopyOutlined, EyeInvisibleOutlined, EyeOutlined, PlusOutlined } from '@ant-design/icons';
|
||||
import type { ColumnsType } from 'antd/es/table';
|
||||
import { create, getList, remove, update } from '@/services/api';
|
||||
import { conflictMessage } from '@/utils/helpers';
|
||||
|
||||
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 GatewayFormModal({
|
||||
open,
|
||||
initial,
|
||||
busy,
|
||||
error,
|
||||
onCancel,
|
||||
onSubmit,
|
||||
}: {
|
||||
open: boolean;
|
||||
initial: any | null;
|
||||
busy: boolean;
|
||||
error: any;
|
||||
onCancel: () => void;
|
||||
onSubmit: (values: { name: string; endpoint: string; token: string }) => Promise<boolean>;
|
||||
}) {
|
||||
const editing = initial !== null;
|
||||
const [form] = Form.useForm<{ name: string; endpoint: string; token?: string }>();
|
||||
useEffect(() => {
|
||||
if (open) form.setFieldsValue(editing ? { name: initial.name, endpoint: initial.endpoint, token: '' } : { name: '', endpoint: '', token: '' });
|
||||
}, [open, initial, editing, form]);
|
||||
|
||||
return (
|
||||
<Modal open={open} title={editing ? '编辑网关' : '注册网关'} onCancel={onCancel} onOk={() => form.submit()} confirmLoading={busy} okText={busy ? (editing ? '保存中…' : '注册中…') : editing ? '保存' : '注册网关'}>
|
||||
{error ? <Alert type="error" showIcon style={{ marginBottom: 16 }} message={error.message} /> : null}
|
||||
<Form form={form} layout="vertical" onFinish={onSubmit}>
|
||||
<Form.Item
|
||||
name="name"
|
||||
label="名称"
|
||||
required
|
||||
rules={[
|
||||
{ required: true, message: '请输入名称' },
|
||||
{ pattern: namePattern, message: '字母、数字与 . _ -,最长 64 字符' },
|
||||
]}
|
||||
extra={editing ? '改名会同步更新引用该网关的环境' : '网关唯一标识,如 gw-main'}
|
||||
>
|
||||
<Input maxLength={64} placeholder="如 gw-main" />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
name="endpoint"
|
||||
label="Endpoint"
|
||||
required
|
||||
rules={[
|
||||
{ required: true, message: '请输入 Endpoint' },
|
||||
{ pattern: /^https?:\/\/\S+$/, message: '须为 http(s) URL' },
|
||||
]}
|
||||
extra="网关进程的可访问地址"
|
||||
>
|
||||
<Input placeholder="http://127.0.0.1:8081" />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
name="token"
|
||||
label={editing ? '令牌(留空保持不变)' : '令牌(可选)'}
|
||||
rules={[{ pattern: tokenPattern, message: '至少 16 个字符,字母、数字与 . _ -' }]}
|
||||
extra={editing ? '填写时须与网关 GATEWAY_TOKEN 一致' : '填写则须与网关 GATEWAY_TOKEN 一致;留空由平台生成'}
|
||||
>
|
||||
<Input maxLength={128} placeholder={editing ? '留空保持现有令牌' : '留空由平台生成'} />
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
export default function Page() {
|
||||
return null;
|
||||
const [gateways, setGateways] = useState<any[]>([]);
|
||||
const [pending, setPending] = useState(true);
|
||||
const [error, setError] = useState<any>(null);
|
||||
const [createOpen, setCreateOpen] = useState(false);
|
||||
const [editing, setEditing] = useState<any | null>(null);
|
||||
const [busy, setBusy] = useState('');
|
||||
const [created, setCreated] = useState<any>(null);
|
||||
const [messageApi, contextHolder] = message.useMessage();
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setPending(true);
|
||||
setError(null);
|
||||
try {
|
||||
const result = await getList({ resource: 'gateways' });
|
||||
setGateways(result.data ?? []);
|
||||
} catch (loadError) {
|
||||
setError(loadError);
|
||||
} finally {
|
||||
setPending(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
load();
|
||||
}, [load]);
|
||||
|
||||
async function createGateway(values: { name: string; endpoint: string; token: string }): Promise<boolean> {
|
||||
setBusy('create');
|
||||
try {
|
||||
const record = await create('gateways', values);
|
||||
setCreated(record?.data ?? record);
|
||||
await load();
|
||||
setCreateOpen(false);
|
||||
return true;
|
||||
} catch (reason) {
|
||||
messageApi.error(conflictMessage(reason, '网关注册失败;表单内容已保留。'));
|
||||
return false;
|
||||
} finally {
|
||||
setBusy('');
|
||||
}
|
||||
}
|
||||
|
||||
async function updateGateway(values: { name: string; endpoint: string; token: string }): Promise<boolean> {
|
||||
if (!editing) return false;
|
||||
setBusy('edit');
|
||||
try {
|
||||
await update('gateways', editing.name, values);
|
||||
await load();
|
||||
setEditing(null);
|
||||
return true;
|
||||
} catch (reason) {
|
||||
messageApi.error(conflictMessage(reason, '网关更新失败;表单内容已保留。'));
|
||||
return false;
|
||||
} finally {
|
||||
setBusy('');
|
||||
}
|
||||
}
|
||||
|
||||
async function removeGateway(gateway: any) {
|
||||
setBusy(gateway.name);
|
||||
try {
|
||||
await remove('gateways', gateway.name);
|
||||
await load();
|
||||
} catch (reason) {
|
||||
messageApi.error(conflictMessage(reason, '网关删除失败'));
|
||||
} finally {
|
||||
setBusy('');
|
||||
}
|
||||
}
|
||||
|
||||
const columns: ColumnsType<any> = [
|
||||
{ title: '名称', dataIndex: 'name', render: (value: string) => <Typography.Text strong>{value}</Typography.Text> },
|
||||
{ title: 'Endpoint', dataIndex: 'endpoint', render: (value: string) => <Typography.Text code style={{ fontSize: 12 }} ellipsis>{value}</Typography.Text> },
|
||||
{
|
||||
title: '令牌',
|
||||
dataIndex: 'token',
|
||||
render: (token: string, gateway) => <TokenCell token={token} name={gateway.name} />,
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
key: 'actions',
|
||||
align: 'right',
|
||||
render: (_, gateway) => (
|
||||
<Space>
|
||||
<Button size="small" disabled={busy === gateway.name || busy === 'edit'} onClick={() => setEditing(gateway)}>
|
||||
编辑
|
||||
</Button>
|
||||
<Popconfirm title={`删除网关 ${gateway.name}?`} onConfirm={() => removeGateway(gateway)}>
|
||||
<Button size="small" danger disabled={busy === gateway.name || busy === 'edit'}>
|
||||
删除
|
||||
</Button>
|
||||
</Popconfirm>
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div>
|
||||
{contextHolder}
|
||||
<div style={{ marginBottom: 16, display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<Typography.Text type="secondary">注册宿主机 native browser gateway;令牌须与 gateway 进程的 GATEWAY_TOKEN 一致</Typography.Text>
|
||||
<Button type="primary" icon={<PlusOutlined />} onClick={() => setCreateOpen(true)}>
|
||||
注册网关
|
||||
</Button>
|
||||
</div>
|
||||
{created ? (
|
||||
<Alert
|
||||
type="success"
|
||||
showIcon
|
||||
style={{ marginBottom: 16 }}
|
||||
message={
|
||||
<Space size={4}>
|
||||
<span>网关 {created.name} 已注册。令牌</span>
|
||||
<Typography.Text code>{created.token}</Typography.Text>
|
||||
<Button
|
||||
type="text"
|
||||
size="small"
|
||||
icon={<CopyOutlined />}
|
||||
aria-label={`复制 ${created.token}`}
|
||||
onClick={() => navigator.clipboard?.writeText(created.token)}
|
||||
/>
|
||||
<Button size="small" onClick={() => setCreated(null)}>
|
||||
知道了
|
||||
</Button>
|
||||
</Space>
|
||||
}
|
||||
/>
|
||||
) : null}
|
||||
{error ? (
|
||||
<Alert type="error" showIcon style={{ marginBottom: 16 }} message={error.message} action={<Button size="small" onClick={load}>重试</Button>} />
|
||||
) : null}
|
||||
<Table rowKey="name" columns={columns} dataSource={gateways} loading={pending} pagination={false} locale={{ emptyText: '暂无网关;注册后环境即可调度到该网关。' }} />
|
||||
<GatewayFormModal
|
||||
open={createOpen}
|
||||
initial={null}
|
||||
busy={busy === 'create'}
|
||||
error={null}
|
||||
onCancel={() => {
|
||||
setCreateOpen(false);
|
||||
}}
|
||||
onSubmit={createGateway}
|
||||
/>
|
||||
<GatewayFormModal
|
||||
open={editing !== null}
|
||||
initial={editing}
|
||||
busy={busy === 'edit'}
|
||||
error={null}
|
||||
onCancel={() => setEditing(null)}
|
||||
onSubmit={updateGateway}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function TokenCell({ token, name }: { token: string; name: string }) {
|
||||
const [visible, setVisible] = useState(false);
|
||||
return (
|
||||
<Space size={0}>
|
||||
<Typography.Text code style={{ fontSize: 12 }} ellipsis>
|
||||
{visible ? token : '••••••••••••'}
|
||||
</Typography.Text>
|
||||
<Button
|
||||
type="text"
|
||||
size="small"
|
||||
aria-label={visible ? `隐藏 ${name} 的令牌` : `显示 ${name} 的令牌`}
|
||||
icon={visible ? <EyeInvisibleOutlined /> : <EyeOutlined />}
|
||||
onClick={() => setVisible((current) => !current)}
|
||||
/>
|
||||
{visible ? (
|
||||
<Button
|
||||
type="text"
|
||||
size="small"
|
||||
aria-label={`复制 ${name} 的令牌`}
|
||||
icon={<CopyOutlined />}
|
||||
onClick={() => navigator.clipboard?.writeText(token)}
|
||||
/>
|
||||
) : null}
|
||||
</Space>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,76 @@
|
||||
// TODO: 从 web.archived 迁移 network-exits/$id
|
||||
// 网络出口详情:语义对齐 web.archived NetworkExitsPage.jsx 的 NetworkExitDetail。
|
||||
import { useEffect, useState } from 'react';
|
||||
import { history, useParams } from '@umijs/max';
|
||||
import { Alert, Button, Card, Descriptions, Space, Tag, Typography } from 'antd';
|
||||
import { getOne } from '@/services/api';
|
||||
import { dateTime } from '@/utils/helpers';
|
||||
|
||||
const healthText: Record<string, string> = {
|
||||
unchecked: '未检测',
|
||||
healthy: '健康',
|
||||
unhealthy: '不健康',
|
||||
disabled: '已停用',
|
||||
};
|
||||
|
||||
export default function Page() {
|
||||
return null;
|
||||
const { id = '' } = useParams<{ id: string }>();
|
||||
const [exit, setExit] = useState<any>(null);
|
||||
const [pending, setPending] = useState(true);
|
||||
const [error, setError] = useState<any>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
setPending(true);
|
||||
setError(null);
|
||||
getOne('network-exits', id)
|
||||
.then((result) => {
|
||||
if (!cancelled) setExit(result);
|
||||
})
|
||||
.catch((loadError) => {
|
||||
if (!cancelled) setError(loadError);
|
||||
})
|
||||
.finally(() => {
|
||||
if (!cancelled) setPending(false);
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [id]);
|
||||
|
||||
if (pending) return <Card loading />;
|
||||
if (error || !exit) return <Alert type="error" showIcon message={error?.message || '网络出口不存在'} />;
|
||||
|
||||
const known = Object.hasOwn(healthText, exit.health_status ?? '');
|
||||
return (
|
||||
<div>
|
||||
<div style={{ marginBottom: 16, display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<div style={{ minWidth: 0 }}>
|
||||
<Typography.Title level={4} style={{ marginBottom: 0 }}>网络出口详情</Typography.Title>
|
||||
<Typography.Text type="secondary" ellipsis>
|
||||
{`${exit.protocol}://${exit.host}:${exit.port}`}
|
||||
</Typography.Text>
|
||||
</div>
|
||||
<Button onClick={() => history.push('/network-exits')}>返回出口列表</Button>
|
||||
</div>
|
||||
<Card>
|
||||
<Space direction="vertical" style={{ width: '100%' }} size={16}>
|
||||
<Tag color={exit.health_status === 'healthy' ? 'success' : exit.health_status === 'disabled' ? 'default' : exit.health_status === 'unchecked' ? 'warning' : 'error'}>
|
||||
{known ? healthText[exit.health_status] : `未知状态:${exit.health_status}`}
|
||||
</Tag>
|
||||
<Descriptions column={1} bordered size="small">
|
||||
<Descriptions.Item label="ID">
|
||||
<Typography.Text copyable>{exit.id}</Typography.Text>
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="健康 / 版本">
|
||||
{`${healthText[exit.health_status] ?? exit.health_status} · ${exit.version}`}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="用户名">{exit.username || '无'}</Descriptions.Item>
|
||||
<Descriptions.Item label="密码">{exit.password || '无'}</Descriptions.Item>
|
||||
<Descriptions.Item label="出口IP">{exit.observed_public_ip || '尚无观测'}</Descriptions.Item>
|
||||
<Descriptions.Item label="最近检测">{exit.last_checked_at ? dateTime(exit.last_checked_at) : '未检测'}</Descriptions.Item>
|
||||
</Descriptions>
|
||||
</Space>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,337 @@
|
||||
// TODO: 从 web.archived 迁移 network-exits/index
|
||||
// 网络出口:语义对齐 web.archived NetworkExitsPage.jsx(列表 + 创建/编辑 Modal + 检测/启停/删除动作 + 出口详情页)。
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { history } from '@umijs/max';
|
||||
import { Alert, Button, Form, Input, InputNumber, Modal, Popconfirm, Select, Space, Table, Tag, Typography, message } from 'antd';
|
||||
import { PlusOutlined } from '@ant-design/icons';
|
||||
import type { ColumnsType } from 'antd/es/table';
|
||||
import { create, getOne, getList, networkExitAction, remove, update } from '@/services/api';
|
||||
import { conflictMessage, dateTime } from '@/utils/helpers';
|
||||
|
||||
const healthText: Record<string, string> = {
|
||||
unchecked: '未检测',
|
||||
healthy: '健康',
|
||||
unhealthy: '不健康',
|
||||
disabled: '已停用',
|
||||
};
|
||||
const protocolOptions = ['http', 'https', 'socks4', 'socks5'].map((value) => ({ value, label: value }));
|
||||
|
||||
function healthTag(status?: string) {
|
||||
const known = Object.hasOwn(healthText, status ?? '');
|
||||
const color = status === 'healthy' ? 'success' : status === 'disabled' ? 'default' : status === 'unchecked' ? 'warning' : 'error';
|
||||
return <Tag color={color}>{known ? healthText[status ?? ''] : `未知状态:${status}`}</Tag>;
|
||||
}
|
||||
|
||||
interface ExitFormValues {
|
||||
protocol: string;
|
||||
host: string;
|
||||
port: number;
|
||||
username?: string;
|
||||
password?: string;
|
||||
expected_public_ip?: string;
|
||||
expected_region?: string;
|
||||
}
|
||||
|
||||
function ExitFormModal({
|
||||
open,
|
||||
initial,
|
||||
title,
|
||||
submitLabel,
|
||||
busy,
|
||||
error,
|
||||
onCancel,
|
||||
onSubmit,
|
||||
}: {
|
||||
open: boolean;
|
||||
initial: any | null;
|
||||
title: string;
|
||||
submitLabel: string;
|
||||
busy: boolean;
|
||||
error: any;
|
||||
onCancel: () => void;
|
||||
onSubmit: (values: ExitFormValues) => Promise<boolean>;
|
||||
}) {
|
||||
const [form] = Form.useForm<ExitFormValues>();
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
form.setFieldsValue(
|
||||
initial
|
||||
? { ...initial, port: initial.port ? Number(initial.port) : undefined }
|
||||
: { protocol: 'socks5', host: '', port: undefined, username: '', password: '', expected_public_ip: '', expected_region: '' },
|
||||
);
|
||||
}
|
||||
}, [open, initial, form]);
|
||||
|
||||
return (
|
||||
<Modal open={open} title={title} onCancel={onCancel} onOk={() => form.submit()} confirmLoading={busy} okText={busy ? '保存中…' : submitLabel} width={640}>
|
||||
{error ? <Alert type="error" showIcon style={{ marginBottom: 16 }} message={conflictMessage(error, '出口地址或认证信息与现有资源冲突;表单内容已保留。')} /> : null}
|
||||
<Form form={form} layout="vertical" onFinish={onSubmit}>
|
||||
<Space size={16} wrap style={{ display: 'flex' }}>
|
||||
<Form.Item name="protocol" label="协议" required rules={[{ required: true }]} extra="代理协议">
|
||||
<Select style={{ width: 120 }} options={protocolOptions} />
|
||||
</Form.Item>
|
||||
<Form.Item name="host" label="主机" required rules={[{ required: true, message: '请输入主机' }]} extra="代理服务器地址">
|
||||
<Input maxLength={253} placeholder="如 proxy.example" style={{ width: 200 }} />
|
||||
</Form.Item>
|
||||
<Form.Item name="port" label="端口" required rules={[{ required: true, message: '1..65535' }, { type: 'integer', min: 1, max: 65535, message: '1..65535' }]} extra="1..65535">
|
||||
<InputNumber min={1} max={65535} style={{ width: 120 }} />
|
||||
</Form.Item>
|
||||
<Form.Item name="username" label="用户名(可选)" extra="直接保存到系统并用于代理认证">
|
||||
<Input maxLength={255} style={{ width: 160 }} />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
name="password"
|
||||
label="密码(可选)"
|
||||
extra="明文保存并直接用于代理认证"
|
||||
rules={[
|
||||
({ getFieldValue }) => ({
|
||||
validator: (_rule, value) =>
|
||||
!value || getFieldValue('username')
|
||||
? Promise.resolve()
|
||||
: Promise.reject(new Error('填写密码时必须同时填写用户名')),
|
||||
}),
|
||||
]}
|
||||
>
|
||||
<Input maxLength={255} style={{ width: 160 }} />
|
||||
</Form.Item>
|
||||
<Form.Item name="expected_public_ip" label="出口IP(可选)" extra="健康检测时比对的预期公网 IP">
|
||||
<Input style={{ width: 180 }} />
|
||||
</Form.Item>
|
||||
<Form.Item name="expected_region" label="出口地区(可选)" extra="如 CN-Shanghai">
|
||||
<Input maxLength={64} style={{ width: 160 }} />
|
||||
</Form.Item>
|
||||
</Space>
|
||||
</Form>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
export default function Page() {
|
||||
return null;
|
||||
const [exits, setExits] = useState<any[]>([]);
|
||||
const [browsers, setBrowsers] = useState<any[]>([]);
|
||||
const [bindingsError, setBindingsError] = useState<any>(null);
|
||||
const [pending, setPending] = useState(true);
|
||||
const [error, setError] = useState<any>(null);
|
||||
const [createOpen, setCreateOpen] = useState(false);
|
||||
const [editing, setEditing] = useState<any | null>(null);
|
||||
const [busy, setBusy] = useState('');
|
||||
const [messageApi, contextHolder] = message.useMessage();
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setPending(true);
|
||||
setError(null);
|
||||
try {
|
||||
const result = await getList({ resource: 'network-exits' });
|
||||
setExits(result.data ?? []);
|
||||
} catch (loadError) {
|
||||
setError(loadError);
|
||||
} finally {
|
||||
setPending(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const loadBindings = useCallback(async () => {
|
||||
try {
|
||||
const result = await getList({ resource: 'browsers' });
|
||||
setBrowsers(result.data ?? []);
|
||||
setBindingsError(null);
|
||||
} catch (loadError) {
|
||||
setBindingsError(loadError);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
load();
|
||||
loadBindings();
|
||||
}, [load, loadBindings]);
|
||||
|
||||
const accountsByExit = useMemo(() => {
|
||||
const map = new Map<string, string[]>();
|
||||
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]);
|
||||
|
||||
async function createExit(values: ExitFormValues): Promise<boolean> {
|
||||
setBusy('create');
|
||||
try {
|
||||
await create('network-exits', values);
|
||||
await load();
|
||||
setCreateOpen(false);
|
||||
messageApi.success('网络出口已创建;检测通过后才可用于账号绑定。');
|
||||
return true;
|
||||
} catch (reason) {
|
||||
messageApi.error(conflictMessage(reason, '出口地址或认证信息与现有资源冲突;表单内容已保留。'));
|
||||
return false;
|
||||
} finally {
|
||||
setBusy('');
|
||||
}
|
||||
}
|
||||
|
||||
async function updateExit(values: ExitFormValues): Promise<boolean> {
|
||||
if (!editing) return false;
|
||||
setBusy(`edit:${editing.id}`);
|
||||
try {
|
||||
await update('network-exits', editing.id, values);
|
||||
await load();
|
||||
setEditing(null);
|
||||
messageApi.success('网络出口已更新,需重新检测后才能使用。');
|
||||
return true;
|
||||
} catch (reason) {
|
||||
messageApi.error(conflictMessage(reason, '出口当前不能修改。'));
|
||||
return false;
|
||||
} finally {
|
||||
setBusy('');
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteExit(exit: any) {
|
||||
setBusy(`delete:${exit.id}`);
|
||||
try {
|
||||
await remove('network-exits', exit.id);
|
||||
await load();
|
||||
messageApi.success('网络出口已删除。');
|
||||
} catch (reason) {
|
||||
messageApi.error(conflictMessage(reason, '出口仍有绑定或运行实例,不能删除。'));
|
||||
} finally {
|
||||
setBusy('');
|
||||
}
|
||||
}
|
||||
|
||||
async function runAction(exit: any, action: 'check' | 'disable' | 'enable') {
|
||||
setBusy(exit.id);
|
||||
try {
|
||||
await networkExitAction(exit.id, action);
|
||||
await Promise.all([load(), loadBindings()]);
|
||||
messageApi.success(
|
||||
action === 'check' ? '出口检测完成。' : action === 'enable' ? '出口已启用;重新检测通过后才能绑定。' : '出口已停用;绑定账号不会自动恢复。',
|
||||
);
|
||||
} catch (reason) {
|
||||
messageApi.error(conflictMessage(reason, action === 'check' ? '已停用的出口不能检测。' : '出口当前不能停用。'));
|
||||
} finally {
|
||||
setBusy('');
|
||||
}
|
||||
}
|
||||
|
||||
const columns: ColumnsType<any> = [
|
||||
{
|
||||
title: '出口',
|
||||
dataIndex: 'id',
|
||||
render: (_, exit) => (
|
||||
<div>
|
||||
<Typography.Link onClick={() => history.push(`/network-exits/${encodeURIComponent(exit.id)}`)}>{exit.id}</Typography.Link>
|
||||
<Typography.Paragraph type="secondary" style={{ marginBottom: 0, fontSize: 12 }} ellipsis>
|
||||
{`${exit.protocol}://${exit.host}:${exit.port}`}
|
||||
</Typography.Paragraph>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{ title: '状态', dataIndex: 'health_status', render: (value?: string) => healthTag(value) },
|
||||
{ title: '用户名', dataIndex: 'username', render: (value?: string) => value || '无' },
|
||||
{ title: '密码', dataIndex: 'password', render: (value?: string) => value || '无' },
|
||||
{ title: '出口IP', dataIndex: 'observed_public_ip', render: (value?: string) => value || '尚无观测' },
|
||||
{ title: '最近检测', dataIndex: 'last_checked_at', render: (value?: string) => (value ? dateTime(value) : '未检测') },
|
||||
{
|
||||
title: '绑定账号',
|
||||
key: 'accounts',
|
||||
render: (_, exit) =>
|
||||
bindingsError ? (
|
||||
<Typography.Text type="secondary">状态未知</Typography.Text>
|
||||
) : (
|
||||
<Typography.Text ellipsis style={{ maxWidth: 200, display: 'inline-block' }}>
|
||||
{accountsByExit.get(exit.id)?.join('、') || '未绑定'}
|
||||
</Typography.Text>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
key: 'actions',
|
||||
align: 'right',
|
||||
render: (_, exit) => (
|
||||
<Space wrap style={{ justifyContent: 'flex-end' }}>
|
||||
<Button size="small" disabled={busy === exit.id} onClick={() => runAction(exit, 'check')}>
|
||||
检测
|
||||
</Button>
|
||||
{exit.health_status === 'disabled' ? (
|
||||
<Button size="small" disabled={busy === exit.id} onClick={() => runAction(exit, 'enable')}>
|
||||
启用
|
||||
</Button>
|
||||
) : (
|
||||
<Popconfirm title={`停用网络出口 ${exit.id}?绑定账号将暂停,排队任务进入 hold。`} onConfirm={() => runAction(exit, 'disable')}>
|
||||
<Button size="small" danger disabled={busy === exit.id}>
|
||||
停用
|
||||
</Button>
|
||||
</Popconfirm>
|
||||
)}
|
||||
<Button size="small" disabled={busy === exit.id} onClick={() => setEditing(exit)}>
|
||||
编辑
|
||||
</Button>
|
||||
<Popconfirm title={`删除网络出口 ${exit.id}?有绑定或运行实例时会拒绝。`} onConfirm={() => deleteExit(exit)}>
|
||||
<Button size="small" danger disabled={busy === exit.id}>
|
||||
删除
|
||||
</Button>
|
||||
</Popconfirm>
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div>
|
||||
{contextHolder}
|
||||
<div style={{ marginBottom: 16, display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<Typography.Text type="secondary">维护固定出口、健康观测与账号绑定</Typography.Text>
|
||||
<Button type="primary" icon={<PlusOutlined />} onClick={() => setCreateOpen(true)}>
|
||||
创建网络出口
|
||||
</Button>
|
||||
</div>
|
||||
{error ? (
|
||||
<Alert type="error" showIcon style={{ marginBottom: 16 }} message={error.message} action={<Button size="small" onClick={load}>重试</Button>} />
|
||||
) : null}
|
||||
{bindingsError ? (
|
||||
<Alert
|
||||
type="error"
|
||||
showIcon
|
||||
style={{ marginBottom: 16 }}
|
||||
message={`环境不可用${bindingsError.status ? `(${bindingsError.status})` : ''}:无法读取账号绑定,状态暂时未知。`}
|
||||
action={
|
||||
<Button size="small" onClick={loadBindings}>
|
||||
重试环境状态
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
) : null}
|
||||
<Table
|
||||
rowKey="id"
|
||||
columns={columns}
|
||||
dataSource={exits}
|
||||
loading={pending}
|
||||
pagination={false}
|
||||
locale={{ emptyText: '暂无网络出口。创建并检测健康后,才能绑定运行环境。' }}
|
||||
/>
|
||||
<ExitFormModal
|
||||
open={createOpen}
|
||||
initial={null}
|
||||
title="创建网络出口"
|
||||
submitLabel="创建网络出口"
|
||||
busy={busy === 'create'}
|
||||
error={null}
|
||||
onCancel={() => {
|
||||
setCreateOpen(false);
|
||||
}}
|
||||
onSubmit={createExit}
|
||||
/>
|
||||
<ExitFormModal
|
||||
open={editing !== null}
|
||||
initial={editing}
|
||||
title="编辑网络出口"
|
||||
submitLabel="保存网络出口"
|
||||
busy={busy === `edit:${editing?.id}`}
|
||||
error={null}
|
||||
onCancel={() => setEditing(null)}
|
||||
onSubmit={updateExit}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user