mirror of
https://github.com/hansjone/netx.git
synced 2026-10-09 03:10:46 +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
|
|
@ -8,9 +8,10 @@ from fastapi import HTTPException
|
||||||
from sqlalchemy import or_
|
from sqlalchemy import or_
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from .device_types import SUPPORTED_DEVICE_TYPES
|
from .device_types import LLDP_DISCOVERED_NE_SOURCE, SUPPORTED_DEVICE_TYPES, SUPPORTED_VENDORS
|
||||||
from .models import ManagedNE
|
from .models import ManagedNE
|
||||||
from .ne_crypto import encrypt_secret
|
from .ne_crypto import encrypt_secret
|
||||||
|
from .ne_hop_templates import default_bastion_username_template, default_hop_command_template
|
||||||
from .ne_schemas import (
|
from .ne_schemas import (
|
||||||
BatchAccountConfig,
|
BatchAccountConfig,
|
||||||
HopProxyConfig,
|
HopProxyConfig,
|
||||||
|
|
@ -21,6 +22,8 @@ from .ne_schemas import (
|
||||||
from .ne_service_common import (
|
from .ne_service_common import (
|
||||||
_apply_hop_create,
|
_apply_hop_create,
|
||||||
_apply_hop_update,
|
_apply_hop_update,
|
||||||
|
_normalize_hop_target_auth_mode,
|
||||||
|
_normalize_hop_vendor,
|
||||||
_normalize_ip,
|
_normalize_ip,
|
||||||
_normalize_protocol,
|
_normalize_protocol,
|
||||||
_normalize_vendor,
|
_normalize_vendor,
|
||||||
|
|
@ -167,7 +170,16 @@ def update_managed_ne(db: Session, ne_id: str, body: ManagedNeUpdate) -> Managed
|
||||||
)
|
)
|
||||||
if any(k in data for k in hop_keys):
|
if any(k in data for k in hop_keys):
|
||||||
_apply_hop_update(row, data)
|
_apply_hop_update(row, data)
|
||||||
|
# Filling IP on an LLDP placeholder promotes it into real inventory.
|
||||||
|
if "ip_address" in data and str(row.ip_address or "").strip():
|
||||||
|
if str(row.source or "").strip().lower() in {LLDP_DISCOVERED_NE_SOURCE, "lldp"}:
|
||||||
|
row.source = ""
|
||||||
row.updated_at = _now()
|
row.updated_at = _now()
|
||||||
|
# Keep linked fabric node identity in sync (name / IP / vendor).
|
||||||
|
if any(k in data for k in ("ip_address", "name", "vendor", "device_type")):
|
||||||
|
from .topology_fabric_nodes import ensure_fabric_node_for_managed
|
||||||
|
|
||||||
|
ensure_fabric_node_for_managed(db, row)
|
||||||
db.commit()
|
db.commit()
|
||||||
db.refresh(row)
|
db.refresh(row)
|
||||||
return row_to_out(row)
|
return row_to_out(row)
|
||||||
|
|
|
||||||
|
|
@ -1388,6 +1388,24 @@ const en = {
|
||||||
edgeDefaults: "Default edge styles",
|
edgeDefaults: "Default edge styles",
|
||||||
edgeDefaultsReset: "Factory reset",
|
edgeDefaultsReset: "Factory reset",
|
||||||
noNeLink: "Not linked to a managed NE",
|
noNeLink: "Not linked to a managed NE",
|
||||||
|
termConnect: {
|
||||||
|
title: "Complete login",
|
||||||
|
titleCreate: "Create NE and connect",
|
||||||
|
hint: "This node is not managed or lacks credentials. Enter IP and protocol to connect (same as creating/completing an NE).",
|
||||||
|
name: "Name",
|
||||||
|
ip: "IP address",
|
||||||
|
port: "Port",
|
||||||
|
protocol: "Protocol",
|
||||||
|
username: "Username",
|
||||||
|
password: "Password",
|
||||||
|
connect: "Save and open terminal",
|
||||||
|
connecting: "Connecting…",
|
||||||
|
cancel: "Cancel",
|
||||||
|
ipRequired: "IP address is required",
|
||||||
|
userRequired: "Username is required for SSH",
|
||||||
|
passwordRequired: "Password is required for SSH",
|
||||||
|
saved: "Login details saved",
|
||||||
|
},
|
||||||
edgeManual: "Manual",
|
edgeManual: "Manual",
|
||||||
edgeDiscovered: "Discovered",
|
edgeDiscovered: "Discovered",
|
||||||
edgeStale: "Missing",
|
edgeStale: "Missing",
|
||||||
|
|
|
||||||
|
|
@ -1382,6 +1382,24 @@ const zh = {
|
||||||
edgeDefaults: "默认链路样式",
|
edgeDefaults: "默认链路样式",
|
||||||
edgeDefaultsReset: "恢复出厂",
|
edgeDefaultsReset: "恢复出厂",
|
||||||
noNeLink: "未关联托管网元",
|
noNeLink: "未关联托管网元",
|
||||||
|
termConnect: {
|
||||||
|
title: "补全登录信息",
|
||||||
|
titleCreate: "新建网元并登录",
|
||||||
|
hint: "该网元尚未纳管或缺少登录信息,请填写 IP 与协议后连接(等同新建/补全网元)。",
|
||||||
|
name: "名称",
|
||||||
|
ip: "IP 地址",
|
||||||
|
port: "端口",
|
||||||
|
protocol: "协议",
|
||||||
|
username: "用户名",
|
||||||
|
password: "密码",
|
||||||
|
connect: "保存并打开终端",
|
||||||
|
connecting: "连接中…",
|
||||||
|
cancel: "取消",
|
||||||
|
ipRequired: "请填写 IP 地址",
|
||||||
|
userRequired: "SSH 请填写用户名",
|
||||||
|
passwordRequired: "SSH 请填写密码",
|
||||||
|
saved: "登录信息已保存",
|
||||||
|
},
|
||||||
edgeManual: "人工",
|
edgeManual: "人工",
|
||||||
edgeDiscovered: "发现",
|
edgeDiscovered: "发现",
|
||||||
edgeStale: "未发现",
|
edgeStale: "未发现",
|
||||||
|
|
|
||||||
|
|
@ -6667,6 +6667,25 @@ pre {
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Selected link: glow + wider interaction halo (inline stroke otherwise hides RF defaults). */
|
||||||
|
.topo-canvas .react-flow__edge.selected .react-flow__edge-interaction,
|
||||||
|
.topo-canvas .react-flow__edge.topo-edge--selected .react-flow__edge-interaction {
|
||||||
|
stroke: #2563eb !important;
|
||||||
|
stroke-width: 14px !important;
|
||||||
|
stroke-opacity: 0.28 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.topo-canvas .react-flow__edge.selected .react-flow__edge-path,
|
||||||
|
.topo-canvas .react-flow__edge.topo-edge--selected .react-flow__edge-path {
|
||||||
|
stroke-linecap: round;
|
||||||
|
}
|
||||||
|
|
||||||
|
.topo-canvas .react-flow__edge.selected .react-flow__edge-text,
|
||||||
|
.topo-canvas .react-flow__edge.topo-edge--selected .react-flow__edge-text {
|
||||||
|
fill: #1d4ed8;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
.topo-node__icon {
|
.topo-node__icon {
|
||||||
display: block;
|
display: block;
|
||||||
width: 56px;
|
width: 56px;
|
||||||
|
|
@ -6797,31 +6816,44 @@ pre {
|
||||||
90deg,
|
90deg,
|
||||||
transparent 0%,
|
transparent 0%,
|
||||||
transparent 52%,
|
transparent 52%,
|
||||||
rgba(0, 5, 12, 0.28) 70%,
|
rgba(0, 5, 12, 0.18) 70%,
|
||||||
rgba(0, 5, 12, 0.68) 88%,
|
rgba(0, 5, 12, 0.48) 88%,
|
||||||
rgba(0, 5, 12, 0.88) 100%
|
rgba(0, 5, 12, 0.72) 100%
|
||||||
),
|
),
|
||||||
linear-gradient(180deg, rgba(0, 0, 0, 0.1) 0%, transparent 30%, transparent 72%, rgba(0, 0, 0, 0.38) 100%);
|
linear-gradient(180deg, rgba(0, 0, 0, 0.1) 0%, transparent 30%, transparent 72%, rgba(0, 0, 0, 0.38) 100%);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Distant sun — upper-left, feeds the lit limb. */
|
/* Tiny sun flare — upper-left void. */
|
||||||
.login-page__sun {
|
.login-page__flares {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
z-index: 4;
|
||||||
|
pointer-events: none;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-page__flare {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
z-index: 0;
|
|
||||||
top: -2%;
|
|
||||||
left: -4%;
|
|
||||||
width: min(520px, 42vw);
|
|
||||||
height: min(520px, 42vw);
|
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
background: radial-gradient(
|
opacity: 0.85;
|
||||||
circle at 55% 55%,
|
}
|
||||||
rgba(160, 205, 255, 0.26) 0%,
|
|
||||||
rgba(90, 150, 240, 0.12) 32%,
|
.login-page__flare--a {
|
||||||
rgba(50, 110, 210, 0.04) 55%,
|
width: 44px;
|
||||||
transparent 72%
|
height: 44px;
|
||||||
);
|
left: 7%;
|
||||||
filter: blur(8px);
|
top: 11%;
|
||||||
|
background:
|
||||||
|
radial-gradient(
|
||||||
|
circle at 50% 50%,
|
||||||
|
rgba(245, 250, 255, 0.92) 0%,
|
||||||
|
rgba(170, 210, 255, 0.48) 28%,
|
||||||
|
rgba(90, 150, 245, 0.14) 55%,
|
||||||
|
transparent 78%
|
||||||
|
);
|
||||||
|
box-shadow: 0 0 16px 3px rgba(120, 180, 255, 0.2);
|
||||||
|
animation: login-flare-pulse-a 10s ease-in-out infinite alternate;
|
||||||
}
|
}
|
||||||
|
|
||||||
.login-planet {
|
.login-planet {
|
||||||
|
|
@ -6834,24 +6866,6 @@ pre {
|
||||||
animation: login-planet-enter 1.8s cubic-bezier(0.76, -0.46, 0.37, 0.99) both;
|
animation: login-planet-enter 1.8s cubic-bezier(0.76, -0.46, 0.37, 0.99) both;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Soft blue atmosphere outside the clipped ball — fades into space. */
|
|
||||||
.login-planet::after {
|
|
||||||
content: "";
|
|
||||||
position: absolute;
|
|
||||||
z-index: 0;
|
|
||||||
inset: -10%;
|
|
||||||
border-radius: 50%;
|
|
||||||
pointer-events: none;
|
|
||||||
background: radial-gradient(
|
|
||||||
120% 105% at 28% 24%,
|
|
||||||
transparent 48%,
|
|
||||||
rgba(40, 100, 210, 0.38) 64%,
|
|
||||||
rgba(55, 125, 230, 0.18) 74%,
|
|
||||||
transparent 86%
|
|
||||||
);
|
|
||||||
filter: blur(10px);
|
|
||||||
}
|
|
||||||
|
|
||||||
.login-planet__earth {
|
.login-planet__earth {
|
||||||
position: relative;
|
position: relative;
|
||||||
z-index: 1;
|
z-index: 1;
|
||||||
|
|
@ -6859,16 +6873,13 @@ pre {
|
||||||
height: 100%;
|
height: 100%;
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
/* Feather the limb into space — no hard circular cut. */
|
/* Soft dissolve into space — no outer glow (Earth does not emit light). */
|
||||||
-webkit-mask-image: radial-gradient(circle at 50% 50%, #000 86%, transparent 100%);
|
-webkit-mask-image: radial-gradient(circle at 50% 50%, #000 87%, transparent 100%);
|
||||||
mask-image: radial-gradient(circle at 50% 50%, #000 86%, transparent 100%);
|
mask-image: radial-gradient(circle at 50% 50%, #000 87%, transparent 100%);
|
||||||
box-shadow:
|
|
||||||
-16px -18px 40px 8px rgba(60, 130, 230, 0.35),
|
|
||||||
-6px -8px 16px 2px rgba(80, 150, 240, 0.22);
|
|
||||||
transform-origin: center center;
|
transform-origin: center center;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Surface light falloff: bright TL → soft mid → deeper BR. */
|
/* Gentle volume shade — cooler blue midtones. */
|
||||||
.login-planet__earth::before {
|
.login-planet__earth::before {
|
||||||
content: "";
|
content: "";
|
||||||
position: absolute;
|
position: absolute;
|
||||||
|
|
@ -6878,19 +6889,16 @@ pre {
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
background: linear-gradient(
|
background: linear-gradient(
|
||||||
148deg,
|
148deg,
|
||||||
rgba(120, 175, 255, 0.22) 0%,
|
rgba(70, 130, 210, 0.16) 0%,
|
||||||
rgba(70, 130, 220, 0.1) 18%,
|
rgba(45, 100, 185, 0.08) 20%,
|
||||||
rgba(40, 90, 180, 0.04) 34%,
|
transparent 42%,
|
||||||
transparent 48%,
|
rgba(0, 12, 36, 0.22) 72%,
|
||||||
rgba(0, 12, 36, 0.16) 68%,
|
rgba(0, 8, 24, 0.42) 100%
|
||||||
rgba(0, 8, 24, 0.36) 86%,
|
|
||||||
rgba(0, 4, 16, 0.5) 100%
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/*
|
/*
|
||||||
* Blue atmosphere crescent on the sun limb + soft edge wash that
|
* Blue atmospheric limb (scattered sunlight) — stays on the sphere only.
|
||||||
* dissolves with the feathered mask.
|
|
||||||
*/
|
*/
|
||||||
.login-planet__earth::after {
|
.login-planet__earth::after {
|
||||||
content: "";
|
content: "";
|
||||||
|
|
@ -6901,23 +6909,23 @@ pre {
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
background:
|
background:
|
||||||
radial-gradient(
|
radial-gradient(
|
||||||
130% 100% at 6% 10%,
|
120% 95% at 10% 12%,
|
||||||
rgba(90, 160, 255, 0.62) 0%,
|
rgba(55, 130, 230, 0.42) 0%,
|
||||||
rgba(50, 120, 230, 0.36) 24%,
|
rgba(40, 110, 210, 0.22) 28%,
|
||||||
rgba(30, 90, 200, 0.12) 46%,
|
rgba(30, 90, 185, 0.08) 48%,
|
||||||
transparent 64%
|
transparent 66%
|
||||||
),
|
),
|
||||||
radial-gradient(
|
radial-gradient(
|
||||||
circle at 50% 50%,
|
circle at 50% 50%,
|
||||||
transparent 72%,
|
transparent 74%,
|
||||||
rgba(40, 105, 215, 0.16) 86%,
|
rgba(40, 105, 200, 0.14) 88%,
|
||||||
rgba(55, 130, 235, 0.32) 93%,
|
rgba(50, 120, 215, 0.22) 94%,
|
||||||
rgba(70, 150, 245, 0.12) 98%,
|
|
||||||
transparent 100%
|
transparent 100%
|
||||||
);
|
);
|
||||||
box-shadow:
|
box-shadow:
|
||||||
inset 28px 32px 36px 4px rgba(70, 140, 235, 0.16),
|
inset 0 0 20px 4px rgba(45, 110, 200, 0.28),
|
||||||
inset -22px -26px 48px 12px rgba(0, 6, 20, 0.28);
|
inset 22px 26px 32px 4px rgba(60, 130, 220, 0.12),
|
||||||
|
inset -20px -24px 40px 10px rgba(0, 6, 20, 0.3);
|
||||||
animation: login-earth-inner 2.2s ease-in-out 0.7s both;
|
animation: login-earth-inner 2.2s ease-in-out 0.7s both;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -6938,7 +6946,7 @@ pre {
|
||||||
|
|
||||||
.login-page__rail {
|
.login-page__rail {
|
||||||
position: relative;
|
position: relative;
|
||||||
z-index: 3;
|
z-index: 5;
|
||||||
grid-column: 2;
|
grid-column: 2;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|
@ -7215,16 +7223,26 @@ pre {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@keyframes login-flare-pulse-a {
|
||||||
|
from { opacity: 0.7; transform: scale(0.96); }
|
||||||
|
to { opacity: 1; transform: scale(1.08); }
|
||||||
|
}
|
||||||
|
|
||||||
@media (prefers-reduced-motion: reduce) {
|
@media (prefers-reduced-motion: reduce) {
|
||||||
/* Keep ambient earth spin; only drop entrance flourishes. */
|
/* Keep ambient earth spin; only drop entrance flourishes. */
|
||||||
.login-planet,
|
.login-planet,
|
||||||
.login-planet__earth::after,
|
.login-planet__earth::after,
|
||||||
.login-card,
|
.login-card,
|
||||||
.login-card__brand,
|
.login-card__brand,
|
||||||
.login-card__error {
|
.login-card__error,
|
||||||
|
.login-page__flare {
|
||||||
animation: none !important;
|
animation: none !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.login-page__flare {
|
||||||
|
opacity: 0.85;
|
||||||
|
}
|
||||||
|
|
||||||
.login-planet {
|
.login-planet {
|
||||||
transform: rotate(5deg);
|
transform: rotate(5deg);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -5,13 +5,16 @@ export function LoginShell({ children }: { children: ReactNode }) {
|
||||||
return (
|
return (
|
||||||
<div className="login-page">
|
<div className="login-page">
|
||||||
<div className="login-page__space" aria-hidden="true">
|
<div className="login-page__space" aria-hidden="true">
|
||||||
<div className="login-page__sun" />
|
|
||||||
<div className="login-planet">
|
<div className="login-planet">
|
||||||
<div className="login-planet__earth">
|
<div className="login-planet__earth">
|
||||||
<div className="login-planet__textures" />
|
<div className="login-planet__textures" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</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>
|
<aside className="login-page__rail">{children}</aside>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -35,12 +35,14 @@ import "@xyflow/react/dist/style.css";
|
||||||
import {
|
import {
|
||||||
addTopologyViewNodes,
|
addTopologyViewNodes,
|
||||||
createFabricManualEdge,
|
createFabricManualEdge,
|
||||||
|
createManagedNe,
|
||||||
createTopologyFolder,
|
createTopologyFolder,
|
||||||
createTopologyView,
|
createTopologyView,
|
||||||
deleteTopologyFolder,
|
deleteTopologyFolder,
|
||||||
deleteTopologyMap,
|
deleteTopologyMap,
|
||||||
fetchLldpDiscoverJob,
|
fetchLldpDiscoverJob,
|
||||||
fetchManagedNe,
|
fetchManagedNe,
|
||||||
|
fetchManagedNeById,
|
||||||
fetchTopologyGraph,
|
fetchTopologyGraph,
|
||||||
fetchTopologyTree,
|
fetchTopologyTree,
|
||||||
fetchUmeNe,
|
fetchUmeNe,
|
||||||
|
|
@ -50,6 +52,7 @@ import {
|
||||||
removeTopologyViewNodes,
|
removeTopologyViewNodes,
|
||||||
searchFabricNodes,
|
searchFabricNodes,
|
||||||
startLldpDiscover,
|
startLldpDiscover,
|
||||||
|
updateManagedNe,
|
||||||
updateTopologyFolder,
|
updateTopologyFolder,
|
||||||
updateTopologyMap,
|
updateTopologyMap,
|
||||||
} from "../services/api";
|
} from "../services/api";
|
||||||
|
|
@ -276,6 +279,17 @@ type CtxMenu =
|
||||||
| { kind: "edge"; id: string; x: number; y: number }
|
| { kind: "edge"; id: string; x: number; y: number }
|
||||||
| { kind: "selection"; 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>({
|
const TopoDisplayContext = createContext<TopoDisplayOpts>({
|
||||||
hideIp: true,
|
hideIp: true,
|
||||||
hideVendor: true,
|
hideVendor: true,
|
||||||
|
|
@ -550,8 +564,25 @@ function edgeMarker(stroke: string) {
|
||||||
|
|
||||||
function withEdgeVisual(edge: Edge, defaults: EdgeDefaults): Edge {
|
function withEdgeVisual(edge: Edge, defaults: EdgeDefaults): Edge {
|
||||||
const data = (edge.data || {}) as EdgeStyleData;
|
const data = (edge.data || {}) as EdgeStyleData;
|
||||||
const style = resolveEdgeStyle(data, defaults);
|
const base = resolveEdgeStyle(data, defaults);
|
||||||
return { ...edge, style, markerEnd: edgeMarker(style.stroke) };
|
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(
|
function graphToFlow(
|
||||||
|
|
@ -697,6 +728,8 @@ export function TopologyPage() {
|
||||||
);
|
);
|
||||||
const [sidebarCollapsed, setSidebarCollapsed] = useState(false);
|
const [sidebarCollapsed, setSidebarCollapsed] = useState(false);
|
||||||
const [addNeOpen, setAddNeOpen] = 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 [paletteSource, setPaletteSource] = useState<PaletteSource>("managed");
|
||||||
const [paletteSelectedKeys, setPaletteSelectedKeys] = useState<string[]>([]);
|
const [paletteSelectedKeys, setPaletteSelectedKeys] = useState<string[]>([]);
|
||||||
const [paletteAdding, setPaletteAdding] = useState(false);
|
const [paletteAdding, setPaletteAdding] = useState(false);
|
||||||
|
|
@ -1665,15 +1698,15 @@ export function TopologyPage() {
|
||||||
const clearSelection = useCallback(() => {
|
const clearSelection = useCallback(() => {
|
||||||
setSelectedEdgeId(null);
|
setSelectedEdgeId(null);
|
||||||
setNodes((ns) => ns.map((n) => ({ ...n, selected: false })));
|
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;
|
connectClickRef.current = null;
|
||||||
}, [setNodes, setEdges]);
|
}, [setNodes, setEdges, edgeDefaults]);
|
||||||
|
|
||||||
const selectAllNodes = useCallback(() => {
|
const selectAllNodes = useCallback(() => {
|
||||||
setSelectedEdgeId(null);
|
setSelectedEdgeId(null);
|
||||||
setNodes((ns) => ns.map((n) => ({ ...n, selected: true })));
|
setNodes((ns) => ns.map((n) => ({ ...n, selected: true })));
|
||||||
setEdges((es) => es.map((e) => ({ ...e, selected: false })));
|
setEdges((es) => es.map((e) => withEdgeVisual({ ...e, selected: false }, edgeDefaults)));
|
||||||
}, [setNodes, setEdges]);
|
}, [setNodes, setEdges, edgeDefaults]);
|
||||||
|
|
||||||
const removeSelected = useCallback(async () => {
|
const removeSelected = useCallback(async () => {
|
||||||
if (!mapId) return;
|
if (!mapId) return;
|
||||||
|
|
@ -1806,15 +1839,48 @@ export function TopologyPage() {
|
||||||
|
|
||||||
const openWebcrtFor = (node: Node<NeNodeData> | null) => {
|
const openWebcrtFor = (node: Node<NeNodeData> | null) => {
|
||||||
closeCtxMenu();
|
closeCtxMenu();
|
||||||
const managedId = node?.data.managed_ne_id;
|
if (!node) return;
|
||||||
const umeId = node?.data.ume_ne_id;
|
const managedId = String(node.data.managed_ne_id || "").trim();
|
||||||
if (managedId) {
|
const umeId = String(node.data.ume_ne_id || "").trim();
|
||||||
|
|
||||||
|
const openManaged = (neId: string) => {
|
||||||
openOrFocusModule({
|
openOrFocusModule({
|
||||||
moduleId: "webcrt",
|
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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (umeId) {
|
if (umeId) {
|
||||||
openOrFocusModule({
|
openOrFocusModule({
|
||||||
moduleId: "webcrt",
|
moduleId: "webcrt",
|
||||||
|
|
@ -1822,7 +1888,81 @@ export function TopologyPage() {
|
||||||
});
|
});
|
||||||
return;
|
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) => {
|
const openNeFor = (node: Node<NeNodeData> | null) => {
|
||||||
|
|
@ -1909,9 +2049,9 @@ export function TopologyPage() {
|
||||||
return { ...n, selected: n.id === nodeId };
|
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(
|
const locateNode = useCallback(
|
||||||
|
|
@ -2080,9 +2220,11 @@ export function TopologyPage() {
|
||||||
(edgeId: string) => {
|
(edgeId: string) => {
|
||||||
setSelectedEdgeId(edgeId);
|
setSelectedEdgeId(edgeId);
|
||||||
setNodes((ns) => ns.map((n) => ({ ...n, selected: false })));
|
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(
|
const onNodeClick = useCallback(
|
||||||
|
|
@ -3649,8 +3791,136 @@ export function TopologyPage() {
|
||||||
</ul>
|
</ul>
|
||||||
) : null}
|
) : 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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue