MissionControl.tsx 6.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130
  1. "use client";
  2. import { useCallback, useEffect, useMemo, useState } from "react";
  3. import {
  4. Background, BackgroundVariant, Controls, Panel, ReactFlow, ReactFlowProvider,
  5. useReactFlow, type Node,
  6. } from "@xyflow/react";
  7. import {
  8. AlertTriangle, CheckCircle2, Focus, GitBranch, Layers3, LoaderCircle,
  9. RefreshCw, Search, Sparkles, Workflow, X,
  10. } from "lucide-react";
  11. import { FlowCard } from "@/components/FlowCard";
  12. import { Inspector } from "@/components/Inspector";
  13. import { PhaseFrameNode } from "@/components/PhaseFrameNode";
  14. import { fetchGraph, fetchRuns } from "@/lib/api";
  15. import { buildCanvas, PHASE_LABELS, type CanvasData } from "@/lib/flow";
  16. import type { FlowNodeRecord, Phase, RunGraph, RunSummary } from "@/lib/types";
  17. const nodeTypes = { flowCard: FlowCard, phaseFrame: PhaseFrameNode };
  18. const ALL_PHASES: Phase[] = ["phase-1", "phase-2", "phase-3", "delivery"];
  19. function LoadingState() {
  20. return <main className="centerState"><LoaderCircle className="spin" /><h1>正在装载 fake mission graph</h1><p>连接本地 FastAPI :8788</p></main>;
  21. }
  22. function ErrorState({ message, retry }: { message: string; retry: () => void }) {
  23. return <main className="centerState error"><AlertTriangle /><h1>可视化后端未就绪</h1><p>{message}</p><button onClick={retry}><RefreshCw size={16} />重新连接</button></main>;
  24. }
  25. function Canvas({ graph, selected, onSelect }: {
  26. graph: RunGraph;
  27. selected: FlowNodeRecord | null;
  28. onSelect: (node: FlowNodeRecord | null) => void;
  29. }) {
  30. const [query, setQuery] = useState("");
  31. const [phases, setPhases] = useState<Set<Phase>>(new Set(ALL_PHASES));
  32. const { fitView } = useReactFlow();
  33. const canvas = useMemo(() => buildCanvas(graph, phases, query), [graph, phases, query]);
  34. const togglePhase = (phase: Phase) => setPhases((current) => {
  35. const next = new Set(current);
  36. if (next.has(phase)) next.delete(phase); else next.add(phase);
  37. return next.size ? next : new Set([phase]);
  38. });
  39. const selectNode = useCallback((_: React.MouseEvent, node: Node<CanvasData>) => {
  40. if (node.data.kind === "card") onSelect(node.data.item);
  41. }, [onSelect]);
  42. const matched = graph.nodes.filter((item) => {
  43. const node = canvas.nodes.find((candidate) => candidate.id === item.id);
  44. return node?.data.kind === "card" && !node.data.dimmed && !node.hidden;
  45. }).length;
  46. return <>
  47. <header className={`topShell ${selected ? "withInspector" : ""}`}>
  48. <div className="brand"><span><Workflow size={22} /></span><div><strong>Mission Control</strong><small>智能创作构建系统</small></div></div>
  49. <div className="runTitle"><span>BUILD #{graph.run.script_build_id}</span><strong>{graph.run.title}</strong></div>
  50. <div className="topStats">
  51. <span className="fakeBadge"><Sparkles size={13} /> FAKE DATA</span>
  52. <span><CheckCircle2 size={14} /> {graph.run.status}</span>
  53. <span><GitBranch size={14} /> {graph.run.node_count} nodes</span>
  54. </div>
  55. </header>
  56. <aside className="phaseRail">
  57. <div className="railTitle"><Layers3 size={16} /><span>流程阶段</span></div>
  58. {ALL_PHASES.map((phase) => <button key={phase} className={phases.has(phase) ? "active" : ""} onClick={() => togglePhase(phase)}>
  59. <span className={`phaseDot phase-${phase}`} />
  60. <span>{PHASE_LABELS[phase]}</span>
  61. <small>{graph.nodes.filter((item) => item.phase === phase).length}</small>
  62. </button>)}
  63. <div className="railDivider" />
  64. <button onClick={() => void fitView({ padding: 0.06, duration: 320 })}><Focus size={15} /><span>适应全图</span></button>
  65. <div className="checkpoint"><small>CURRENT CHECKPOINT</small><code>{graph.run.checkpoint}</code></div>
  66. </aside>
  67. <section className={`canvasShell ${selected ? "withInspector" : ""}`}>
  68. <ReactFlow
  69. nodes={canvas.nodes.map((node) => ({ ...node, selected: node.id === selected?.id }))}
  70. edges={canvas.edges}
  71. nodeTypes={nodeTypes}
  72. onNodeClick={selectNode}
  73. onPaneClick={() => onSelect(null)}
  74. onInit={(instance) => void instance.fitView({ padding: 0.06, duration: 0 })}
  75. minZoom={0.06}
  76. maxZoom={1.4}
  77. nodesConnectable={false}
  78. zoomOnDoubleClick={false}
  79. panOnScroll
  80. zoomOnPinch
  81. proOptions={{ hideAttribution: true }}
  82. >
  83. <Background variant={BackgroundVariant.Dots} gap={24} size={1.15} color="#383b44" />
  84. <Controls position="bottom-left" showInteractive={false} />
  85. <Panel position="top-left" className="canvasToolbar">
  86. <label><Search size={16} /><input value={query} onChange={(event) => setQuery(event.target.value)} placeholder="搜索节点、模型、Task kind…" />{query ? <button onClick={() => setQuery("")}><X size={14} /></button> : null}</label>
  87. <span>{query ? `${matched} / ${graph.nodes.length}` : `${graph.nodes.length} nodes · ${graph.edges.length} edges`}</span>
  88. </Panel>
  89. <Panel position="bottom-right" className="legend">
  90. <span><i className="legend-control" />控制流</span>
  91. <span><i className="legend-artifact" />Artifact</span>
  92. <span><i className="legend-validation" />Validation</span>
  93. <span><i className="legend-publication" />Publication</span>
  94. </Panel>
  95. </ReactFlow>
  96. </section>
  97. {selected ? <Inspector item={selected} graph={graph} onClose={() => onSelect(null)} /> : null}
  98. </>;
  99. }
  100. function MissionControlInner() {
  101. const [runs, setRuns] = useState<RunSummary[]>([]);
  102. const [graph, setGraph] = useState<RunGraph | null>(null);
  103. const [selected, setSelected] = useState<FlowNodeRecord | null>(null);
  104. const [error, setError] = useState<string | null>(null);
  105. const [nonce, setNonce] = useState(0);
  106. useEffect(() => {
  107. const controller = new AbortController();
  108. setError(null);
  109. Promise.all([fetchRuns(controller.signal), fetchGraph(10001, controller.signal)])
  110. .then(([nextRuns, nextGraph]) => { setRuns(nextRuns); setGraph(nextGraph); })
  111. .catch((reason: unknown) => {
  112. if (!controller.signal.aborted) setError(reason instanceof Error ? reason.message : "未知错误");
  113. });
  114. return () => controller.abort();
  115. }, [nonce]);
  116. if (error) return <ErrorState message={error} retry={() => setNonce((value) => value + 1)} />;
  117. if (!graph || !runs.length) return <LoadingState />;
  118. return <main className="appFrame"><Canvas graph={graph} selected={selected} onSelect={setSelected} /></main>;
  119. }
  120. export function MissionControl() {
  121. return <ReactFlowProvider><MissionControlInner /></ReactFlowProvider>;
  122. }