修完bug
This commit is contained in:
+256
-39
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user