diff --git a/atsf_server/web/src/App.js b/atsf_server/web/src/App.js index 8af724e7..35fdae85 100644 --- a/atsf_server/web/src/App.js +++ b/atsf_server/web/src/App.js @@ -16,6 +16,10 @@ import PasswordResetConfirm from './components/PasswordResetConfirm'; import { UserContext } from './context/User'; import { StatusContext } from './context/Status'; import File from './pages/File'; +import ProxyRoute from './pages/ProxyRoute'; +import ConfigVersion from './pages/ConfigVersion'; +import Node from './pages/Node'; +import ApplyLog from './pages/ApplyLog'; const Home = lazy(() => import('./pages/Home')); const About = lazy(() => import('./pages/About')); @@ -72,6 +76,38 @@ function App() { } /> + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> } /> - + } /> ); } diff --git a/atsf_server/web/src/components/Header.js b/atsf_server/web/src/components/Header.js index aa842958..aaea95ea 100644 --- a/atsf_server/web/src/components/Header.js +++ b/atsf_server/web/src/components/Header.js @@ -20,6 +20,30 @@ const headerButtons = [ to: '/', icon: 'home', }, + { + name: '规则', + to: '/proxy-route', + icon: 'sitemap', + admin: true, + }, + { + name: '版本', + to: '/config-version', + icon: 'history', + admin: true, + }, + { + name: '节点', + to: '/node', + icon: 'server', + admin: true, + }, + { + name: '记录', + to: '/apply-log', + icon: 'tasks', + admin: true, + }, { name: '文件', to: '/file', diff --git a/atsf_server/web/src/helpers/utils.js b/atsf_server/web/src/helpers/utils.js index 70de49aa..f8764f58 100644 --- a/atsf_server/web/src/helpers/utils.js +++ b/atsf_server/web/src/helpers/utils.js @@ -144,3 +144,10 @@ export function timestamp2string(timestamp) { year + '-' + month + '-' + day + ' ' + hour + ':' + minute + ':' + second ); } + +export function formatDateTime(value) { + if (!value) return '无'; + const date = new Date(value); + if (Number.isNaN(date.getTime())) return value; + return date.toLocaleString('zh-CN', { hour12: false }); +} diff --git a/atsf_server/web/src/index.css b/atsf_server/web/src/index.css index 5d60e377..713b9594 100644 --- a/atsf_server/web/src/index.css +++ b/atsf_server/web/src/index.css @@ -28,8 +28,47 @@ code { font-size: 1.1em; } +.page-toolbar { + display: flex; + justify-content: space-between; + align-items: flex-start; + gap: 16px; + margin-bottom: 20px; +} + +.page-subtitle { + margin-top: 6px; + color: rgba(0, 0, 0, 0.65); +} + +.atsf-table td { + vertical-align: top !important; +} + +.atsf-pre { + white-space: pre-wrap; + word-break: break-word; + background: #f7f8fa; + border: 1px solid #e3e6ea; + border-radius: 8px; + padding: 12px; + font-size: 12px; + line-height: 1.5; +} + +.table-meta { + margin-top: 6px; + color: rgba(0, 0, 0, 0.58); + font-size: 12px; +} + @media only screen and (max-width: 600px) { .hide-on-mobile { display: none !important; } + + .page-toolbar { + flex-direction: column; + align-items: stretch; + } } diff --git a/atsf_server/web/src/pages/ApplyLog/index.js b/atsf_server/web/src/pages/ApplyLog/index.js new file mode 100644 index 00000000..6d70fd87 --- /dev/null +++ b/atsf_server/web/src/pages/ApplyLog/index.js @@ -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 ( + +
应用记录
+

按节点查看配置应用成功或失败记录。

+ +
+ setKeyword(value)} + /> + + + + + + Node ID + 版本 + 结果 + 信息 + 时间 + + + + {logs.map((log) => ( + + {log.node_id} + {log.version} + + {log.result === 'success' ? : } + + {log.message || '无'} + {formatDateTime(log.created_at)} + + ))} + +
+
+ ); +}; + +export default ApplyLog; diff --git a/atsf_server/web/src/pages/ConfigVersion/index.js b/atsf_server/web/src/pages/ConfigVersion/index.js new file mode 100644 index 00000000..70456dbe --- /dev/null +++ b/atsf_server/web/src/pages/ConfigVersion/index.js @@ -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 ( + +
+
+
版本发布
+

查看历史快照,发布新版本,或重新激活旧版本。

+
+ +
+ + + + + 版本号 + 状态 + 创建人 + Checksum + 创建时间 + 操作 + + + + {versions.map((version) => ( + + {version.version} + + {version.is_active ? : } + + {version.created_by} + {(version.checksum || '').slice(0, 16)}... + {formatDateTime(version.created_at)} + + + {!version.is_active ? ( + + ) : null} + + + ))} + +
+ + setPreview(null)} closeIcon> + 版本预览 + + {preview ? ( + <> +
快照 JSON
+
{preview.snapshot_json}
+
渲染结果
+
{preview.rendered_config}
+ + ) : null} +
+
+
+ ); +}; + +export default ConfigVersion; diff --git a/atsf_server/web/src/pages/Node/index.js b/atsf_server/web/src/pages/Node/index.js new file mode 100644 index 00000000..60f0ef46 --- /dev/null +++ b/atsf_server/web/src/pages/Node/index.js @@ -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 ; + } + return ; +}; + +const renderApply = (result) => { + if (result === 'success') { + return ; + } + if (result === 'failed') { + return ; + } + return ; +}; + +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 ( + +
节点状态
+

查看节点在线状态、当前版本和最近一次应用结果。

+ + + + + 节点名 + Node ID + IP + 状态 + Agent / Nginx + 当前版本 + 最近应用 + 最近心跳 + 错误 + + + + {nodes.map((node) => ( + + {node.name} + {node.node_id} + {node.ip} + {renderStatus(node.status)} + {node.agent_version} / {node.nginx_version || 'unknown'} + {node.current_version || '未应用'} + + {renderApply(node.latest_apply_result)} +
{node.latest_apply_message || '暂无记录'}
+
+ {formatDateTime(node.last_seen_at)} + {node.last_error || '无'} +
+ ))} +
+
+
+ ); +}; + +export default Node; diff --git a/atsf_server/web/src/pages/ProxyRoute/index.js b/atsf_server/web/src/pages/ProxyRoute/index.js new file mode 100644 index 00000000..64f50f08 --- /dev/null +++ b/atsf_server/web/src/pages/ProxyRoute/index.js @@ -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 ( + +
+
+
反代规则
+

维护 Host 到 Origin 的映射,并可直接触发发布。

+
+ +
+ +
+ + setForm({ ...form, domain: value })} + /> + setForm({ ...form, origin_url: value })} + /> + + + setForm({ ...form, remark: value })} + /> + setForm({ ...form, enabled: checked })} + style={{ alignSelf: 'flex-end', marginBottom: '1rem' }} + /> + + + {editingId ? ( + + ) : null} +
+ + + + + 域名 + 源站地址 + 状态 + 备注 + 更新时间 + 操作 + + + + {routes.map((route) => ( + + {route.domain} + {route.origin_url} + + {route.enabled ? : } + + {route.remark || '无'} + {formatDateTime(route.updated_at)} + + + + + + ))} + +
+
+ ); +}; + +export default ProxyRoute;