Polish topology toolbar search and restore sidebar icons.

Group tools with display, drop the redundant locate button, clear find query after Enter, and restore corrupted edit/delete/collapse glyphs.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
oliver 2026-08-01 13:49:58 +08:00
parent 8cf1696d8c
commit 728ec815a3
2 changed files with 233 additions and 210 deletions

View file

@ -2257,26 +2257,47 @@ pre {
} }
.topo-toolbar__group + .topo-toolbar__group, .topo-toolbar__group + .topo-toolbar__group,
.topo-tools + .topo-toolbar__group, .topo-toolbar__panel + .topo-toolbar__group,
.topo-toolbar__group + .topo-toolbar__display, .topo-toolbar__group + .topo-toolbar__group--find,
.topo-tools + .topo-toolbar__display { .topo-toolbar__panel + .topo-toolbar__group--find {
margin-left: 2px; margin-left: 2px;
padding-left: 10px; padding-left: 10px;
border-left: 1px solid #e2e8f0; border-left: 1px solid #e2e8f0;
} }
.topo-toolbar__panel {
display: inline-flex;
align-items: center;
gap: 8px;
flex-shrink: 0;
padding: 4px 8px;
background: #f1f5f9;
border: 1px solid #e2e8f0;
border-radius: 10px;
}
.topo-toolbar__panel .topo-tools {
background: #e2e8f0;
}
.topo-toolbar__panel .topo-toolbar__display > summary {
background: #fff;
}
.topo-toolbar__group--find { .topo-toolbar__group--find {
position: relative; position: relative;
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
gap: 6px; gap: 6px;
min-width: 0; min-width: 0;
margin-left: auto;
} }
.topo-find-suggest { .topo-find-suggest {
position: absolute; position: absolute;
top: calc(100% + 4px); top: calc(100% + 4px);
left: 0; right: 0;
left: auto;
z-index: 30; z-index: 30;
width: min(320px, 70vw); width: min(320px, 70vw);
max-height: 280px; max-height: 280px;

View file

@ -461,6 +461,7 @@ export function TopologyPage() {
const discoverAbortRef = useRef<AbortController | null>(null); const discoverAbortRef = useRef<AbortController | null>(null);
const searchHitTimerRef = useRef<number | null>(null); const searchHitTimerRef = useRef<number | null>(null);
const findBoxRef = useRef<HTMLDivElement | null>(null); const findBoxRef = useRef<HTMLDivElement | null>(null);
const findJustLocatedRef = useRef(false);
const [nodes, setNodes, onNodesChange] = useNodesState<Node<NeNodeData>>([]); const [nodes, setNodes, onNodesChange] = useNodesState<Node<NeNodeData>>([]);
const [edges, setEdges, onEdgesChange] = useEdgesState<Edge>([]); const [edges, setEdges, onEdgesChange] = useEdgesState<Edge>([]);
const rfRef = useRef<ReactFlowInstance<Node<NeNodeData>, Edge> | null>(null); const rfRef = useRef<ReactFlowInstance<Node<NeNodeData>, Edge> | null>(null);
@ -1430,9 +1431,13 @@ export function TopologyPage() {
useEffect(() => { useEffect(() => {
const q = canvasQuery.trim(); const q = canvasQuery.trim();
if (!q) { if (!q) {
setSearchHitIds([]);
setFindActiveIdx(0); setFindActiveIdx(0);
setFindOpen(false); setFindOpen(false);
if (findJustLocatedRef.current) {
findJustLocatedRef.current = false;
return;
}
setSearchHitIds([]);
return; return;
} }
setFindOpen(true); setFindOpen(true);
@ -1452,7 +1457,10 @@ export function TopologyPage() {
nodeId || nodeId ||
canvasHits[Math.max(0, Math.min(findActiveIdx, canvasHits.length - 1))]?.id || canvasHits[Math.max(0, Math.min(findActiveIdx, canvasHits.length - 1))]?.id ||
canvasHits[0].id; canvasHits[0].id;
findJustLocatedRef.current = true;
setFindOpen(false); setFindOpen(false);
setCanvasQuery("");
setFindActiveIdx(0);
locateNode(id); locateNode(id);
}, },
[canvasQuery, canvasHits, findActiveIdx, locateNode, showError, t], [canvasQuery, canvasHits, findActiveIdx, locateNode, showError, t],
@ -1533,7 +1541,7 @@ export function TopologyPage() {
name, name,
ip: ne.ip_address || "", ip: ne.ip_address || "",
vendor: "ZTE", vendor: "ZTE",
meta: `${ne.ip_address || "-"} · ${ne.ne_type || "UME"}`, meta: `${ne.ip_address || "-"} ? ${ne.ne_type || "UME"}`,
connect_status: ne.connection_status || "", connect_status: ne.connection_status || "",
}; };
}); });
@ -1839,109 +1847,136 @@ export function TopologyPage() {
</div> </div>
</div> </div>
<div className="topo-toolbar__row topo-toolbar__row--tools"> <div className="topo-toolbar__row topo-toolbar__row--tools">
<div className="topo-tools" role="toolbar" aria-label={t("topology.toolModes")}> <div className="topo-toolbar__panel" aria-label={t("topology.toolModes")}>
{( <div className="topo-tools" role="toolbar" aria-label={t("topology.toolModes")}>
[ {(
["select", t("topology.toolSelect"), "V"], [
["pan", t("topology.toolPan"), "H"], ["select", t("topology.toolSelect"), "V"],
["drag", t("topology.toolDrag"), "A"], ["pan", t("topology.toolPan"), "H"],
["connect", t("topology.toolConnect"), "C"], ["drag", t("topology.toolDrag"), "A"],
] as const ["connect", t("topology.toolConnect"), "C"],
).map(([mode, label, key]) => ( ] as const
<button ).map(([mode, label, key]) => (
key={mode} <button
type="button" key={mode}
className={`topo-tools__btn${toolMode === mode ? " is-active" : ""}`} type="button"
title={`${label} (${key})`} className={`topo-tools__btn${toolMode === mode ? " is-active" : ""}`}
aria-pressed={toolMode === mode} title={`${label} (${key})`}
onClick={() => { aria-pressed={toolMode === mode}
setToolMode(mode); onClick={() => {
connectClickRef.current = null; setToolMode(mode);
}} connectClickRef.current = null;
> }}
{label} >
</button> {label}
))} </button>
</div> ))}
<div </div>
className="topo-toolbar__group topo-toolbar__group--find" <details className="topo-toolbar__display">
aria-label={t("topology.findNode")} <summary>{t("topology.display")}</summary>
ref={findBoxRef} <div className="topo-display-toggles" role="group" aria-label={t("topology.display")}>
> <label className="topo-display-toggles__item">
<input <input type="checkbox" checked={hideIp} onChange={(e) => setHideIp(e.target.checked)} />
className="topo-toolbar__find" {t("topology.hideIp")}
value={canvasQuery} </label>
onChange={(e) => setCanvasQuery(e.target.value)} <label className="topo-display-toggles__item">
onFocus={() => { <input
if (canvasQuery.trim()) setFindOpen(true); type="checkbox"
}} checked={hideVendor}
onKeyDown={(e) => { onChange={(e) => setHideVendor(e.target.checked)}
if (e.key === "ArrowDown") { />
e.preventDefault(); {t("topology.hideVendor")}
if (!canvasHits.length) return; </label>
setFindOpen(true); <label className="topo-display-toggles__item">
setFindActiveIdx((i) => (i + 1) % canvasHits.length); <input
return; type="checkbox"
} checked={hidePorts}
if (e.key === "ArrowUp") { onChange={(e) => setHidePorts(e.target.checked)}
e.preventDefault(); />
if (!canvasHits.length) return; {t("topology.hidePorts")}
setFindOpen(true); </label>
setFindActiveIdx((i) => (i - 1 + canvasHits.length) % canvasHits.length); <label className="topo-display-toggles__item">
return; <input
} type="checkbox"
if (e.key === "Escape") { checked={edgeFlow}
setFindOpen(false); onChange={(e) => setEdgeFlow(e.target.checked)}
return; />
} {t("topology.edgeFlow")}
if (e.key === "Enter") { </label>
e.preventDefault(); <label className="topo-display-toggles__item">
findOnCanvas(); <input
} type="checkbox"
}} checked={snapToGrid}
placeholder={t("topology.findNodePh")} onChange={(e) => setSnapToGrid(e.target.checked)}
disabled={!mapId || nodes.length === 0} />
aria-label={t("topology.findNode")} {t("topology.snapGrid")}
aria-autocomplete="list" </label>
aria-expanded={findOpen} <label className="topo-display-toggles__item">
/> <input
<button type="checkbox"
type="button" checked={autoLayoutAfterDiscover}
className="btn btn--sm btn--ghost" onChange={(e) => setAutoLayoutAfterDiscover(e.target.checked)}
disabled={!mapId || nodes.length === 0 || !canvasQuery.trim()} />
onClick={() => findOnCanvas()} {t("topology.autoLayoutDiscover")}
> </label>
{t("topology.findNode")} <div className="topo-display-defaults">
</button> <div className="topo-display-defaults__head">
{findOpen && canvasQuery.trim() ? ( <strong>{t("topology.edgeDefaults")}</strong>
<div className="topo-find-suggest" role="listbox"> <button type="button" className="btn btn--sm btn--ghost" onClick={resetEdgeDefaults}>
{canvasHits.length === 0 ? ( {t("topology.edgeDefaultsReset")}
<div className="topo-find-suggest__empty">{t("topology.findNoMatch")}</div>
) : (
canvasHits.slice(0, 12).map((n, idx) => (
<button
key={n.id}
type="button"
role="option"
aria-selected={idx === findActiveIdx}
className={`topo-find-suggest__item${idx === findActiveIdx ? " is-active" : ""}`}
onMouseEnter={() => setFindActiveIdx(idx)}
onClick={() => findOnCanvas(n.id)}
>
<span className="topo-find-suggest__name">{n.data.label || n.id}</span>
<span className="topo-find-suggest__meta">
{[n.data.ne_ip, n.data.vendor].filter(Boolean).join(" ? ")}
</span>
</button> </button>
))
)}
{canvasHits.length > 12 ? (
<div className="topo-find-suggest__more">
{t("topology.findMore").replace("{{count}}", String(canvasHits.length - 12))}
</div> </div>
) : null} {(
[
["manual", t("topology.edgeManual")],
["discovered", t("topology.edgeDiscovered")],
["stale", t("topology.edgeStale")],
] as const
).map(([kind, label]) => {
const d = edgeDefaults[kind];
return (
<div key={kind} className="topo-display-defaults__row">
<span className="topo-display-defaults__name">{label}</span>
<input
type="color"
value={d.stroke_color}
title={t("topology.edgeColor")}
onChange={(e) => updateEdgeDefault(kind, { stroke_color: e.target.value })}
/>
<select
className="topo-toolbar__select"
aria-label={t("topology.edgeLineStyle")}
value={d.line_style}
onChange={(e) =>
updateEdgeDefault(kind, {
line_style: e.target.value as EdgeLineStyle,
})
}
>
<option value="solid">{t("topology.edgeLineSolid")}</option>
<option value="dashed">{t("topology.edgeLineDashed")}</option>
<option value="dotted">{t("topology.edgeLineDotted")}</option>
</select>
<select
className="topo-toolbar__select"
aria-label={t("topology.edgeWidth")}
value={String(d.stroke_width)}
onChange={(e) =>
updateEdgeDefault(kind, { stroke_width: Number(e.target.value) || 2 })
}
>
{[1, 2, 3, 4, 5, 6, 8].map((w) => (
<option key={w} value={w}>
{w}px
</option>
))}
</select>
</div>
);
})}
</div>
</div> </div>
) : null} </details>
</div> </div>
<div className="topo-toolbar__group" aria-label={t("topology.layout")}> <div className="topo-toolbar__group" aria-label={t("topology.layout")}>
<select <select
@ -1991,111 +2026,78 @@ export function TopologyPage() {
<option value="v-distribute">{t("topology.alignVDistribute")}</option> <option value="v-distribute">{t("topology.alignVDistribute")}</option>
</select> </select>
</div> </div>
<details className="topo-toolbar__display"> <div
<summary>{t("topology.display")}</summary> className="topo-toolbar__group topo-toolbar__group--find"
<div className="topo-display-toggles" role="group" aria-label={t("topology.display")}> aria-label={t("topology.findNode")}
<label className="topo-display-toggles__item"> ref={findBoxRef}
<input type="checkbox" checked={hideIp} onChange={(e) => setHideIp(e.target.checked)} /> >
{t("topology.hideIp")} <input
</label> className="topo-toolbar__find"
<label className="topo-display-toggles__item"> value={canvasQuery}
<input onChange={(e) => setCanvasQuery(e.target.value)}
type="checkbox" onFocus={() => {
checked={hideVendor} if (canvasQuery.trim()) setFindOpen(true);
onChange={(e) => setHideVendor(e.target.checked)} }}
/> onKeyDown={(e) => {
{t("topology.hideVendor")} if (e.key === "ArrowDown") {
</label> e.preventDefault();
<label className="topo-display-toggles__item"> if (!canvasHits.length) return;
<input setFindOpen(true);
type="checkbox" setFindActiveIdx((i) => (i + 1) % canvasHits.length);
checked={hidePorts} return;
onChange={(e) => setHidePorts(e.target.checked)} }
/> if (e.key === "ArrowUp") {
{t("topology.hidePorts")} e.preventDefault();
</label> if (!canvasHits.length) return;
<label className="topo-display-toggles__item"> setFindOpen(true);
<input setFindActiveIdx((i) => (i - 1 + canvasHits.length) % canvasHits.length);
type="checkbox" return;
checked={edgeFlow} }
onChange={(e) => setEdgeFlow(e.target.checked)} if (e.key === "Escape") {
/> setFindOpen(false);
{t("topology.edgeFlow")} return;
</label> }
<label className="topo-display-toggles__item"> if (e.key === "Enter") {
<input e.preventDefault();
type="checkbox" findOnCanvas();
checked={snapToGrid} }
onChange={(e) => setSnapToGrid(e.target.checked)} }}
/> placeholder={t("topology.findNodePh")}
{t("topology.snapGrid")} disabled={!mapId || nodes.length === 0}
</label> aria-label={t("topology.findNode")}
<label className="topo-display-toggles__item"> aria-autocomplete="list"
<input aria-expanded={findOpen}
type="checkbox" />
checked={autoLayoutAfterDiscover} {findOpen && canvasQuery.trim() ? (
onChange={(e) => setAutoLayoutAfterDiscover(e.target.checked)} <div className="topo-find-suggest" role="listbox">
/> {canvasHits.length === 0 ? (
{t("topology.autoLayoutDiscover")} <div className="topo-find-suggest__empty">{t("topology.findNoMatch")}</div>
</label> ) : (
<div className="topo-display-defaults"> canvasHits.slice(0, 12).map((n, idx) => (
<div className="topo-display-defaults__head"> <button
<strong>{t("topology.edgeDefaults")}</strong> key={n.id}
<button type="button" className="btn btn--sm btn--ghost" onClick={resetEdgeDefaults}> type="button"
{t("topology.edgeDefaultsReset")} role="option"
</button> aria-selected={idx === findActiveIdx}
</div> className={`topo-find-suggest__item${idx === findActiveIdx ? " is-active" : ""}`}
{( onMouseEnter={() => setFindActiveIdx(idx)}
[ onClick={() => findOnCanvas(n.id)}
["manual", t("topology.edgeManual")], >
["discovered", t("topology.edgeDiscovered")], <span className="topo-find-suggest__name">{n.data.label || n.id}</span>
["stale", t("topology.edgeStale")], <span className="topo-find-suggest__meta">
] as const {[n.data.ne_ip, n.data.vendor].filter(Boolean).join(" ? ")}
).map(([kind, label]) => { </span>
const d = edgeDefaults[kind]; </button>
return ( ))
<div key={kind} className="topo-display-defaults__row"> )}
<span className="topo-display-defaults__name">{label}</span> {canvasHits.length > 12 ? (
<input <div className="topo-find-suggest__more">
type="color" {t("topology.findMore").replace("{{count}}", String(canvasHits.length - 12))}
value={d.stroke_color} </div>
title={t("topology.edgeColor")} ) : null}
onChange={(e) => updateEdgeDefault(kind, { stroke_color: e.target.value })}
/>
<select
className="topo-toolbar__select"
aria-label={t("topology.edgeLineStyle")}
value={d.line_style}
onChange={(e) =>
updateEdgeDefault(kind, {
line_style: e.target.value as EdgeLineStyle,
})
}
>
<option value="solid">{t("topology.edgeLineSolid")}</option>
<option value="dashed">{t("topology.edgeLineDashed")}</option>
<option value="dotted">{t("topology.edgeLineDotted")}</option>
</select>
<select
className="topo-toolbar__select"
aria-label={t("topology.edgeWidth")}
value={String(d.stroke_width)}
onChange={(e) =>
updateEdgeDefault(kind, { stroke_width: Number(e.target.value) || 2 })
}
>
{[1, 2, 3, 4, 5, 6, 8].map((w) => (
<option key={w} value={w}>
{w}px
</option>
))}
</select>
</div>
);
})}
</div> </div>
</div> ) : null}
</details> </div>
</div> </div>
</div> </div>