mirror of
https://github.com/Rain-kl/OpenFlare.git
synced 2026-10-02 23:06:36 +08:00
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:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user