feat: add preview and diff endpoints for config versions

- Implemented PreviewConfigVersion and DiffConfigVersion functions to provide configuration previews and differences.
- Updated API router to include new endpoints for preview and diff.
- Enhanced config version publishing to include custom headers in the rendered configuration.
- Added tests for preview and diff functionalities, ensuring correct behavior with custom headers.
- Updated frontend to support previewing and publishing configurations with a detailed change summary.
This commit is contained in:
ryan
2026-03-10 16:23:49 +08:00
parent b76fb822f8
commit 08e4de4898
10 changed files with 830 additions and 187 deletions
+213 -125
View File
@@ -1,125 +1,213 @@
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;
import React, { useEffect, useState } from 'react';
import {
Button,
Divider,
Header,
Icon,
Label,
Modal,
Segment,
Table,
} from 'semantic-ui-react';
import { API, formatDateTime, showError, showSuccess } from '../../helpers';
const renderDomainList = (items) => {
if (!items || items.length === 0) {
return <p className='page-subtitle'>无</p>;
}
return (
<ul>
{items.map((item) => (
<li key={item}>{item}</li>
))}
</ul>
);
};
const ConfigVersion = () => {
const [versions, setVersions] = useState([]);
const [loading, setLoading] = useState(false);
const [publishing, setPublishing] = useState(false);
const [preview, setPreview] = useState(null);
const [publishPreviewOpen, setPublishPreviewOpen] = useState(false);
const [previewLoading, setPreviewLoading] = useState(false);
const [pendingPreview, setPendingPreview] = useState(null);
const [pendingDiff, setPendingDiff] = 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);
return success;
};
const openPublishPreview = async () => {
setPreviewLoading(true);
const [previewRes, diffRes] = await Promise.all([
API.get('/api/config-versions/preview'),
API.get('/api/config-versions/diff'),
]);
const previewPayload = previewRes.data;
const diffPayload = diffRes.data;
if (!previewPayload.success) {
showError(previewPayload.message);
setPreviewLoading(false);
return;
}
if (!diffPayload.success) {
showError(diffPayload.message);
setPreviewLoading(false);
return;
}
setPendingPreview(previewPayload.data || null);
setPendingDiff(diffPayload.data || null);
setPublishPreviewOpen(true);
setPreviewLoading(false);
};
const confirmPublish = async () => {
const success = await publishConfig();
if (success) {
setPublishPreviewOpen(false);
setPendingPreview(null);
setPendingDiff(null);
}
};
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={previewLoading} onClick={openPublishPreview}>
<Icon name='eye' />
预览并发布
</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>
<Modal open={publishPreviewOpen} onClose={() => setPublishPreviewOpen(false)} closeIcon>
<Modal.Header>发布前预览</Modal.Header>
<Modal.Content scrolling>
{pendingDiff ? (
<>
<Header as='h4'>变更摘要</Header>
<p className='page-subtitle'>当前激活版本:{pendingDiff.active_version || '无'}</p>
<Label color='green'>新增 {pendingDiff.added_domains?.length || 0}</Label>
<Label color='orange'>删除 {pendingDiff.removed_domains?.length || 0}</Label>
<Label color='blue'>修改 {pendingDiff.modified_domains?.length || 0}</Label>
<Divider />
<Header as='h5'>新增域名</Header>
{renderDomainList(pendingDiff.added_domains)}
<Header as='h5'>删除域名</Header>
{renderDomainList(pendingDiff.removed_domains)}
<Header as='h5'>修改域名</Header>
{renderDomainList(pendingDiff.modified_domains)}
</>
) : null}
{pendingPreview ? (
<>
<Divider />
<Header as='h4'>渲染结果</Header>
<p className='page-subtitle'>启用规则数:{pendingPreview.route_count},Checksum:{pendingPreview.checksum}</p>
<pre className='atsf-pre'>{pendingPreview.rendered_config}</pre>
</>
) : null}
</Modal.Content>
<Modal.Actions>
<Button onClick={() => setPublishPreviewOpen(false)}>取消</Button>
<Button primary loading={publishing} onClick={confirmPublish}>
确认发布
</Button>
</Modal.Actions>
</Modal>
</Segment>
);
};
export default ConfigVersion;
@@ -5,6 +5,7 @@ import {
Form,
Header,
Icon,
Input,
Label,
Segment,
Table,
@@ -12,6 +13,23 @@ import {
} from 'semantic-ui-react';
import { API, showError, showSuccess, formatDateTime } from '../../helpers';
const emptyHeader = () => ({ key: '', value: '' });
const parseCustomHeaders = (rawValue) => {
if (Array.isArray(rawValue)) {
return rawValue;
}
if (!rawValue) {
return [];
}
try {
const parsed = JSON.parse(rawValue);
return Array.isArray(parsed) ? parsed : [];
} catch (error) {
return [];
}
};
const initialForm = {
domain: '',
origin_url: '',
@@ -19,6 +37,7 @@ const initialForm = {
enable_https: false,
cert_id: '',
redirect_http: false,
custom_headers: [emptyHeader()],
remark: '',
};
@@ -106,6 +125,7 @@ const ProxyRoute = () => {
domain: form.domain.trim(),
origin_url: form.origin_url.trim(),
cert_id: form.enable_https && form.cert_id ? Number(form.cert_id) : null,
custom_headers: (form.custom_headers || []).filter((item) => item.key.trim() || item.value.trim()),
remark: form.remark.trim(),
};
const res = editingId
@@ -145,6 +165,7 @@ const ProxyRoute = () => {
};
const beginEdit = (route) => {
const customHeaders = parseCustomHeaders(route.custom_headers);
setEditingId(route.id);
setForm({
domain: route.domain,
@@ -153,11 +174,38 @@ const ProxyRoute = () => {
enable_https: route.enable_https || false,
cert_id: route.cert_id || '',
redirect_http: route.redirect_http || false,
custom_headers: customHeaders.length > 0 ? customHeaders : [emptyHeader()],
remark: route.remark || '',
});
setMatchResult(null);
};
const updateCustomHeader = (index, field, value) => {
setForm((current) => ({
...current,
custom_headers: current.custom_headers.map((item, itemIndex) => (
itemIndex === index ? { ...item, [field]: value } : item
)),
}));
};
const addCustomHeader = () => {
setForm((current) => ({
...current,
custom_headers: [...(current.custom_headers || []), emptyHeader()],
}));
};
const removeCustomHeader = (index) => {
setForm((current) => {
const nextHeaders = (current.custom_headers || []).filter((item, itemIndex) => itemIndex !== index);
return {
...current,
custom_headers: nextHeaders.length > 0 ? nextHeaders : [emptyHeader()],
};
});
};
const certificateOptions = certificates.map((certificate) => ({
key: certificate.id,
text: `${certificate.name} (${certificate.not_after ? formatDateTime(certificate.not_after) : 'unknown'})`,
@@ -254,6 +302,34 @@ const ProxyRoute = () => {
style={{ alignSelf: 'flex-end', marginBottom: '1rem' }}
/>
</Form.Group>
<Form.Field>
<label>自定义请求头</label>
{(form.custom_headers || []).map((header, index) => (
<Form.Group widths='equal' key={`header-${index}`}>
<Form.Field
control={Input}
label={index === 0 ? 'Header 名称' : undefined}
placeholder='X-Trace-Id'
value={header.key}
onChange={(e, { value }) => updateCustomHeader(index, 'key', value)}
/>
<Form.Field
control={Input}
label={index === 0 ? 'Header 值' : undefined}
placeholder='$request_id'
value={header.value}
onChange={(e, { value }) => updateCustomHeader(index, 'value', value)}
/>
<Form.Field style={{ display: 'flex', alignItems: index === 0 ? 'flex-end' : 'center' }}>
<Button type='button' icon='trash' negative basic onClick={() => removeCustomHeader(index)} />
</Form.Field>
</Form.Group>
))}
<Button type='button' basic icon labelPosition='left' onClick={addCustomHeader}>
<Icon name='plus' />
添加请求头
</Button>
</Form.Field>
<Button primary type='submit'>
{editingId ? '保存修改' : '新增规则'}
</Button>
@@ -270,6 +346,7 @@ const ProxyRoute = () => {
<Table.HeaderCell>域名</Table.HeaderCell>
<Table.HeaderCell>源站地址</Table.HeaderCell>
<Table.HeaderCell>HTTPS</Table.HeaderCell>
<Table.HeaderCell>自定义头</Table.HeaderCell>
<Table.HeaderCell>状态</Table.HeaderCell>
<Table.HeaderCell>备注</Table.HeaderCell>
<Table.HeaderCell>更新时间</Table.HeaderCell>
@@ -288,6 +365,13 @@ const ProxyRoute = () => {
<Label>HTTP</Label>
)}
</Table.Cell>
<Table.Cell>
{parseCustomHeaders(route.custom_headers).length > 0 ? (
<Label color='teal'>{parseCustomHeaders(route.custom_headers).length} 条</Label>
) : (
<Label>无</Label>
)}
</Table.Cell>
<Table.Cell>
{route.enabled ? <Label color='green'>启用</Label> : <Label>停用</Label>}
</Table.Cell>