拓扑图

This commit is contained in:
2026-08-10 00:08:47 +08:00
parent 2be6dc84f5
commit d8bbe64e06
5 changed files with 1260 additions and 4 deletions
+885 -4
View File
File diff suppressed because it is too large Load Diff
+1
View File
@@ -17,6 +17,7 @@
}, },
"dependencies": { "dependencies": {
"@ant-design/icons": "^6.1.0", "@ant-design/icons": "^6.1.0",
"@antv/g6": "^5.1.1",
"antd": "^5.29.1", "antd": "^5.29.1",
"axios": "^1.8.4", "axios": "^1.8.4",
"cesium": "^1.143.0", "cesium": "^1.143.0",
+362
View File
@@ -0,0 +1,362 @@
import {CanvasEvent, Graph, GraphEvent, NodeEvent} from "@antv/g6"
import {Alert, Button, Card, Collapse, Descriptions, Drawer, Empty, Space, Spin, Tag, Typography} from "antd"
import axios from "axios"
import {useCallback, useEffect, useMemo, useRef, useState} from "react"
import {AmisPanel} from "./AmisPanel.tsx"
type Topology_Node = {
id: string
kind: string
entity_id: number
key: string
label: string
type: string
enabled: boolean
status_api?: string
}
type Topology_Edge = {
id: string
source: string
target: string
active: boolean
}
type Topology_View = {
protocol: "ecap.data-topology-view"
protocol_version: 1
initialized: boolean
zoom: number
pan_x: number
pan_y: number
node_positions: Record<string, {x: number, y: number}>
}
type Topology_Data = {
protocol: "ecap.data-topology"
protocol_version: 1
nodes: Topology_Node[]
edges: Topology_Edge[]
view: Topology_View
}
type Node_Kind = {
kind: string
title: string
fill: string
stroke: string
}
type Topology_Editor = {
kind: string
title: string
button_label: string
schema: Record<string, unknown>
}
export type Data_Topology_Schema = {
title: string
data_api: string
view_api: string
graph: {
height: number
layout: Record<string, unknown>
behaviors: string[]
active_edge_color: string
inactive_edge_color: string
}
node_kinds: Node_Kind[]
status: {
title: string
poll_interval_ms: number
collapsed_hint: string
}
editors: Topology_Editor[]
}
type Adminive_Response<T> = {
status: number
msg: string
data: T
}
const topology_changed_event = "ecap-data-topology-changed"
function status_text(value: unknown): string {
if (value === null) return "null"
if (typeof value === "object") return JSON.stringify(value, null, 2)
return String(value)
}
function Node_Status({node, schema}: {node: Topology_Node, schema: Data_Topology_Schema["status"]}) {
const [expanded, set_expanded] = useState(false)
const [status, set_status] = useState<Record<string, unknown> | null>(null)
const [error, set_error] = useState("")
const [updated_at, set_updated_at] = useState<Date | null>(null)
useEffect(() => {
if (!expanded || !node.status_api) return
let active = true
let timer: number | undefined
let controller: AbortController | undefined
const poll = async () => {
controller = new AbortController()
try {
const response = await axios.get<Adminive_Response<Record<string, unknown>>>(node.status_api, {signal: controller.signal})
if (!active) return
set_status(response.data.data)
set_error("")
set_updated_at(new Date())
} catch (reason) {
if (!active || axios.isCancel(reason)) return
set_error(reason instanceof Error ? reason.message : String(reason))
} finally {
if (active) timer = window.setTimeout(poll, Math.max(250, schema.poll_interval_ms))
}
}
void poll()
return () => {
active = false
controller?.abort()
if (timer !== undefined) window.clearTimeout(timer)
}
}, [expanded, node.status_api, schema.poll_interval_ms])
if (!node.status_api) return <Typography.Text type="secondary"></Typography.Text>
return <Collapse size="small" activeKey={expanded ? ["status"] : []}
onChange={keys => {
const open = keys.includes("status")
set_expanded(open)
if (!open) {
set_status(null)
set_error("")
set_updated_at(null)
}
}}
items={[{
key: "status",
label: <Space><span>{schema.title}</span><Typography.Text type="secondary">{expanded ? "轮询中" : schema.collapsed_hint}</Typography.Text></Space>,
children: error
? <Alert type="error" showIcon message="状态读取失败" description={error}/>
: status
? <Space direction="vertical" size={8} style={{width: "100%"}}>
<Descriptions size="small" bordered column={1}
items={Object.entries(status).map(([key, value]) => ({
key,
label: key,
children: <Typography.Text style={{whiteSpace: "pre-wrap", wordBreak: "break-word"}}>{status_text(value)}</Typography.Text>
}))}/>
{updated_at && <Typography.Text type="secondary"> {updated_at.toLocaleTimeString()}</Typography.Text>}
</Space>
: <Spin size="small"/>
}]}/>
}
export function DataTopology({schema}: {schema: Data_Topology_Schema}) {
const container = useRef<HTMLDivElement>(null)
const graph_ref = useRef<Graph | null>(null)
const [data, set_data] = useState<Topology_Data | null>(null)
const [selected_id, set_selected_id] = useState("")
const [editor_kind, set_editor_kind] = useState("")
const [error, set_error] = useState("")
const [view_error, set_view_error] = useState("")
const load = useCallback(async () => {
try {
const response = await axios.get<Adminive_Response<Topology_Data>>(schema.data_api)
if (response.data.data.protocol !== "ecap.data-topology" || response.data.data.protocol_version !== 1)
throw new Error("数据拓扑协议或版本不受支持")
if (response.data.data.view.protocol !== "ecap.data-topology-view" || response.data.data.view.protocol_version !== 1)
throw new Error("数据拓扑视图协议或版本不受支持")
set_data(response.data.data)
set_error("")
} catch (reason) {
set_error(reason instanceof Error ? reason.message : String(reason))
}
}, [schema.data_api])
useEffect(() => {
void load()
window.addEventListener(topology_changed_event, load)
return () => window.removeEventListener(topology_changed_event, load)
}, [load])
const kind_by_name = useMemo(() => new Map(schema.node_kinds.map(kind => [kind.kind, kind])), [schema.node_kinds])
const selected = data?.nodes.find(node => node.id === selected_id)
const selected_kind = selected && kind_by_name.get(selected.kind)
const editor = schema.editors.find(item => item.kind === editor_kind)
useEffect(() => {
if (!container.current || !data) return
let disposed = false
let restoring = true
let save_timer: number | undefined
const graph = new Graph({
container: container.current,
autoFit: data.view.initialized ? undefined : "view",
animation: false,
data: {
nodes: data.nodes.map(node => ({id: node.id, data: node})),
edges: data.edges.map(edge => ({id: edge.id, source: edge.source, target: edge.target, data: edge}))
},
layout: schema.graph.layout as never,
behaviors: schema.graph.behaviors,
node: {
type: "rect",
style: datum => {
const node = datum.data as unknown as Topology_Node
const kind = kind_by_name.get(node.kind)
return {
size: [190, 62],
radius: 10,
fill: kind?.fill ?? "#fafafa",
fillOpacity: node.enabled ? 1 : 0.48,
stroke: kind?.stroke ?? "#8c8c8c",
lineWidth: node.enabled ? 2 : 1,
lineDash: node.enabled ? undefined : [5, 4],
labelText: `${node.label}\n${node.type}`,
labelFill: "#1f1f1f",
labelFontSize: 13,
labelLineHeight: 18,
cursor: "pointer"
}
}
},
edge: {
type: "polyline",
style: datum => {
const edge = datum.data as unknown as Topology_Edge
return {
stroke: edge.active ? schema.graph.active_edge_color : schema.graph.inactive_edge_color,
lineWidth: edge.active ? 2.5 : 1.5,
lineDash: edge.active ? undefined : [6, 5],
endArrow: true,
opacity: edge.active ? 1 : 0.62
}
}
}
})
graph.on(NodeEvent.CLICK, event => {
const target = (event as unknown as {target: {id: string}}).target
set_selected_id(target.id)
})
graph.on(CanvasEvent.CLICK, () => set_selected_id(""))
const persist_view = async () => {
if (restoring || disposed) return
const node_positions: Topology_View["node_positions"] = {}
for (const node of data.nodes) {
const [x, y] = graph.getElementPosition(node.id)
node_positions[node.id] = {x, y}
}
const [pan_x, pan_y] = graph.getPosition()
const payload: Topology_View = {
protocol: "ecap.data-topology-view",
protocol_version: 1,
initialized: true,
zoom: graph.getZoom(),
pan_x,
pan_y,
node_positions
}
try {
await axios.put(schema.view_api, payload)
if (!disposed) set_view_error("")
} catch (reason) {
if (!disposed) set_view_error(reason instanceof Error ? reason.message : String(reason))
}
}
const schedule_view_save = () => {
if (restoring || disposed) return
if (save_timer !== undefined) window.clearTimeout(save_timer)
save_timer = window.setTimeout(() => void persist_view(), 400)
}
graph.on(NodeEvent.DRAG_END, schedule_view_save)
graph.on(GraphEvent.AFTER_TRANSFORM, schedule_view_save)
graph_ref.current = graph
const initialize = async () => {
await graph.render()
if (disposed) return
if (data.view.initialized) {
const positions: Record<string, [number, number]> = {}
for (const [id, position] of Object.entries(data.view.node_positions)) {
if (data.nodes.some(node => node.id === id))
positions[id] = [position.x, position.y]
}
if (Object.keys(positions).length > 0)
await graph.translateElementTo(positions, false)
await graph.zoomTo(data.view.zoom, false)
await graph.translateTo([data.view.pan_x, data.view.pan_y], false)
}
restoring = false
}
void initialize()
const observer = new ResizeObserver(() => graph.resize())
observer.observe(container.current)
return () => {
if (save_timer !== undefined) window.clearTimeout(save_timer)
if (!restoring) void persist_view()
disposed = true
observer.disconnect()
graph.destroy()
graph_ref.current = null
}
}, [data, kind_by_name, schema.graph, schema.view_api])
return <Card title={schema.title} extra={<Space wrap>
<Button onClick={() => void load()}></Button>
<Button onClick={() => void graph_ref.current?.fitView()}></Button>
{schema.editors.map(item => <Button key={item.kind} onClick={() => set_editor_kind(item.kind)}>{item.button_label}</Button>)}
</Space>}>
{error && <Alert type="error" showIcon message="数据拓扑加载失败" description={error} style={{marginBottom: 12}}/>}
{view_error && <Alert type="warning" showIcon message="拓扑视图保存失败" description={view_error} closable onClose={() => set_view_error("")} style={{marginBottom: 12}}/>}
{!data && !error ? <div style={{padding: 36, textAlign: "center"}}><Spin/></div> : data && <div style={{display: "flex", flexWrap: "wrap", gap: 12, width: "100%"}}>
<div style={{flex: "1 1 680px", minWidth: 0, height: schema.graph.height, border: "1px solid #f0f0f0", borderRadius: 8, overflow: "hidden"}} ref={container}/>
<Card size="small" title="节点详情" style={{flex: "1 1 320px", minWidth: 0}}
extra={selected && <Button type="link" onClick={() => set_editor_kind(selected.kind)}></Button>}>
{selected ? <Space direction="vertical" size={12} style={{width: "100%"}}>
<Space wrap><Tag color={selected_kind?.stroke}>{selected_kind?.title ?? selected.kind}</Tag><Tag color={selected.enabled ? "success" : "default"}>{selected.enabled ? "已启用" : "未启用"}</Tag></Space>
<Descriptions size="small" bordered column={1} items={[
{key: "key", label: "名称", children: selected.key},
{key: "type", label: "类型", children: selected.type},
{key: "id", label: "后端序号", children: selected.entity_id}
]}/>
<Node_Status key={`${selected.id}:${selected.status_api ?? ""}`} node={selected} schema={schema.status}/>
</Space> : <Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description="点击图中的节点查看详情;节点可直接拖拽"/>}
</Card>
</div>}
<Drawer open={Boolean(editor)} title={editor?.title} width="88vw" destroyOnClose
onClose={() => {
set_editor_kind("")
void load()
}}>
{editor && <AmisPanel schema={editor.schema}/>}
</Drawer>
</Card>
}
export function DataTopologyPage() {
const [schema, set_schema] = useState<Data_Topology_Schema | null>(null)
const [error, set_error] = useState("")
useEffect(() => {
const controller = new AbortController()
const load_schema = async () => {
try {
const response = await axios.get<Adminive_Response<Data_Topology_Schema>>("/api/adminive/config/data_topology/schema", {signal: controller.signal})
set_schema(response.data.data)
set_error("")
} catch (reason) {
if (!axios.isCancel(reason)) set_error(reason instanceof Error ? reason.message : String(reason))
}
}
const handle_topology_changed = () => void load_schema()
void load_schema()
window.addEventListener(topology_changed_event, handle_topology_changed)
return () => {
controller.abort()
window.removeEventListener(topology_changed_event, handle_topology_changed)
}
}, [])
if (error) return <Alert type="error" showIcon message="数据拓扑页面加载失败" description={error} style={{margin: 16}}/>
if (!schema) return <div style={{padding: 36, textAlign: "center"}}><Spin/></div>
return <div style={{flex: 1, minWidth: 0, minHeight: 0, overflow: "auto", padding: 12}}><DataTopology schema={schema}/></div>
}
+4
View File
@@ -3,6 +3,7 @@ import {emit_cesium_graphics_config, load_cesium_graphics_config} from "../Map/C
import type {Amis_Request} from "./amis_runtime.ts" import type {Amis_Request} from "./amis_runtime.ts"
const cesium_graphics_data_api = "/api/adminive/config/cesium_graphics/data" const cesium_graphics_data_api = "/api/adminive/config/cesium_graphics/data"
const data_sources_api = "/api/adminive/config/data_sources" const data_sources_api = "/api/adminive/config/data_sources"
const topology_collection_apis = [data_sources_api, "/api/adminive/config/data_feeds"]
const raw_action_apis = new Set(["/api/refresh_external_databases", "/api/refresh_external_database", "/api/upload_external_database", "/api/clear_external_database_table", "/api/restart_device"]) const raw_action_apis = new Set(["/api/refresh_external_databases", "/api/refresh_external_database", "/api/upload_external_database", "/api/clear_external_database_table", "/api/restart_device"])
export async function amis_fetcher(request: Amis_Request): Promise<AxiosResponse> { export async function amis_fetcher(request: Amis_Request): Promise<AxiosResponse> {
const method = (request.method ?? "get").toLowerCase() const method = (request.method ?? "get").toLowerCase()
@@ -29,5 +30,8 @@ export async function amis_fetcher(request: Amis_Request): Promise<AxiosResponse
if (method !== "get" && method !== "head" && request.url.startsWith(data_sources_api) && success) { if (method !== "get" && method !== "head" && request.url.startsWith(data_sources_api) && success) {
window.dispatchEvent(new Event("ecap-data-sources-changed")) window.dispatchEvent(new Event("ecap-data-sources-changed"))
} }
if (method !== "get" && method !== "head" && topology_collection_apis.some(api => pathname.startsWith(api)) && success) {
window.dispatchEvent(new Event("ecap-data-topology-changed"))
}
return response return response
} }
+8
View File
@@ -27,6 +27,7 @@ import {Data_Source_Config} from "./Data_Source/Data_Source_Config.tsx";
import L from 'leaflet'; import L from 'leaflet';
const Cesium_Map_View = React.lazy(() => import("./Map/Cesium_Map.tsx")); const Cesium_Map_View = React.lazy(() => import("./Map/Cesium_Map.tsx"));
const Data_Topology_View = React.lazy(() => import("./Adminive/DataTopology.tsx").then(module => ({default: module.DataTopologyPage})));
delete (L.Icon.Default.prototype as any)._getIconUrl; delete (L.Icon.Default.prototype as any)._getIconUrl;
@@ -83,6 +84,11 @@ class App extends enhance.Base {
icon: <SettingOutlined/>, icon: <SettingOutlined/>,
key: '/aircraftlist', key: '/aircraftlist',
}, },
{
label: '数据拓扑',
icon: <SettingOutlined/>,
key: '/topology',
},
]; ];
axios.post(`${baseURL}/version`, {}).then(res => { axios.post(`${baseURL}/version`, {}).then(res => {
@@ -166,6 +172,7 @@ class App extends enhance.Base {
const map2d = <div style={{width: '100%', height: '100%', position: 'relative'}}><this.leaflet_map.x/></div>; const map2d = <div style={{width: '100%', height: '100%', position: 'relative'}}><this.leaflet_map.x/></div>;
const settings = <this.setting.x/>; const settings = <this.setting.x/>;
const aircraftList = <div style={{flex: 1}}><this.aircraft_list.x/></div>; const aircraftList = <div style={{flex: 1}}><this.aircraft_list.x/></div>;
const dataTopology = <React.Suspense fallback={<div style={{padding: 20}}>...</div>}><Data_Topology_View/></React.Suspense>;
const map3d = (fallback: string) => this.webgl_supported const map3d = (fallback: string) => this.webgl_supported
? <div style={{width: '100%', height: '100%', position: 'relative'}}> ? <div style={{width: '100%', height: '100%', position: 'relative'}}>
<React.Suspense fallback={<div style={{padding: 20}}>3D地图加载中...</div>}> <React.Suspense fallback={<div style={{padding: 20}}>3D地图加载中...</div>}>
@@ -215,6 +222,7 @@ class App extends enhance.Base {
<Route path="/map3d" element={map3d("/map")}/> <Route path="/map3d" element={map3d("/map")}/>
<Route path="/ui/map3d" element={map3d("/ui/map")}/> <Route path="/ui/map3d" element={map3d("/ui/map")}/>
{["/aircraftlist", "/ui/aircraftlist"].map(path => <Route key={path} path={path} element={aircraftList}/>)} {["/aircraftlist", "/ui/aircraftlist"].map(path => <Route key={path} path={path} element={aircraftList}/>)}
{["/topology", "/ui/topology"].map(path => <Route key={path} path={path} element={dataTopology}/>)}
<Route path="*" element={<Navigate to="/map"/>}/> <Route path="*" element={<Navigate to="/map"/>}/>
</Routes> </Routes>