diff --git a/src/Adminive/DataTopology.tsx b/src/Adminive/DataTopology.tsx index 36e7ddb..e12d3ef 100644 --- a/src/Adminive/DataTopology.tsx +++ b/src/Adminive/DataTopology.tsx @@ -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): Map { +function topology_layout(data: Topology_Data, layout: Record, measured?: ReadonlyMap, edge_statuses?: ReadonlyMap): Map { 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): Map { + 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 { + return new Map(Array.from(element.querySelectorAll('[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, measured: ReadonlyMap, edge_statuses: ReadonlyMap, layout: Record, enforce_ranks: boolean): Map { + 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() + 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, 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(resolve => window.setTimeout(resolve, 50)) + await new Promise(resolve => window.requestAnimationFrame(() => window.requestAnimationFrame(() => resolve()))) +} +async function set_fit_view(instance: ReactFlowInstance, 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('[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, layout_revision: number, kinds: Map, status: Topology_Status_Config, status_expansion: MutableRefObject>): 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>, 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) : [] +} +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
+
+ {branch ?
+ {branch && expanded &&
{children.map(([key, child]) => )}
} +
+} +function Status_Tree({value}: {value: Record}) { + const entries = Object.entries(value) + return entries.length > 0 ?
{entries.map(([key, child]) => )}
: 暂无状态 +} function use_polling_status(api: string | undefined, expanded: boolean, poll_interval_ms: number) { const [status, set_status] = useState | 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>) { - 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>}) { 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
event.stopPropagation()} onClick={event => event.stopPropagation()} style={{marginTop: 7, borderTop: "1px solid rgba(0,0,0,.08)", paddingTop: 5}}> - + const current_status = node.status_api ? status ?? {} : Object.fromEntries(node.details.map(detail => [detail.label, detail.value])) + return
event.stopPropagation()} onClick={event => event.stopPropagation()} style={{marginTop: 7, borderTop: "1px solid rgba(0,0,0,.08)", paddingTop: 5}}> +
{expanded && }
{expanded &&
{loading &&
} {error && {error}} - {!loading && !error && rows.map(([key, value]) =>
{key}{status_text(value)}
)} + {!loading && !error && } {updated_at &&
更新于 {updated_at}
}
}
@@ -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 <> -
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}}> +
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 &&
+
{edge.kind === "runtime" ? "客户端连接状态" : "数据连接状态"}
{loading &&
} {error && {error}} - {!loading && !error && rows.map(([key, value]) =>
{key}{status_text(value)}
)} - {!loading && !error && rows.length === 0 && 暂无连接状态} + {!loading && !error && } {updated_at &&
更新于 {updated_at}
}
}
@@ -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(null) const [connection_state, set_connection_state] = useState(null) const [context_menu, set_context_menu] = useState(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(() => { 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 = {schema.title}{dirty && 未保存} - return }> + return }> {error && } {!draft || !flow_ready ?
: