添加代理节点、配置版本、节点状态和应用记录页面,更新路由和样式,完善相关功能

This commit is contained in:
ryan
2026-03-09 23:16:51 +08:00
parent f1624c20a3
commit 6a9d56e045
8 changed files with 583 additions and 1 deletions
@@ -0,0 +1,74 @@
import React, { useEffect, useState } from 'react';
import { Form, Header, Label, Segment, Table } from 'semantic-ui-react';
import { API, formatDateTime, showError } from '../../helpers';
const ApplyLog = () => {
const [logs, setLogs] = useState([]);
const [keyword, setKeyword] = useState('');
const [loading, setLoading] = useState(false);
const loadLogs = async (nodeId = '') => {
setLoading(true);
const path = nodeId ? `/api/apply-logs/?node_id=${encodeURIComponent(nodeId)}` : '/api/apply-logs/';
const res = await API.get(path);
const { success, message, data } = res.data;
if (success) {
setLogs(data || []);
} else {
showError(message);
}
setLoading(false);
};
useEffect(() => {
loadLogs().then();
}, []);
const submit = async () => {
await loadLogs(keyword.trim());
};
return (
<Segment loading={loading}>
<Header as='h3'>应用记录</Header>
<p className='page-subtitle'>按节点查看配置应用成功或失败记录。</p>
<Form onSubmit={submit}>
<Form.Input
icon='search'
iconPosition='left'
placeholder='输入 node_id 过滤应用记录'
value={keyword}
onChange={(e, { value }) => setKeyword(value)}
/>
</Form>
<Table celled stackable className='atsf-table'>
<Table.Header>
<Table.Row>
<Table.HeaderCell>Node ID</Table.HeaderCell>
<Table.HeaderCell>版本</Table.HeaderCell>
<Table.HeaderCell>结果</Table.HeaderCell>
<Table.HeaderCell>信息</Table.HeaderCell>
<Table.HeaderCell>时间</Table.HeaderCell>
</Table.Row>
</Table.Header>
<Table.Body>
{logs.map((log) => (
<Table.Row key={log.id}>
<Table.Cell>{log.node_id}</Table.Cell>
<Table.Cell>{log.version}</Table.Cell>
<Table.Cell>
{log.result === 'success' ? <Label color='green'>成功</Label> : <Label color='red'>失败</Label>}
</Table.Cell>
<Table.Cell>{log.message || '无'}</Table.Cell>
<Table.Cell>{formatDateTime(log.created_at)}</Table.Cell>
</Table.Row>
))}
</Table.Body>
</Table>
</Segment>
);
};
export default ApplyLog;
@@ -0,0 +1,125 @@
import React, { useEffect, useState } from 'react';
import {
Button,
Header,
Icon,
Label,
Modal,
Segment,
Table,
} from 'semantic-ui-react';
import { API, formatDateTime, showError, showSuccess } from '../../helpers';
const ConfigVersion = () => {
const [versions, setVersions] = useState([]);
const [loading, setLoading] = useState(false);
const [publishing, setPublishing] = useState(false);
const [preview, setPreview] = useState(null);
const loadVersions = async () => {
setLoading(true);
const res = await API.get('/api/config-versions/');
const { success, message, data } = res.data;
if (success) {
setVersions(data || []);
} else {
showError(message);
}
setLoading(false);
};
useEffect(() => {
loadVersions().then();
}, []);
const publishConfig = async () => {
setPublishing(true);
const res = await API.post('/api/config-versions/publish');
const { success, message, data } = res.data;
if (success) {
showSuccess(`发布成功,版本 ${data.version}`);
await loadVersions();
} else {
showError(message);
}
setPublishing(false);
};
const activateVersion = async (id) => {
const res = await API.put(`/api/config-versions/${id}/activate`);
const { success, message, data } = res.data;
if (success) {
showSuccess(`已激活版本 ${data.version}`);
await loadVersions();
} else {
showError(message);
}
};
return (
<Segment loading={loading}>
<div className='page-toolbar'>
<div>
<Header as='h3'>版本发布</Header>
<p className='page-subtitle'>查看历史快照,发布新版本,或重新激活旧版本。</p>
</div>
<Button primary icon labelPosition='left' loading={publishing} onClick={publishConfig}>
<Icon name='plus' />
生成新版本
</Button>
</div>
<Table celled stackable className='atsf-table'>
<Table.Header>
<Table.Row>
<Table.HeaderCell>版本号</Table.HeaderCell>
<Table.HeaderCell>状态</Table.HeaderCell>
<Table.HeaderCell>创建人</Table.HeaderCell>
<Table.HeaderCell>Checksum</Table.HeaderCell>
<Table.HeaderCell>创建时间</Table.HeaderCell>
<Table.HeaderCell>操作</Table.HeaderCell>
</Table.Row>
</Table.Header>
<Table.Body>
{versions.map((version) => (
<Table.Row key={version.id}>
<Table.Cell>{version.version}</Table.Cell>
<Table.Cell>
{version.is_active ? <Label color='green'>当前激活</Label> : <Label>历史版本</Label>}
</Table.Cell>
<Table.Cell>{version.created_by}</Table.Cell>
<Table.Cell title={version.checksum}>{(version.checksum || '').slice(0, 16)}...</Table.Cell>
<Table.Cell>{formatDateTime(version.created_at)}</Table.Cell>
<Table.Cell>
<Button size='small' onClick={() => setPreview(version)}>
预览
</Button>
{!version.is_active ? (
<Button size='small' positive onClick={() => activateVersion(version.id)}>
激活
</Button>
) : null}
</Table.Cell>
</Table.Row>
))}
</Table.Body>
</Table>
<Modal open={!!preview} onClose={() => setPreview(null)} closeIcon>
<Modal.Header>版本预览</Modal.Header>
<Modal.Content scrolling>
{preview ? (
<>
<Header as='h4'>快照 JSON</Header>
<pre className='atsf-pre'>{preview.snapshot_json}</pre>
<Header as='h4'>渲染结果</Header>
<pre className='atsf-pre'>{preview.rendered_config}</pre>
</>
) : null}
</Modal.Content>
</Modal>
</Segment>
);
};
export default ConfigVersion;
+84
View File
@@ -0,0 +1,84 @@
import React, { useEffect, useState } from 'react';
import { Header, Label, Segment, Table } from 'semantic-ui-react';
import { API, formatDateTime, showError } from '../../helpers';
const renderStatus = (status) => {
if (status === 'online') {
return <Label color='green'>在线</Label>;
}
return <Label color='grey'>离线</Label>;
};
const renderApply = (result) => {
if (result === 'success') {
return <Label color='green'>成功</Label>;
}
if (result === 'failed') {
return <Label color='red'>失败</Label>;
}
return <Label>暂无</Label>;
};
const Node = () => {
const [nodes, setNodes] = useState([]);
const [loading, setLoading] = useState(false);
const loadNodes = async () => {
setLoading(true);
const res = await API.get('/api/nodes/');
const { success, message, data } = res.data;
if (success) {
setNodes(data || []);
} else {
showError(message);
}
setLoading(false);
};
useEffect(() => {
loadNodes().then();
}, []);
return (
<Segment loading={loading}>
<Header as='h3'>节点状态</Header>
<p className='page-subtitle'>查看节点在线状态、当前版本和最近一次应用结果。</p>
<Table celled stackable className='atsf-table'>
<Table.Header>
<Table.Row>
<Table.HeaderCell>节点名</Table.HeaderCell>
<Table.HeaderCell>Node ID</Table.HeaderCell>
<Table.HeaderCell>IP</Table.HeaderCell>
<Table.HeaderCell>状态</Table.HeaderCell>
<Table.HeaderCell>Agent / Nginx</Table.HeaderCell>
<Table.HeaderCell>当前版本</Table.HeaderCell>
<Table.HeaderCell>最近应用</Table.HeaderCell>
<Table.HeaderCell>最近心跳</Table.HeaderCell>
<Table.HeaderCell>错误</Table.HeaderCell>
</Table.Row>
</Table.Header>
<Table.Body>
{nodes.map((node) => (
<Table.Row key={node.id}>
<Table.Cell>{node.name}</Table.Cell>
<Table.Cell>{node.node_id}</Table.Cell>
<Table.Cell>{node.ip}</Table.Cell>
<Table.Cell>{renderStatus(node.status)}</Table.Cell>
<Table.Cell>{node.agent_version} / {node.nginx_version || 'unknown'}</Table.Cell>
<Table.Cell>{node.current_version || '未应用'}</Table.Cell>
<Table.Cell>
{renderApply(node.latest_apply_result)}
<div className='table-meta'>{node.latest_apply_message || '暂无记录'}</div>
</Table.Cell>
<Table.Cell>{formatDateTime(node.last_seen_at)}</Table.Cell>
<Table.Cell>{node.last_error || '无'}</Table.Cell>
</Table.Row>
))}
</Table.Body>
</Table>
</Segment>
);
};
export default Node;
@@ -0,0 +1,193 @@
import React, { useEffect, useState } from 'react';
import {
Button,
Form,
Header,
Icon,
Label,
Segment,
Table,
TextArea,
} from 'semantic-ui-react';
import { API, showError, showSuccess, formatDateTime } from '../../helpers';
const initialForm = {
domain: '',
origin_url: '',
enabled: true,
remark: '',
};
const ProxyRoute = () => {
const [routes, setRoutes] = useState([]);
const [loading, setLoading] = useState(false);
const [publishing, setPublishing] = useState(false);
const [form, setForm] = useState(initialForm);
const [editingId, setEditingId] = useState(null);
const loadRoutes = async () => {
setLoading(true);
const res = await API.get('/api/proxy-routes/');
const { success, message, data } = res.data;
if (success) {
setRoutes(data || []);
} else {
showError(message);
}
setLoading(false);
};
useEffect(() => {
loadRoutes().then();
}, []);
const resetForm = () => {
setForm(initialForm);
setEditingId(null);
};
const submitRoute = async () => {
const payload = {
...form,
domain: form.domain.trim(),
origin_url: form.origin_url.trim(),
remark: form.remark.trim(),
};
const res = editingId
? await API.put(`/api/proxy-routes/${editingId}`, payload)
: await API.post('/api/proxy-routes/', payload);
const { success, message } = res.data;
if (success) {
showSuccess(editingId ? '规则已更新' : '规则已创建');
resetForm();
await loadRoutes();
} else {
showError(message);
}
};
const deleteRoute = async (id) => {
const res = await API.delete(`/api/proxy-routes/${id}`);
const { success, message } = res.data;
if (success) {
showSuccess('规则已删除');
await loadRoutes();
} else {
showError(message);
}
};
const publishConfig = async () => {
setPublishing(true);
const res = await API.post('/api/config-versions/publish');
const { success, message, data } = res.data;
if (success) {
showSuccess(`发布成功,版本 ${data.version}`);
} else {
showError(message);
}
setPublishing(false);
};
const beginEdit = (route) => {
setEditingId(route.id);
setForm({
domain: route.domain,
origin_url: route.origin_url,
enabled: route.enabled,
remark: route.remark || '',
});
};
return (
<Segment loading={loading}>
<div className='page-toolbar'>
<div>
<Header as='h3'>反代规则</Header>
<p className='page-subtitle'>维护 Host 到 Origin 的映射,并可直接触发发布。</p>
</div>
<Button primary icon labelPosition='left' loading={publishing} onClick={publishConfig}>
<Icon name='cloud upload' />
发布当前规则
</Button>
</div>
<Form onSubmit={submitRoute}>
<Form.Group widths='equal'>
<Form.Input
label='域名'
placeholder='example.com'
value={form.domain}
onChange={(e, { value }) => setForm({ ...form, domain: value })}
/>
<Form.Input
label='源站地址'
placeholder='https://origin.internal'
value={form.origin_url}
onChange={(e, { value }) => setForm({ ...form, origin_url: value })}
/>
</Form.Group>
<Form.Group widths='equal'>
<Form.Field
control={TextArea}
label='备注'
placeholder='可选备注'
value={form.remark}
onChange={(e, { value }) => setForm({ ...form, remark: value })}
/>
<Form.Checkbox
toggle
label='启用规则'
checked={form.enabled}
onChange={(e, { checked }) => setForm({ ...form, enabled: checked })}
style={{ alignSelf: 'flex-end', marginBottom: '1rem' }}
/>
</Form.Group>
<Button primary type='submit'>
{editingId ? '保存修改' : '新增规则'}
</Button>
{editingId ? (
<Button type='button' onClick={resetForm}>
取消编辑
</Button>
) : null}
</Form>
<Table celled stackable className='atsf-table'>
<Table.Header>
<Table.Row>
<Table.HeaderCell>域名</Table.HeaderCell>
<Table.HeaderCell>源站地址</Table.HeaderCell>
<Table.HeaderCell>状态</Table.HeaderCell>
<Table.HeaderCell>备注</Table.HeaderCell>
<Table.HeaderCell>更新时间</Table.HeaderCell>
<Table.HeaderCell>操作</Table.HeaderCell>
</Table.Row>
</Table.Header>
<Table.Body>
{routes.map((route) => (
<Table.Row key={route.id}>
<Table.Cell>{route.domain}</Table.Cell>
<Table.Cell>{route.origin_url}</Table.Cell>
<Table.Cell>
{route.enabled ? <Label color='green'>启用</Label> : <Label>停用</Label>}
</Table.Cell>
<Table.Cell>{route.remark || '无'}</Table.Cell>
<Table.Cell>{formatDateTime(route.updated_at)}</Table.Cell>
<Table.Cell>
<Button size='small' onClick={() => beginEdit(route)}>
编辑
</Button>
<Button size='small' negative onClick={() => deleteRoute(route.id)}>
删除
</Button>
</Table.Cell>
</Table.Row>
))}
</Table.Body>
</Table>
</Segment>
);
};
export default ProxyRoute;