修完bug

This commit is contained in:
2026-08-10 15:34:38 +08:00
parent 1c500ec5eb
commit 7280e2595d
+256 -39
View File
@@ -1,6 +1,6 @@
import {CaretDownOutlined, CaretRightOutlined, DeleteOutlined, DisconnectOutlined, EditOutlined, LinkOutlined, PlusOutlined, SaveOutlined, SyncOutlined, UndoOutlined} from "@ant-design/icons"
import {CaretDownOutlined, CaretRightOutlined, CopyOutlined, DeleteOutlined, DisconnectOutlined, EditOutlined, LinkOutlined, PlusOutlined, SaveOutlined, SyncOutlined, UndoOutlined} from "@ant-design/icons"
import dagre from "@dagrejs/dagre"
import {Background, BackgroundVariant, BaseEdge, Controls, EdgeLabelRenderer, Handle, MarkerType, Position, ReactFlow, applyNodeChanges, getSmoothStepPath, type Connection, type Edge, type EdgeMouseHandler, type EdgeProps, type Node, type NodeMouseHandler, type NodeProps, type OnNodesChange, type ReactFlowInstance} from "@xyflow/react"
import {Background, BackgroundVariant, BaseEdge, Controls, EdgeLabelRenderer, Handle, MarkerType, Position, ReactFlow, applyNodeChanges, getSmoothStepPath, getViewportForBounds, type Connection, type Edge, type EdgeMouseHandler, type EdgeProps, type Node, type NodeMouseHandler, type NodeProps, type OnNodesChange, type ReactFlowInstance} from "@xyflow/react"
import "@xyflow/react/dist/style.css"
import {Alert, Button, Card, Menu, type MenuProps, Modal, Select, Space, Spin, Tag, Typography, message} from "antd"
import axios from "axios"
@@ -223,22 +223,161 @@ function draft_editor_schema(editor: Topology_Editor, mode: Editor_State["mode"]
schema.api = {method: "post", url: draft_submit_api}
return schema
}
function topology_layout(data: Topology_Data, layout: Record<string, unknown>): Map<string, {x: number, y: number}> {
function topology_layout(data: Topology_Data, layout: Record<string, unknown>, measured?: ReadonlyMap<string, {width: number, height: number}>, edge_statuses?: ReadonlyMap<string, {width: number, height: number}>): Map<string, {x: number, y: number}> {
const graph = new dagre.graphlib.Graph().setDefaultEdgeLabel(() => ({}))
graph.setGraph({rankdir: "TB", align: "UL", nodesep: Number(layout.nodesep ?? 52), ranksep: Number(layout.ranksep ?? 96), marginx: 32, marginy: 32})
for(const node of data.nodes) {
const [width, height] = node_size(node)
const [default_width, default_height] = node_size(node)
const {width, height} = measured?.get(node.id) ?? {width: default_width, height: default_height}
graph.setNode(node.id, {width, height})
}
for(const edge of data.edges)
graph.setEdge(edge.source, edge.target)
for(const edge of data.edges) {
const status = edge_statuses?.get(edge.id)
if(status) {
const status_id = `edge-status:${edge.id}`
graph.setNode(status_id, status)
graph.setEdge(edge.source, status_id)
graph.setEdge(status_id, edge.target)
} else
graph.setEdge(edge.source, edge.target)
}
dagre.layout(graph)
return new Map(data.nodes.map(node => {
const position = graph.node(node.id)
const [width, height] = node_size(node)
const [default_width, default_height] = node_size(node)
const {width, height} = measured?.get(node.id) ?? {width: default_width, height: default_height}
return [node.id, {x: position.x - width / 2, y: position.y - height / 2}]
}))
}
function measured_node_sizes(instance: ReactFlowInstance<Flow_Node, Flow_Edge>): Map<string, {width: number, height: number}> {
return new Map(instance.getNodes().map(node => {
const [width, height] = node_size(node.data.topology_node)
return [node.id, {width: node.measured?.width ?? width, height: node.measured?.height ?? height}]
}))
}
function measured_edge_statuses(element: HTMLElement): Map<string, {width: number, height: number}> {
return new Map(Array.from(element.querySelectorAll<HTMLElement>('[data-edge-status-expanded="true"]')).map(status => [status.dataset.edgeId!, {width: status.offsetWidth, height: status.offsetHeight}]))
}
function avoid_overlap_layout(data: Topology_Data, current: ReadonlyMap<string, {x: number, y: number}>, measured: ReadonlyMap<string, {width: number, height: number}>, edge_statuses: ReadonlyMap<string, {width: number, height: number}>, layout: Record<string, unknown>, enforce_ranks: boolean): Map<string, {x: number, y: number}> {
const positions = new Map(Array.from(current, ([id, position]) => [id, {...position}]))
const dimensions = new Map(data.nodes.map(node => {
const [width, height] = node_size(node)
return [node.id, measured.get(node.id) ?? {width, height}]
}))
const nodesep = Number(layout.nodesep ?? 52)
const ranksep = Number(layout.ranksep ?? 96)
for(let pass = 0; pass < data.nodes.length * 8; ++pass) {
let changed = false
for(const edge of data.edges) {
const status = edge_statuses.get(edge.id)
if(!enforce_ranks && !status)
continue
const source = positions.get(edge.source)!
const target = positions.get(edge.target)!
const source_size = dimensions.get(edge.source)!
const target_y = source.y + source_size.height + Math.max(ranksep, (status?.height ?? 0) + 48)
if(target.y < target_y) {
target.y = target_y
changed = true
}
}
const edges_by_source = new Map<string, Topology_Edge[]>()
for(const edge of data.edges) {
if(!edge_statuses.has(edge.id))
continue
const edges = edges_by_source.get(edge.source) ?? []
edges.push(edge)
edges_by_source.set(edge.source, edges)
}
for(const edges of edges_by_source.values()) {
edges.sort((left, right) => {
const left_position = positions.get(left.target)!
const right_position = positions.get(right.target)!
return left_position.x + dimensions.get(left.target)!.width / 2 - right_position.x - dimensions.get(right.target)!.width / 2
})
for(let index = 1; index < edges.length; ++index) {
const left = edges[index - 1]
const right = edges[index]
const left_position = positions.get(left.target)!
const right_position = positions.get(right.target)!
const left_center = left_position.x + dimensions.get(left.target)!.width / 2
const right_center = right_position.x + dimensions.get(right.target)!.width / 2
const required_center = left_center + edge_statuses.get(left.id)!.width + edge_statuses.get(right.id)!.width + 2 * nodesep
if(right_center < required_center) {
right_position.x += required_center - right_center
changed = true
}
}
}
for(let left_index = 0; left_index < data.nodes.length; ++left_index) {
const left_node = data.nodes[left_index]
const left = positions.get(left_node.id)!
const left_size = dimensions.get(left_node.id)!
for(let right_index = left_index + 1; right_index < data.nodes.length; ++right_index) {
const right_node = data.nodes[right_index]
const right = positions.get(right_node.id)!
const right_size = dimensions.get(right_node.id)!
const overlap_x = Math.min(left.x + left_size.width, right.x + right_size.width) - Math.max(left.x, right.x)
const overlap_y = Math.min(left.y + left_size.height, right.y + right_size.height) - Math.max(left.y, right.y)
if(overlap_x <= 0 || overlap_y <= 0)
continue
const center_x = left.x + left_size.width / 2 - right.x - right_size.width / 2
const center_y = left.y + left_size.height / 2 - right.y - right_size.height / 2
if(Math.abs(center_x) >= Math.abs(center_y)) {
const moved = center_x <= 0 ? right : left
moved.x += overlap_x + nodesep
} else {
const moved = center_y <= 0 ? right : left
moved.y += overlap_y + ranksep
}
changed = true
}
}
const status_rectangles: {edge: Topology_Edge, x: number, y: number, width: number, height: number}[] = []
for(const edge of data.edges) {
const status_size = edge_statuses.get(edge.id)
if(!status_size)
continue
const source = positions.get(edge.source)!
const target = positions.get(edge.target)!
const source_size = dimensions.get(edge.source)!
const target_size = dimensions.get(edge.target)!
const [, label_x, label_y] = getSmoothStepPath({sourceX: source.x + source_size.width / 2, sourceY: source.y + source_size.height, sourcePosition: Position.Bottom, targetX: target.x + target_size.width / 2, targetY: target.y, targetPosition: Position.Top})
const status = {x: label_x - status_size.width / 2, y: label_y - status_size.height / 2}
status_rectangles.push({edge, ...status, ...status_size})
for(const node of data.nodes) {
if(node.id === edge.source || node.id === edge.target)
continue
const position = positions.get(node.id)!
const size = dimensions.get(node.id)!
const overlap_x = Math.min(status.x + status_size.width, position.x + size.width) - Math.max(status.x, position.x)
const overlap_y = Math.min(status.y + status_size.height, position.y + size.height) - Math.max(status.y, position.y)
if(overlap_x <= 0 || overlap_y <= 0)
continue
position.x += position.x + size.width / 2 < label_x ? -overlap_x - nodesep : overlap_x + nodesep
changed = true
}
}
for(let left_index = 0; left_index < status_rectangles.length; ++left_index) {
const left = status_rectangles[left_index]
for(let right_index = left_index + 1; right_index < status_rectangles.length; ++right_index) {
const right = status_rectangles[right_index]
if(left.edge.source === right.edge.source)
continue
const overlap_x = Math.min(left.x + left.width, right.x + right.width) - Math.max(left.x, right.x)
const overlap_y = Math.min(left.y + left.height, right.y + right.height) - Math.max(left.y, right.y)
if(overlap_x <= 0 || overlap_y <= 0)
continue
const target = positions.get(right.edge.target)!
target.x += right.x + right.width / 2 < left.x + left.width / 2 ? -2 * (overlap_x + nodesep) : 2 * (overlap_x + nodesep)
changed = true
}
}
if(!changed)
break
}
return new Map(data.nodes.map(node => [node.id, positions.get(node.id)!]))
}
function canonical_view(view: Topology_View, nodes: Topology_Node[]): Topology_View {
const node_positions: Topology_View["node_positions"] = {}
for(const node of nodes) {
@@ -259,6 +398,43 @@ function capture_flow_view(instance: ReactFlowInstance<Flow_Node, Flow_Edge>, da
const viewport = instance.getViewport()
return {protocol: "ecap.data-topology-view", protocol_version: 1, initialized: true, layout_revision, zoom: viewport.zoom, pan_x: viewport.x, pan_y: viewport.y, node_positions}
}
async function wait_for_status_sizes(element: HTMLElement) {
const deadline = performance.now() + 5000
while(element.querySelector('[data-status-loading="true"]') && performance.now() < deadline)
await new Promise<void>(resolve => window.setTimeout(resolve, 50))
await new Promise<void>(resolve => window.requestAnimationFrame(() => window.requestAnimationFrame(() => resolve())))
}
async function set_fit_view(instance: ReactFlowInstance<Flow_Node, Flow_Edge>, element: HTMLElement) {
await wait_for_status_sizes(element)
const nodes = instance.getNodes()
if(nodes.length === 0)
return
const sizes = measured_node_sizes(instance)
const first_size = sizes.get(nodes[0].id)!
const bounds = {x: nodes[0].position.x, y: nodes[0].position.y, width: first_size.width, height: first_size.height}
for(const node of nodes.slice(1)) {
const size = sizes.get(node.id)!
const right = Math.max(bounds.x + bounds.width, node.position.x + size.width)
const bottom = Math.max(bounds.y + bounds.height, node.position.y + size.height)
bounds.x = Math.min(bounds.x, node.position.x)
bounds.y = Math.min(bounds.y, node.position.y)
bounds.width = right - bounds.x
bounds.height = bottom - bounds.y
}
for(const status of element.querySelectorAll<HTMLElement>('[data-edge-status-expanded="true"]')) {
const rectangle = status.getBoundingClientRect()
const top_left = instance.screenToFlowPosition({x: rectangle.left, y: rectangle.top})
const bottom_right = instance.screenToFlowPosition({x: rectangle.right, y: rectangle.bottom})
const right = Math.max(bounds.x + bounds.width, bottom_right.x)
const bottom = Math.max(bounds.y + bounds.height, bottom_right.y)
bounds.x = Math.min(bounds.x, top_left.x)
bounds.y = Math.min(bounds.y, top_left.y)
bounds.width = right - bounds.x
bounds.height = bottom - bounds.y
}
const viewport = getViewportForBounds(bounds, element.clientWidth, element.clientHeight, 0.15, 2.5, 0.12)
await instance.setViewport(viewport, {duration: 0})
}
function create_flow_nodes(data: Topology_Data, view: Topology_View, layout: Record<string, unknown>, layout_revision: number, kinds: Map<string, Node_Kind>, status: Topology_Status_Config, status_expansion: MutableRefObject<Set<string>>): Flow_Node[] {
const arranged = topology_layout(data, layout)
const restore = view.initialized && view.layout_revision === layout_revision
@@ -276,7 +452,7 @@ function create_flow_nodes(data: Topology_Data, view: Topology_View, layout: Rec
}
return data.nodes.map(node => {
const [width] = node_size(node)
return {id: node.id, type: "topology", position: positions.get(node.id)!, draggable: node.kind !== "client", selectable: true, sourcePosition: Position.Bottom, targetPosition: Position.Top, style: {width}, data: {topology_node: node, kind: kinds.get(node.kind), status, status_expansion}}
return {id: node.id, type: "topology", position: positions.get(node.id)!, draggable: true, selectable: true, sourcePosition: Position.Bottom, targetPosition: Position.Top, style: {width}, data: {topology_node: node, kind: kinds.get(node.kind), status, status_expansion}}
})
}
function create_flow_edges(data: Topology_Data, schema: Data_Topology_Schema, status_expansion: MutableRefObject<Set<string>>, on_context_menu: (event: React_Mouse_Event, edge_id: string) => void): Flow_Edge[] {
@@ -286,14 +462,16 @@ function create_flow_edges(data: Topology_Data, schema: Data_Topology_Schema, st
})
}
function merge_flow_nodes(current: Flow_Node[], incoming: Flow_Node[], replace: boolean): Flow_Node[] {
if(replace)
if(replace && current.length === 0)
return incoming
const current_by_id = new Map(current.map(node => [node.id, node]))
return incoming.map(node => {
const previous = current_by_id.get(node.id)
if(!previous)
return node
const position = node.data.topology_node.kind === "client" ? node.position : previous.position
if(replace && node.data.topology_node.kind !== "client")
return node
const position = previous.position
if(JSON.stringify(previous.data.topology_node) === JSON.stringify(node.data.topology_node) && previous.position.x === position.x && previous.position.y === position.y)
return previous
return {...previous, ...node, position, selected: previous.selected, measured: previous.measured}
@@ -317,6 +495,41 @@ function status_text(value: unknown): string {
return "—"
return typeof value === "object" ? JSON.stringify(value) : String(value)
}
function status_copy_text(value: unknown): string {
if(value !== null && typeof value === "object")
return JSON.stringify(value, null, 2)
return status_text(value)
}
async function copy_status(value: unknown) {
try {
await navigator.clipboard.writeText(status_copy_text(value))
message.success("当前状态已复制")
} catch(reason) {
message.error(`复制失败:${reason instanceof Error ? reason.message : String(reason)}`)
}
}
function status_children(value: unknown): [string, unknown][] {
if(Array.isArray(value))
return value.map((item, index) => [String(index), item])
return value !== null && typeof value === "object" ? Object.entries(value as Record<string, unknown>) : []
}
function Status_Tree_Node({name, value, depth}: {name: string, value: unknown, depth: number}) {
const children = status_children(value)
const branch = children.length > 0
const [expanded, set_expanded] = useState(depth === 0)
return <div style={{fontSize: 11}}>
<div title={branch ? undefined : status_text(value)} style={{display: "flex", alignItems: "flex-start", minHeight: 22, paddingLeft: depth * 11}}>
{branch ? <Button type="text" size="small" icon={expanded ? <CaretDownOutlined/> : <CaretRightOutlined/>} title={expanded ? "收起分支" : "展开分支"} onClick={() => set_expanded(!expanded)} style={{flex: "none", width: 20, minWidth: 20, height: 20, padding: 0}}/> : <span style={{width: 20, flex: "none", color: "#d9d9d9", lineHeight: "20px", textAlign: "center"}}></span>}
<span style={{color: "#667085", flex: "0 0 42%", minWidth: 0, lineHeight: "20px", overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap"}}>{name}</span>
<span style={{flex: 1, minWidth: 0, lineHeight: "20px", color: branch ? "#98a2b3" : "#101828", overflowWrap: "anywhere"}}>{branch ? `${Array.isArray(value) ? "[" : "{"}${children.length}${Array.isArray(value) ? "]" : "}"}` : status_text(value)}</span>
</div>
{branch && expanded && <div style={{marginLeft: depth * 11 + 9, borderLeft: "1px solid #d0d5dd"}}>{children.map(([key, child]) => <Status_Tree_Node key={key} name={key} value={child} depth={depth + 1}/>)}</div>}
</div>
}
function Status_Tree({value}: {value: Record<string, unknown>}) {
const entries = Object.entries(value)
return entries.length > 0 ? <div>{entries.map(([key, child]) => <Status_Tree_Node key={key} name={key} value={child} depth={0}/>)}</div> : <Typography.Text type="secondary" style={{fontSize: 11}}></Typography.Text>
}
function use_polling_status(api: string | undefined, expanded: boolean, poll_interval_ms: number) {
const [status, set_status] = useState<Record<string, unknown> | null>(null)
const [loading, set_loading] = useState(false)
@@ -355,26 +568,27 @@ function use_polling_status(api: string | undefined, expanded: boolean, poll_int
return {status, loading, error, updated_at}
}
function use_status_expansion(key: string, expansion: MutableRefObject<Set<string>>) {
const [expanded, set_state] = useState(() => expansion.current.has(key))
const [, set_revision] = useState(0)
const expanded = expansion.current.has(key)
const set_expanded = useCallback((value: boolean) => {
if(value)
expansion.current.add(key)
else
expansion.current.delete(key)
set_state(value)
set_revision(revision => revision + 1)
}, [expansion, key])
return [expanded, set_expanded] as const
}
function Node_Status({node, config, expansion}: {node: Topology_Node, config: Topology_Status_Config, expansion: MutableRefObject<Set<string>>}) {
const [expanded, set_expanded] = use_status_expansion(`node:${node.id}`, expansion)
const {status, loading, error, updated_at} = use_polling_status(node.status_api, expanded, config.poll_interval_ms)
const rows = node.status_api ? Object.entries(status ?? {}) : node.details.map(detail => [detail.label, detail.value] as [string, unknown])
return <div className="nodrag nopan" onPointerDown={event => event.stopPropagation()} onClick={event => event.stopPropagation()} style={{marginTop: 7, borderTop: "1px solid rgba(0,0,0,.08)", paddingTop: 5}}>
<Button type="text" size="small" icon={expanded ? <CaretDownOutlined/> : <CaretRightOutlined/>} title={expanded ? "收起后停止轮询" : config.collapsed_hint} onClick={() => set_expanded(!expanded)} style={{height: 24, padding: "0 4px"}}>{expanded ? "收起状态" : "展开状态"}</Button>
const current_status = node.status_api ? status ?? {} : Object.fromEntries(node.details.map(detail => [detail.label, detail.value]))
return <div className="nodrag nopan" data-status-loading={expanded && loading} onPointerDown={event => event.stopPropagation()} onClick={event => event.stopPropagation()} style={{marginTop: 7, borderTop: "1px solid rgba(0,0,0,.08)", paddingTop: 5}}>
<div style={{display: "flex", alignItems: "center", justifyContent: "space-between"}}><Button type="text" size="small" icon={expanded ? <CaretDownOutlined/> : <CaretRightOutlined/>} title={expanded ? "收起后停止轮询" : config.collapsed_hint} onClick={() => set_expanded(!expanded)} style={{height: 24, padding: "0 4px"}}>{expanded ? "收起状态" : "展开状态"}</Button>{expanded && <Button type="text" size="small" icon={<CopyOutlined/>} title="复制当前状态" disabled={loading || Boolean(error)} onClick={() => void copy_status(current_status)} style={{height: 24, padding: "0 4px"}}></Button>}</div>
{expanded && <div style={{marginTop: 5, padding: 7, background: "rgba(255,255,255,.72)", borderRadius: 6, fontSize: 11}}>
{loading && <div style={{padding: 5, textAlign: "center"}}><Spin size="small"/></div>}
{error && <Typography.Text type="danger" style={{fontSize: 11}}>{error}</Typography.Text>}
{!loading && !error && rows.map(([key, value]) => <div key={key} title={status_text(value)} style={{display: "flex", gap: 5, lineHeight: "20px"}}><span style={{color: "#8c8c8c", flex: "0 0 40%", overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap"}}>{key}</span><span style={{flex: 1, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap"}}>{status_text(value)}</span></div>)}
{!loading && !error && <Status_Tree value={current_status}/>}
{updated_at && <div style={{marginTop: 4, color: "#bfbfbf", textAlign: "right"}}> {updated_at}</div>}
</div>}
</div>
@@ -384,17 +598,16 @@ function Topology_Flow_Edge({id, sourceX, sourceY, targetX, targetY, sourcePosit
const edge = data!.topology_edge
const [expanded, set_expanded] = use_status_expansion(`edge:${edge.id}`, data!.status_expansion)
const {status, loading, error, updated_at} = use_polling_status(edge.status_api, expanded, data!.status.poll_interval_ms)
const rows = edge.status_api ? Object.entries(status ?? {}) : (edge.details ?? []).map(detail => [detail.label, detail.value] as [string, unknown])
const current_status = edge.status_api ? status ?? {} : Object.fromEntries((edge.details ?? []).map(detail => [detail.label, detail.value]))
return <>
<BaseEdge id={id} path={edge_path} markerEnd={markerEnd} style={style} interactionWidth={interactionWidth}/>
<EdgeLabelRenderer><div className="nodrag nopan" onContextMenu={event => data!.on_context_menu(event)} style={{position: "absolute", transform: `translate(-50%, -50%) translate(${label_x}px, ${label_y}px)`, pointerEvents: "all", zIndex: expanded ? 8 : 2}}>
<EdgeLabelRenderer><div className="ecap-topology-edge-status nodrag nopan" data-edge-id={edge.id} data-edge-status-expanded={expanded} data-status-loading={expanded && loading} onContextMenu={event => data!.on_context_menu(event)} style={{position: "absolute", transform: `translate(-50%, -50%) translate(${label_x}px, ${label_y}px)`, pointerEvents: "all", zIndex: expanded ? 8 : 2}}>
{!expanded && <Button shape="circle" size="small" icon={<CaretRightOutlined/>} title="展开连接状态" onClick={() => set_expanded(true)} style={{width: 24, minWidth: 24, height: 24, color: edge.active ? "#52c41a" : "#8c8c8c", boxShadow: "0 2px 7px rgba(0,0,0,.16)"}}/>}
{expanded && <div style={{width: 280, padding: 9, background: "rgba(255,255,255,.96)", border: `1px solid ${edge.active ? "#95de64" : "#d9d9d9"}`, borderRadius: 8, boxShadow: "0 5px 18px rgba(0,0,0,.18)", fontSize: 11}}>
<div style={{display: "flex", alignItems: "center", justifyContent: "space-between", marginBottom: 6}}><Typography.Text strong style={{fontSize: 12}}>{edge.kind === "runtime" ? "客户端连接状态" : "数据连接状态"}</Typography.Text><Button type="text" size="small" icon={<CaretDownOutlined/>} title="收起并停止轮询" onClick={() => set_expanded(false)} style={{height: 22, padding: "0 4px"}}/></div>
<div style={{display: "flex", alignItems: "center", justifyContent: "space-between", marginBottom: 6}}><Typography.Text strong style={{fontSize: 12}}>{edge.kind === "runtime" ? "客户端连接状态" : "数据连接状态"}</Typography.Text><Space size={2}><Button type="text" size="small" icon={<CopyOutlined/>} title="复制当前状态" disabled={loading || Boolean(error)} onClick={() => void copy_status(current_status)} style={{height: 22, padding: "0 4px"}}/><Button type="text" size="small" icon={<CaretDownOutlined/>} title="收起并停止轮询" onClick={() => set_expanded(false)} style={{height: 22, padding: "0 4px"}}/></Space></div>
{loading && <div style={{padding: 5, textAlign: "center"}}><Spin size="small"/></div>}
{error && <Typography.Text type="danger" style={{fontSize: 11}}>{error}</Typography.Text>}
{!loading && !error && rows.map(([key, value]) => <div key={key} title={status_text(value)} style={{display: "flex", gap: 6, lineHeight: "19px"}}><span style={{color: "#8c8c8c", flex: "0 0 38%"}}>{key}</span><span style={{flex: 1, minWidth: 0, wordBreak: "break-all"}}>{status_text(value)}</span></div>)}
{!loading && !error && rows.length === 0 && <Typography.Text type="secondary" style={{fontSize: 11}}></Typography.Text>}
{!loading && !error && <Status_Tree value={current_status}/>}
{updated_at && <div style={{marginTop: 4, color: "#bfbfbf", textAlign: "right"}}> {updated_at}</div>}
</div>}
</div></EdgeLabelRenderer>
@@ -470,6 +683,7 @@ export function DataTopology({schema}: {schema: Data_Topology_Schema}) {
const [flow_ready, set_flow_ready] = useState(false)
const [view_dirty, set_view_dirty] = useState(false)
const [saving, set_saving] = useState(false)
const [arranging, set_arranging] = useState<"" | "vertical" | "avoid">("")
const [editor_state, set_editor_state] = useState<Editor_State | null>(null)
const [connection_state, set_connection_state] = useState<Connection_State | null>(null)
const [context_menu, set_context_menu] = useState<Context_Menu_State | null>(null)
@@ -642,8 +856,8 @@ export function DataTopology({schema}: {schema: Data_Topology_Schema}) {
const view = working_view_ref.current ?? draft?.view
if(view?.initialized && view.layout_revision === schema.graph.layout_revision)
await instance.setViewport({x: view.pan_x, y: view.pan_y, zoom: view.zoom}, {duration: 0})
else
await instance.fitView({padding: 0.12})
else if(shell.current)
await set_fit_view(instance, shell.current)
if(draft)
working_view_ref.current = capture_flow_view(instance, draft, schema.graph.layout_revision)
finish_restoring()
@@ -719,26 +933,29 @@ export function DataTopology({schema}: {schema: Data_Topology_Schema}) {
execute()
}, [dirty, load])
const fit_view = useCallback(async () => {
if(!flow_ref.current)
if(!flow_ref.current || !shell.current)
return
await flow_ref.current.fitView({padding: 0.12})
await set_fit_view(flow_ref.current, shell.current)
sync_working_view(true)
}, [sync_working_view])
const layout = useCallback(() => {
if(!draft)
const arrange = useCallback(async (mode: "vertical" | "avoid") => {
if(!draft || !flow_ref.current || !shell.current || arranging)
return
const positions = topology_layout(draft, schema.graph.layout)
set_flow_nodes(nodes => nodes.map(node => ({...node, position: positions.get(node.id)!})))
const viewport = flow_ref.current?.getViewport() ?? {x: 0, y: 0, zoom: 1}
const node_positions: Topology_View["node_positions"] = {}
for(const node of draft.nodes) {
if(node.kind !== "client")
node_positions[node.id] = positions.get(node.id)!
set_arranging(mode)
try {
await wait_for_status_sizes(shell.current)
const measured = measured_node_sizes(flow_ref.current)
const edge_statuses = measured_edge_statuses(shell.current)
const current = new Map(flow_ref.current.getNodes().map(node => [node.id, node.position]))
const arranged = mode === "vertical" ? topology_layout(draft, schema.graph.layout, measured, edge_statuses) : current
const positions = avoid_overlap_layout(draft, arranged, measured, edge_statuses, schema.graph.layout, mode === "vertical")
set_flow_nodes(nodes => nodes.map(node => ({...node, position: positions.get(node.id)!})))
set_view_dirty(true)
await fit_view()
} finally {
set_arranging("")
}
working_view_ref.current = {protocol: "ecap.data-topology-view", protocol_version: 1, initialized: true, layout_revision: schema.graph.layout_revision, zoom: viewport.zoom, pan_x: viewport.x, pan_y: viewport.y, node_positions}
set_view_dirty(true)
window.requestAnimationFrame(() => void fit_view())
}, [draft, fit_view, schema.graph.layout, schema.graph.layout_revision])
}, [arranging, draft, fit_view, schema.graph.layout])
const context_items = useMemo<MenuProps["items"]>(() => {
if(!context_menu)
return []
@@ -763,7 +980,7 @@ export function DataTopology({schema}: {schema: Data_Topology_Schema}) {
const active_editor = editor_state ? editor_by_kind.get(editor_state.kind) : undefined
const editor_node = editor_state?.node_id ? draft?.nodes.find(node => node.id === editor_state.node_id) : undefined
const title = <Space size={8}><span>{schema.title}</span>{dirty && <Typography.Text type="warning" style={{fontSize: 12, fontWeight: 400}}></Typography.Text>}</Space>
return <Card title={title} extra={<Space wrap><Button type="primary" icon={<SaveOutlined/>} loading={saving} disabled={!dirty} onClick={() => void save()}></Button><Button icon={<UndoOutlined/>} disabled={!dirty || saving} onClick={undo}></Button><Button icon={<SyncOutlined/>} disabled={saving} onClick={sync}></Button><Button onClick={() => void fit_view()}></Button><Button onClick={layout}></Button></Space>}>
return <Card title={title} extra={<Space wrap><Button type="primary" icon={<SaveOutlined/>} loading={saving} disabled={!dirty} onClick={() => void save()}></Button><Button icon={<UndoOutlined/>} disabled={!dirty || saving} onClick={undo}></Button><Button icon={<SyncOutlined/>} disabled={saving} onClick={sync}></Button><Button disabled={Boolean(arranging)} onClick={() => void fit_view()}></Button><Button loading={arranging === "vertical"} disabled={Boolean(arranging)} onClick={() => void arrange("vertical")}></Button><Button loading={arranging === "avoid"} disabled={Boolean(arranging)} onClick={() => void arrange("avoid")}></Button></Space>}>
{error && <Alert type="error" showIcon message="数据拓扑加载失败" description={error} style={{marginBottom: 12}}/>}
{!draft || !flow_ready ? <div style={{padding: 36, textAlign: "center"}}><Spin/></div> : <div ref={shell} style={{position: "relative", width: "100%", height: schema.graph.height, border: "1px solid #f0f0f0", borderRadius: 8, overflow: "hidden"}}>
<style>{`.ecap-topology-node,.ecap-topology-node *{overflow:hidden!important;scrollbar-width:none!important}.ecap-topology-node *::-webkit-scrollbar{display:none!important}.react-flow__edge-path,.react-flow__edge-interaction{cursor:context-menu}.react-flow__node{cursor:grab}.react-flow__node.dragging{cursor:grabbing}`}</style>