Add topology NE create/connect flows and trim connect-test logs.

Bring full managed-NE create onto the canvas (keep placeholders), add single/multi connect test and multi discover links, split topology/managedNe dialogs into modules, and shorten redundant connectivity failure details.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
oliver 2026-09-01 20:37:42 +08:00
parent f8d4a396f1
commit 56569e49d1
19 changed files with 1980 additions and 931 deletions

View file

@ -33,12 +33,18 @@ export type TopologyCtxMenuProps = {
onClose: () => void;
onToggleFullscreen: () => void;
onRemoveSelected: () => void;
onDiscoverSelected: () => void;
onConnectTestSelected: () => void;
selectedDiscoverableCount: number;
selectedConnectableCount: number;
onOpenCreateNe: (flowX: number, flowY: number) => void;
onPromptNewSubRegion: () => void;
onRenameSelectedNode: () => void;
onDiscoverOne: (node: Node<NeNodeData> | null) => void;
onOpenWebcrt: (node: Node<NeNodeData> | null) => void;
onOpenNe: (node: Node<NeNodeData> | null) => void;
onConnectTest: (node: Node<NeNodeData> | null) => void;
connectTestBusy?: boolean;
onPurgePlaceholder: (id: string) => void;
onRemoveNode: (id: string) => void;
onExpandPhysicalLinks: () => void;
@ -70,12 +76,18 @@ export function TopologyCtxMenu({
onClose,
onToggleFullscreen,
onRemoveSelected,
onDiscoverSelected,
onConnectTestSelected,
selectedDiscoverableCount,
selectedConnectableCount,
onOpenCreateNe,
onPromptNewSubRegion,
onRenameSelectedNode,
onDiscoverOne,
onOpenWebcrt,
onOpenNe,
onConnectTest,
connectTestBusy = false,
onPurgePlaceholder,
onRemoveNode,
onExpandPhysicalLinks,
@ -98,7 +110,47 @@ export function TopologyCtxMenu({
<>
<li className="topo-ctx__head" role="presentation">
{t("topology.selectionMenu")}
<span className="topo-ctx__count"> · {selectedNodeCount}</span>
</li>
<li role="none">
<button
type="button"
className="topo-ctx__item"
role="menuitem"
disabled={discovering || selectedDiscoverableCount === 0}
title={
selectedDiscoverableCount === 0 ? t("topology.discoverOneNeedNe") : undefined
}
onClick={() => onDiscoverSelected()}
>
{t("topology.discoverSelected").replace(
"{{count}}",
String(selectedDiscoverableCount || selectedNodeCount),
)}
</button>
</li>
<li role="none">
<button
type="button"
className="topo-ctx__item"
role="menuitem"
disabled={connectTestBusy || selectedConnectableCount === 0}
title={
selectedConnectableCount === 0
? t("topology.connectTestNeedManaged")
: undefined
}
onClick={() => onConnectTestSelected()}
>
{connectTestBusy
? t("managedNe.connect.running")
: t("topology.connectTestSelected").replace(
"{{count}}",
String(selectedConnectableCount || selectedNodeCount),
)}
</button>
</li>
<li className="topo-ctx__sep" aria-hidden />
<li role="none">
<button
type="button"
@ -225,6 +277,24 @@ export function TopologyCtxMenu({
{t("topology.openNe")}
</button>
</li>
<li role="none">
<button
type="button"
className="topo-ctx__item"
role="menuitem"
disabled={
connectTestBusy || !String(selectedNode?.data.managed_ne_id || "").trim()
}
title={
!String(selectedNode?.data.managed_ne_id || "").trim()
? t("topology.connectTestNeedManaged")
: undefined
}
onClick={() => onConnectTest(selectedNode)}
>
{connectTestBusy ? t("managedNe.connect.running") : t("topology.connectTest")}
</button>
</li>
<li className="topo-ctx__sep" aria-hidden />
{selectedNode &&
isPlaceholderSource(selectedNode.data.managed_source, selectedNode.data.ne_ip) ? (

View file

@ -1,14 +1,20 @@
import type { DragEvent } from "react";
import type { TopologyOutsidePeer } from "../../types";
import { useI18n } from "../../i18n";
import type { PaletteItem, PaletteSource } from "./pageTypes";
import { AddNePaletteDialog } from "./modals/AddNePaletteDialog";
import { CreateNeModeDialog } from "./modals/CreateNeModeDialog";
import { NewRootDialog } from "./modals/NewRootDialog";
import {
PlaceholderCreateDialog,
type PlaceholderCreateDialogState,
} from "./modals/PlaceholderCreateDialog";
import { OutsidePeersDialog } from "./modals/OutsidePeersDialog";
import { ManagedNeFormDialog } from "../managedNe/ManagedNeFormDialog";
import { ManagedNeConnectDetailDialog } from "../managedNe/ManagedNeConnectDetailDialog";
import type { ManagedNeItem } from "../../types";
import type { ManagedNeFormState } from "../managedNe/formState";
export type CreateNeDialogState = {
flowX: number;
flowY: number;
name: string;
ip_address: string;
};
export type { PlaceholderCreateDialogState as CreateNeDialogState };
export type TopologyModalsProps = {
canvasMode: boolean;
@ -17,11 +23,27 @@ export type TopologyModalsProps = {
onCloseNewRoot: () => void;
onSubmitNewRoot: () => void;
createRegionPending: boolean;
createNeDialog: CreateNeDialogState | null;
createNeBusy: boolean;
onCreateNeChange: (patch: Partial<Pick<CreateNeDialogState, "name" | "ip_address">>) => void;
onCloseCreateNe: () => void;
onSubmitCreateNe: () => void;
createNeModeOpen: boolean;
onCloseCreateNeMode: () => void;
onPickCreateManaged: () => void;
onPickCreatePlaceholder: () => void;
placeholderDialog: PlaceholderCreateDialogState | null;
placeholderBusy: boolean;
onPlaceholderChange: (patch: Partial<Pick<PlaceholderCreateDialogState, "name" | "ip_address">>) => void;
onClosePlaceholder: () => void;
onSubmitPlaceholder: () => void;
managedFormOpen: boolean;
managedFormInitial?: Partial<ManagedNeFormState>;
onCloseManagedForm: () => void;
onManagedFormSaved: (item: ManagedNeItem) => void;
connectDetailRow: ManagedNeItem | null;
onCloseConnectDetail: () => void;
onConnectRetestSubmitted: (rowId: string) => void;
outsidePeersOpen: boolean;
outsidePeers: TopologyOutsidePeer[];
outsidePeersVisible: TopologyOutsidePeer[];
@ -33,6 +55,7 @@ export type TopologyModalsProps = {
outsidePeersAdding: boolean;
onCloseOutsidePeers: () => void;
onAddOutsidePeers: (ids: string[]) => void;
addNeOpen: boolean;
paletteSource: PaletteSource;
onPaletteSourceChange: (source: PaletteSource) => void;
@ -48,6 +71,7 @@ export type TopologyModalsProps = {
onAddSelectedPalette: () => void;
};
/** Thin composer — keep individual dialogs in `modals/` / `managedNe/`. */
export function TopologyModals({
canvasMode,
newRootDialog,
@ -55,11 +79,22 @@ export function TopologyModals({
onCloseNewRoot,
onSubmitNewRoot,
createRegionPending,
createNeDialog,
createNeBusy,
onCreateNeChange,
onCloseCreateNe,
onSubmitCreateNe,
createNeModeOpen,
onCloseCreateNeMode,
onPickCreateManaged,
onPickCreatePlaceholder,
placeholderDialog,
placeholderBusy,
onPlaceholderChange,
onClosePlaceholder,
onSubmitPlaceholder,
managedFormOpen,
managedFormInitial,
onCloseManagedForm,
onManagedFormSaved,
connectDetailRow,
onCloseConnectDetail,
onConnectRetestSubmitted,
outsidePeersOpen,
outsidePeers,
outsidePeersVisible,
@ -85,460 +120,72 @@ export function TopologyModals({
onPaletteDragStart,
onAddSelectedPalette,
}: TopologyModalsProps) {
const { t } = useI18n();
return (
<>
{newRootDialog ? (
<div
className="topo-modal"
role="dialog"
aria-modal="true"
aria-labelledby="topo-new-root-title"
>
<div className="topo-modal__backdrop" onClick={onCloseNewRoot} />
<div className="topo-modal__panel" style={{ maxWidth: 420 }}>
<div className="topo-modal__head">
<strong id="topo-new-root-title">{t("topology.newRegion")}</strong>
<button type="button" className="btn btn--sm btn--ghost" onClick={onCloseNewRoot}>
{t("topology.discoverClose")}
</button>
</div>
<p className="panel__hint topo-modal__hint">{t("topology.folderHint")}</p>
<div className="form-grid" style={{ padding: "0 16px 8px" }}>
<label>
<span className="form-label">{t("topology.newRegionPrompt")}</span>
<input
autoFocus
value={newRootDialog.name}
disabled={createRegionPending}
onChange={(e) => onNewRootNameChange(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") onSubmitNewRoot();
}}
/>
</label>
</div>
<div className="topo-modal__foot">
<button
type="button"
className="btn btn--sm btn--ghost"
disabled={createRegionPending}
onClick={onCloseNewRoot}
>
{t("topology.discoverClose")}
</button>
<button
type="button"
className="btn btn--sm"
disabled={createRegionPending || !String(newRootDialog.name || "").trim()}
onClick={onSubmitNewRoot}
>
{createRegionPending ? "…" : t("topology.newRegion")}
</button>
</div>
</div>
</div>
) : null}
<NewRootDialog
dialog={newRootDialog}
pending={createRegionPending}
onNameChange={onNewRootNameChange}
onClose={onCloseNewRoot}
onSubmit={onSubmitNewRoot}
/>
{createNeDialog && canvasMode ? (
<div
className="topo-modal"
role="dialog"
aria-modal="true"
aria-labelledby="topo-create-ne-title"
>
<div
className="topo-modal__backdrop"
onClick={() => {
if (createNeBusy) return;
onCloseCreateNe();
}}
{canvasMode ? (
<>
<CreateNeModeDialog
open={createNeModeOpen}
onClose={onCloseCreateNeMode}
onPickManaged={onPickCreateManaged}
onPickPlaceholder={onPickCreatePlaceholder}
/>
<div className="topo-modal__panel" style={{ maxWidth: 420 }}>
<div className="topo-modal__head">
<strong id="topo-create-ne-title">{t("topology.createNeTitle")}</strong>
<button
type="button"
className="btn btn--sm btn--ghost"
disabled={createNeBusy}
onClick={onCloseCreateNe}
>
{t("topology.discoverClose")}
</button>
</div>
<p className="panel__hint topo-modal__hint">{t("topology.createNeHint")}</p>
<div className="form-grid" style={{ padding: "0 16px 8px" }}>
<label>
<span className="form-label">
{t("topology.createNeName")}
<span className="form-label__required" aria-hidden="true">
{" "}
*
</span>
</span>
<input
autoFocus
value={createNeDialog.name}
placeholder={t("topology.createNeNamePh")}
disabled={createNeBusy}
onChange={(e) => onCreateNeChange({ name: e.target.value })}
onKeyDown={(e) => {
if (e.key === "Enter") void onSubmitCreateNe();
}}
/>
</label>
<label>
<span className="form-label">{t("topology.createNeIp")}</span>
<input
value={createNeDialog.ip_address}
placeholder={t("topology.createNeIpPh")}
disabled={createNeBusy}
onChange={(e) => onCreateNeChange({ ip_address: e.target.value })}
onKeyDown={(e) => {
if (e.key === "Enter") void onSubmitCreateNe();
}}
/>
</label>
</div>
<div className="topo-modal__foot">
<button
type="button"
className="btn btn--ghost"
disabled={createNeBusy}
onClick={onCloseCreateNe}
>
{t("topology.discoverClose")}
</button>
<button
type="button"
className="btn btn--primary"
disabled={createNeBusy}
onClick={() => void onSubmitCreateNe()}
>
{createNeBusy ? t("topology.createNeBusy") : t("topology.createNe")}
</button>
</div>
</div>
</div>
) : null}
{outsidePeersOpen && canvasMode ? (
<div
className="topo-modal"
role="dialog"
aria-modal="true"
aria-label={t("topology.outsidePeersTitle")}
>
<div
className="topo-modal__backdrop"
onClick={() => {
if (outsidePeersAdding) return;
onCloseOutsidePeers();
}}
<PlaceholderCreateDialog
dialog={placeholderDialog}
busy={placeholderBusy}
onChange={onPlaceholderChange}
onClose={onClosePlaceholder}
onSubmit={onSubmitPlaceholder}
/>
<div className="topo-modal__panel topo-modal__panel--wide">
<div className="topo-modal__head">
<strong>
{t("topology.outsidePeersTitle")}
<span className="topo-modal__count"> · {outsidePeers.length}</span>
</strong>
<button
type="button"
className="btn btn--sm btn--ghost"
disabled={outsidePeersAdding}
onClick={onCloseOutsidePeers}
>
{t("topology.discoverClose")}
</button>
</div>
<p className="panel__hint topo-modal__hint">{t("topology.outsidePeersHint")}</p>
<input
className="input"
value={outsidePeerQuery}
onChange={(e) => onOutsidePeerQueryChange(e.target.value)}
placeholder={t("topology.outsidePeersFilterPh")}
disabled={outsidePeersAdding}
autoFocus
/>
{outsidePeersVisible.length > 0 ? (
<div className="topo-modal__selectbar">
<label className="topo-modal__selectall">
<input
type="checkbox"
checked={
outsidePeersVisible.length > 0 &&
outsidePeersVisible.every((p) =>
outsidePeerSelectedIds.includes(p.fabric_node_id),
)
}
disabled={outsidePeersAdding}
onChange={(e) => {
if (e.target.checked) {
onOutsidePeerSelectedIdsChange((prev) => [
...new Set([
...prev,
...outsidePeersVisible.map((p) => p.fabric_node_id),
]),
]);
return;
}
const drop = new Set(outsidePeersVisible.map((p) => p.fabric_node_id));
onOutsidePeerSelectedIdsChange((prev) => prev.filter((id) => !drop.has(id)));
}}
aria-label={t("topology.selectAllVisible")}
/>
<span>{t("topology.selectAllVisible")}</span>
</label>
<span className="panel__hint">
{t("topology.selectedCount").replace(
"{{count}}",
String(outsidePeerSelectedIds.length),
)}
</span>
</div>
) : null}
<ul className="topo-palette topo-modal__list">
{outsidePeersVisible.length === 0 ? (
<li className="topo-palette__empty">
<span className="panel__hint">{t("topology.outsidePeersEmpty")}</span>
</li>
) : (
outsidePeersVisible.map((peer) => {
const checked = outsidePeerSelectedIds.includes(peer.fabric_node_id);
const viaName =
outsidePeerNameById.get(peer.via_node_id) || peer.via_node_id.slice(0, 8);
const title = peer.name || peer.ip || peer.fabric_node_id.slice(0, 8);
return (
<li key={peer.fabric_node_id}>
<div className={`topo-palette__row${checked ? " is-selected" : ""}`}>
<label className="topo-palette__check">
<input
type="checkbox"
checked={checked}
disabled={outsidePeersAdding}
onChange={(e) => {
onOutsidePeerSelectedIdsChange((prev) =>
e.target.checked
? [...new Set([...prev, peer.fabric_node_id])]
: prev.filter((id) => id !== peer.fabric_node_id),
);
}}
aria-label={title}
/>
</label>
<button
type="button"
className="topo-palette__item"
disabled={outsidePeersAdding}
onClick={() => {
onOutsidePeerSelectedIdsChange((prev) =>
prev.includes(peer.fabric_node_id)
? prev.filter((id) => id !== peer.fabric_node_id)
: [...prev, peer.fabric_node_id],
);
}}
>
<span className="topo-palette__name">{title}</span>
<span className="topo-palette__meta">
{[peer.ip, t("topology.outsidePeersVia").replace("{{name}}", viaName)]
.filter(Boolean)
.join(" · ")}
</span>
</button>
</div>
</li>
);
})
)}
</ul>
<div className="topo-modal__foot">
<button
type="button"
className="btn btn--sm btn--ghost"
disabled={outsidePeersAdding}
onClick={onCloseOutsidePeers}
>
{t("topology.discoverClose")}
</button>
<button
type="button"
className="btn btn--sm"
disabled={outsidePeerSelectedIds.length === 0 || outsidePeersAdding}
onClick={() => void onAddOutsidePeers(outsidePeerSelectedIds)}
>
{outsidePeersAdding
? t("topology.addingNe")
: t("topology.addSelected").replace(
"{{count}}",
String(outsidePeerSelectedIds.length),
)}
</button>
</div>
</div>
</div>
) : null}
{addNeOpen && canvasMode ? (
<div className="topo-modal" role="dialog" aria-modal="true" aria-label={t("topology.addNe")}>
<div
className="topo-modal__backdrop"
onClick={() => {
if (paletteAdding) return;
onCloseAddNe();
}}
<ManagedNeFormDialog
open={managedFormOpen}
editing={null}
initialValues={managedFormInitial}
onClose={onCloseManagedForm}
onSaved={onManagedFormSaved}
/>
<div className="topo-modal__panel">
<div className="topo-modal__head">
<strong>{t("topology.addNe")}</strong>
<button
type="button"
className="btn btn--sm btn--ghost"
disabled={paletteAdding}
onClick={onCloseAddNe}
>
{t("topology.discoverClose")}
</button>
</div>
<p className="panel__hint topo-modal__hint">{t("topology.paletteHint")}</p>
<div className="topo-palette-source" role="tablist">
<button
type="button"
role="tab"
aria-selected={paletteSource === "managed"}
className={`topo-palette-source__btn${paletteSource === "managed" ? " is-active" : ""}`}
disabled={paletteAdding}
onClick={() => onPaletteSourceChange("managed")}
>
{t("topology.paletteManaged")}
</button>
<button
type="button"
role="tab"
aria-selected={paletteSource === "ume"}
className={`topo-palette-source__btn${paletteSource === "ume" ? " is-active" : ""}`}
disabled={paletteAdding}
onClick={() => onPaletteSourceChange("ume")}
>
{t("topology.paletteUme")}
</button>
</div>
<input
className="input"
value={keyword}
onChange={(e) => onKeywordChange(e.target.value)}
placeholder={t("topology.filterPh")}
disabled={paletteAdding}
autoFocus
/>
{paletteVisible.length > 0 ? (
<div className="topo-modal__selectbar">
<label className="topo-modal__selectall">
<input
type="checkbox"
checked={
paletteVisible.length > 0 &&
paletteVisible.every((item) => paletteSelectedKeys.includes(item.key))
}
disabled={paletteAdding}
onChange={(e) => {
if (e.target.checked) {
onPaletteSelectedKeysChange(paletteVisible.map((item) => item.key));
return;
}
onPaletteSelectedKeysChange([]);
}}
aria-label={t("topology.selectAllVisible")}
/>
<span>{t("topology.selectAllVisible")}</span>
</label>
<span className="panel__hint">
{t("topology.selectedCount").replace(
"{{count}}",
String(paletteSelectedKeys.length),
)}
</span>
</div>
) : null}
<ul className="topo-palette topo-modal__list">
{paletteLoading ? (
<li className="topo-palette__empty">
<span className="panel__hint">{t("topology.paletteLoading")}</span>
</li>
) : paletteVisible.length === 0 ? (
<li className="topo-palette__empty">
<span className="panel__hint">{t("topology.paletteEmpty")}</span>
</li>
) : (
paletteVisible.map((item) => {
const checked = paletteSelectedKeys.includes(item.key);
return (
<li key={item.key}>
<div className={`topo-palette__row${checked ? " is-selected" : ""}`}>
<label className="topo-palette__check">
<input
type="checkbox"
checked={checked}
disabled={paletteAdding}
onChange={(e) => {
onPaletteSelectedKeysChange((prev) =>
e.target.checked
? [...new Set([...prev, item.key])]
: prev.filter((key) => key !== item.key),
);
}}
aria-label={item.name}
/>
</label>
<button
type="button"
className="topo-palette__item"
draggable={!paletteAdding}
disabled={paletteAdding}
onClick={() => {
onPaletteSelectedKeysChange((prev) =>
prev.includes(item.key)
? prev.filter((key) => key !== item.key)
: [...prev, item.key],
);
}}
onDragStart={(e) => onPaletteDragStart(e, item)}
title={t("topology.paletteDragHint")}
>
<span className="topo-palette__name">{item.name}</span>
<span className="topo-palette__meta">{item.meta}</span>
</button>
</div>
</li>
);
})
)}
</ul>
<div className="topo-modal__foot">
<button
type="button"
className="btn btn--sm btn--ghost"
disabled={paletteAdding}
onClick={onCloseAddNe}
>
{t("topology.discoverClose")}
</button>
<button
type="button"
className="btn btn--sm"
disabled={paletteSelectedKeys.length === 0 || paletteAdding}
onClick={() => void onAddSelectedPalette()}
>
{paletteAdding
? t("topology.addingNe")
: t("topology.addSelected").replace(
"{{count}}",
String(paletteSelectedKeys.length),
)}
</button>
</div>
</div>
</div>
<ManagedNeConnectDetailDialog
row={connectDetailRow}
onClose={onCloseConnectDetail}
onRetestSubmitted={onConnectRetestSubmitted}
/>
<OutsidePeersDialog
open={outsidePeersOpen}
outsidePeers={outsidePeers}
outsidePeersVisible={outsidePeersVisible}
outsidePeerQuery={outsidePeerQuery}
onOutsidePeerQueryChange={onOutsidePeerQueryChange}
outsidePeerSelectedIds={outsidePeerSelectedIds}
onOutsidePeerSelectedIdsChange={onOutsidePeerSelectedIdsChange}
outsidePeerNameById={outsidePeerNameById}
outsidePeersAdding={outsidePeersAdding}
onClose={onCloseOutsidePeers}
onAddOutsidePeers={onAddOutsidePeers}
/>
<AddNePaletteDialog
open={addNeOpen}
paletteSource={paletteSource}
onPaletteSourceChange={onPaletteSourceChange}
keyword={keyword}
onKeywordChange={onKeywordChange}
paletteVisible={paletteVisible}
paletteSelectedKeys={paletteSelectedKeys}
onPaletteSelectedKeysChange={onPaletteSelectedKeysChange}
paletteLoading={paletteLoading}
paletteAdding={paletteAdding}
onClose={onCloseAddNe}
onPaletteDragStart={onPaletteDragStart}
onAddSelected={onAddSelectedPalette}
/>
</>
) : null}
</>
);

View file

@ -0,0 +1,111 @@
import { useCallback, useEffect, useState } from "react";
import type { Node } from "@xyflow/react";
import { connectTestManagedNe, fetchManagedNeById } from "../../../services/api";
import { useI18n } from "../../../i18n";
import { useToast } from "../../../hooks/useToast";
import type { ManagedNeItem } from "../../../types";
import type { NeNodeData } from "../TopologyReactFlowView";
type Args = {
closeCtxMenu: () => void;
};
export function useTopologyConnectTest({ closeCtxMenu }: Args) {
const { t } = useI18n();
const { showOk, showError } = useToast();
const [detailRow, setDetailRow] = useState<ManagedNeItem | null>(null);
const [submitting, setSubmitting] = useState(false);
const refreshRow = useCallback(async (id: string) => {
const row = await fetchManagedNeById(id);
setDetailRow(row);
return row;
}, []);
useEffect(() => {
if (!detailRow || detailRow.connect_status !== "testing") return;
const id = detailRow.id;
const timer = window.setInterval(() => {
void refreshRow(id).catch(() => {
/* ignore transient poll errors */
});
}, 2000);
return () => window.clearInterval(timer);
}, [detailRow?.id, detailRow?.connect_status, refreshRow]);
const runForNode = useCallback(
async (node: Node<NeNodeData> | null) => {
closeCtxMenu();
const managedId = String(node?.data.managed_ne_id || "").trim();
if (!managedId) {
showError(t("topology.connectTestNeedManaged"));
return;
}
setSubmitting(true);
try {
const res = await connectTestManagedNe([managedId]);
showOk(t("managedNe.connect.submitted", { n: res.submitted }));
await refreshRow(managedId);
} catch (err) {
showError(String(err));
} finally {
setSubmitting(false);
}
},
[closeCtxMenu, refreshRow, showError, showOk, t],
);
/** Batch: submit connect tests; open detail only when exactly one managed NE. */
const runForNodes = useCallback(
async (nodes: Node<NeNodeData>[]) => {
closeCtxMenu();
const ids = [
...new Set(
nodes
.map((n) => String(n.data.managed_ne_id || "").trim())
.filter(Boolean),
),
];
if (!ids.length) {
showError(t("topology.connectTestNeedManaged"));
return;
}
const skipped = nodes.length - ids.length;
setSubmitting(true);
try {
const res = await connectTestManagedNe(ids);
const msg = t("managedNe.connect.submitted", { n: res.submitted });
showOk(
skipped > 0
? `${msg} · ${t("topology.connectTestSkipped").replace("{{count}}", String(skipped))}`
: msg,
);
if (ids.length === 1) {
await refreshRow(ids[0]);
}
} catch (err) {
showError(String(err));
} finally {
setSubmitting(false);
}
},
[closeCtxMenu, refreshRow, showError, showOk, t],
);
const onRetestSubmitted = useCallback(
(rowId: string) => {
showOk(t("managedNe.connect.submitted", { n: 1 }));
void refreshRow(rowId);
},
[refreshRow, showOk, t],
);
return {
connectDetailRow: detailRow,
connectTestSubmitting: submitting,
closeConnectDetail: () => setDetailRow(null),
runConnectTestForNode: runForNode,
runConnectTestForNodes: runForNodes,
onConnectRetestSubmitted: onRetestSubmitted,
};
}

View file

@ -0,0 +1,225 @@
import {
useCallback,
useRef,
useState,
type Dispatch,
type MutableRefObject,
type SetStateAction,
} from "react";
import { useQueryClient } from "@tanstack/react-query";
import type { Edge, Node } from "@xyflow/react";
import {
addTopologyViewNodes,
createTopologyPlaceholder,
fetchTopologyGraph,
patchTopologyPositions,
} from "../../../services/api";
import { queryKeys } from "../../../constants/queryKeys";
import { useI18n } from "../../../i18n";
import { useToast } from "../../../hooks/useToast";
import type { ManagedNeItem } from "../../../types";
import { applyViewGraph } from "../graphFlow";
import type { EdgeDefaults } from "../edgeStyle";
import type { PlaceholderCreateDialogState } from "../modals/PlaceholderCreateDialog";
import type { ManagedNeFormState } from "../../managedNe/formState";
import { isWorldFlatViewName } from "../treeUtils";
import type { NeNodeData } from "../TopologyReactFlowView";
type FocusNode = (fabricNodeId: string, fit?: boolean) => void;
type Args = {
mapId: string | null | undefined;
activeViewName: string | undefined;
canWrite: boolean;
edgeDefaults: EdgeDefaults;
setNodes: Dispatch<SetStateAction<Node<NeNodeData>[]>>;
setEdges: Dispatch<SetStateAction<Edge[]>>;
clearDirty: () => void;
historyLockRef: MutableRefObject<boolean>;
focusNode: FocusNode;
closeCtxMenu: () => void;
};
export function useTopologyCreateNe({
mapId,
activeViewName,
canWrite,
edgeDefaults,
setNodes,
setEdges,
clearDirty,
historyLockRef,
focusNode,
closeCtxMenu,
}: Args) {
const { t } = useI18n();
const { showOk, showError } = useToast();
const queryClient = useQueryClient();
const [modeOpen, setModeOpen] = useState(false);
const [placeAt, setPlaceAt] = useState<{ flowX: number; flowY: number } | null>(null);
const [placeholderDialog, setPlaceholderDialog] = useState<PlaceholderCreateDialogState | null>(
null,
);
const [placeholderBusy, setPlaceholderBusy] = useState(false);
const [managedFormOpen, setManagedFormOpen] = useState(false);
const [managedFormInitial, setManagedFormInitial] = useState<Partial<ManagedNeFormState> | undefined>();
const [managedBusy, setManagedBusy] = useState(false);
const placeAtRef = useRef(placeAt);
placeAtRef.current = placeAt;
const openCreateNeAt = useCallback(
(flowX: number, flowY: number) => {
if (!canWrite) {
showError(t("topology.readOnlyHint"));
return;
}
if (isWorldFlatViewName(activeViewName)) {
showError(t("topology.worldMapNoDirectNes"));
return;
}
closeCtxMenu();
setPlaceAt({ flowX, flowY });
setModeOpen(true);
},
[canWrite, activeViewName, closeCtxMenu, showError, t],
);
const closeMode = useCallback(() => setModeOpen(false), []);
const pickManaged = useCallback(() => {
setModeOpen(false);
setManagedFormInitial(undefined);
setManagedFormOpen(true);
}, []);
const pickPlaceholder = useCallback(() => {
const at = placeAtRef.current;
if (!at) return;
setModeOpen(false);
setPlaceholderDialog({ ...at, name: "", ip_address: "" });
}, []);
const closePlaceholder = useCallback(() => setPlaceholderDialog(null), []);
const closeManagedForm = useCallback(() => {
if (managedBusy) return;
setManagedFormOpen(false);
}, [managedBusy]);
const applyGraph = useCallback(
(graph: Awaited<ReturnType<typeof fetchTopologyGraph>>) => {
if (!mapId) return;
queryClient.setQueryData(queryKeys.topologyGraph(mapId), graph);
historyLockRef.current = true;
applyViewGraph(graph, edgeDefaults, setNodes, setEdges);
historyLockRef.current = false;
clearDirty();
},
[mapId, queryClient, edgeDefaults, setNodes, setEdges, clearDirty, historyLockRef],
);
const submitPlaceholder = useCallback(async () => {
if (!mapId || !placeholderDialog) return;
const name = placeholderDialog.name.trim();
if (!name) {
showError(t("topology.createNeNameRequired"));
return;
}
setPlaceholderBusy(true);
try {
const graph = await createTopologyPlaceholder(mapId, {
name,
ip_address: placeholderDialog.ip_address.trim(),
x: placeholderDialog.flowX,
y: placeholderDialog.flowY,
});
applyGraph(graph);
const created = graph.nodes.find(
(n) =>
String(n.name || "").trim() === name &&
Math.abs(Number(n.x) - placeholderDialog.flowX) < 0.5 &&
Math.abs(Number(n.y) - placeholderDialog.flowY) < 0.5,
);
if (created?.fabric_node_id) {
focusNode(created.fabric_node_id, false);
}
setPlaceholderDialog(null);
showOk(t("topology.createNeDone").replace("{{name}}", name));
void queryClient.invalidateQueries({ queryKey: queryKeys.managedNeAll });
void queryClient.invalidateQueries({ queryKey: ["webcrtTargets"] });
} catch (err) {
showError(String(err));
} finally {
setPlaceholderBusy(false);
}
}, [
mapId,
placeholderDialog,
applyGraph,
focusNode,
queryClient,
showError,
showOk,
t,
]);
const onManagedFormSaved = useCallback(
async (item: ManagedNeItem) => {
setManagedFormOpen(false);
if (!mapId) {
showOk(t("managedNe.form.created"));
return;
}
const at = placeAtRef.current || { flowX: 80, flowY: 80 };
setManagedBusy(true);
try {
const graph = await addTopologyViewNodes(mapId, {
managed_ne_ids: [item.id],
layout: "grid",
});
const added = graph.nodes.find((n) => n.managed_ne_id === item.id);
if (added?.fabric_node_id) {
await patchTopologyPositions(mapId, [
{
fabric_node_id: added.fabric_node_id,
x: at.flowX,
y: at.flowY,
label: added.label || added.name || item.name || "",
},
]);
}
const refreshed = await fetchTopologyGraph(mapId);
applyGraph(refreshed);
if (added?.fabric_node_id) {
focusNode(added.fabric_node_id, false);
}
showOk(t("topology.createManagedNeDone").replace("{{name}}", item.name || item.ip_address));
void queryClient.invalidateQueries({ queryKey: queryKeys.managedNeAll });
void queryClient.invalidateQueries({ queryKey: ["webcrtTargets"] });
} catch (err) {
showError(String(err));
} finally {
setManagedBusy(false);
}
},
[mapId, applyGraph, focusNode, queryClient, showError, showOk, t],
);
return {
modeOpen,
openCreateNeAt,
closeMode,
pickManaged,
pickPlaceholder,
placeholderDialog,
setPlaceholderDialog,
placeholderBusy,
closePlaceholder,
submitPlaceholder,
managedFormOpen,
managedFormInitial,
closeManagedForm,
onManagedFormSaved,
};
}

View file

@ -0,0 +1,194 @@
import type { DragEvent } from "react";
import { useI18n } from "../../../i18n";
import type { PaletteItem, PaletteSource } from "../pageTypes";
export type AddNePaletteDialogProps = {
open: boolean;
paletteSource: PaletteSource;
onPaletteSourceChange: (source: PaletteSource) => void;
keyword: string;
onKeywordChange: (keyword: string) => void;
paletteVisible: PaletteItem[];
paletteSelectedKeys: string[];
onPaletteSelectedKeysChange: (keys: string[] | ((prev: string[]) => string[])) => void;
paletteLoading: boolean;
paletteAdding: boolean;
onClose: () => void;
onPaletteDragStart: (e: DragEvent, item: PaletteItem) => void;
onAddSelected: () => void;
};
export function AddNePaletteDialog({
open,
paletteSource,
onPaletteSourceChange,
keyword,
onKeywordChange,
paletteVisible,
paletteSelectedKeys,
onPaletteSelectedKeysChange,
paletteLoading,
paletteAdding,
onClose,
onPaletteDragStart,
onAddSelected,
}: AddNePaletteDialogProps) {
const { t } = useI18n();
if (!open) return null;
return (
<div className="topo-modal" role="dialog" aria-modal="true" aria-label={t("topology.addNe")}>
<div
className="topo-modal__backdrop"
onClick={() => {
if (paletteAdding) return;
onClose();
}}
/>
<div className="topo-modal__panel">
<div className="topo-modal__head">
<strong>{t("topology.addNe")}</strong>
<button
type="button"
className="btn btn--sm btn--ghost"
disabled={paletteAdding}
onClick={onClose}
>
{t("topology.discoverClose")}
</button>
</div>
<p className="panel__hint topo-modal__hint">{t("topology.paletteHint")}</p>
<div className="topo-palette-source" role="tablist">
<button
type="button"
role="tab"
aria-selected={paletteSource === "managed"}
className={`topo-palette-source__btn${paletteSource === "managed" ? " is-active" : ""}`}
disabled={paletteAdding}
onClick={() => onPaletteSourceChange("managed")}
>
{t("topology.paletteManaged")}
</button>
<button
type="button"
role="tab"
aria-selected={paletteSource === "ume"}
className={`topo-palette-source__btn${paletteSource === "ume" ? " is-active" : ""}`}
disabled={paletteAdding}
onClick={() => onPaletteSourceChange("ume")}
>
{t("topology.paletteUme")}
</button>
</div>
<input
className="input"
value={keyword}
onChange={(e) => onKeywordChange(e.target.value)}
placeholder={t("topology.filterPh")}
disabled={paletteAdding}
autoFocus
/>
{paletteVisible.length > 0 ? (
<div className="topo-modal__selectbar">
<label className="topo-modal__selectall">
<input
type="checkbox"
checked={
paletteVisible.length > 0 &&
paletteVisible.every((item) => paletteSelectedKeys.includes(item.key))
}
disabled={paletteAdding}
onChange={(e) => {
if (e.target.checked) {
onPaletteSelectedKeysChange(paletteVisible.map((item) => item.key));
return;
}
onPaletteSelectedKeysChange([]);
}}
aria-label={t("topology.selectAllVisible")}
/>
<span>{t("topology.selectAllVisible")}</span>
</label>
<span className="panel__hint">
{t("topology.selectedCount").replace("{{count}}", String(paletteSelectedKeys.length))}
</span>
</div>
) : null}
<ul className="topo-palette topo-modal__list">
{paletteLoading ? (
<li className="topo-palette__empty">
<span className="panel__hint">{t("topology.paletteLoading")}</span>
</li>
) : paletteVisible.length === 0 ? (
<li className="topo-palette__empty">
<span className="panel__hint">{t("topology.paletteEmpty")}</span>
</li>
) : (
paletteVisible.map((item) => {
const checked = paletteSelectedKeys.includes(item.key);
return (
<li key={item.key}>
<div className={`topo-palette__row${checked ? " is-selected" : ""}`}>
<label className="topo-palette__check">
<input
type="checkbox"
checked={checked}
disabled={paletteAdding}
onChange={(e) => {
onPaletteSelectedKeysChange((prev) =>
e.target.checked
? [...new Set([...prev, item.key])]
: prev.filter((key) => key !== item.key),
);
}}
aria-label={item.name}
/>
</label>
<button
type="button"
className="topo-palette__item"
draggable={!paletteAdding}
disabled={paletteAdding}
onClick={() => {
onPaletteSelectedKeysChange((prev) =>
prev.includes(item.key)
? prev.filter((key) => key !== item.key)
: [...prev, item.key],
);
}}
onDragStart={(e) => onPaletteDragStart(e, item)}
title={t("topology.paletteDragHint")}
>
<span className="topo-palette__name">{item.name}</span>
<span className="topo-palette__meta">{item.meta}</span>
</button>
</div>
</li>
);
})
)}
</ul>
<div className="topo-modal__foot">
<button
type="button"
className="btn btn--sm btn--ghost"
disabled={paletteAdding}
onClick={onClose}
>
{t("topology.discoverClose")}
</button>
<button
type="button"
className="btn btn--sm"
disabled={paletteSelectedKeys.length === 0 || paletteAdding}
onClick={() => void onAddSelected()}
>
{paletteAdding
? t("topology.addingNe")
: t("topology.addSelected").replace("{{count}}", String(paletteSelectedKeys.length))}
</button>
</div>
</div>
</div>
);
}

View file

@ -0,0 +1,48 @@
import { useI18n } from "../../../i18n";
export type CreateNeModeDialogProps = {
open: boolean;
onClose: () => void;
onPickManaged: () => void;
onPickPlaceholder: () => void;
};
/** Choose formal Managed NE vs topology placeholder before opening the create flow. */
export function CreateNeModeDialog({
open,
onClose,
onPickManaged,
onPickPlaceholder,
}: CreateNeModeDialogProps) {
const { t } = useI18n();
if (!open) return null;
return (
<div className="topo-modal" role="dialog" aria-modal="true" aria-labelledby="topo-create-ne-mode-title">
<div className="topo-modal__backdrop" onClick={onClose} />
<div className="topo-modal__panel" style={{ maxWidth: 440 }}>
<div className="topo-modal__head">
<strong id="topo-create-ne-mode-title">{t("topology.createNe")}</strong>
<button type="button" className="btn btn--sm btn--ghost" onClick={onClose}>
{t("topology.discoverClose")}
</button>
</div>
<p className="panel__hint topo-modal__hint">{t("topology.createNeModeHint")}</p>
<div className="topo-create-ne-mode" style={{ padding: "0 16px 16px", display: "grid", gap: 10 }}>
<button type="button" className="btn" onClick={onPickManaged}>
<span style={{ display: "block", fontWeight: 600 }}>{t("topology.createNeManaged")}</span>
<span className="panel__hint" style={{ display: "block", marginTop: 4 }}>
{t("topology.createNeManagedHint")}
</span>
</button>
<button type="button" className="btn btn--ghost" onClick={onPickPlaceholder}>
<span style={{ display: "block", fontWeight: 600 }}>{t("topology.createNePlaceholder")}</span>
<span className="panel__hint" style={{ display: "block", marginTop: 4 }}>
{t("topology.createNePlaceholderHint")}
</span>
</button>
</div>
</div>
</div>
);
}

View file

@ -0,0 +1,66 @@
import { useI18n } from "../../../i18n";
export type NewRootDialogProps = {
dialog: { name: string } | null;
pending: boolean;
onNameChange: (name: string) => void;
onClose: () => void;
onSubmit: () => void;
};
export function NewRootDialog({ dialog, pending, onNameChange, onClose, onSubmit }: NewRootDialogProps) {
const { t } = useI18n();
if (!dialog) return null;
return (
<div
className="topo-modal"
role="dialog"
aria-modal="true"
aria-labelledby="topo-new-root-title"
>
<div className="topo-modal__backdrop" onClick={onClose} />
<div className="topo-modal__panel" style={{ maxWidth: 420 }}>
<div className="topo-modal__head">
<strong id="topo-new-root-title">{t("topology.newRegion")}</strong>
<button type="button" className="btn btn--sm btn--ghost" onClick={onClose}>
{t("topology.discoverClose")}
</button>
</div>
<p className="panel__hint topo-modal__hint">{t("topology.folderHint")}</p>
<div className="form-grid" style={{ padding: "0 16px 8px" }}>
<label>
<span className="form-label">{t("topology.newRegionPrompt")}</span>
<input
autoFocus
value={dialog.name}
disabled={pending}
onChange={(e) => onNameChange(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") onSubmit();
}}
/>
</label>
</div>
<div className="topo-modal__foot">
<button
type="button"
className="btn btn--sm btn--ghost"
disabled={pending}
onClick={onClose}
>
{t("topology.discoverClose")}
</button>
<button
type="button"
className="btn btn--sm"
disabled={pending || !String(dialog.name || "").trim()}
onClick={onSubmit}
>
{pending ? "…" : t("topology.newRegion")}
</button>
</div>
</div>
</div>
);
}

View file

@ -0,0 +1,186 @@
import type { TopologyOutsidePeer } from "../../../types";
import { useI18n } from "../../../i18n";
export type OutsidePeersDialogProps = {
open: boolean;
outsidePeers: TopologyOutsidePeer[];
outsidePeersVisible: TopologyOutsidePeer[];
outsidePeerQuery: string;
onOutsidePeerQueryChange: (q: string) => void;
outsidePeerSelectedIds: string[];
onOutsidePeerSelectedIdsChange: (ids: string[] | ((prev: string[]) => string[])) => void;
outsidePeerNameById: Map<string, string>;
outsidePeersAdding: boolean;
onClose: () => void;
onAddOutsidePeers: (ids: string[]) => void;
};
export function OutsidePeersDialog({
open,
outsidePeers,
outsidePeersVisible,
outsidePeerQuery,
onOutsidePeerQueryChange,
outsidePeerSelectedIds,
onOutsidePeerSelectedIdsChange,
outsidePeerNameById,
outsidePeersAdding,
onClose,
onAddOutsidePeers,
}: OutsidePeersDialogProps) {
const { t } = useI18n();
if (!open) return null;
return (
<div
className="topo-modal"
role="dialog"
aria-modal="true"
aria-label={t("topology.outsidePeersTitle")}
>
<div
className="topo-modal__backdrop"
onClick={() => {
if (outsidePeersAdding) return;
onClose();
}}
/>
<div className="topo-modal__panel topo-modal__panel--wide">
<div className="topo-modal__head">
<strong>
{t("topology.outsidePeersTitle")}
<span className="topo-modal__count"> · {outsidePeers.length}</span>
</strong>
<button
type="button"
className="btn btn--sm btn--ghost"
disabled={outsidePeersAdding}
onClick={onClose}
>
{t("topology.discoverClose")}
</button>
</div>
<p className="panel__hint topo-modal__hint">{t("topology.outsidePeersHint")}</p>
<input
className="input"
value={outsidePeerQuery}
onChange={(e) => onOutsidePeerQueryChange(e.target.value)}
placeholder={t("topology.outsidePeersFilterPh")}
disabled={outsidePeersAdding}
autoFocus
/>
{outsidePeersVisible.length > 0 ? (
<div className="topo-modal__selectbar">
<label className="topo-modal__selectall">
<input
type="checkbox"
checked={
outsidePeersVisible.length > 0 &&
outsidePeersVisible.every((p) =>
outsidePeerSelectedIds.includes(p.fabric_node_id),
)
}
disabled={outsidePeersAdding}
onChange={(e) => {
if (e.target.checked) {
onOutsidePeerSelectedIdsChange((prev) => [
...new Set([...prev, ...outsidePeersVisible.map((p) => p.fabric_node_id)]),
]);
return;
}
const drop = new Set(outsidePeersVisible.map((p) => p.fabric_node_id));
onOutsidePeerSelectedIdsChange((prev) => prev.filter((id) => !drop.has(id)));
}}
aria-label={t("topology.selectAllVisible")}
/>
<span>{t("topology.selectAllVisible")}</span>
</label>
<span className="panel__hint">
{t("topology.selectedCount").replace(
"{{count}}",
String(outsidePeerSelectedIds.length),
)}
</span>
</div>
) : null}
<ul className="topo-palette topo-modal__list">
{outsidePeersVisible.length === 0 ? (
<li className="topo-palette__empty">
<span className="panel__hint">{t("topology.outsidePeersEmpty")}</span>
</li>
) : (
outsidePeersVisible.map((peer) => {
const checked = outsidePeerSelectedIds.includes(peer.fabric_node_id);
const viaName =
outsidePeerNameById.get(peer.via_node_id) || peer.via_node_id.slice(0, 8);
const title = peer.name || peer.ip || peer.fabric_node_id.slice(0, 8);
return (
<li key={peer.fabric_node_id}>
<div className={`topo-palette__row${checked ? " is-selected" : ""}`}>
<label className="topo-palette__check">
<input
type="checkbox"
checked={checked}
disabled={outsidePeersAdding}
onChange={(e) => {
onOutsidePeerSelectedIdsChange((prev) =>
e.target.checked
? [...new Set([...prev, peer.fabric_node_id])]
: prev.filter((id) => id !== peer.fabric_node_id),
);
}}
aria-label={title}
/>
</label>
<button
type="button"
className="topo-palette__item"
disabled={outsidePeersAdding}
onClick={() => {
onOutsidePeerSelectedIdsChange((prev) =>
prev.includes(peer.fabric_node_id)
? prev.filter((id) => id !== peer.fabric_node_id)
: [...prev, peer.fabric_node_id],
);
}}
>
<span className="topo-palette__name">{title}</span>
<span className="topo-palette__meta">
{[peer.ip, t("topology.outsidePeersVia").replace("{{name}}", viaName)]
.filter(Boolean)
.join(" · ")}
</span>
</button>
</div>
</li>
);
})
)}
</ul>
<div className="topo-modal__foot">
<button
type="button"
className="btn btn--sm btn--ghost"
disabled={outsidePeersAdding}
onClick={onClose}
>
{t("topology.discoverClose")}
</button>
<button
type="button"
className="btn btn--sm"
disabled={outsidePeerSelectedIds.length === 0 || outsidePeersAdding}
onClick={() => void onAddOutsidePeers(outsidePeerSelectedIds)}
>
{outsidePeersAdding
? t("topology.addingNe")
: t("topology.addSelected").replace(
"{{count}}",
String(outsidePeerSelectedIds.length),
)}
</button>
</div>
</div>
</div>
);
}

View file

@ -0,0 +1,104 @@
import { useI18n } from "../../../i18n";
export type PlaceholderCreateDialogState = {
flowX: number;
flowY: number;
name: string;
ip_address: string;
};
export type PlaceholderCreateDialogProps = {
dialog: PlaceholderCreateDialogState | null;
busy: boolean;
onChange: (patch: Partial<Pick<PlaceholderCreateDialogState, "name" | "ip_address">>) => void;
onClose: () => void;
onSubmit: () => void;
};
export function PlaceholderCreateDialog({
dialog,
busy,
onChange,
onClose,
onSubmit,
}: PlaceholderCreateDialogProps) {
const { t } = useI18n();
if (!dialog) return null;
return (
<div
className="topo-modal"
role="dialog"
aria-modal="true"
aria-labelledby="topo-create-ne-title"
>
<div
className="topo-modal__backdrop"
onClick={() => {
if (busy) return;
onClose();
}}
/>
<div className="topo-modal__panel" style={{ maxWidth: 420 }}>
<div className="topo-modal__head">
<strong id="topo-create-ne-title">{t("topology.createNeTitle")}</strong>
<button
type="button"
className="btn btn--sm btn--ghost"
disabled={busy}
onClick={onClose}
>
{t("topology.discoverClose")}
</button>
</div>
<p className="panel__hint topo-modal__hint">{t("topology.createNeHint")}</p>
<div className="form-grid" style={{ padding: "0 16px 8px" }}>
<label>
<span className="form-label">
{t("topology.createNeName")}
<span className="form-label__required" aria-hidden="true">
{" "}
*
</span>
</span>
<input
autoFocus
value={dialog.name}
placeholder={t("topology.createNeNamePh")}
disabled={busy}
onChange={(e) => onChange({ name: e.target.value })}
onKeyDown={(e) => {
if (e.key === "Enter") void onSubmit();
}}
/>
</label>
<label>
<span className="form-label">{t("topology.createNeIp")}</span>
<input
value={dialog.ip_address}
placeholder={t("topology.createNeIpPh")}
disabled={busy}
onChange={(e) => onChange({ ip_address: e.target.value })}
onKeyDown={(e) => {
if (e.key === "Enter") void onSubmit();
}}
/>
</label>
</div>
<div className="topo-modal__foot">
<button type="button" className="btn btn--ghost" disabled={busy} onClick={onClose}>
{t("topology.discoverClose")}
</button>
<button
type="button"
className="btn btn--primary"
disabled={busy}
onClick={() => void onSubmit()}
>
{busy ? t("topology.createNeBusy") : t("topology.createNePlaceholder")}
</button>
</div>
</div>
</div>
);
}