mirror of
https://github.com/hansjone/netx.git
synced 2026-10-10 13:50:44 +08:00
Improve topology terminal open flow and edge selection feedback.
Managed NEs reuse stored credentials; unmanaged/LLDP placeholders get an IP and SSH/Telnet form. Selected links now show a clear highlight, and login page flare tweaks are included. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
306f90a21c
commit
728894a692
6 changed files with 423 additions and 84 deletions
|
|
@ -5,13 +5,16 @@ export function LoginShell({ children }: { children: ReactNode }) {
|
|||
return (
|
||||
<div className="login-page">
|
||||
<div className="login-page__space" aria-hidden="true">
|
||||
<div className="login-page__sun" />
|
||||
<div className="login-planet">
|
||||
<div className="login-planet__earth">
|
||||
<div className="login-planet__textures" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/* Sibling of space so blooms sit above the stage, under the login card. */}
|
||||
<div className="login-page__flares" aria-hidden="true">
|
||||
<span className="login-page__flare login-page__flare--a" />
|
||||
</div>
|
||||
<aside className="login-page__rail">{children}</aside>
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -35,12 +35,14 @@ import "@xyflow/react/dist/style.css";
|
|||
import {
|
||||
addTopologyViewNodes,
|
||||
createFabricManualEdge,
|
||||
createManagedNe,
|
||||
createTopologyFolder,
|
||||
createTopologyView,
|
||||
deleteTopologyFolder,
|
||||
deleteTopologyMap,
|
||||
fetchLldpDiscoverJob,
|
||||
fetchManagedNe,
|
||||
fetchManagedNeById,
|
||||
fetchTopologyGraph,
|
||||
fetchTopologyTree,
|
||||
fetchUmeNe,
|
||||
|
|
@ -50,6 +52,7 @@ import {
|
|||
removeTopologyViewNodes,
|
||||
searchFabricNodes,
|
||||
startLldpDiscover,
|
||||
updateManagedNe,
|
||||
updateTopologyFolder,
|
||||
updateTopologyMap,
|
||||
} from "../services/api";
|
||||
|
|
@ -276,6 +279,17 @@ type CtxMenu =
|
|||
| { kind: "edge"; id: string; x: number; y: number }
|
||||
| { kind: "selection"; x: number; y: number };
|
||||
|
||||
/** Complete / create login for unmanaged topology nodes (LLDP placeholders, orphans). */
|
||||
type TermConnectDialog = {
|
||||
neId: string;
|
||||
name: string;
|
||||
ip_address: string;
|
||||
port: number;
|
||||
protocol: "ssh" | "telnet";
|
||||
username: string;
|
||||
password: string;
|
||||
};
|
||||
|
||||
const TopoDisplayContext = createContext<TopoDisplayOpts>({
|
||||
hideIp: true,
|
||||
hideVendor: true,
|
||||
|
|
@ -550,8 +564,25 @@ function edgeMarker(stroke: string) {
|
|||
|
||||
function withEdgeVisual(edge: Edge, defaults: EdgeDefaults): Edge {
|
||||
const data = (edge.data || {}) as EdgeStyleData;
|
||||
const style = resolveEdgeStyle(data, defaults);
|
||||
return { ...edge, style, markerEnd: edgeMarker(style.stroke) };
|
||||
const base = resolveEdgeStyle(data, defaults);
|
||||
const selected = Boolean(edge.selected);
|
||||
// Inline stroke beats RF's default .selected CSS — bump width + glow so selection is obvious.
|
||||
const style = {
|
||||
...base,
|
||||
strokeWidth: selected ? Math.min(14, Number(base.strokeWidth || 2) + 2.5) : base.strokeWidth,
|
||||
...(selected
|
||||
? {
|
||||
filter:
|
||||
"drop-shadow(0 0 2.5px rgba(37, 99, 235, 0.95)) drop-shadow(0 0 1px rgba(15, 23, 42, 0.4))",
|
||||
}
|
||||
: { filter: undefined }),
|
||||
};
|
||||
return {
|
||||
...edge,
|
||||
style,
|
||||
markerEnd: edgeMarker(base.stroke),
|
||||
className: selected ? "topo-edge--selected" : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
function graphToFlow(
|
||||
|
|
@ -697,6 +728,8 @@ export function TopologyPage() {
|
|||
);
|
||||
const [sidebarCollapsed, setSidebarCollapsed] = useState(false);
|
||||
const [addNeOpen, setAddNeOpen] = useState(false);
|
||||
const [termDialog, setTermDialog] = useState<TermConnectDialog | null>(null);
|
||||
const [termBusy, setTermBusy] = useState(false);
|
||||
const [paletteSource, setPaletteSource] = useState<PaletteSource>("managed");
|
||||
const [paletteSelectedKeys, setPaletteSelectedKeys] = useState<string[]>([]);
|
||||
const [paletteAdding, setPaletteAdding] = useState(false);
|
||||
|
|
@ -1665,15 +1698,15 @@ export function TopologyPage() {
|
|||
const clearSelection = useCallback(() => {
|
||||
setSelectedEdgeId(null);
|
||||
setNodes((ns) => ns.map((n) => ({ ...n, selected: false })));
|
||||
setEdges((es) => es.map((e) => ({ ...e, selected: false })));
|
||||
setEdges((es) => es.map((e) => withEdgeVisual({ ...e, selected: false }, edgeDefaults)));
|
||||
connectClickRef.current = null;
|
||||
}, [setNodes, setEdges]);
|
||||
}, [setNodes, setEdges, edgeDefaults]);
|
||||
|
||||
const selectAllNodes = useCallback(() => {
|
||||
setSelectedEdgeId(null);
|
||||
setNodes((ns) => ns.map((n) => ({ ...n, selected: true })));
|
||||
setEdges((es) => es.map((e) => ({ ...e, selected: false })));
|
||||
}, [setNodes, setEdges]);
|
||||
setEdges((es) => es.map((e) => withEdgeVisual({ ...e, selected: false }, edgeDefaults)));
|
||||
}, [setNodes, setEdges, edgeDefaults]);
|
||||
|
||||
const removeSelected = useCallback(async () => {
|
||||
if (!mapId) return;
|
||||
|
|
@ -1806,15 +1839,48 @@ export function TopologyPage() {
|
|||
|
||||
const openWebcrtFor = (node: Node<NeNodeData> | null) => {
|
||||
closeCtxMenu();
|
||||
const managedId = node?.data.managed_ne_id;
|
||||
const umeId = node?.data.ume_ne_id;
|
||||
if (managedId) {
|
||||
if (!node) return;
|
||||
const managedId = String(node.data.managed_ne_id || "").trim();
|
||||
const umeId = String(node.data.ume_ne_id || "").trim();
|
||||
|
||||
const openManaged = (neId: string) => {
|
||||
openOrFocusModule({
|
||||
moduleId: "webcrt",
|
||||
path: `/webcrt?ne_id=${encodeURIComponent(managedId)}`,
|
||||
path: `/webcrt?ne_id=${encodeURIComponent(neId)}`,
|
||||
});
|
||||
};
|
||||
|
||||
if (managedId) {
|
||||
void (async () => {
|
||||
try {
|
||||
const ne = await fetchManagedNeById(managedId);
|
||||
const src = String(ne.source || "").trim().toLowerCase();
|
||||
const hasIp = Boolean(String(ne.ip_address || "").trim());
|
||||
// Ready inventory / WebCRT hosts: reuse stored credentials.
|
||||
// LLDP placeholders (empty IP) and other incomplete rows need a form.
|
||||
const needsSetup = !hasIp || src === "lldp";
|
||||
if (!needsSetup) {
|
||||
openManaged(managedId);
|
||||
return;
|
||||
}
|
||||
const hintIp = String(ne.ip_address || ne.source_ref || node.data.ne_ip || "").trim();
|
||||
const proto = String(ne.protocol || "ssh").toLowerCase() === "telnet" ? "telnet" : "ssh";
|
||||
setTermDialog({
|
||||
neId: managedId,
|
||||
name: String(ne.name || node.data.label || "").trim(),
|
||||
ip_address: hintIp,
|
||||
port: Number(ne.port) || (proto === "telnet" ? 23 : 22),
|
||||
protocol: proto,
|
||||
username: String(ne.username || "").trim(),
|
||||
password: "",
|
||||
});
|
||||
} catch (err) {
|
||||
showError(String(err));
|
||||
}
|
||||
})();
|
||||
return;
|
||||
}
|
||||
|
||||
if (umeId) {
|
||||
openOrFocusModule({
|
||||
moduleId: "webcrt",
|
||||
|
|
@ -1822,7 +1888,81 @@ export function TopologyPage() {
|
|||
});
|
||||
return;
|
||||
}
|
||||
showError(t("topology.noNeLink"));
|
||||
|
||||
// No inventory link — same as creating a managed NE, then open terminal.
|
||||
setTermDialog({
|
||||
neId: "",
|
||||
name: String(node.data.label || "").trim(),
|
||||
ip_address: String(node.data.ne_ip || "").trim(),
|
||||
port: 22,
|
||||
protocol: "ssh",
|
||||
username: "",
|
||||
password: "",
|
||||
});
|
||||
};
|
||||
|
||||
const submitTermDialog = async () => {
|
||||
if (!termDialog || termBusy) return;
|
||||
const ip = termDialog.ip_address.trim();
|
||||
if (!ip) {
|
||||
showError(t("topology.termConnect.ipRequired"));
|
||||
return;
|
||||
}
|
||||
const protocol = termDialog.protocol;
|
||||
const username = termDialog.username.trim();
|
||||
if (protocol === "ssh" && !username) {
|
||||
showError(t("topology.termConnect.userRequired"));
|
||||
return;
|
||||
}
|
||||
if (protocol === "ssh" && !termDialog.password) {
|
||||
showError(t("topology.termConnect.passwordRequired"));
|
||||
return;
|
||||
}
|
||||
const port = Number(termDialog.port) || (protocol === "telnet" ? 23 : 22);
|
||||
const name = termDialog.name.trim() || ip;
|
||||
setTermBusy(true);
|
||||
try {
|
||||
let neId = termDialog.neId;
|
||||
if (neId) {
|
||||
await updateManagedNe(neId, {
|
||||
name,
|
||||
ip_address: ip,
|
||||
port,
|
||||
protocol,
|
||||
username,
|
||||
...(protocol === "ssh" ? { password: termDialog.password } : {}),
|
||||
});
|
||||
setNodes((ns) =>
|
||||
ns.map((n) =>
|
||||
String(n.data.managed_ne_id || "") === neId
|
||||
? { ...n, data: { ...n.data, ne_ip: ip, label: name || n.data.label } }
|
||||
: n,
|
||||
),
|
||||
);
|
||||
} else {
|
||||
const created = await createManagedNe({
|
||||
name,
|
||||
vendor: "Other",
|
||||
device_type: "generic",
|
||||
ip_address: ip,
|
||||
port,
|
||||
protocol,
|
||||
username: username || (protocol === "telnet" ? "" : username),
|
||||
password: protocol === "ssh" ? termDialog.password : "",
|
||||
});
|
||||
neId = created.id;
|
||||
}
|
||||
setTermDialog(null);
|
||||
showOk(t("topology.termConnect.saved"));
|
||||
openOrFocusModule({
|
||||
moduleId: "webcrt",
|
||||
path: `/webcrt?ne_id=${encodeURIComponent(neId)}`,
|
||||
});
|
||||
} catch (err) {
|
||||
showError(String(err));
|
||||
} finally {
|
||||
setTermBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const openNeFor = (node: Node<NeNodeData> | null) => {
|
||||
|
|
@ -1909,9 +2049,9 @@ export function TopologyPage() {
|
|||
return { ...n, selected: n.id === nodeId };
|
||||
}),
|
||||
);
|
||||
setEdges((es) => es.map((e) => ({ ...e, selected: false })));
|
||||
setEdges((es) => es.map((e) => withEdgeVisual({ ...e, selected: false }, edgeDefaults)));
|
||||
},
|
||||
[setNodes, setEdges],
|
||||
[setNodes, setEdges, edgeDefaults],
|
||||
);
|
||||
|
||||
const locateNode = useCallback(
|
||||
|
|
@ -2080,9 +2220,11 @@ export function TopologyPage() {
|
|||
(edgeId: string) => {
|
||||
setSelectedEdgeId(edgeId);
|
||||
setNodes((ns) => ns.map((n) => ({ ...n, selected: false })));
|
||||
setEdges((es) => es.map((e) => ({ ...e, selected: e.id === edgeId })));
|
||||
setEdges((es) =>
|
||||
es.map((e) => withEdgeVisual({ ...e, selected: e.id === edgeId }, edgeDefaults)),
|
||||
);
|
||||
},
|
||||
[setNodes, setEdges],
|
||||
[setNodes, setEdges, edgeDefaults],
|
||||
);
|
||||
|
||||
const onNodeClick = useCallback(
|
||||
|
|
@ -3649,8 +3791,136 @@ export function TopologyPage() {
|
|||
</ul>
|
||||
) : null}
|
||||
|
||||
|
||||
|
||||
{termDialog ? (
|
||||
<div
|
||||
className="modal-backdrop"
|
||||
role="presentation"
|
||||
onClick={() => {
|
||||
if (!termBusy) setTermDialog(null);
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="modal"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="topo-term-connect-title"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<h3 id="topo-term-connect-title">
|
||||
{termDialog.neId ? t("topology.termConnect.title") : t("topology.termConnect.titleCreate")}
|
||||
</h3>
|
||||
<p className="form-hint">{t("topology.termConnect.hint")}</p>
|
||||
<div className="form-grid">
|
||||
<label>
|
||||
<span className="form-label">{t("topology.termConnect.name")}</span>
|
||||
<input
|
||||
value={termDialog.name}
|
||||
disabled={termBusy}
|
||||
onChange={(e) => setTermDialog({ ...termDialog, name: e.target.value })}
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
<span className="form-label">
|
||||
{t("topology.termConnect.protocol")}
|
||||
<span className="form-label__required" aria-hidden="true">
|
||||
{" "}
|
||||
*
|
||||
</span>
|
||||
</span>
|
||||
<select
|
||||
value={termDialog.protocol}
|
||||
disabled={termBusy}
|
||||
onChange={(e) => {
|
||||
const protocol = e.target.value === "telnet" ? "telnet" : "ssh";
|
||||
setTermDialog({
|
||||
...termDialog,
|
||||
protocol,
|
||||
port: protocol === "telnet" ? 23 : 22,
|
||||
});
|
||||
}}
|
||||
>
|
||||
<option value="ssh">SSH</option>
|
||||
<option value="telnet">Telnet</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
<span className="form-label">
|
||||
{t("topology.termConnect.ip")}
|
||||
<span className="form-label__required" aria-hidden="true">
|
||||
{" "}
|
||||
*
|
||||
</span>
|
||||
</span>
|
||||
<input
|
||||
required
|
||||
autoFocus
|
||||
value={termDialog.ip_address}
|
||||
disabled={termBusy}
|
||||
placeholder="192.168.1.1"
|
||||
onChange={(e) => setTermDialog({ ...termDialog, ip_address: e.target.value })}
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
<span className="form-label">{t("topology.termConnect.port")}</span>
|
||||
<input
|
||||
type="number"
|
||||
value={termDialog.port}
|
||||
disabled={termBusy}
|
||||
onChange={(e) =>
|
||||
setTermDialog({
|
||||
...termDialog,
|
||||
port: Number(e.target.value) || (termDialog.protocol === "telnet" ? 23 : 22),
|
||||
})
|
||||
}
|
||||
/>
|
||||
</label>
|
||||
{termDialog.protocol === "ssh" ? (
|
||||
<>
|
||||
<label>
|
||||
<span className="form-label">
|
||||
{t("topology.termConnect.username")}
|
||||
<span className="form-label__required" aria-hidden="true">
|
||||
{" "}
|
||||
*
|
||||
</span>
|
||||
</span>
|
||||
<input
|
||||
required
|
||||
value={termDialog.username}
|
||||
disabled={termBusy}
|
||||
onChange={(e) => setTermDialog({ ...termDialog, username: e.target.value })}
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
<span className="form-label">
|
||||
{t("topology.termConnect.password")}
|
||||
<span className="form-label__required" aria-hidden="true">
|
||||
{" "}
|
||||
*
|
||||
</span>
|
||||
</span>
|
||||
<input
|
||||
type="password"
|
||||
required
|
||||
value={termDialog.password}
|
||||
disabled={termBusy}
|
||||
onChange={(e) => setTermDialog({ ...termDialog, password: e.target.value })}
|
||||
/>
|
||||
</label>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="modal__actions">
|
||||
<button type="button" disabled={termBusy} onClick={() => setTermDialog(null)}>
|
||||
{t("topology.termConnect.cancel")}
|
||||
</button>
|
||||
<button type="button" disabled={termBusy} onClick={() => void submitTermDialog()}>
|
||||
{termBusy ? t("topology.termConnect.connecting") : t("topology.termConnect.connect")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue