From 3c9a38307113dce748bca5e3276f04f3d88913f2 Mon Sep 17 00:00:00 2001 From: oliver Date: Sun, 6 Sep 2026 01:46:07 +0800 Subject: [PATCH] Ship capability groups (nms/common/topology) with shared netx skills. One group maps to one skill and tools-*; rename NMS tools to *Nms*; sync playbooks from netx/skills. Co-authored-by: Cursor --- .gitignore | 3 + README.md | 8 +- cordis.patch.yml | 11 +- docs/INSTALL.md | 11 +- docs/PORTING.md | 15 +- docs/TOOL_MAP.md | 50 +- lib/agent-tools-common.js | 1783 ++++++++++++++++ lib/agent-tools-nms.js | 1783 ++++++++++++++++ lib/agent-tools-topology.js | 1783 ++++++++++++++++ lib/agent-tools.js | 1377 ++++++++++-- lib/client.js | 430 +++- lib/index.js | 1842 ++++++++++++++++- package.json | 12 +- presets/netxops/PERSONA.md | 9 +- presets/netxops/agent.cordis.yml | 23 +- presets/netxops/preset.yml | 2 +- presets/netxops/skills/README.md | 10 + .../skills/common/netx-common/SKILL.md | 91 + presets/netxops/skills/nms/netx-nms/SKILL.md | 70 + .../netxops/skills/nms/netx-nms/reference.md | 20 + .../ops-netx-managed-ne-playbook/SKILL.md | 107 - .../skills/ops-netx-ume-playbook/SKILL.md | 116 -- .../skills/ops-netx-ume-playbook/reference.md | 58 - .../skills/topology/netx-topology/SKILL.md | 378 ++++ scripts/build-host.mjs | 3 + scripts/sync-skills-from-netx.ps1 | 47 + src/agent-tools-common.ts | 15 + src/agent-tools-nms.ts | 16 + src/agent-tools-topology.ts | 15 + src/agent-tools.ts | 39 +- src/client/NetxopsCard.tsx | 267 ++- src/client/controller.ts | 82 +- src/client/im-delivery-catalog.ts | 98 + src/client/locales.ts | 55 +- src/client/styles.ts | 4 + src/index.ts | 151 +- src/netx/alarm-push.ts | 4 +- src/netx/capability-groups.ts | 176 ++ src/netx/group-skills.ts | 196 ++ src/netx/group-tools-plugin.ts | 107 + src/netx/handlers.ts | 34 +- src/netx/http.ts | 3 + src/netx/tools.ts | 395 +++- src/netx/topology-handlers.ts | 668 ++++++ test/alarm-push.test.mjs | 1 + test/capability-groups.test.mjs | 70 + test/group-skills.test.mjs | 32 + 47 files changed, 11790 insertions(+), 680 deletions(-) create mode 100644 lib/agent-tools-common.js create mode 100644 lib/agent-tools-nms.js create mode 100644 lib/agent-tools-topology.js create mode 100644 presets/netxops/skills/README.md create mode 100644 presets/netxops/skills/common/netx-common/SKILL.md create mode 100644 presets/netxops/skills/nms/netx-nms/SKILL.md create mode 100644 presets/netxops/skills/nms/netx-nms/reference.md delete mode 100644 presets/netxops/skills/ops-netx-managed-ne-playbook/SKILL.md delete mode 100644 presets/netxops/skills/ops-netx-ume-playbook/SKILL.md delete mode 100644 presets/netxops/skills/ops-netx-ume-playbook/reference.md create mode 100644 presets/netxops/skills/topology/netx-topology/SKILL.md create mode 100644 scripts/sync-skills-from-netx.ps1 create mode 100644 src/agent-tools-common.ts create mode 100644 src/agent-tools-nms.ts create mode 100644 src/agent-tools-topology.ts create mode 100644 src/client/im-delivery-catalog.ts create mode 100644 src/netx/capability-groups.ts create mode 100644 src/netx/group-skills.ts create mode 100644 src/netx/group-tools-plugin.ts create mode 100644 src/netx/topology-handlers.ts create mode 100644 test/capability-groups.test.mjs create mode 100644 test/group-skills.test.mjs diff --git a/.gitignore b/.gitignore index 630bb13..0d4b35e 100644 --- a/.gitignore +++ b/.gitignore @@ -4,6 +4,9 @@ dist/ lib/* !lib/index.js !lib/agent-tools.js +!lib/agent-tools-nms.js +!lib/agent-tools-common.js +!lib/agent-tools-topology.js !lib/client.js *.log .DS_Store diff --git a/README.md b/README.md index a7b2775..4e79a38 100644 --- a/README.md +++ b/README.md @@ -15,20 +15,22 @@ dsh web Then: -1. Settings → **Plugins** → **Netx Ops** → API URL / token +1. Settings → **Plugins** → **Netx Ops** → API URL / token / capability groups 2. Optional: enable **关键告警推送** — DSH dials out to netx; choose **Deliver to DSH session** and/or **Deliver to WhatsApp / IM** (paste `botId`+`targetId` from IM 投递设置). The card shows live WSS status. 3. Settings → **Agent presets** → Custom → **Netx Ops** (installed automatically on first host activate) 4. New session → choose **Netx Ops** Also need: a reachable **netx API** (no local `pip install netx_mcp`). Details: [docs/INSTALL.md](docs/INSTALL.md). +**Skills:** edit only in the sibling **netx** repo `skills/` tree, then `powershell -File .\scripts\sync-skills-from-netx.ps1` before publish. Linked local installs prefer `../netx/skills` or `NETX_SKILLS_ROOT` at runtime. + ## What is coupled | In the plugin | Outside (data / runtime) | |---------------|---------------------------| -| `netx__*` REST tools (Ops preset only) + settings + credentials | netx HTTP API (URL + token) | +| `netx__*` REST tools (groups: nms / common / topology; one group ↔ one skill; optional host publish + `tools-*`) + settings + credentials | netx HTTP API (URL + token) | | Optional key-alarm push (WSS client → sticky DSH session) | netx `/v1/integrations/dsh-alarm/ws` hub | -| Persona + UME / managed-NE skills | | +| Persona + skills (`netx-nms` / `netx-common` / `netx-topology`) | | | Agent preset auto-install to `~/.dsh/.agent-presets` | | MCP (`python -m netx_mcp`) remains available for OpenClaw / other hosts — not required for DSH. diff --git a/cordis.patch.yml b/cordis.patch.yml index 2f3cbdb..dbfe78d 100644 --- a/cordis.patch.yml +++ b/cordis.patch.yml @@ -1,7 +1,9 @@ # Host-plane Netx Ops bridge: settings + credentials → connection publish. # Ops-scoped `netx__*` tools mount from the agent preset (`dsh-netxops/tools`). +# Capability groups: nms + common in preset; topology / public off. +# Rule: one group ↔ one skill (netx-nms / netx-common / netx-topology). # -# After `dsh plugin add` / link, open Settings → Plugins to edit apiUrl / lang +# After `dsh plugin add` / link, open Settings → Plugins to edit apiUrl / lang / groups # (settings card client half; see docs/INSTALL.md). Token: credential NETX_API_TOKEN. - insert: @@ -10,6 +12,7 @@ config: apiUrl: http://127.0.0.1:8890 lang: zh + nmsProvider: zte-ume tokenCredentialRef: NETX_API_TOKEN toolCallTimeoutMs: 120000 installAgentPreset: true @@ -18,3 +21,9 @@ alarmDeliverIm: false imBotId: '' imTargetId: '' + groupNmsInPreset: true + groupNmsPublic: false + groupCommonInPreset: true + groupCommonPublic: false + groupTopologyInPreset: false + groupTopologyPublic: false diff --git a/docs/INSTALL.md b/docs/INSTALL.md index 25ed50d..badbda3 100644 --- a/docs/INSTALL.md +++ b/docs/INSTALL.md @@ -6,9 +6,9 @@ One install wires **all of**: | Piece | How you use it | |-------|----------------| -| Host tools | Ops-preset-scoped `netx__*` → netx REST (Bearer token); other presets do not see them | -| Plugins card | Settings → Plugins → **Netx Ops** (URL / lang / token) | -| Agent preset + skills | Settings → Agent presets → **Custom → Netx Ops** (copied into `~/.dsh/.agent-presets` on first boot) | +| Host tools | Capability groups **nms / common / topology** (one group ↔ one skill). Default nms+common in Ops preset; topology and all public off. Other agents may mount `dsh-netxops/tools-*` | +| Plugins card | Settings → Plugins → **Netx Ops** (URL / lang / token / capability groups) | +| Agent preset + skills | Settings → Agent presets → **Custom → Netx Ops** (copied into `~/.dsh/.agent-presets` on first boot); playbooks follow the same group toggles | You do **not** run `link-preset.ps1` for normal use. That script is only a manual fallback. @@ -30,7 +30,8 @@ dsh web # or: pnpm dsh web 1. **Settings → Plugins → Netx Ops** → API URL (+ token if the field is enabled). Token fallback: `scripts/set-netx-token.ps1` / `.sh`. - Optional: enable **关键告警推送** so this DSH dials `ws(s):///v1/integrations/dsh-alarm/ws`. Choose **投递到 DSH 会话** and/or **投递到 WhatsApp / IM**. For IM, install `dsh-im-ops`, create a delivery target, then paste `imBotId` / `imTargetId`. The card header shows live WSS status. + Optional: enable **关键告警推送** so this DSH dials `ws(s):///v1/integrations/dsh-alarm/ws`. Choose **投递到 DSH 会话** and/or **投递到 WhatsApp / IM**. For IM, install `dsh-im-ops`, create a delivery target, then paste `imBotId` / `imTargetId`. The card header shows live WSS status. + Capability groups: leave defaults for nms+common, or enable **topology** / **对其他预设公开** (new sessions after save). 2. Restart or open Settings → **Agent presets** → Custom → **Netx Ops** should appear after the host plugin has activated once. 3. **New session → preset Netx Ops** → ask e.g. Critical Top / single-host alarms. @@ -46,7 +47,7 @@ dsh web # or: pnpm dsh web 1. Plugins card **Netx Ops** visible. 2. With key-alarm push on: card badge shows **Connected** (or reconnecting / auth failed with detail). 3. Agent presets → Custom → **Netx Ops**. -4. Tools include `netx__queryUmeAlarms`. +4. Tools include `netx__queryNmsAlarms` (**nms**). `netx__findTopologyPaths` is **common**. Canvas / dual_unit need **topology** (`netx-topology` skill). See [TOOL_MAP.md](TOOL_MAP.md). diff --git a/docs/PORTING.md b/docs/PORTING.md index 2e329ef..5312b64 100644 --- a/docs/PORTING.md +++ b/docs/PORTING.md @@ -5,21 +5,22 @@ | Source (oclaw) | Destination | |----------------|-------------| | `runtime/workspaces/ops/ROLE_SYSTEM.md` | `presets/netxops/PERSONA.md` + persona in `agent.cordis.yml` (brand **Netx Ops**) | -| `skills/_workspace/ops/ops-netx-ume-playbook/` | `presets/netxops/skills/ops-netx-ume-playbook/` | -| `skills/_workspace/ops/ops-netx-managed-ne-playbook/` | `presets/netxops/skills/ops-netx-managed-ne-playbook/` | +| `skills/_workspace/ops/ops-netx-ume-playbook/` | **Canonical:** `netx/skills/nms/netx-nms/` | +| `skills/_workspace/ops/ops-netx-managed-ne-playbook/` | **Canonical:** `netx/skills/common/netx-common/` | +| `skills/.../netx-topology` (MCP) | **Canonical:** `netx/skills/topology/netx-topology/` | | netx MCP HTTP surface | Host plugin `src/netx/*` + `src/index.ts`: settings namespace `netxops` + credential `NETX_API_TOKEN` → native `netx__*` tools | -Adaptations: removed oclaw Admin / WhatsApp / `ume_alarm_xlsx_report` / wiki capture / skill_auto_install; fiber/offline recipes use Raw/aggregate tools only. DSH no longer spawns `python -m netx_mcp`. +Adaptations: removed oclaw Admin / WhatsApp / `ume_alarm_xlsx_report` / wiki capture / skill_auto_install; fiber/offline recipes use Raw/aggregate tools only. DSH no longer spawns `python -m netx_mcp`. Model-facing NMS tools renamed to `*Nms*`; ZTE UME remains the `nmsProvider=zte-ume` adapter. -**Config UX:** Settings → Plugins → **Netx Ops** card (`src/client/` → `lib/client.js`). Token via credentials `NETX_API_TOKEN`; `apiUrl` / `lang` via settings namespace `netxops`. +**Config UX:** Settings → Plugins → **Netx Ops** card (`src/client/` → `lib/client.js`). Token via credentials `NETX_API_TOKEN`; `apiUrl` / `lang` / capability groups / `nmsProvider` via settings namespace `netxops`. -## Stay in oclaw +## Stay in oclaw (until retired) Gateway, WhatsApp/Weixin, Admin MCP UI, specialist router, memory-wiki, ops-ai HTTP, schedulers. ## Phase 2 candidates - `ume_alarm_xlsx_report` + thin HTTP client as a DSH tool plugin -- UME sync context inject +- NMS sync context inject - `ops-ip-knowledge-playbook` + KB content -- `netx-topology` MCP + topology skill +- Full dual_unit layout engine in-process (today: MCP or experimental stubs) diff --git a/docs/TOOL_MAP.md b/docs/TOOL_MAP.md index 51254fc..3a50dac 100644 --- a/docs/TOOL_MAP.md +++ b/docs/TOOL_MAP.md @@ -1,28 +1,40 @@ # Netx Ops tool map -Tools are registered by the Ops agent preset (`dsh-netxops/tools`) into that preset's tool scope — not the host global layer — so other agent presets do not see them. +Tools register by capability group. **One group ↔ one skill.** -Model-facing name: `netx__`. +| Group | Skill | Default in Ops preset | +|-------|-------|------------------------| +| **nms** | `netx-nms` | on | +| **common** | `netx-common` | on | +| **topology** | `netx-topology` | off | + +Public flags mount the same group on the host for other presets. +Forced mounts: `dsh-netxops/tools-nms` | `tools-common` | `tools-topology`. + +Model names: `netx__`. NMS tools use `Nms`; adapter `nmsProvider=zte-ume` still hits `/v1/ume/*`. + +## nms → `netx-nms` | Tool | Role | |------|------| -| `queryUmeAlarms` | Paged current alarms | -| `aggregateUmeAlarms` | Severity / Top NE aggregate | -| `runUmeDiagnostics` | Diagnostics + freshness meta | -| `queryUmeNeInventory` | UME NE list | -| `getUmeNe` | Single UME NE detail | -| `queryUmeAlarmsRaw` | Raw / evidence fields | -| `aggregateUmeAlarmsRaw` | Dynamic group_by | -| `listUmeAlarmFields` | Field catalog | -| `sqlQueryUme` | Read-only SELECT | -| `findTopologyPaths` | Shortest path between UME NEs (path query only; no canvas layout in v1) | -| `listManagedNe` | Managed device list | -| `getManagedNe` | Managed device detail | -| `execManagedNe` | Read-only CLI (batch-first) | -| `listCliTargets` | CLI target index (managed + ume) | +| `queryNmsAlarms` … `sqlQueryNms` | Alarm + inventory + SQL | -Execution: browser/`fetch` from the DSH host → HTTP `apiUrl` + Bearer `NETX_API_TOKEN`. +## common → `netx-common` -Handler/paths mirror [netx `packages/netx-mcp`](https://github.com/hansjone/netx/tree/main/packages/netx-mcp) (`http_tools.py` / `http_client.py`). +| Tool | Role | +|------|------| +| `listManagedNe` / `getManagedNe` / `execManagedNe` / `listCliTargets` | Managed CLI | +| `findTopologyPaths` | Fabric path lookup | -**Out of v1:** `netx-topology` MCP / topology canvas skills. +## topology → `netx-topology` + +| Tool | Role | +|------|------| +| Tree / view / folders / membership / neighbors | Canvas CRUD | +| Fabric query / classify / edges | Inventory + adjacency | +| `layoutTopologyView` | move_nodes / catalog / … | +| `suggestSinkHubs` / `analyzeTopologyViewLayout` / `sinkTopologyDualUnits` | Dual-unit / QA (may stub → MCP layout engine) | + +Canonical skill bodies: sibling **`netx/skills/`**. DSH loads them at runtime (`NETX_SKILLS_ROOT` or `../netx/skills`) or from `presets/netxops/skills` after sync. + +Execution: HTTP `apiUrl` + Bearer `NETX_API_TOKEN`. diff --git a/lib/agent-tools-common.js b/lib/agent-tools-common.js new file mode 100644 index 0000000..3c89263 --- /dev/null +++ b/lib/agent-tools-common.js @@ -0,0 +1,1783 @@ +// src/netx/capability-groups.ts +var DEFAULT_CAPABILITY_GROUPS = Object.freeze({ + nms: Object.freeze({ inPreset: true, public: false }), + common: Object.freeze({ inPreset: true, public: false }), + topology: Object.freeze({ inPreset: false, public: false }) +}); +var TOOLS_BY_GROUP = Object.freeze({ + nms: Object.freeze([ + "netx__queryNmsAlarms", + "netx__aggregateNmsAlarms", + "netx__runNmsDiagnostics", + "netx__queryNmsNeInventory", + "netx__getNmsNe", + "netx__queryNmsAlarmsRaw", + "netx__aggregateNmsAlarmsRaw", + "netx__listNmsAlarmFields", + "netx__sqlQueryNms" + ]), + common: Object.freeze([ + "netx__listManagedNe", + "netx__getManagedNe", + "netx__execManagedNe", + "netx__listCliTargets", + "netx__findTopologyPaths" + ]), + topology: Object.freeze([ + "netx__getTopologyTree", + "netx__getTopologyView", + "netx__createTopologyFolder", + "netx__addTopologyViewNodes", + "netx__removeTopologyViewNodes", + "netx__copyTopologyViewNodes", + "netx__updateTopologyViewPositions", + "netx__projectTopologyNeighbors", + "netx__queryTopologyFabricNodes", + "netx__classifyTopologyFabricNodes", + "netx__queryTopologyNeighborhood", + "netx__queryTopologyEdges", + "netx__layoutTopologyView", + "netx__suggestSinkHubs", + "netx__analyzeTopologyViewLayout", + "netx__sinkTopologyDualUnits" + ]) +}); +var SKILL_DIR_BY_GROUP = Object.freeze({ + nms: "nms", + common: "common", + topology: "topology" +}); +var CAPABILITY_GROUP_IDS = Object.freeze([ + "nms", + "common", + "topology" +]); +function groupsForPlane(groups, plane, only) { + const policy = groups ?? DEFAULT_CAPABILITY_GROUPS; + const enabled = CAPABILITY_GROUP_IDS.filter((id) => plane === "preset" ? policy[id].inPreset : policy[id].public); + if (!only || only.length === 0) + return enabled; + const allow = new Set(only); + return enabled.filter((id) => allow.has(id)); +} +function groupsForced(only) { + return CAPABILITY_GROUP_IDS.filter((id) => only.includes(id)); +} +function toolNamesForGroups(groupIds) { + const names = new Set; + for (const id of groupIds) { + for (const tool of TOOLS_BY_GROUP[id]) + names.add(tool); + } + return names; +} + +// src/netx/group-skills.ts +import { existsSync } from "node:fs"; +import { readdir, readFile, stat } from "node:fs/promises"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; +function opsSkillsRoot() { + const envRoot = process.env.NETX_SKILLS_ROOT?.trim(); + if (envRoot && existsSync(envRoot)) + return envRoot; + const here = dirname(fileURLToPath(import.meta.url)); + const siblingCandidates = [ + join(here, "..", "..", "..", "netx", "skills"), + join(here, "..", "..", "netx", "skills") + ]; + for (const candidate of siblingCandidates) { + if (existsSync(candidate)) + return candidate; + } + const packaged = [ + join(here, "..", "presets", "netxops", "skills"), + join(here, "..", "..", "presets", "netxops", "skills") + ]; + for (const candidate of packaged) { + if (existsSync(candidate)) + return candidate; + } + return packaged[0]; +} +function parseFrontmatter(raw) { + if (!raw.startsWith("---")) + return; + const end = raw.indexOf(` +---`, 3); + if (end < 0) + return; + const yaml = raw.slice(3, end).replace(/^\r?\n/, ""); + const body = raw.slice(end + 4).replace(/^\r?\n/, ""); + const data = {}; + const lines = yaml.split(/\r?\n/); + for (let i = 0;i < lines.length; i += 1) { + const line = lines[i]; + const nameMatch = /^name\s*:\s*(.+)\s*$/.exec(line); + if (nameMatch) { + data.name = stripQuotes(nameMatch[1].trim()); + continue; + } + const descMatch = /^description\s*:\s*(.*)$/.exec(line); + if (!descMatch) + continue; + const head = descMatch[1].trim(); + if (head === ">" || head === ">-" || head === "|" || head === "|-") { + const parts = []; + while (i + 1 < lines.length && /^[ \t]+/.test(lines[i + 1])) { + i += 1; + parts.push(lines[i].trim()); + } + data.description = parts.filter(Boolean).join(" "); + continue; + } + data.description = stripQuotes(head); + } + return { data, body }; +} +function stripQuotes(value) { + if (value.startsWith('"') && value.endsWith('"') || value.startsWith("'") && value.endsWith("'")) { + return value.slice(1, -1); + } + return value; +} +async function loadSkillBundle(dir) { + const skillPath = join(dir, "SKILL.md"); + let raw; + try { + raw = await readFile(skillPath, "utf8"); + } catch { + return null; + } + const parsed = parseFrontmatter(raw); + if (!parsed) + return null; + const name = typeof parsed.data.name === "string" ? parsed.data.name.trim() : ""; + const description = typeof parsed.data.description === "string" ? parsed.data.description.trim() : ""; + if (!name || !description) + return null; + return { + name, + description, + content: parsed.body.trimStart(), + path: skillPath, + directory: dir + }; +} +async function loadGroupSkills(skillsRoot, groupId) { + const groupDir = join(skillsRoot, SKILL_DIR_BY_GROUP[groupId]); + let entries; + try { + entries = await readdir(groupDir); + } catch { + return []; + } + const skills = []; + for (const entry of entries) { + const full = join(groupDir, entry); + let isDir = false; + try { + isDir = (await stat(full)).isDirectory(); + } catch { + continue; + } + if (!isDir) + continue; + const skill = await loadSkillBundle(full); + if (skill) + skills.push(skill); + } + return skills; +} +async function registerGroupSkills(ctx, groupIds, providerLabel) { + const skillsApi = ctx.skills; + if (!skillsApi || typeof skillsApi.register !== "function") { + return () => {}; + } + const root = opsSkillsRoot(); + const disposers = []; + const enabled = new Set(groupIds); + for (const groupId of CAPABILITY_GROUP_IDS) { + if (!enabled.has(groupId)) + continue; + const skills = await loadGroupSkills(root, groupId); + for (const skill of skills) { + disposers.push(skillsApi.register({ + name: skill.name, + description: skill.description, + content: skill.content, + path: skill.path, + resourceBase: { kind: "directory", path: skill.directory }, + provider: providerLabel, + source: "custom" + })); + } + } + return () => { + for (const dispose of disposers) + dispose(); + }; +} + +// src/netx/runtime.ts +var STORE_KEY = Symbol.for("dsh-netxops.connection-store"); +function store() { + const root = globalThis; + let current = root[STORE_KEY]; + if (current === undefined) { + current = { connection: undefined, listeners: new Set }; + root[STORE_KEY] = current; + } + return current; +} +function getNetxConnection() { + return store().connection; +} +function watchNetxConnection(listener) { + const state = store(); + state.listeners.add(listener); + return () => { + state.listeners.delete(listener); + }; +} + +// src/netx/tools.ts +import { defineTool } from "@deepseek-ai/dsh-tools"; + +// src/netx/http.ts +var PROTOCOL_KEY_ZH_TO_EN = { + 其他: "Other", + 时钟: "Clock", + "OTN/光": "OTN/Optical", + 电源: "Power" +}; +function localizePayload(lang, data) { + if (!lang.trim().toLowerCase().startsWith("en")) + return data; + const proto = data.protocol_summary; + if (!Array.isArray(proto)) + return data; + for (const row of proto) { + if (typeof row !== "object" || row === null || Array.isArray(row)) + continue; + const rec = row; + const key = typeof rec.key === "string" ? rec.key : ""; + const mapped = PROTOCOL_KEY_ZH_TO_EN[key]; + if (mapped !== undefined) + rec.key = mapped; + } + return data; +} +function encodeQuery(params) { + const sp = new URLSearchParams; + for (const [key, value] of Object.entries(params)) { + sp.set(key, String(value)); + } + const q = sp.toString(); + return q.length > 0 ? `?${q}` : ""; +} +function createNetxClient(connection) { + const base = connection.apiUrl.replace(/\/$/, ""); + const langParams = () => { + const lang = connection.lang.trim().toLowerCase(); + if (lang.startsWith("en")) + return { lang: "en" }; + return {}; + }; + async function request(method, path, options = {}) { + const token = connection.getToken().trim(); + if (token.length === 0) { + return { + ok: false, + error: "netx_token_missing", + detail: "Set credential NETX_API_TOKEN (Plugins → Netx Ops or scripts/set-netx-token)." + }; + } + const merged = { ...langParams(), ...options.params }; + const url = `${base}${path}${encodeQuery(merged)}`; + const timeoutMs = options.timeoutMs ?? connection.timeoutMs; + const controller = new AbortController; + const timer = setTimeout(() => { + controller.abort(); + }, timeoutMs); + const onOuterAbort = () => { + controller.abort(); + }; + options.signal?.addEventListener("abort", onOuterAbort, { once: true }); + try { + const headers = { + accept: "application/json", + authorization: `Bearer ${token}` + }; + if (options.body !== undefined) + headers["content-type"] = "application/json"; + const init = { + method, + headers, + signal: controller.signal + }; + if (options.body !== undefined) + init.body = JSON.stringify(options.body); + const resp = await fetch(url, init); + const text = await resp.text(); + if (!resp.ok) { + return { ok: false, error: `netx_http_${resp.status}`, detail: text.slice(0, 800) }; + } + const data = text.length > 0 ? JSON.parse(text) : {}; + if (typeof data === "object" && data !== null && !Array.isArray(data)) { + return { ok: true, data: localizePayload(connection.lang, data) }; + } + return { ok: true, data: { raw: data } }; + } catch (error) { + const detail = error instanceof Error ? error.message : String(error); + return { ok: false, error: "netx_request_failed", detail: detail.slice(0, 800) }; + } finally { + clearTimeout(timer); + options.signal?.removeEventListener("abort", onOuterAbort); + } + } + return { + get(path, params, signal, timeoutMs) { + return request("GET", path, { params, signal, timeoutMs }); + }, + post(path, body, signal, timeoutMs) { + return request("POST", path, { body, signal, timeoutMs }); + }, + patch(path, body, signal, timeoutMs) { + return request("PATCH", path, { body, signal, timeoutMs }); + } + }; +} +function quoteNeId(neId) { + return encodeURIComponent(neId.trim()); +} + +// src/netx/handlers.ts +var EXEC_MAX_COMMANDS = 5; +var UME_RAW_FIELD_PRESETS = { + brief: [ + "alarm_alarm_key", + "alarm_host_name", + "alarm_perceived_severity", + "alarm_event_type", + "alarm_last_seen_at", + "ne_host_name", + "ne_user_label", + "ne_ne_name", + "ne_ip_address", + "ne_exists" + ], + evidence: [ + "alarm_alarm_key", + "alarm_host_name", + "alarm_object_name", + "alarm_event_type", + "alarm_native_probable_cause", + "alarm_perceived_severity", + "alarm_is_cleared", + "alarm_time_created", + "alarm_last_seen_at", + "ne_host_name", + "ne_user_label", + "ne_ne_name", + "ne_ip_address", + "ne_connection_status", + "ne_exists" + ], + ne_debug: [ + "alarm_alarm_key", + "alarm_ne_id", + "alarm_perceived_severity", + "alarm_last_seen_at", + "ne_user_label", + "ne_ne_name", + "ne_ip_address", + "ne_ipv6_address", + "ne_device_level", + "ne_host_name", + "ne_connection_status", + "ne_admin_status", + "ne_address_type", + "ne_maintain_status", + "ne_exists" + ] +}; +function asRecord(value) { + return typeof value === "object" && value !== null && !Array.isArray(value) ? value : {}; +} +function str(args, key, fallback = "") { + const v = args[key]; + if (typeof v === "string") + return v; + if (typeof v === "number" || typeof v === "boolean") + return String(v); + return fallback; +} +function nmsOrUme(args, nmsKey, umeKey) { + return str(args, nmsKey).trim() || str(args, umeKey).trim(); +} +function nmsOrUmeList(args, nmsKey, umeKey) { + const primary = strList(args, nmsKey); + return primary.length > 0 ? primary : strList(args, umeKey); +} +function num(args, key) { + const v = args[key]; + return typeof v === "number" && Number.isFinite(v) ? v : undefined; +} +function bool(args, key) { + const v = args[key]; + return typeof v === "boolean" ? v : undefined; +} +function strList(args, key) { + const v = args[key]; + if (!Array.isArray(v)) + return []; + return v.map((x) => String(x).trim()).filter((x) => x.length > 0); +} +function clampInt(value, fallback, min, max) { + const n = value === undefined ? fallback : Math.trunc(value); + return Math.max(min, Math.min(max, n)); +} +function putStr(params, args, keys) { + for (const key of keys) { + const v = str(args, key).trim(); + if (v) + params[key] = v; + } +} +async function queryUmeAlarms(client, args, signal) { + let page = clampInt(num(args, "page"), 1, 1, 2); + const pageSize = clampInt(num(args, "page_size"), 50, 1, 500); + const params = { page, page_size: pageSize }; + putStr(params, args, ["severity", "ne_id", "host_name", "time_from", "time_to"]); + const keyword = str(args, "keyword").trim(); + const neName = str(args, "ne_name").trim(); + if (keyword) + params.keyword = keyword; + else if (neName) + params.keyword = neName; + return client.get("/v1/ume/alarms", params, signal); +} +async function aggregateUmeAlarmsRaw(client, args, signal) { + const params = {}; + putStr(params, args, [ + "group_by", + "group_by2", + "severity", + "is_cleared", + "ne_id", + "event_type", + "keyword", + "time_from", + "time_to", + "limit" + ]); + if ("exclude_missing_host" in args) { + const flag = bool(args, "exclude_missing_host"); + if (flag !== undefined) + params.exclude_missing_host = flag; + } + return client.get("/v1/ume/alarms/aggregate/raw", params, signal); +} +async function aggregateUmeAlarms(client, args, signal) { + if (str(args, "group_by").trim()) + return aggregateUmeAlarmsRaw(client, args, signal); + const topNe = clampInt(num(args, "top_ne"), 50, 0, 500); + const params = { top_ne: topNe }; + if ("exclude_missing_host" in args) { + const flag = bool(args, "exclude_missing_host"); + if (flag !== undefined) + params.exclude_missing_host = flag; + } + putStr(params, args, ["severity", "time_from", "time_to"]); + return client.get("/v1/ume/alarms/aggregate", params, signal); +} +async function runUmeDiagnostics(client, _args, signal) { + return client.get("/v1/ume/diagnostics", undefined, signal); +} +async function queryUmeNeInventory(client, args, signal) { + const params = { + page: clampInt(num(args, "page"), 1, 1, Number.MAX_SAFE_INTEGER), + page_size: clampInt(num(args, "page_size"), 50, 1, 500) + }; + putStr(params, args, ["keyword"]); + return client.get("/v1/ume/inventory/ne", params, signal); +} +async function getUmeNe(client, args, signal) { + const neId = str(args, "ne_id").trim(); + if (!neId) + return { ok: false, error: "ne_id_required", error_code: "ne_id_required" }; + return client.get(`/v1/ume/inventory/ne/${quoteNeId(neId)}`, undefined, signal); +} +async function queryUmeAlarmsRaw(client, args, signal) { + const params = { + page: clampInt(num(args, "page"), 1, 1, Number.MAX_SAFE_INTEGER), + page_size: clampInt(num(args, "page_size"), 50, 1, 500) + }; + putStr(params, args, [ + "severity", + "is_cleared", + "ne_id", + "event_type", + "keyword", + "time_from", + "time_to", + "order_by", + "order" + ]); + let fields = strList(args, "select_fields"); + if (fields.length === 0) { + const preset = str(args, "field_preset").trim().toLowerCase(); + fields = UME_RAW_FIELD_PRESETS[preset] ?? []; + } + if (fields.length > 0) + params.select_fields = fields.join(","); + return client.get("/v1/ume/alarms/raw", params, signal); +} +async function listUmeAlarmFields(client, _args, signal) { + return client.get("/v1/ume/alarms/fields", undefined, signal); +} +async function sqlQueryUme(client, args, signal) { + const sql = str(args, "sql").trim(); + if (!sql) + return { ok: false, error: "sql_required" }; + const limit = clampInt(num(args, "limit"), 200, 1, 2000); + const statementTimeoutMs = clampInt(num(args, "statement_timeout_ms"), 0, 0, 30000); + return client.post("/v1/sql/ume_query", { + sql, + limit, + statement_timeout_ms: statementTimeoutMs + }, signal, 60000); +} +async function listManagedNe(client, args, signal) { + const keyword = str(args, "keyword").trim(); + const vendor = str(args, "vendor").trim(); + const connectStatus = str(args, "connect_status").trim(); + if (!(keyword || vendor || connectStatus)) { + return { ok: false, error: "managed_ne_filter_required", error_code: "managed_ne_filter_required" }; + } + if (keyword && keyword.length < 2) { + return { ok: false, error: "managed_ne_keyword_too_short", error_code: "managed_ne_keyword_too_short" }; + } + const params = { + page: clampInt(num(args, "page"), 1, 1, Number.MAX_SAFE_INTEGER), + page_size: clampInt(num(args, "page_size"), 20, 1, 100) + }; + if (keyword) + params.keyword = keyword; + if (vendor) + params.vendor = vendor; + if (connectStatus) + params.connect_status = connectStatus; + return client.get("/v1/managed-ne", params, signal); +} +async function getManagedNe(client, args, signal) { + const neId = (str(args, "ne_id") || str(args, "managed_ne_id") || str(args, "id")).trim(); + if (!neId) { + return { + ok: false, + error: "ne_id_required", + error_code: "ne_id_required", + hint: "Pass managed NE id from listManagedNe/listCliTargets (source=managed). For NMS inventory UUIDs use execManagedNe(nms_ne_id=...) or getNmsNe, not getManagedNe.", + example: { ne_id: "" } + }; + } + const out = await client.get(`/v1/managed-ne/${quoteNeId(neId)}`, undefined, signal); + if (out.ok === false) { + const detail = `${str(out, "detail")}${str(out, "error")}`.toLowerCase(); + if (detail.includes("404") || detail.includes("not_found") || detail.includes("not found") || out.error === "netx_http_404") { + return { + ...out, + hint: "Managed NE not found for this ne_id. Call listManagedNe(keyword=...) or listCliTargets(source=managed) first. If this is an NMS inventory id, use execManagedNe(nms_ne_id=...) / getNmsNe instead of getManagedNe." + }; + } + } + return out; +} +async function execManagedNe(client, args, signal) { + const targetsRaw = args.targets; + const neIds = strList(args, "ne_ids"); + const umeNeIds = nmsOrUmeList(args, "nms_ne_ids", "ume_ne_ids"); + const sharedCommands = strList(args, "commands"); + const multi = Array.isArray(targetsRaw) && targetsRaw.length > 0 || neIds.length > 0 || umeNeIds.length > 0; + if (multi) { + const body2 = {}; + if (Array.isArray(targetsRaw) && targetsRaw.length > 0) { + const cleaned = []; + for (const t of targetsRaw) { + if (typeof t !== "object" || t === null || Array.isArray(t)) + continue; + const row = t; + const item = {}; + const neId2 = str(row, "ne_id").trim(); + const umeNeId2 = nmsOrUme(row, "nms_ne_id", "ume_ne_id"); + if (neId2) + item.ne_id = neId2; + if (umeNeId2) + item.ume_ne_id = umeNeId2; + const cmds = Array.isArray(row.commands) ? row.commands.map((c) => String(c).trim()).filter((c) => c.length > 0) : []; + if (cmds.length > 0) + item.commands = cmds; + if (Object.keys(item).length > 0) + cleaned.push(item); + } + body2.targets = cleaned; + } + if (neIds.length > 0) + body2.ne_ids = neIds; + if (umeNeIds.length > 0) + body2.ume_ne_ids = umeNeIds; + if (sharedCommands.length > 0) { + if (sharedCommands.length > EXEC_MAX_COMMANDS) { + return { ok: false, error: "too_many_commands", error_code: "too_many_commands" }; + } + body2.commands = sharedCommands; + } + body2.read_timeout_sec = clampInt(num(args, "read_timeout_sec"), 60, 10, 120); + const concurrency = num(args, "concurrency"); + if (concurrency !== undefined) + body2.concurrency = clampInt(concurrency, 4, 1, 8); + const out2 = await client.post("/v1/managed-ne/exec-batch", body2, signal, 600000); + if (out2.ok !== true) + return out2; + const data2 = asRecord(out2.data); + if (data2.ok === false) { + return { ok: false, data: data2, error: str(data2, "error", "exec_batch_failed") }; + } + return { ok: true, data: data2 }; + } + const neId = str(args, "ne_id").trim(); + const umeNeId = nmsOrUme(args, "nms_ne_id", "ume_ne_id"); + if (Boolean(neId) === Boolean(umeNeId)) { + return { + ok: false, + error: "exactly_one_of_ne_id_or_nms_ne_id_required", + error_code: "exactly_one_of_ne_id_or_nms_ne_id_required", + hint: "For one NE pass ne_id OR nms_ne_id (alias ume_ne_id). For many NEs pass ne_ids / nms_ne_ids with shared commands, or targets[] — one call, concurrent on server." + }; + } + if (sharedCommands.length === 0) { + return { ok: false, error: "commands_required", error_code: "commands_required" }; + } + if (sharedCommands.length > EXEC_MAX_COMMANDS) { + return { ok: false, error: "too_many_commands", error_code: "too_many_commands" }; + } + const body = { + commands: sharedCommands, + read_timeout_sec: clampInt(num(args, "read_timeout_sec"), 60, 10, 120) + }; + if (neId) + body.ne_id = neId; + if (umeNeId) + body.ume_ne_id = umeNeId; + const out = await client.post("/v1/managed-ne/exec", body, signal, 300000); + if (out.ok !== true) + return out; + const data = asRecord(out.data); + if (data.ok === false) { + return { ok: false, data, error: str(data, "error", "exec_failed") }; + } + return { ok: true, data }; +} +async function listCliTargets(client, args, signal) { + const params = { + page: clampInt(num(args, "page"), 1, 1, Number.MAX_SAFE_INTEGER), + page_size: clampInt(num(args, "page_size"), 50, 1, 500) + }; + putStr(params, args, ["source", "keyword"]); + return client.get("/v1/cli/targets", params, signal); +} +async function findTopologyPaths(client, args, signal) { + const fromUid = nmsOrUme(args, "from_nms_ne_id", "from_ume_ne_id"); + const fromMid = str(args, "from_managed_ne_id").trim(); + const toUid = nmsOrUme(args, "to_nms_ne_id", "to_ume_ne_id"); + const toMid = str(args, "to_managed_ne_id").trim(); + if (Boolean(fromUid) === Boolean(fromMid)) { + return { ok: false, error: "exactly_one_of_from_nms_ne_id_or_from_managed_ne_id_required" }; + } + if (Boolean(toUid) === Boolean(toMid)) { + return { ok: false, error: "exactly_one_of_to_nms_ne_id_or_to_managed_ne_id_required" }; + } + let detail = str(args, "detail", "summary").trim().toLowerCase() || "summary"; + if (detail !== "summary" && detail !== "full") + detail = "summary"; + const body = { + max_paths: clampInt(num(args, "max_paths"), 3, 1, 10), + max_hops: clampInt(num(args, "max_hops"), 6, 1, 12), + layer: str(args, "layer", "physical").trim() || "physical", + detail + }; + if (fromUid) + body.from_ume_ne_id = fromUid; + else + body.from_managed_ne_id = fromMid; + if (toUid) + body.to_ume_ne_id = toUid; + else + body.to_managed_ne_id = toMid; + return client.post("/v1/topology/fabric/paths", body, signal, 30000); +} + +// src/netx/topology-handlers.ts +function asRecord2(value) { + return typeof value === "object" && value !== null && !Array.isArray(value) ? value : {}; +} +function str2(args, key, fallback = "") { + const v = args[key]; + if (typeof v === "string") + return v; + if (typeof v === "number" || typeof v === "boolean") + return String(v); + return fallback; +} +function num2(args, key) { + const v = args[key]; + return typeof v === "number" && Number.isFinite(v) ? v : undefined; +} +function bool2(args, key) { + const v = args[key]; + return typeof v === "boolean" ? v : undefined; +} +function strList2(args, key) { + const v = args[key]; + if (!Array.isArray(v)) + return []; + return v.map((x) => String(x).trim()).filter((x) => x.length > 0); +} +function clampInt2(value, fallback, min, max) { + const n = value === undefined ? fallback : Math.trunc(value); + return Math.max(min, Math.min(max, n)); +} +function unwrap(out) { + if (out.ok !== true) + return out; + const data = asRecord2(out.data); + if (Object.keys(data).length === 0) + return { ok: true, data: out.data }; + return { ok: true, ...data, data }; +} +function filterFields(args) { + const out = {}; + for (const key of ["keyword", "role", "vendor", "link_status"]) { + const v = str2(args, key).trim(); + if (v) + out[key] = v; + } + return out; +} +function edgeEndpoints(edge) { + const a = str2(edge, "a_node_id") || str2(edge, "a"); + const b = str2(edge, "b_node_id") || str2(edge, "b"); + return [a.trim(), b.trim()]; +} +function collapseEdgesToLinks(edges, includeNames = false) { + const buckets = new Map; + for (const raw of edges) { + if (!raw || typeof raw !== "object" || Array.isArray(raw)) + continue; + const e = raw; + const [a, b] = edgeEndpoints(e); + if (!a || !b || a === b) + continue; + const lo = a < b ? a : b; + const hi = a < b ? b : a; + const key = `${lo}\x00${hi}`; + const row = buckets.get(key) ?? { + a_node_id: lo, + b_node_id: hi, + link_count: 0 + }; + row.link_count = Number(row.link_count || 0) + 1; + if (includeNames && !row.a_name) { + if (a === lo) { + row.a_name = str2(e, "a_name"); + row.b_name = str2(e, "b_name"); + } else { + row.a_name = str2(e, "b_name"); + row.b_name = str2(e, "a_name"); + } + } + buckets.set(key, row); + } + return [...buckets.values()]; +} +function summarizeViewGraph(graph, sample = 20) { + const nodes = Array.isArray(graph.nodes) ? graph.nodes : []; + const edges = Array.isArray(graph.edges) ? graph.edges : Array.isArray(graph.links) ? graph.links : []; + const sampleNodes = nodes.slice(0, Math.max(0, sample)).map((n) => { + const row = asRecord2(n); + return { + id: str2(row, "id") || str2(row, "fabric_node_id"), + name: str2(row, "name"), + x: row.x, + y: row.y, + level: row.level, + role: row.role + }; + }); + return { + ok: true, + view_id: str2(graph, "id") || str2(graph, "view_id"), + node_count: nodes.length, + edge_count: edges.length, + link_count: collapseEdgesToLinks(edges).length, + links: collapseEdgesToLinks(edges), + sample_nodes: sampleNodes, + detail: "summary" + }; +} +function layoutRecipeUnavailable(action) { + return { + ok: false, + error: "layout_recipe_requires_netx_topology_mcp", + action, + hint: "Canvas CRUD/query and move_nodes work in dsh-netxops. Dual-unit / orbit / polish recipes still need the netx-topology MCP layout engine (or enable that MCP alongside)." + }; +} +async function getTopologyTree(client, args, signal) { + const out = unwrap(await client.get("/v1/topology/tree", undefined, signal)); + if (out.ok !== true) + return out; + const compact = bool2(args, "compact") !== false; + const maxDepth = num2(args, "max_depth"); + if (!compact && maxDepth === undefined) + return out; + return { ...out, compact, max_depth: maxDepth ?? null }; +} +async function getTopologyView(client, args, signal) { + const viewId = str2(args, "view_id").trim(); + if (!viewId) + return { ok: false, error: "view_id_required" }; + const out = unwrap(await client.get(`/v1/topology/views/${encodeURIComponent(viewId)}`, undefined, signal, 120000)); + if (out.ok !== true) + return out; + const detail = str2(args, "detail", "summary").trim().toLowerCase() || "summary"; + if (detail === "full") + return { ...out, detail: "full" }; + return summarizeViewGraph(out, clampInt2(num2(args, "sample"), 20, 0, 200)); +} +async function createTopologyFolder(client, args, signal) { + const name = str2(args, "name").trim(); + if (!name) + return { ok: false, error: "name_required" }; + const body = { name }; + const parentId = str2(args, "parent_id").trim(); + if (parentId) + body.parent_id = parentId; + const locale = str2(args, "locale").trim(); + if (locale) + body.locale = locale; + if (num2(args, "sort_order") !== undefined) + body.sort_order = Math.trunc(num2(args, "sort_order")); + return unwrap(await client.post("/v1/topology/folders", body, signal)); +} +async function addTopologyViewNodes(client, args, signal) { + const viewId = str2(args, "view_id").trim(); + if (!viewId) + return { ok: false, error: "view_id_required" }; + if (strList2(args, "managed_ne_ids").length || strList2(args, "ume_ne_ids").length) { + return { + ok: false, + error: "fabric_nodes_only", + detail: "Use keyword/role/vendor/link_status or fabric_node_ids; never managed/UME ids." + }; + } + const filters = filterFields(args); + const fabricIds = strList2(args, "fabric_node_ids"); + if (Object.keys(filters).length === 0 && fabricIds.length === 0) { + return { + ok: false, + error: "filter_or_fabric_node_ids_required", + detail: "Pass keyword/role/vendor/link_status (preferred) or fabric_node_ids." + }; + } + if (num2(args, "max_nodes") !== undefined) { + const got = unwrap(await client.get(`/v1/topology/views/${encodeURIComponent(viewId)}`, undefined, signal)); + if (got.ok === true) { + const data = asRecord2(got.data); + const filt = asRecord2(got.filter ?? data.filter); + const membership = asRecord2(filt.membership ?? {}); + membership.max_nodes = clampInt2(num2(args, "max_nodes"), 2000, 1, 2000); + filt.membership = membership; + await client.patch(`/v1/topology/views/${encodeURIComponent(viewId)}`, { filter: filt }, signal); + } + } + const layout = str2(args, "layout", "grid").trim() || "grid"; + const body = { + managed_ne_ids: [], + layout, + ...filters + }; + if (fabricIds.length) + body.fabric_node_ids = fabricIds; + if (num2(args, "limit") !== undefined) + body.limit = clampInt2(num2(args, "limit"), 500, 1, 2000); + if (num2(args, "offset") !== undefined) + body.offset = clampInt2(num2(args, "offset"), 0, 0, Number.MAX_SAFE_INTEGER); + return unwrap(await client.post(`/v1/topology/views/${encodeURIComponent(viewId)}/nodes`, body, signal, 180000)); +} +async function removeTopologyViewNodes(client, args, signal) { + const viewId = str2(args, "view_id").trim(); + if (!viewId) + return { ok: false, error: "view_id_required" }; + const body = { ...filterFields(args) }; + const fabricIds = strList2(args, "fabric_node_ids"); + if (fabricIds.length) + body.fabric_node_ids = fabricIds; + return unwrap(await client.post(`/v1/topology/views/${encodeURIComponent(viewId)}/nodes/remove`, body, signal)); +} +async function copyTopologyViewNodes(client, args, signal) { + const sourceId = str2(args, "source_view_id").trim(); + const targetId = str2(args, "target_view_id").trim(); + if (!sourceId || !targetId) { + return { ok: false, error: "source_view_id_and_target_view_id_required" }; + } + const source = unwrap(await client.get(`/v1/topology/views/${encodeURIComponent(sourceId)}`, undefined, signal, 120000)); + if (source.ok !== true) + return source; + const nodes = Array.isArray(source.nodes) ? source.nodes : []; + const limit = num2(args, "limit"); + const selected = typeof limit === "number" ? nodes.slice(0, clampInt2(limit, nodes.length, 1, 2000)) : nodes; + const fabricIds = selected.map((n) => { + const row = asRecord2(n); + return str2(row, "id") || str2(row, "fabric_node_id"); + }).filter(Boolean); + if (bool2(args, "dry_run") === true) { + return { ok: true, dry_run: true, would_copy: fabricIds.length, source_view_id: sourceId, target_view_id: targetId }; + } + if (bool2(args, "clear_target") === true) { + await client.post(`/v1/topology/views/${encodeURIComponent(targetId)}/nodes/remove`, { + fabric_node_ids: "ALL", + clear_all: true + }, signal); + } + if (fabricIds.length === 0) { + return { ok: true, added: 0, source_view_id: sourceId, target_view_id: targetId }; + } + const add = unwrap(await client.post(`/v1/topology/views/${encodeURIComponent(targetId)}/nodes`, { fabric_node_ids: fabricIds, layout: "keep", managed_ne_ids: [] }, signal, 180000)); + if (add.ok !== true) + return add; + if (bool2(args, "copy_positions") !== false) { + const ox = num2(args, "offset_x") ?? 0; + const oy = num2(args, "offset_y") ?? 0; + const positions = selected.map((n) => { + const row = asRecord2(n); + const id = str2(row, "id") || str2(row, "fabric_node_id"); + return { + fabric_node_id: id, + x: Number(row.x || 0) + ox, + y: Number(row.y || 0) + oy + }; + }).filter((p) => p.fabric_node_id); + if (positions.length) { + await client.patch(`/v1/topology/views/${encodeURIComponent(targetId)}/positions`, { positions }, signal, 120000); + } + } + return { ok: true, added: fabricIds.length, source_view_id: sourceId, target_view_id: targetId, ...asRecord2(add) }; +} +async function updateTopologyViewPositions(client, args, signal) { + const viewId = str2(args, "view_id").trim(); + if (!viewId) + return { ok: false, error: "view_id_required" }; + const body = {}; + if (Array.isArray(args.positions)) + body.positions = args.positions; + const layout = str2(args, "layout").trim(); + if (layout) + body.layout = layout; + Object.assign(body, filterFields(args)); + for (const key of ["offset_x", "offset_y", "cols", "gap_x", "gap_y"]) { + if (num2(args, key) !== undefined) + body[key] = num2(args, key); + } + const fabricIds = strList2(args, "fabric_node_ids"); + if (fabricIds.length) + body.fabric_node_ids = fabricIds; + return unwrap(await client.patch(`/v1/topology/views/${encodeURIComponent(viewId)}/positions`, body, signal, 120000)); +} +async function projectTopologyNeighbors(client, args, signal) { + const viewId = str2(args, "view_id").trim(); + if (!viewId) + return { ok: false, error: "view_id_required" }; + const body = {}; + const seeds = strList2(args, "seed_fabric_node_ids").length ? strList2(args, "seed_fabric_node_ids") : strList2(args, "fabric_node_ids"); + if (seeds.length) + body.seed_fabric_node_ids = seeds; + const mids = strList2(args, "managed_ne_ids"); + if (mids.length) + body.managed_ne_ids = mids; + const region = str2(args, "region_folder_id").trim(); + if (region) + body.region_folder_id = region; + const out = unwrap(await client.post(`/v1/topology/views/${encodeURIComponent(viewId)}/project-neighbors`, body, signal, 180000)); + if (out.ok !== true) + return out; + const detail = str2(args, "detail", "summary").trim().toLowerCase() || "summary"; + if (detail === "full" || detail === "raw" || detail === "graph") { + return { ...out, detail: "full", projected: true }; + } + return { ...summarizeViewGraph(out, clampInt2(num2(args, "sample"), 20, 0, 100)), projected: true, view_id: viewId }; +} +async function queryTopologyFabricNodes(client, args, signal) { + let mode = str2(args, "mode").trim().toLowerCase(); + const q = str2(args, "q").trim(); + const keyword = str2(args, "keyword").trim(); + if (!mode) { + if (bool2(args, "summary") === true) + mode = "summary"; + else if (q) + mode = "search"; + else + mode = "list"; + } + if (mode === "summary" || mode === "stats" || mode === "count") { + const out2 = unwrap(await client.get("/v1/topology/fabric/summary", undefined, signal)); + return out2.ok === true ? { ...out2, mode: "summary" } : out2; + } + if (mode === "search" || mode === "find") { + const needle = q || keyword; + if (!needle) + return { ok: false, error: "q_required", hint: "mode=search needs q (or keyword)." }; + return unwrap(await client.get("/v1/topology/fabric/nodes/search", { + q: needle, + page: clampInt2(num2(args, "page"), 1, 1, Number.MAX_SAFE_INTEGER), + page_size: clampInt2(num2(args, "page_size") ?? num2(args, "limit"), 50, 1, 200) + }, signal)); + } + const params = { + page: clampInt2(num2(args, "page"), 1, 1, Number.MAX_SAFE_INTEGER), + page_size: clampInt2(num2(args, "page_size") ?? num2(args, "limit"), 50, 1, 500) + }; + for (const key of ["keyword", "role", "level", "level_major", "region_folder_id", "link_status"]) { + const v = str2(args, key).trim() || (key === "keyword" ? q : ""); + if (v) + params[key] = v; + } + const out = unwrap(await client.get("/v1/topology/fabric/nodes", params, signal)); + return out.ok === true ? { ...out, mode: "list" } : out; +} +async function classifyTopologyFabricNodes(client, args, signal) { + const action = str2(args, "action").trim().toLowerCase(); + if (!action) + return { ok: false, error: "action_required" }; + if (action === "match") { + const pattern = str2(args, "pattern").trim() || str2(args, "q").trim(); + if (!pattern) + return { ok: false, error: "pattern_required" }; + return unwrap(await client.post("/v1/topology/fabric/nodes/match", { + pattern, + match_field: str2(args, "match_field", "name").trim() || "name", + sample_limit: clampInt2(num2(args, "sample_limit"), 50, 1, 200) + }, signal)); + } + if (action === "tag") { + const body = { + fabric_node_ids: strList2(args, "fabric_node_ids"), + dry_run: bool2(args, "dry_run") === true + }; + const pattern = str2(args, "pattern").trim() || str2(args, "q").trim(); + if (pattern) + body.pattern = pattern; + if (args.level !== undefined) + body.level = args.level; + const role = str2(args, "role").trim(); + if (role) + body.role = role; + const region = str2(args, "region_folder_id").trim(); + if (region) + body.region_folder_id = region; + if (bool2(args, "clear_region") === true) + body.clear_region = true; + return unwrap(await client.post("/v1/topology/fabric/nodes/tags/bulk", body, signal)); + } + if (action === "patch") { + const nodeId = str2(args, "fabric_node_id").trim() || str2(args, "node_id").trim(); + if (!nodeId) + return { ok: false, error: "fabric_node_id_required" }; + const body = {}; + if (args.level !== undefined) + body.level = args.level; + const role = str2(args, "role").trim(); + if (role) + body.role = role; + const region = str2(args, "region_folder_id").trim(); + if (region) + body.region_folder_id = region; + if (bool2(args, "clear_region") === true) + body.clear_region = true; + return unwrap(await client.patch(`/v1/topology/fabric/nodes/${encodeURIComponent(nodeId)}/tags`, body, signal)); + } + if (action === "unmatched") { + return unwrap(await client.get("/v1/topology/classify/unmatched", { + kind: str2(args, "kind", "any").trim() || "any", + page: clampInt2(num2(args, "page"), 1, 1, Number.MAX_SAFE_INTEGER), + page_size: clampInt2(num2(args, "page_size") ?? num2(args, "limit"), 50, 1, 500) + }, signal)); + } + if (action === "preview_rules") { + return unwrap(await client.post("/v1/topology/classify/preview", {}, signal)); + } + if (action === "apply_rules") { + const params = {}; + if (bool2(args, "overwrite_manual") === true) + params.skip_manual = false; + else if (bool2(args, "skip_manual") !== undefined) + params.skip_manual = bool2(args, "skip_manual") !== false; + if (bool2(args, "fill_empty_only") === true) + params.fill_empty_only = true; + return unwrap(await client.post("/v1/topology/classify/apply", {}, signal)); + } + if (action === "list_rules") { + return unwrap(await client.get("/v1/topology/classify/rules", undefined, signal)); + } + return { ok: false, error: "unknown_action", action }; +} +async function queryTopologyNeighborhood(client, args, signal) { + const nodeId = str2(args, "node_id").trim(); + if (!nodeId) + return { ok: false, error: "node_id_required" }; + const out = unwrap(await client.get("/v1/topology/fabric/neighborhood", { + node_id: nodeId, + depth: clampInt2(num2(args, "depth"), 1, 1, 3), + layer: str2(args, "layer", "physical").trim() || "physical" + }, signal)); + if (out.ok !== true) + return out; + const edges = Array.isArray(out.edges) ? out.edges : []; + return { ...out, links: collapseEdgesToLinks(edges) }; +} +async function queryTopologyEdges(client, args, signal) { + const params = { + page: clampInt2(num2(args, "page"), 1, 1, Number.MAX_SAFE_INTEGER), + page_size: clampInt2(num2(args, "page_size"), 100, 1, 500), + layer: str2(args, "layer", "physical").trim() || "physical" + }; + for (const key of ["node_id", "keyword", "status", "source"]) { + const v = str2(args, key).trim(); + if (v) + params[key] = v; + } + const out = unwrap(await client.get("/v1/topology/fabric/edges", params, signal)); + if (out.ok !== true) + return out; + const detail = str2(args, "detail", "adjacency").trim().toLowerCase() || "adjacency"; + const items = Array.isArray(out.items) ? out.items : Array.isArray(out.edges) ? out.edges : []; + if (detail === "ports") + return { ...out, detail: "ports", items }; + const links = collapseEdgesToLinks(items, true); + return { ...out, detail: "adjacency", links, items: undefined }; +} +async function suggestSinkHubs(client, args, signal) { + const viewId = str2(args, "source_view_id").trim() || str2(args, "view_id").trim(); + if (!viewId) + return { ok: false, error: "source_view_id_required" }; + const out = unwrap(await client.get(`/v1/topology/views/${encodeURIComponent(viewId)}`, undefined, signal, 120000)); + if (out.ok !== true) + return out; + const nodes = Array.isArray(out.nodes) ? out.nodes : []; + const edges = Array.isArray(out.edges) ? out.edges : []; + const degree = new Map; + for (const raw of edges) { + const [a, b] = edgeEndpoints(asRecord2(raw)); + if (!a || !b) + continue; + degree.set(a, (degree.get(a) || 0) + 1); + degree.set(b, (degree.get(b) || 0) + 1); + } + const exclude = new Set([ + ...strList2(args, "exclude_portal_ids"), + ...strList2(args, "exclude_fabric_node_ids") + ]); + const pick = clampInt2(num2(args, "pick"), 5, 1, 50); + const hubs = [...degree.entries()].filter(([id]) => !exclude.has(id)).sort((a, b) => b[1] - a[1]).slice(0, pick).map(([fabric_node_id, deg]) => { + const node = nodes.find((n) => { + const row2 = asRecord2(n); + return (str2(row2, "id") || str2(row2, "fabric_node_id")) === fabric_node_id; + }); + const row = asRecord2(node); + return { + fabric_node_id, + degree: deg, + name: str2(row, "name"), + level: row.level, + role: row.role + }; + }); + return { + ok: true, + source_view_id: viewId, + hubs, + hint: "Degree ranking only — full dual_unit-aware suggestSinkHubs lives in netx-topology MCP." + }; +} +async function analyzeTopologyViewLayout(client, args, signal) { + const viewId = str2(args, "view_id").trim(); + if (!viewId) { + return { + ok: false, + error: "view_id_required", + hint: "Pass view_id. folder_id sampling without view_id needs netx-topology MCP structure analysis." + }; + } + const out = unwrap(await client.get(`/v1/topology/views/${encodeURIComponent(viewId)}`, undefined, signal, 120000)); + if (out.ok !== true) + return out; + const nodes = Array.isArray(out.nodes) ? out.nodes : []; + const edges = Array.isArray(out.edges) ? out.edges : []; + let minX = Infinity; + let minY = Infinity; + let maxX = -Infinity; + let maxY = -Infinity; + let missingxy = 0; + for (const raw of nodes) { + const row = asRecord2(raw); + const x = Number(row.x); + const y = Number(row.y); + if (!Number.isFinite(x) || !Number.isFinite(y)) { + missingxy += 1; + continue; + } + minX = Math.min(minX, x); + minY = Math.min(minY, y); + maxX = Math.max(maxX, x); + maxY = Math.max(maxY, y); + } + const degree = new Map; + for (const raw of edges) { + const [a, b] = edgeEndpoints(asRecord2(raw)); + if (!a || !b) + continue; + degree.set(a, (degree.get(a) || 0) + 1); + degree.set(b, (degree.get(b) || 0) + 1); + } + const hubs = [...degree.entries()].sort((a, b) => b[1] - a[1]).slice(0, 10).map(([id, deg]) => ({ fabric_node_id: id, degree: deg })); + return { + ok: true, + view_id: viewId, + detail: str2(args, "detail", "summary") || "summary", + node_count: nodes.length, + edge_count: edges.length, + link_count: collapseEdgesToLinks(edges).length, + missing_xy: missingxy, + bbox: Number.isFinite(minX) ? { minX, minY, maxX, maxY, width: maxX - minX, height: maxY - minY } : null, + hubs, + hint: "Basic graph stats only. Crossing/dual_unit/score.total analysis needs netx-topology MCP." + }; +} +async function sinkTopologyDualUnits(_client, args) { + return { + ok: false, + error: "sink_dual_units_requires_netx_topology_mcp", + source_view_id: str2(args, "source_view_id"), + sink_view_id: str2(args, "sink_view_id"), + hint: "sinkTopologyDualUnits needs the dual_unit layout engine from netx-topology MCP. Use layoutTopologyView(action=move_nodes, park=true) for HTTP-only parking, or enable the topology MCP." + }; +} +async function layoutTopologyView(client, args, signal) { + if (bool2(args, "catalog") === true) { + return { + ok: true, + catalog: true, + actions: [ + "layout", + "layout_dual_unit", + "move_nodes", + "sink_nodes", + "orbit_sweep", + "level_bands", + "polish_crossings", + "clear_edge_hits", + "compact_bbox", + "pull_far_chains", + "align_reference", + "fix_overlaps", + "resolve_overlaps", + "untangle", + "straighten_channels", + "job_status", + "job_cancel" + ], + dsh_supported: ["move_nodes", "sink_nodes", "catalog"], + recipes: ["rings", "corridor", "compact", "unstick"], + hint: "Only move_nodes/sink_nodes/catalog run in dsh-netxops; other actions need netx-topology MCP." + }; + } + const action = str2(args, "action", "layout").trim().toLowerCase() || "layout"; + if (action === "job_status" || action === "job_cancel") { + return { + ok: false, + error: "layout_jobs_not_in_dsh_host", + hint: "Background layout jobs are owned by netx-topology MCP." + }; + } + if (action === "move_nodes" || action === "sink_nodes") { + const toId = str2(args, "view_id").trim(); + const fromId = str2(args, "source_view_id").trim(); + const ids = strList2(args, "fabric_node_ids"); + const params = asRecord2(args.params); + const fabricIds = ids.length ? ids : strList2(params, "fabric_node_ids"); + if (!toId || !fromId) { + return { ok: false, error: "view_id_and_source_view_id_required" }; + } + if (fabricIds.length === 0) { + return { ok: false, error: "fabric_node_ids_required" }; + } + const copyPositions = bool2(params, "copy_positions") !== false && bool2(args, "copy_positions") !== false; + const removeFromSource = bool2(params, "remove_from_source") !== false; + const source = unwrap(await client.get(`/v1/topology/views/${encodeURIComponent(fromId)}`, undefined, signal, 120000)); + if (source.ok !== true) + return source; + const nodes = Array.isArray(source.nodes) ? source.nodes : []; + const wanted = new Set(fabricIds); + const selected = nodes.filter((n) => { + const row = asRecord2(n); + const id = str2(row, "id") || str2(row, "fabric_node_id"); + return wanted.has(id); + }); + const add = unwrap(await client.post(`/v1/topology/views/${encodeURIComponent(toId)}/nodes`, { fabric_node_ids: fabricIds, layout: copyPositions ? "keep" : "grid", managed_ne_ids: [] }, signal, 180000)); + if (add.ok !== true) + return add; + if (copyPositions && selected.length) { + const ox = Number(params.offset_x || 0); + const oy = Number(params.offset_y || 0); + const positions = selected.map((n) => { + const row = asRecord2(n); + return { + fabric_node_id: str2(row, "id") || str2(row, "fabric_node_id"), + x: Number(row.x || 0) + ox, + y: Number(row.y || 0) + oy + }; + }).filter((p) => p.fabric_node_id); + await client.patch(`/v1/topology/views/${encodeURIComponent(toId)}/positions`, { positions }, signal, 120000); + } + if (removeFromSource) { + await client.post(`/v1/topology/views/${encodeURIComponent(fromId)}/nodes/remove`, { fabric_node_ids: fabricIds }, signal); + } + return { + ok: true, + action, + moved: fabricIds.length, + source_view_id: fromId, + view_id: toId, + park: bool2(params, "park") === true || bool2(args, "park") === true + }; + } + return layoutRecipeUnavailable(action); +} + +// src/netx/tools.ts +var str3 = (description) => ({ type: "string", ...description ? { description } : {} }); +var num3 = (description) => ({ type: "number", ...description ? { description } : {} }); +var bool3 = (description) => ({ type: "boolean", ...description ? { description } : {} }); +var strArr = (description) => ({ + type: "array", + items: { type: "string" }, + ...description ? { description } : {} +}); +function renderJson(_args, value) { + return [{ type: "text", text: JSON.stringify(value, null, 0) }]; +} +var jsonOut = { + schema: { type: "json" }, + render: renderJson +}; +function tool(name, description, parameters, handler, getClient, timeoutMs) { + return defineTool({ + name, + description, + parameters, + output: jsonOut, + timeoutMs, + isConcurrencySafe: () => true, + async execute(args, exec) { + const result = await handler(getClient(), args, exec.signal); + if (result.ok === false) { + throw new Error(JSON.stringify(result)); + } + return result; + } + }); +} +function registerNetxTools(ctx, connection, options) { + const enabledGroups = options.forceGroups ?? groupsForPlane(connection.groups, options.plane, options.only); + const allow = toolNamesForGroups(enabledGroups); + if (allow.size === 0) + return () => {}; + const client = createNetxClient({ + apiUrl: connection.apiUrl, + lang: connection.lang, + timeoutMs: Math.min(connection.toolCallTimeoutMs, 45000), + getToken: () => getNetxConnection()?.token ?? "" + }); + const getClient = () => client; + const t = connection.toolCallTimeoutMs; + const catalog = [ + tool("netx__queryNmsAlarms", "Query NMS current alarms (each row includes host_name). Supports severity/ne_id/host_name/keyword, last_seen time_from/time_to, pagination. Prefer host_name for display; ne_id is for filters only.", { + severity: str3(), + ne_id: str3("Filter only; do not show UUID to users"), + host_name: str3("Filter by NE host_name"), + ne_name: str3("Legacy alias mapped to keyword"), + keyword: str3("Substring on cause/object/event. Examples: LOS, Fiber Break, bandwidth, CRC."), + time_from: str3("ISO time; filters last_seen_at >="), + time_to: str3("ISO time; filters last_seen_at <="), + page: num3(), + page_size: num3() + }, queryUmeAlarms, getClient, t), + tool("netx__aggregateNmsAlarms", "Aggregate NMS current alarms (by_severity + top by_ne). If group_by is set, routes to aggregateUmeAlarmsRaw. Always filter severity/keyword/time before paging.", { + severity: str3("Optional perceived_severity filter (critical/major/minor/warning)."), + top_ne: num3("Max NE buckets (default 50). Ignored when group_by is set."), + exclude_missing_host: bool3("Omit missing host_name from by_ne."), + time_from: str3(), + time_to: str3(), + group_by: str3("When set, routes to raw aggregation. Prefer alarm_host_name."), + group_by2: str3(), + is_cleared: str3(), + ne_id: str3(), + event_type: str3(), + keyword: str3(), + limit: num3() + }, aggregateUmeAlarms, getClient, t), + tool("netx__runNmsDiagnostics", "NMS alarm diagnostics: severity, top_event_types, top_alarm_codes, top_ne, protocol buckets, freshness meta.", {}, runUmeDiagnostics, getClient, t), + tool("netx__queryNmsNeInventory", "Paged NMS NE inventory synced in netx (keyword matches ne_id/ne_name/user_label/ip/host_name).", { + keyword: str3(), + page: num3(), + page_size: num3() + }, queryUmeNeInventory, getClient, t), + tool("netx__getNmsNe", "Get single NMS NE detail by ne_id (UUID).", { + ne_id: { type: "string", required: true, description: "NMS inventory ne_id (UUID)." } + }, getUmeNe, getClient, t), + tool("netx__queryNmsAlarmsRaw", "Power query NMS current alarms with full alarm_* + ne_* fields; optional field_preset or select_fields. Use field_preset=evidence for citations.", { + severity: str3(), + is_cleared: str3(), + ne_id: str3(), + event_type: str3(), + keyword: str3(), + time_from: str3(), + time_to: str3(), + order_by: str3("last_seen_at | time_created | perceived_severity | event_type | ne_id"), + order: str3("asc | desc"), + select_fields: strArr(), + field_preset: str3("brief | evidence | ne_debug"), + page: num3(), + page_size: num3() + }, queryUmeAlarmsRaw, getClient, t), + tool("netx__aggregateNmsAlarmsRaw", "Dynamic aggregation on NMS raw fields (group_by/group_by2); prefer alarm_host_name.", { + group_by: { type: "string", required: true }, + group_by2: str3(), + severity: str3(), + is_cleared: str3(), + ne_id: str3(), + event_type: str3(), + keyword: str3(), + time_from: str3(), + time_to: str3(), + exclude_missing_host: bool3(), + limit: num3() + }, aggregateUmeAlarmsRaw, getClient, t), + tool("netx__listNmsAlarmFields", "List available fields for NMS raw alarm queries.", {}, listUmeAlarmFields, getClient, t), + tool("netx__sqlQueryNms", "Read-only SELECT on NMS tables (ume_alarms_current/ume_inventory_ne); server enforces limits. Requires sql:query scope.", { + sql: { type: "string", required: true }, + limit: num3(), + statement_timeout_ms: num3() + }, sqlQueryUme, getClient, t), + tool("netx__listManagedNe", "List filtered netx managed NEs (keyword/vendor/connect_status required); use before execManagedNe.", { + keyword: str3(), + vendor: str3(), + connect_status: str3("unknown | testing | pass | fail"), + page: num3(), + page_size: num3() + }, listManagedNe, getClient, t), + tool("netx__getManagedNe", "Get one managed NE by managed ne_id (from listManagedNe / listCliTargets source=managed). Do NOT pass NMS inventory UUID here.", { + ne_id: str3("Managed NE id"), + managed_ne_id: str3("Alias for ne_id"), + id: str3("Alias for ne_id") + }, getManagedNe, getClient, t), + tool("netx__execManagedNe", "Run read-only CLI via netx (show/display/ping/traceroute). Single NE: ne_id OR ume_ne_id + commands. Many NEs: ne_ids[]/ume_ne_ids[] + shared commands, or targets[{ume_ne_id|ne_id, commands}]. Do NOT loop one-NE calls for multi-NE work.", { + ne_id: str3(), + nms_ne_id: str3("NMS inventory id; alias ume_ne_id"), + ume_ne_id: str3("Legacy alias of nms_ne_id"), + ne_ids: strArr("Managed NE ids for concurrent batch (shared commands)."), + nms_ne_ids: strArr("NMS inventory ids; alias ume_ne_ids"), + ume_ne_ids: strArr("Legacy alias of nms_ne_ids"), + targets: { + type: "array", + description: "Per-NE command sets: each item is one NE (ne_id OR ume_ne_id) with commands[].", + items: { + type: "object", + additionalProperties: false, + properties: { + ne_id: str3(), + ume_ne_id: str3(), + commands: strArr() + } + } + }, + commands: strArr("Commands for single NE, or shared commands for batch."), + read_timeout_sec: num3("Per-command read timeout (default 60; use 90–120 for slow show)."), + concurrency: num3("Parallel NEs for batch mode (1–8, default 4)."), + async: bool3("oclaw-only async hint; ignored by native REST client.") + }, execManagedNe, getClient, Math.max(t, 300000)), + tool("netx__listCliTargets", "List CLI-capable targets (managed NE and/or NMS inventory). Call once per session with keyword/source, cache ids, then execManagedNe.", { + source: str3("managed | nms | ume | all"), + keyword: str3(), + page: num3(), + page_size: num3() + }, listCliTargets, getClient, t), + tool("netx__findTopologyPaths", "Find up to max_paths simple paths between two fabric nodes (common group — native netx, not NMS-bound). For each endpoint provide exactly one of ume_ne_id or managed_ne_id.", { + from_nms_ne_id: str3("NMS inventory id"), + from_ume_ne_id: str3("Legacy alias of from_nms_ne_id"), + from_managed_ne_id: str3(), + to_nms_ne_id: str3("NMS inventory id"), + to_ume_ne_id: str3("Legacy alias of to_nms_ne_id"), + to_managed_ne_id: str3(), + max_paths: num3(), + max_hops: num3(), + layer: str3(), + detail: str3("summary | full") + }, findTopologyPaths, getClient, t), + tool("netx__getTopologyTree", "Get topology folder tree (nav roots + Root map canvases). Start here before createTopologyFolder.", { compact: bool3(), max_depth: num3() }, getTopologyTree, getClient, t), + tool("netx__getTopologyView", "Get a topology view by view_id. Default detail=summary with sample_nodes + links[].", { + view_id: { type: "string", required: true }, + detail: str3("summary | full"), + sample: num3() + }, getTopologyView, getClient, t), + tool("netx__createTopologyFolder", "Create topology folders / region canvases. Only way to create canvases (ne:write).", { + name: { type: "string", required: true }, + parent_id: str3(), + locale: str3("zh | en"), + sort_order: num3() + }, createTopologyFolder, getClient, t), + tool("netx__addTopologyViewNodes", "Bulk-place fabric nodes on a view via filters or fabric_node_ids (never managed/UME ids).", { + view_id: { type: "string", required: true }, + max_nodes: num3(), + keyword: str3(), + role: str3(), + vendor: str3(), + link_status: str3(), + limit: num3(), + offset: num3(), + fabric_node_ids: strArr(), + layout: str3("grid | keep") + }, addTopologyViewNodes, getClient, t), + tool("netx__removeTopologyViewNodes", "Remove placements from a view (not fabric) by filters or fabric_node_ids.", { + view_id: { type: "string", required: true }, + keyword: str3(), + role: str3(), + vendor: str3(), + link_status: str3(), + fabric_node_ids: strArr() + }, removeTopologyViewNodes, getClient, t), + tool("netx__copyTopologyViewNodes", "Clone fabric placements from source_view_id onto target_view_id (optional clear_target / copy_positions).", { + source_view_id: { type: "string", required: true }, + target_view_id: { type: "string", required: true }, + copy_positions: bool3(), + clear_target: bool3(), + offset_x: num3(), + offset_y: num3(), + limit: num3(), + dry_run: bool3() + }, copyTopologyViewNodes, getClient, t), + tool("netx__updateTopologyViewPositions", "Move nodes on a view via positions[] or layout=grid|offset|stack + filters.", { + view_id: { type: "string", required: true }, + positions: { type: "array", items: { type: "object" } }, + layout: str3("grid | offset | stack"), + keyword: str3(), + role: str3(), + vendor: str3(), + link_status: str3(), + fabric_node_ids: strArr(), + offset_x: num3(), + offset_y: num3() + }, updateTopologyViewPositions, getClient, t), + tool("netx__projectTopologyNeighbors", "Project existing fabric neighbors of on-view nodes onto the canvas.", { + view_id: { type: "string", required: true }, + seed_fabric_node_ids: strArr(), + managed_ne_ids: strArr(), + region_folder_id: str3(), + detail: str3("summary | full"), + sample: num3() + }, projectTopologyNeighbors, getClient, t), + tool("netx__queryTopologyFabricNodes", "Fabric inventory: mode=summary|list|search (keyword/role/level/region/link_status).", { + mode: str3("summary | list | search"), + q: str3(), + keyword: str3(), + role: str3(), + level: str3(), + level_major: str3(), + region_folder_id: str3(), + link_status: str3(), + page: num3(), + page_size: num3(), + limit: num3(), + summary: bool3() + }, queryTopologyFabricNodes, getClient, t), + tool("netx__classifyTopologyFabricNodes", "Classify fabric nodes: action=match|tag|patch|unmatched|preview_rules|apply_rules|list_rules.", { + action: { type: "string", required: true }, + pattern: str3(), + q: str3(), + match_field: str3(), + sample_limit: num3(), + fabric_node_ids: strArr(), + fabric_node_id: str3(), + node_id: str3(), + level: str3(), + role: str3(), + region_folder_id: str3(), + clear_region: bool3(), + dry_run: bool3(), + kind: str3(), + page: num3(), + page_size: num3(), + skip_manual: bool3(), + overwrite_manual: bool3(), + fill_empty_only: bool3() + }, classifyTopologyFabricNodes, getClient, t), + tool("netx__queryTopologyNeighborhood", "Neighborhood around a fabric node (depth 1–3) with compact links[].", { + node_id: { type: "string", required: true }, + depth: num3(), + layer: str3() + }, queryTopologyNeighborhood, getClient, t), + tool("netx__queryTopologyEdges", "Fabric adjacency links[] (detail=adjacency) or port rows (detail=ports).", { + node_id: str3(), + keyword: str3(), + layer: str3(), + status: str3(), + source: str3("lldp | ume | manual"), + detail: str3("adjacency | ports"), + page: num3(), + page_size: num3() + }, queryTopologyEdges, getClient, t), + tool("netx__suggestSinkHubs", "Rank hub territories on a source view for non-dual move_nodes(park) batches (degree stand-in in DSH).", { + source_view_id: str3(), + view_id: str3(), + pick: num3(), + exclude_portal_ids: strArr(), + exclude_fabric_node_ids: strArr() + }, suggestSinkHubs, getClient, t), + tool("netx__analyzeTopologyViewLayout", "Basic layout QA for a view (bbox/hubs). Full dual_unit/crossing scores need netx-topology MCP.", { + view_id: str3(), + folder_id: str3(), + detail: str3("summary | structure | hotspots | blocks | both"), + score_profile: str3(), + sight_limit: num3(), + max_views: num3(), + min_nodes: num3(), + max_nodes: num3(), + with_meta: bool3() + }, analyzeTopologyViewLayout, getClient, t), + tool("netx__sinkTopologyDualUnits", "Drain dual_unit eyes from source to sink (requires netx-topology MCP layout engine in DSH).", { + source_view_id: str3(), + sink_view_id: str3(), + max_units: num3(), + min_nodes: num3(), + max_nodes: num3(), + max_batch_nodes: num3(), + layout_batch: bool3(), + until_empty: bool3(), + dry_run: bool3() + }, sinkTopologyDualUnits, getClient, t), + tool("netx__layoutTopologyView", "Layout / polish a canvas. DSH supports catalog + move_nodes|sink_nodes over HTTP; dual_unit/orbit/polish recipes need netx-topology MCP.", { + view_id: str3(), + action: str3(), + source_view_id: str3(), + recipe: str3(), + preset: str3(), + mode: str3("preview | apply"), + tune: bool3(), + params: { type: "object" }, + catalog: bool3(), + fabric_node_ids: strArr(), + park: bool3(), + copy_positions: bool3() + }, layoutTopologyView, getClient, Math.max(t, 180000)) + ]; + const disposers = catalog.filter((entry) => allow.has(entry.name)).map((entry) => ctx.tools.register(entry)); + return () => { + for (const dispose of disposers) + dispose(); + }; +} + +// src/netx/group-tools-plugin.ts +function applyGroupToolsPlugin(ctx, options) { + let unregisterTools; + let unregisterSkills; + let skillGeneration = 0; + const resolveGroups = () => { + const connection = getNetxConnection(); + if (options.mode === "forced") { + return groupsForced(options.only ?? []); + } + return groupsForPlane(connection?.groups, "preset", options.only); + }; + const remountTools = () => { + unregisterTools?.(); + unregisterTools = undefined; + const connection = getNetxConnection(); + if (connection === undefined) { + ctx.logger.warn("%s: no connection yet — waiting for host settings bridge", options.name); + return; + } + const enabled = resolveGroups(); + if (enabled.length === 0) { + ctx.logger.info("%s: no groups enabled", options.name); + return; + } + unregisterTools = registerNetxTools(ctx, connection, { + plane: "preset", + only: options.mode === "forced" ? enabled : options.only, + forceGroups: options.mode === "forced" ? enabled : undefined + }); + ctx.logger.info("%s: groups=[%s] → %s tokenConfigured=%s", options.name, enabled.join(",") || "(none)", connection.apiUrl, connection.token.trim().length > 0); + }; + remountTools(); + const stopToolWatch = watchNetxConnection(() => { + remountTools(); + }); + ctx.inject(["skills"], (skillsCtx) => { + const remountSkills = () => { + const gen = ++skillGeneration; + unregisterSkills?.(); + unregisterSkills = undefined; + if (getNetxConnection() === undefined) + return; + const enabled = resolveGroups(); + registerGroupSkills(skillsCtx, enabled, options.name).then((dispose) => { + if (gen !== skillGeneration) { + dispose(); + return; + } + unregisterSkills = dispose; + }).catch((error) => { + skillsCtx.logger.warn("%s: skill register failed: %s", options.name, error); + }); + }; + remountSkills(); + const stopSkillWatch = watchNetxConnection(() => { + remountSkills(); + }); + skillsCtx.effect(() => () => { + skillGeneration += 1; + stopSkillWatch(); + unregisterSkills?.(); + unregisterSkills = undefined; + }, `${options.name}: dispose skills`); + }); + ctx.effect(() => () => { + stopToolWatch(); + unregisterTools?.(); + unregisterTools = undefined; + }, `${options.name}: dispose tools`); +} + +// src/agent-tools-common.ts +var name = "netxops-tools-common"; +var inject = ["tools"]; +function apply(ctx) { + applyGroupToolsPlugin(ctx, { name, mode: "forced", only: ["common"] }); +} +export { + name, + inject, + apply +}; diff --git a/lib/agent-tools-nms.js b/lib/agent-tools-nms.js new file mode 100644 index 0000000..627575a --- /dev/null +++ b/lib/agent-tools-nms.js @@ -0,0 +1,1783 @@ +// src/netx/capability-groups.ts +var DEFAULT_CAPABILITY_GROUPS = Object.freeze({ + nms: Object.freeze({ inPreset: true, public: false }), + common: Object.freeze({ inPreset: true, public: false }), + topology: Object.freeze({ inPreset: false, public: false }) +}); +var TOOLS_BY_GROUP = Object.freeze({ + nms: Object.freeze([ + "netx__queryNmsAlarms", + "netx__aggregateNmsAlarms", + "netx__runNmsDiagnostics", + "netx__queryNmsNeInventory", + "netx__getNmsNe", + "netx__queryNmsAlarmsRaw", + "netx__aggregateNmsAlarmsRaw", + "netx__listNmsAlarmFields", + "netx__sqlQueryNms" + ]), + common: Object.freeze([ + "netx__listManagedNe", + "netx__getManagedNe", + "netx__execManagedNe", + "netx__listCliTargets", + "netx__findTopologyPaths" + ]), + topology: Object.freeze([ + "netx__getTopologyTree", + "netx__getTopologyView", + "netx__createTopologyFolder", + "netx__addTopologyViewNodes", + "netx__removeTopologyViewNodes", + "netx__copyTopologyViewNodes", + "netx__updateTopologyViewPositions", + "netx__projectTopologyNeighbors", + "netx__queryTopologyFabricNodes", + "netx__classifyTopologyFabricNodes", + "netx__queryTopologyNeighborhood", + "netx__queryTopologyEdges", + "netx__layoutTopologyView", + "netx__suggestSinkHubs", + "netx__analyzeTopologyViewLayout", + "netx__sinkTopologyDualUnits" + ]) +}); +var SKILL_DIR_BY_GROUP = Object.freeze({ + nms: "nms", + common: "common", + topology: "topology" +}); +var CAPABILITY_GROUP_IDS = Object.freeze([ + "nms", + "common", + "topology" +]); +function groupsForPlane(groups, plane, only) { + const policy = groups ?? DEFAULT_CAPABILITY_GROUPS; + const enabled = CAPABILITY_GROUP_IDS.filter((id) => plane === "preset" ? policy[id].inPreset : policy[id].public); + if (!only || only.length === 0) + return enabled; + const allow = new Set(only); + return enabled.filter((id) => allow.has(id)); +} +function groupsForced(only) { + return CAPABILITY_GROUP_IDS.filter((id) => only.includes(id)); +} +function toolNamesForGroups(groupIds) { + const names = new Set; + for (const id of groupIds) { + for (const tool of TOOLS_BY_GROUP[id]) + names.add(tool); + } + return names; +} + +// src/netx/group-skills.ts +import { existsSync } from "node:fs"; +import { readdir, readFile, stat } from "node:fs/promises"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; +function opsSkillsRoot() { + const envRoot = process.env.NETX_SKILLS_ROOT?.trim(); + if (envRoot && existsSync(envRoot)) + return envRoot; + const here = dirname(fileURLToPath(import.meta.url)); + const siblingCandidates = [ + join(here, "..", "..", "..", "netx", "skills"), + join(here, "..", "..", "netx", "skills") + ]; + for (const candidate of siblingCandidates) { + if (existsSync(candidate)) + return candidate; + } + const packaged = [ + join(here, "..", "presets", "netxops", "skills"), + join(here, "..", "..", "presets", "netxops", "skills") + ]; + for (const candidate of packaged) { + if (existsSync(candidate)) + return candidate; + } + return packaged[0]; +} +function parseFrontmatter(raw) { + if (!raw.startsWith("---")) + return; + const end = raw.indexOf(` +---`, 3); + if (end < 0) + return; + const yaml = raw.slice(3, end).replace(/^\r?\n/, ""); + const body = raw.slice(end + 4).replace(/^\r?\n/, ""); + const data = {}; + const lines = yaml.split(/\r?\n/); + for (let i = 0;i < lines.length; i += 1) { + const line = lines[i]; + const nameMatch = /^name\s*:\s*(.+)\s*$/.exec(line); + if (nameMatch) { + data.name = stripQuotes(nameMatch[1].trim()); + continue; + } + const descMatch = /^description\s*:\s*(.*)$/.exec(line); + if (!descMatch) + continue; + const head = descMatch[1].trim(); + if (head === ">" || head === ">-" || head === "|" || head === "|-") { + const parts = []; + while (i + 1 < lines.length && /^[ \t]+/.test(lines[i + 1])) { + i += 1; + parts.push(lines[i].trim()); + } + data.description = parts.filter(Boolean).join(" "); + continue; + } + data.description = stripQuotes(head); + } + return { data, body }; +} +function stripQuotes(value) { + if (value.startsWith('"') && value.endsWith('"') || value.startsWith("'") && value.endsWith("'")) { + return value.slice(1, -1); + } + return value; +} +async function loadSkillBundle(dir) { + const skillPath = join(dir, "SKILL.md"); + let raw; + try { + raw = await readFile(skillPath, "utf8"); + } catch { + return null; + } + const parsed = parseFrontmatter(raw); + if (!parsed) + return null; + const name = typeof parsed.data.name === "string" ? parsed.data.name.trim() : ""; + const description = typeof parsed.data.description === "string" ? parsed.data.description.trim() : ""; + if (!name || !description) + return null; + return { + name, + description, + content: parsed.body.trimStart(), + path: skillPath, + directory: dir + }; +} +async function loadGroupSkills(skillsRoot, groupId) { + const groupDir = join(skillsRoot, SKILL_DIR_BY_GROUP[groupId]); + let entries; + try { + entries = await readdir(groupDir); + } catch { + return []; + } + const skills = []; + for (const entry of entries) { + const full = join(groupDir, entry); + let isDir = false; + try { + isDir = (await stat(full)).isDirectory(); + } catch { + continue; + } + if (!isDir) + continue; + const skill = await loadSkillBundle(full); + if (skill) + skills.push(skill); + } + return skills; +} +async function registerGroupSkills(ctx, groupIds, providerLabel) { + const skillsApi = ctx.skills; + if (!skillsApi || typeof skillsApi.register !== "function") { + return () => {}; + } + const root = opsSkillsRoot(); + const disposers = []; + const enabled = new Set(groupIds); + for (const groupId of CAPABILITY_GROUP_IDS) { + if (!enabled.has(groupId)) + continue; + const skills = await loadGroupSkills(root, groupId); + for (const skill of skills) { + disposers.push(skillsApi.register({ + name: skill.name, + description: skill.description, + content: skill.content, + path: skill.path, + resourceBase: { kind: "directory", path: skill.directory }, + provider: providerLabel, + source: "custom" + })); + } + } + return () => { + for (const dispose of disposers) + dispose(); + }; +} + +// src/netx/runtime.ts +var STORE_KEY = Symbol.for("dsh-netxops.connection-store"); +function store() { + const root = globalThis; + let current = root[STORE_KEY]; + if (current === undefined) { + current = { connection: undefined, listeners: new Set }; + root[STORE_KEY] = current; + } + return current; +} +function getNetxConnection() { + return store().connection; +} +function watchNetxConnection(listener) { + const state = store(); + state.listeners.add(listener); + return () => { + state.listeners.delete(listener); + }; +} + +// src/netx/tools.ts +import { defineTool } from "@deepseek-ai/dsh-tools"; + +// src/netx/http.ts +var PROTOCOL_KEY_ZH_TO_EN = { + 其他: "Other", + 时钟: "Clock", + "OTN/光": "OTN/Optical", + 电源: "Power" +}; +function localizePayload(lang, data) { + if (!lang.trim().toLowerCase().startsWith("en")) + return data; + const proto = data.protocol_summary; + if (!Array.isArray(proto)) + return data; + for (const row of proto) { + if (typeof row !== "object" || row === null || Array.isArray(row)) + continue; + const rec = row; + const key = typeof rec.key === "string" ? rec.key : ""; + const mapped = PROTOCOL_KEY_ZH_TO_EN[key]; + if (mapped !== undefined) + rec.key = mapped; + } + return data; +} +function encodeQuery(params) { + const sp = new URLSearchParams; + for (const [key, value] of Object.entries(params)) { + sp.set(key, String(value)); + } + const q = sp.toString(); + return q.length > 0 ? `?${q}` : ""; +} +function createNetxClient(connection) { + const base = connection.apiUrl.replace(/\/$/, ""); + const langParams = () => { + const lang = connection.lang.trim().toLowerCase(); + if (lang.startsWith("en")) + return { lang: "en" }; + return {}; + }; + async function request(method, path, options = {}) { + const token = connection.getToken().trim(); + if (token.length === 0) { + return { + ok: false, + error: "netx_token_missing", + detail: "Set credential NETX_API_TOKEN (Plugins → Netx Ops or scripts/set-netx-token)." + }; + } + const merged = { ...langParams(), ...options.params }; + const url = `${base}${path}${encodeQuery(merged)}`; + const timeoutMs = options.timeoutMs ?? connection.timeoutMs; + const controller = new AbortController; + const timer = setTimeout(() => { + controller.abort(); + }, timeoutMs); + const onOuterAbort = () => { + controller.abort(); + }; + options.signal?.addEventListener("abort", onOuterAbort, { once: true }); + try { + const headers = { + accept: "application/json", + authorization: `Bearer ${token}` + }; + if (options.body !== undefined) + headers["content-type"] = "application/json"; + const init = { + method, + headers, + signal: controller.signal + }; + if (options.body !== undefined) + init.body = JSON.stringify(options.body); + const resp = await fetch(url, init); + const text = await resp.text(); + if (!resp.ok) { + return { ok: false, error: `netx_http_${resp.status}`, detail: text.slice(0, 800) }; + } + const data = text.length > 0 ? JSON.parse(text) : {}; + if (typeof data === "object" && data !== null && !Array.isArray(data)) { + return { ok: true, data: localizePayload(connection.lang, data) }; + } + return { ok: true, data: { raw: data } }; + } catch (error) { + const detail = error instanceof Error ? error.message : String(error); + return { ok: false, error: "netx_request_failed", detail: detail.slice(0, 800) }; + } finally { + clearTimeout(timer); + options.signal?.removeEventListener("abort", onOuterAbort); + } + } + return { + get(path, params, signal, timeoutMs) { + return request("GET", path, { params, signal, timeoutMs }); + }, + post(path, body, signal, timeoutMs) { + return request("POST", path, { body, signal, timeoutMs }); + }, + patch(path, body, signal, timeoutMs) { + return request("PATCH", path, { body, signal, timeoutMs }); + } + }; +} +function quoteNeId(neId) { + return encodeURIComponent(neId.trim()); +} + +// src/netx/handlers.ts +var EXEC_MAX_COMMANDS = 5; +var UME_RAW_FIELD_PRESETS = { + brief: [ + "alarm_alarm_key", + "alarm_host_name", + "alarm_perceived_severity", + "alarm_event_type", + "alarm_last_seen_at", + "ne_host_name", + "ne_user_label", + "ne_ne_name", + "ne_ip_address", + "ne_exists" + ], + evidence: [ + "alarm_alarm_key", + "alarm_host_name", + "alarm_object_name", + "alarm_event_type", + "alarm_native_probable_cause", + "alarm_perceived_severity", + "alarm_is_cleared", + "alarm_time_created", + "alarm_last_seen_at", + "ne_host_name", + "ne_user_label", + "ne_ne_name", + "ne_ip_address", + "ne_connection_status", + "ne_exists" + ], + ne_debug: [ + "alarm_alarm_key", + "alarm_ne_id", + "alarm_perceived_severity", + "alarm_last_seen_at", + "ne_user_label", + "ne_ne_name", + "ne_ip_address", + "ne_ipv6_address", + "ne_device_level", + "ne_host_name", + "ne_connection_status", + "ne_admin_status", + "ne_address_type", + "ne_maintain_status", + "ne_exists" + ] +}; +function asRecord(value) { + return typeof value === "object" && value !== null && !Array.isArray(value) ? value : {}; +} +function str(args, key, fallback = "") { + const v = args[key]; + if (typeof v === "string") + return v; + if (typeof v === "number" || typeof v === "boolean") + return String(v); + return fallback; +} +function nmsOrUme(args, nmsKey, umeKey) { + return str(args, nmsKey).trim() || str(args, umeKey).trim(); +} +function nmsOrUmeList(args, nmsKey, umeKey) { + const primary = strList(args, nmsKey); + return primary.length > 0 ? primary : strList(args, umeKey); +} +function num(args, key) { + const v = args[key]; + return typeof v === "number" && Number.isFinite(v) ? v : undefined; +} +function bool(args, key) { + const v = args[key]; + return typeof v === "boolean" ? v : undefined; +} +function strList(args, key) { + const v = args[key]; + if (!Array.isArray(v)) + return []; + return v.map((x) => String(x).trim()).filter((x) => x.length > 0); +} +function clampInt(value, fallback, min, max) { + const n = value === undefined ? fallback : Math.trunc(value); + return Math.max(min, Math.min(max, n)); +} +function putStr(params, args, keys) { + for (const key of keys) { + const v = str(args, key).trim(); + if (v) + params[key] = v; + } +} +async function queryUmeAlarms(client, args, signal) { + let page = clampInt(num(args, "page"), 1, 1, 2); + const pageSize = clampInt(num(args, "page_size"), 50, 1, 500); + const params = { page, page_size: pageSize }; + putStr(params, args, ["severity", "ne_id", "host_name", "time_from", "time_to"]); + const keyword = str(args, "keyword").trim(); + const neName = str(args, "ne_name").trim(); + if (keyword) + params.keyword = keyword; + else if (neName) + params.keyword = neName; + return client.get("/v1/ume/alarms", params, signal); +} +async function aggregateUmeAlarmsRaw(client, args, signal) { + const params = {}; + putStr(params, args, [ + "group_by", + "group_by2", + "severity", + "is_cleared", + "ne_id", + "event_type", + "keyword", + "time_from", + "time_to", + "limit" + ]); + if ("exclude_missing_host" in args) { + const flag = bool(args, "exclude_missing_host"); + if (flag !== undefined) + params.exclude_missing_host = flag; + } + return client.get("/v1/ume/alarms/aggregate/raw", params, signal); +} +async function aggregateUmeAlarms(client, args, signal) { + if (str(args, "group_by").trim()) + return aggregateUmeAlarmsRaw(client, args, signal); + const topNe = clampInt(num(args, "top_ne"), 50, 0, 500); + const params = { top_ne: topNe }; + if ("exclude_missing_host" in args) { + const flag = bool(args, "exclude_missing_host"); + if (flag !== undefined) + params.exclude_missing_host = flag; + } + putStr(params, args, ["severity", "time_from", "time_to"]); + return client.get("/v1/ume/alarms/aggregate", params, signal); +} +async function runUmeDiagnostics(client, _args, signal) { + return client.get("/v1/ume/diagnostics", undefined, signal); +} +async function queryUmeNeInventory(client, args, signal) { + const params = { + page: clampInt(num(args, "page"), 1, 1, Number.MAX_SAFE_INTEGER), + page_size: clampInt(num(args, "page_size"), 50, 1, 500) + }; + putStr(params, args, ["keyword"]); + return client.get("/v1/ume/inventory/ne", params, signal); +} +async function getUmeNe(client, args, signal) { + const neId = str(args, "ne_id").trim(); + if (!neId) + return { ok: false, error: "ne_id_required", error_code: "ne_id_required" }; + return client.get(`/v1/ume/inventory/ne/${quoteNeId(neId)}`, undefined, signal); +} +async function queryUmeAlarmsRaw(client, args, signal) { + const params = { + page: clampInt(num(args, "page"), 1, 1, Number.MAX_SAFE_INTEGER), + page_size: clampInt(num(args, "page_size"), 50, 1, 500) + }; + putStr(params, args, [ + "severity", + "is_cleared", + "ne_id", + "event_type", + "keyword", + "time_from", + "time_to", + "order_by", + "order" + ]); + let fields = strList(args, "select_fields"); + if (fields.length === 0) { + const preset = str(args, "field_preset").trim().toLowerCase(); + fields = UME_RAW_FIELD_PRESETS[preset] ?? []; + } + if (fields.length > 0) + params.select_fields = fields.join(","); + return client.get("/v1/ume/alarms/raw", params, signal); +} +async function listUmeAlarmFields(client, _args, signal) { + return client.get("/v1/ume/alarms/fields", undefined, signal); +} +async function sqlQueryUme(client, args, signal) { + const sql = str(args, "sql").trim(); + if (!sql) + return { ok: false, error: "sql_required" }; + const limit = clampInt(num(args, "limit"), 200, 1, 2000); + const statementTimeoutMs = clampInt(num(args, "statement_timeout_ms"), 0, 0, 30000); + return client.post("/v1/sql/ume_query", { + sql, + limit, + statement_timeout_ms: statementTimeoutMs + }, signal, 60000); +} +async function listManagedNe(client, args, signal) { + const keyword = str(args, "keyword").trim(); + const vendor = str(args, "vendor").trim(); + const connectStatus = str(args, "connect_status").trim(); + if (!(keyword || vendor || connectStatus)) { + return { ok: false, error: "managed_ne_filter_required", error_code: "managed_ne_filter_required" }; + } + if (keyword && keyword.length < 2) { + return { ok: false, error: "managed_ne_keyword_too_short", error_code: "managed_ne_keyword_too_short" }; + } + const params = { + page: clampInt(num(args, "page"), 1, 1, Number.MAX_SAFE_INTEGER), + page_size: clampInt(num(args, "page_size"), 20, 1, 100) + }; + if (keyword) + params.keyword = keyword; + if (vendor) + params.vendor = vendor; + if (connectStatus) + params.connect_status = connectStatus; + return client.get("/v1/managed-ne", params, signal); +} +async function getManagedNe(client, args, signal) { + const neId = (str(args, "ne_id") || str(args, "managed_ne_id") || str(args, "id")).trim(); + if (!neId) { + return { + ok: false, + error: "ne_id_required", + error_code: "ne_id_required", + hint: "Pass managed NE id from listManagedNe/listCliTargets (source=managed). For NMS inventory UUIDs use execManagedNe(nms_ne_id=...) or getNmsNe, not getManagedNe.", + example: { ne_id: "" } + }; + } + const out = await client.get(`/v1/managed-ne/${quoteNeId(neId)}`, undefined, signal); + if (out.ok === false) { + const detail = `${str(out, "detail")}${str(out, "error")}`.toLowerCase(); + if (detail.includes("404") || detail.includes("not_found") || detail.includes("not found") || out.error === "netx_http_404") { + return { + ...out, + hint: "Managed NE not found for this ne_id. Call listManagedNe(keyword=...) or listCliTargets(source=managed) first. If this is an NMS inventory id, use execManagedNe(nms_ne_id=...) / getNmsNe instead of getManagedNe." + }; + } + } + return out; +} +async function execManagedNe(client, args, signal) { + const targetsRaw = args.targets; + const neIds = strList(args, "ne_ids"); + const umeNeIds = nmsOrUmeList(args, "nms_ne_ids", "ume_ne_ids"); + const sharedCommands = strList(args, "commands"); + const multi = Array.isArray(targetsRaw) && targetsRaw.length > 0 || neIds.length > 0 || umeNeIds.length > 0; + if (multi) { + const body2 = {}; + if (Array.isArray(targetsRaw) && targetsRaw.length > 0) { + const cleaned = []; + for (const t of targetsRaw) { + if (typeof t !== "object" || t === null || Array.isArray(t)) + continue; + const row = t; + const item = {}; + const neId2 = str(row, "ne_id").trim(); + const umeNeId2 = nmsOrUme(row, "nms_ne_id", "ume_ne_id"); + if (neId2) + item.ne_id = neId2; + if (umeNeId2) + item.ume_ne_id = umeNeId2; + const cmds = Array.isArray(row.commands) ? row.commands.map((c) => String(c).trim()).filter((c) => c.length > 0) : []; + if (cmds.length > 0) + item.commands = cmds; + if (Object.keys(item).length > 0) + cleaned.push(item); + } + body2.targets = cleaned; + } + if (neIds.length > 0) + body2.ne_ids = neIds; + if (umeNeIds.length > 0) + body2.ume_ne_ids = umeNeIds; + if (sharedCommands.length > 0) { + if (sharedCommands.length > EXEC_MAX_COMMANDS) { + return { ok: false, error: "too_many_commands", error_code: "too_many_commands" }; + } + body2.commands = sharedCommands; + } + body2.read_timeout_sec = clampInt(num(args, "read_timeout_sec"), 60, 10, 120); + const concurrency = num(args, "concurrency"); + if (concurrency !== undefined) + body2.concurrency = clampInt(concurrency, 4, 1, 8); + const out2 = await client.post("/v1/managed-ne/exec-batch", body2, signal, 600000); + if (out2.ok !== true) + return out2; + const data2 = asRecord(out2.data); + if (data2.ok === false) { + return { ok: false, data: data2, error: str(data2, "error", "exec_batch_failed") }; + } + return { ok: true, data: data2 }; + } + const neId = str(args, "ne_id").trim(); + const umeNeId = nmsOrUme(args, "nms_ne_id", "ume_ne_id"); + if (Boolean(neId) === Boolean(umeNeId)) { + return { + ok: false, + error: "exactly_one_of_ne_id_or_nms_ne_id_required", + error_code: "exactly_one_of_ne_id_or_nms_ne_id_required", + hint: "For one NE pass ne_id OR nms_ne_id (alias ume_ne_id). For many NEs pass ne_ids / nms_ne_ids with shared commands, or targets[] — one call, concurrent on server." + }; + } + if (sharedCommands.length === 0) { + return { ok: false, error: "commands_required", error_code: "commands_required" }; + } + if (sharedCommands.length > EXEC_MAX_COMMANDS) { + return { ok: false, error: "too_many_commands", error_code: "too_many_commands" }; + } + const body = { + commands: sharedCommands, + read_timeout_sec: clampInt(num(args, "read_timeout_sec"), 60, 10, 120) + }; + if (neId) + body.ne_id = neId; + if (umeNeId) + body.ume_ne_id = umeNeId; + const out = await client.post("/v1/managed-ne/exec", body, signal, 300000); + if (out.ok !== true) + return out; + const data = asRecord(out.data); + if (data.ok === false) { + return { ok: false, data, error: str(data, "error", "exec_failed") }; + } + return { ok: true, data }; +} +async function listCliTargets(client, args, signal) { + const params = { + page: clampInt(num(args, "page"), 1, 1, Number.MAX_SAFE_INTEGER), + page_size: clampInt(num(args, "page_size"), 50, 1, 500) + }; + putStr(params, args, ["source", "keyword"]); + return client.get("/v1/cli/targets", params, signal); +} +async function findTopologyPaths(client, args, signal) { + const fromUid = nmsOrUme(args, "from_nms_ne_id", "from_ume_ne_id"); + const fromMid = str(args, "from_managed_ne_id").trim(); + const toUid = nmsOrUme(args, "to_nms_ne_id", "to_ume_ne_id"); + const toMid = str(args, "to_managed_ne_id").trim(); + if (Boolean(fromUid) === Boolean(fromMid)) { + return { ok: false, error: "exactly_one_of_from_nms_ne_id_or_from_managed_ne_id_required" }; + } + if (Boolean(toUid) === Boolean(toMid)) { + return { ok: false, error: "exactly_one_of_to_nms_ne_id_or_to_managed_ne_id_required" }; + } + let detail = str(args, "detail", "summary").trim().toLowerCase() || "summary"; + if (detail !== "summary" && detail !== "full") + detail = "summary"; + const body = { + max_paths: clampInt(num(args, "max_paths"), 3, 1, 10), + max_hops: clampInt(num(args, "max_hops"), 6, 1, 12), + layer: str(args, "layer", "physical").trim() || "physical", + detail + }; + if (fromUid) + body.from_ume_ne_id = fromUid; + else + body.from_managed_ne_id = fromMid; + if (toUid) + body.to_ume_ne_id = toUid; + else + body.to_managed_ne_id = toMid; + return client.post("/v1/topology/fabric/paths", body, signal, 30000); +} + +// src/netx/topology-handlers.ts +function asRecord2(value) { + return typeof value === "object" && value !== null && !Array.isArray(value) ? value : {}; +} +function str2(args, key, fallback = "") { + const v = args[key]; + if (typeof v === "string") + return v; + if (typeof v === "number" || typeof v === "boolean") + return String(v); + return fallback; +} +function num2(args, key) { + const v = args[key]; + return typeof v === "number" && Number.isFinite(v) ? v : undefined; +} +function bool2(args, key) { + const v = args[key]; + return typeof v === "boolean" ? v : undefined; +} +function strList2(args, key) { + const v = args[key]; + if (!Array.isArray(v)) + return []; + return v.map((x) => String(x).trim()).filter((x) => x.length > 0); +} +function clampInt2(value, fallback, min, max) { + const n = value === undefined ? fallback : Math.trunc(value); + return Math.max(min, Math.min(max, n)); +} +function unwrap(out) { + if (out.ok !== true) + return out; + const data = asRecord2(out.data); + if (Object.keys(data).length === 0) + return { ok: true, data: out.data }; + return { ok: true, ...data, data }; +} +function filterFields(args) { + const out = {}; + for (const key of ["keyword", "role", "vendor", "link_status"]) { + const v = str2(args, key).trim(); + if (v) + out[key] = v; + } + return out; +} +function edgeEndpoints(edge) { + const a = str2(edge, "a_node_id") || str2(edge, "a"); + const b = str2(edge, "b_node_id") || str2(edge, "b"); + return [a.trim(), b.trim()]; +} +function collapseEdgesToLinks(edges, includeNames = false) { + const buckets = new Map; + for (const raw of edges) { + if (!raw || typeof raw !== "object" || Array.isArray(raw)) + continue; + const e = raw; + const [a, b] = edgeEndpoints(e); + if (!a || !b || a === b) + continue; + const lo = a < b ? a : b; + const hi = a < b ? b : a; + const key = `${lo}\x00${hi}`; + const row = buckets.get(key) ?? { + a_node_id: lo, + b_node_id: hi, + link_count: 0 + }; + row.link_count = Number(row.link_count || 0) + 1; + if (includeNames && !row.a_name) { + if (a === lo) { + row.a_name = str2(e, "a_name"); + row.b_name = str2(e, "b_name"); + } else { + row.a_name = str2(e, "b_name"); + row.b_name = str2(e, "a_name"); + } + } + buckets.set(key, row); + } + return [...buckets.values()]; +} +function summarizeViewGraph(graph, sample = 20) { + const nodes = Array.isArray(graph.nodes) ? graph.nodes : []; + const edges = Array.isArray(graph.edges) ? graph.edges : Array.isArray(graph.links) ? graph.links : []; + const sampleNodes = nodes.slice(0, Math.max(0, sample)).map((n) => { + const row = asRecord2(n); + return { + id: str2(row, "id") || str2(row, "fabric_node_id"), + name: str2(row, "name"), + x: row.x, + y: row.y, + level: row.level, + role: row.role + }; + }); + return { + ok: true, + view_id: str2(graph, "id") || str2(graph, "view_id"), + node_count: nodes.length, + edge_count: edges.length, + link_count: collapseEdgesToLinks(edges).length, + links: collapseEdgesToLinks(edges), + sample_nodes: sampleNodes, + detail: "summary" + }; +} +function layoutRecipeUnavailable(action) { + return { + ok: false, + error: "layout_recipe_requires_netx_topology_mcp", + action, + hint: "Canvas CRUD/query and move_nodes work in dsh-netxops. Dual-unit / orbit / polish recipes still need the netx-topology MCP layout engine (or enable that MCP alongside)." + }; +} +async function getTopologyTree(client, args, signal) { + const out = unwrap(await client.get("/v1/topology/tree", undefined, signal)); + if (out.ok !== true) + return out; + const compact = bool2(args, "compact") !== false; + const maxDepth = num2(args, "max_depth"); + if (!compact && maxDepth === undefined) + return out; + return { ...out, compact, max_depth: maxDepth ?? null }; +} +async function getTopologyView(client, args, signal) { + const viewId = str2(args, "view_id").trim(); + if (!viewId) + return { ok: false, error: "view_id_required" }; + const out = unwrap(await client.get(`/v1/topology/views/${encodeURIComponent(viewId)}`, undefined, signal, 120000)); + if (out.ok !== true) + return out; + const detail = str2(args, "detail", "summary").trim().toLowerCase() || "summary"; + if (detail === "full") + return { ...out, detail: "full" }; + return summarizeViewGraph(out, clampInt2(num2(args, "sample"), 20, 0, 200)); +} +async function createTopologyFolder(client, args, signal) { + const name = str2(args, "name").trim(); + if (!name) + return { ok: false, error: "name_required" }; + const body = { name }; + const parentId = str2(args, "parent_id").trim(); + if (parentId) + body.parent_id = parentId; + const locale = str2(args, "locale").trim(); + if (locale) + body.locale = locale; + if (num2(args, "sort_order") !== undefined) + body.sort_order = Math.trunc(num2(args, "sort_order")); + return unwrap(await client.post("/v1/topology/folders", body, signal)); +} +async function addTopologyViewNodes(client, args, signal) { + const viewId = str2(args, "view_id").trim(); + if (!viewId) + return { ok: false, error: "view_id_required" }; + if (strList2(args, "managed_ne_ids").length || strList2(args, "ume_ne_ids").length) { + return { + ok: false, + error: "fabric_nodes_only", + detail: "Use keyword/role/vendor/link_status or fabric_node_ids; never managed/UME ids." + }; + } + const filters = filterFields(args); + const fabricIds = strList2(args, "fabric_node_ids"); + if (Object.keys(filters).length === 0 && fabricIds.length === 0) { + return { + ok: false, + error: "filter_or_fabric_node_ids_required", + detail: "Pass keyword/role/vendor/link_status (preferred) or fabric_node_ids." + }; + } + if (num2(args, "max_nodes") !== undefined) { + const got = unwrap(await client.get(`/v1/topology/views/${encodeURIComponent(viewId)}`, undefined, signal)); + if (got.ok === true) { + const data = asRecord2(got.data); + const filt = asRecord2(got.filter ?? data.filter); + const membership = asRecord2(filt.membership ?? {}); + membership.max_nodes = clampInt2(num2(args, "max_nodes"), 2000, 1, 2000); + filt.membership = membership; + await client.patch(`/v1/topology/views/${encodeURIComponent(viewId)}`, { filter: filt }, signal); + } + } + const layout = str2(args, "layout", "grid").trim() || "grid"; + const body = { + managed_ne_ids: [], + layout, + ...filters + }; + if (fabricIds.length) + body.fabric_node_ids = fabricIds; + if (num2(args, "limit") !== undefined) + body.limit = clampInt2(num2(args, "limit"), 500, 1, 2000); + if (num2(args, "offset") !== undefined) + body.offset = clampInt2(num2(args, "offset"), 0, 0, Number.MAX_SAFE_INTEGER); + return unwrap(await client.post(`/v1/topology/views/${encodeURIComponent(viewId)}/nodes`, body, signal, 180000)); +} +async function removeTopologyViewNodes(client, args, signal) { + const viewId = str2(args, "view_id").trim(); + if (!viewId) + return { ok: false, error: "view_id_required" }; + const body = { ...filterFields(args) }; + const fabricIds = strList2(args, "fabric_node_ids"); + if (fabricIds.length) + body.fabric_node_ids = fabricIds; + return unwrap(await client.post(`/v1/topology/views/${encodeURIComponent(viewId)}/nodes/remove`, body, signal)); +} +async function copyTopologyViewNodes(client, args, signal) { + const sourceId = str2(args, "source_view_id").trim(); + const targetId = str2(args, "target_view_id").trim(); + if (!sourceId || !targetId) { + return { ok: false, error: "source_view_id_and_target_view_id_required" }; + } + const source = unwrap(await client.get(`/v1/topology/views/${encodeURIComponent(sourceId)}`, undefined, signal, 120000)); + if (source.ok !== true) + return source; + const nodes = Array.isArray(source.nodes) ? source.nodes : []; + const limit = num2(args, "limit"); + const selected = typeof limit === "number" ? nodes.slice(0, clampInt2(limit, nodes.length, 1, 2000)) : nodes; + const fabricIds = selected.map((n) => { + const row = asRecord2(n); + return str2(row, "id") || str2(row, "fabric_node_id"); + }).filter(Boolean); + if (bool2(args, "dry_run") === true) { + return { ok: true, dry_run: true, would_copy: fabricIds.length, source_view_id: sourceId, target_view_id: targetId }; + } + if (bool2(args, "clear_target") === true) { + await client.post(`/v1/topology/views/${encodeURIComponent(targetId)}/nodes/remove`, { + fabric_node_ids: "ALL", + clear_all: true + }, signal); + } + if (fabricIds.length === 0) { + return { ok: true, added: 0, source_view_id: sourceId, target_view_id: targetId }; + } + const add = unwrap(await client.post(`/v1/topology/views/${encodeURIComponent(targetId)}/nodes`, { fabric_node_ids: fabricIds, layout: "keep", managed_ne_ids: [] }, signal, 180000)); + if (add.ok !== true) + return add; + if (bool2(args, "copy_positions") !== false) { + const ox = num2(args, "offset_x") ?? 0; + const oy = num2(args, "offset_y") ?? 0; + const positions = selected.map((n) => { + const row = asRecord2(n); + const id = str2(row, "id") || str2(row, "fabric_node_id"); + return { + fabric_node_id: id, + x: Number(row.x || 0) + ox, + y: Number(row.y || 0) + oy + }; + }).filter((p) => p.fabric_node_id); + if (positions.length) { + await client.patch(`/v1/topology/views/${encodeURIComponent(targetId)}/positions`, { positions }, signal, 120000); + } + } + return { ok: true, added: fabricIds.length, source_view_id: sourceId, target_view_id: targetId, ...asRecord2(add) }; +} +async function updateTopologyViewPositions(client, args, signal) { + const viewId = str2(args, "view_id").trim(); + if (!viewId) + return { ok: false, error: "view_id_required" }; + const body = {}; + if (Array.isArray(args.positions)) + body.positions = args.positions; + const layout = str2(args, "layout").trim(); + if (layout) + body.layout = layout; + Object.assign(body, filterFields(args)); + for (const key of ["offset_x", "offset_y", "cols", "gap_x", "gap_y"]) { + if (num2(args, key) !== undefined) + body[key] = num2(args, key); + } + const fabricIds = strList2(args, "fabric_node_ids"); + if (fabricIds.length) + body.fabric_node_ids = fabricIds; + return unwrap(await client.patch(`/v1/topology/views/${encodeURIComponent(viewId)}/positions`, body, signal, 120000)); +} +async function projectTopologyNeighbors(client, args, signal) { + const viewId = str2(args, "view_id").trim(); + if (!viewId) + return { ok: false, error: "view_id_required" }; + const body = {}; + const seeds = strList2(args, "seed_fabric_node_ids").length ? strList2(args, "seed_fabric_node_ids") : strList2(args, "fabric_node_ids"); + if (seeds.length) + body.seed_fabric_node_ids = seeds; + const mids = strList2(args, "managed_ne_ids"); + if (mids.length) + body.managed_ne_ids = mids; + const region = str2(args, "region_folder_id").trim(); + if (region) + body.region_folder_id = region; + const out = unwrap(await client.post(`/v1/topology/views/${encodeURIComponent(viewId)}/project-neighbors`, body, signal, 180000)); + if (out.ok !== true) + return out; + const detail = str2(args, "detail", "summary").trim().toLowerCase() || "summary"; + if (detail === "full" || detail === "raw" || detail === "graph") { + return { ...out, detail: "full", projected: true }; + } + return { ...summarizeViewGraph(out, clampInt2(num2(args, "sample"), 20, 0, 100)), projected: true, view_id: viewId }; +} +async function queryTopologyFabricNodes(client, args, signal) { + let mode = str2(args, "mode").trim().toLowerCase(); + const q = str2(args, "q").trim(); + const keyword = str2(args, "keyword").trim(); + if (!mode) { + if (bool2(args, "summary") === true) + mode = "summary"; + else if (q) + mode = "search"; + else + mode = "list"; + } + if (mode === "summary" || mode === "stats" || mode === "count") { + const out2 = unwrap(await client.get("/v1/topology/fabric/summary", undefined, signal)); + return out2.ok === true ? { ...out2, mode: "summary" } : out2; + } + if (mode === "search" || mode === "find") { + const needle = q || keyword; + if (!needle) + return { ok: false, error: "q_required", hint: "mode=search needs q (or keyword)." }; + return unwrap(await client.get("/v1/topology/fabric/nodes/search", { + q: needle, + page: clampInt2(num2(args, "page"), 1, 1, Number.MAX_SAFE_INTEGER), + page_size: clampInt2(num2(args, "page_size") ?? num2(args, "limit"), 50, 1, 200) + }, signal)); + } + const params = { + page: clampInt2(num2(args, "page"), 1, 1, Number.MAX_SAFE_INTEGER), + page_size: clampInt2(num2(args, "page_size") ?? num2(args, "limit"), 50, 1, 500) + }; + for (const key of ["keyword", "role", "level", "level_major", "region_folder_id", "link_status"]) { + const v = str2(args, key).trim() || (key === "keyword" ? q : ""); + if (v) + params[key] = v; + } + const out = unwrap(await client.get("/v1/topology/fabric/nodes", params, signal)); + return out.ok === true ? { ...out, mode: "list" } : out; +} +async function classifyTopologyFabricNodes(client, args, signal) { + const action = str2(args, "action").trim().toLowerCase(); + if (!action) + return { ok: false, error: "action_required" }; + if (action === "match") { + const pattern = str2(args, "pattern").trim() || str2(args, "q").trim(); + if (!pattern) + return { ok: false, error: "pattern_required" }; + return unwrap(await client.post("/v1/topology/fabric/nodes/match", { + pattern, + match_field: str2(args, "match_field", "name").trim() || "name", + sample_limit: clampInt2(num2(args, "sample_limit"), 50, 1, 200) + }, signal)); + } + if (action === "tag") { + const body = { + fabric_node_ids: strList2(args, "fabric_node_ids"), + dry_run: bool2(args, "dry_run") === true + }; + const pattern = str2(args, "pattern").trim() || str2(args, "q").trim(); + if (pattern) + body.pattern = pattern; + if (args.level !== undefined) + body.level = args.level; + const role = str2(args, "role").trim(); + if (role) + body.role = role; + const region = str2(args, "region_folder_id").trim(); + if (region) + body.region_folder_id = region; + if (bool2(args, "clear_region") === true) + body.clear_region = true; + return unwrap(await client.post("/v1/topology/fabric/nodes/tags/bulk", body, signal)); + } + if (action === "patch") { + const nodeId = str2(args, "fabric_node_id").trim() || str2(args, "node_id").trim(); + if (!nodeId) + return { ok: false, error: "fabric_node_id_required" }; + const body = {}; + if (args.level !== undefined) + body.level = args.level; + const role = str2(args, "role").trim(); + if (role) + body.role = role; + const region = str2(args, "region_folder_id").trim(); + if (region) + body.region_folder_id = region; + if (bool2(args, "clear_region") === true) + body.clear_region = true; + return unwrap(await client.patch(`/v1/topology/fabric/nodes/${encodeURIComponent(nodeId)}/tags`, body, signal)); + } + if (action === "unmatched") { + return unwrap(await client.get("/v1/topology/classify/unmatched", { + kind: str2(args, "kind", "any").trim() || "any", + page: clampInt2(num2(args, "page"), 1, 1, Number.MAX_SAFE_INTEGER), + page_size: clampInt2(num2(args, "page_size") ?? num2(args, "limit"), 50, 1, 500) + }, signal)); + } + if (action === "preview_rules") { + return unwrap(await client.post("/v1/topology/classify/preview", {}, signal)); + } + if (action === "apply_rules") { + const params = {}; + if (bool2(args, "overwrite_manual") === true) + params.skip_manual = false; + else if (bool2(args, "skip_manual") !== undefined) + params.skip_manual = bool2(args, "skip_manual") !== false; + if (bool2(args, "fill_empty_only") === true) + params.fill_empty_only = true; + return unwrap(await client.post("/v1/topology/classify/apply", {}, signal)); + } + if (action === "list_rules") { + return unwrap(await client.get("/v1/topology/classify/rules", undefined, signal)); + } + return { ok: false, error: "unknown_action", action }; +} +async function queryTopologyNeighborhood(client, args, signal) { + const nodeId = str2(args, "node_id").trim(); + if (!nodeId) + return { ok: false, error: "node_id_required" }; + const out = unwrap(await client.get("/v1/topology/fabric/neighborhood", { + node_id: nodeId, + depth: clampInt2(num2(args, "depth"), 1, 1, 3), + layer: str2(args, "layer", "physical").trim() || "physical" + }, signal)); + if (out.ok !== true) + return out; + const edges = Array.isArray(out.edges) ? out.edges : []; + return { ...out, links: collapseEdgesToLinks(edges) }; +} +async function queryTopologyEdges(client, args, signal) { + const params = { + page: clampInt2(num2(args, "page"), 1, 1, Number.MAX_SAFE_INTEGER), + page_size: clampInt2(num2(args, "page_size"), 100, 1, 500), + layer: str2(args, "layer", "physical").trim() || "physical" + }; + for (const key of ["node_id", "keyword", "status", "source"]) { + const v = str2(args, key).trim(); + if (v) + params[key] = v; + } + const out = unwrap(await client.get("/v1/topology/fabric/edges", params, signal)); + if (out.ok !== true) + return out; + const detail = str2(args, "detail", "adjacency").trim().toLowerCase() || "adjacency"; + const items = Array.isArray(out.items) ? out.items : Array.isArray(out.edges) ? out.edges : []; + if (detail === "ports") + return { ...out, detail: "ports", items }; + const links = collapseEdgesToLinks(items, true); + return { ...out, detail: "adjacency", links, items: undefined }; +} +async function suggestSinkHubs(client, args, signal) { + const viewId = str2(args, "source_view_id").trim() || str2(args, "view_id").trim(); + if (!viewId) + return { ok: false, error: "source_view_id_required" }; + const out = unwrap(await client.get(`/v1/topology/views/${encodeURIComponent(viewId)}`, undefined, signal, 120000)); + if (out.ok !== true) + return out; + const nodes = Array.isArray(out.nodes) ? out.nodes : []; + const edges = Array.isArray(out.edges) ? out.edges : []; + const degree = new Map; + for (const raw of edges) { + const [a, b] = edgeEndpoints(asRecord2(raw)); + if (!a || !b) + continue; + degree.set(a, (degree.get(a) || 0) + 1); + degree.set(b, (degree.get(b) || 0) + 1); + } + const exclude = new Set([ + ...strList2(args, "exclude_portal_ids"), + ...strList2(args, "exclude_fabric_node_ids") + ]); + const pick = clampInt2(num2(args, "pick"), 5, 1, 50); + const hubs = [...degree.entries()].filter(([id]) => !exclude.has(id)).sort((a, b) => b[1] - a[1]).slice(0, pick).map(([fabric_node_id, deg]) => { + const node = nodes.find((n) => { + const row2 = asRecord2(n); + return (str2(row2, "id") || str2(row2, "fabric_node_id")) === fabric_node_id; + }); + const row = asRecord2(node); + return { + fabric_node_id, + degree: deg, + name: str2(row, "name"), + level: row.level, + role: row.role + }; + }); + return { + ok: true, + source_view_id: viewId, + hubs, + hint: "Degree ranking only — full dual_unit-aware suggestSinkHubs lives in netx-topology MCP." + }; +} +async function analyzeTopologyViewLayout(client, args, signal) { + const viewId = str2(args, "view_id").trim(); + if (!viewId) { + return { + ok: false, + error: "view_id_required", + hint: "Pass view_id. folder_id sampling without view_id needs netx-topology MCP structure analysis." + }; + } + const out = unwrap(await client.get(`/v1/topology/views/${encodeURIComponent(viewId)}`, undefined, signal, 120000)); + if (out.ok !== true) + return out; + const nodes = Array.isArray(out.nodes) ? out.nodes : []; + const edges = Array.isArray(out.edges) ? out.edges : []; + let minX = Infinity; + let minY = Infinity; + let maxX = -Infinity; + let maxY = -Infinity; + let missingxy = 0; + for (const raw of nodes) { + const row = asRecord2(raw); + const x = Number(row.x); + const y = Number(row.y); + if (!Number.isFinite(x) || !Number.isFinite(y)) { + missingxy += 1; + continue; + } + minX = Math.min(minX, x); + minY = Math.min(minY, y); + maxX = Math.max(maxX, x); + maxY = Math.max(maxY, y); + } + const degree = new Map; + for (const raw of edges) { + const [a, b] = edgeEndpoints(asRecord2(raw)); + if (!a || !b) + continue; + degree.set(a, (degree.get(a) || 0) + 1); + degree.set(b, (degree.get(b) || 0) + 1); + } + const hubs = [...degree.entries()].sort((a, b) => b[1] - a[1]).slice(0, 10).map(([id, deg]) => ({ fabric_node_id: id, degree: deg })); + return { + ok: true, + view_id: viewId, + detail: str2(args, "detail", "summary") || "summary", + node_count: nodes.length, + edge_count: edges.length, + link_count: collapseEdgesToLinks(edges).length, + missing_xy: missingxy, + bbox: Number.isFinite(minX) ? { minX, minY, maxX, maxY, width: maxX - minX, height: maxY - minY } : null, + hubs, + hint: "Basic graph stats only. Crossing/dual_unit/score.total analysis needs netx-topology MCP." + }; +} +async function sinkTopologyDualUnits(_client, args) { + return { + ok: false, + error: "sink_dual_units_requires_netx_topology_mcp", + source_view_id: str2(args, "source_view_id"), + sink_view_id: str2(args, "sink_view_id"), + hint: "sinkTopologyDualUnits needs the dual_unit layout engine from netx-topology MCP. Use layoutTopologyView(action=move_nodes, park=true) for HTTP-only parking, or enable the topology MCP." + }; +} +async function layoutTopologyView(client, args, signal) { + if (bool2(args, "catalog") === true) { + return { + ok: true, + catalog: true, + actions: [ + "layout", + "layout_dual_unit", + "move_nodes", + "sink_nodes", + "orbit_sweep", + "level_bands", + "polish_crossings", + "clear_edge_hits", + "compact_bbox", + "pull_far_chains", + "align_reference", + "fix_overlaps", + "resolve_overlaps", + "untangle", + "straighten_channels", + "job_status", + "job_cancel" + ], + dsh_supported: ["move_nodes", "sink_nodes", "catalog"], + recipes: ["rings", "corridor", "compact", "unstick"], + hint: "Only move_nodes/sink_nodes/catalog run in dsh-netxops; other actions need netx-topology MCP." + }; + } + const action = str2(args, "action", "layout").trim().toLowerCase() || "layout"; + if (action === "job_status" || action === "job_cancel") { + return { + ok: false, + error: "layout_jobs_not_in_dsh_host", + hint: "Background layout jobs are owned by netx-topology MCP." + }; + } + if (action === "move_nodes" || action === "sink_nodes") { + const toId = str2(args, "view_id").trim(); + const fromId = str2(args, "source_view_id").trim(); + const ids = strList2(args, "fabric_node_ids"); + const params = asRecord2(args.params); + const fabricIds = ids.length ? ids : strList2(params, "fabric_node_ids"); + if (!toId || !fromId) { + return { ok: false, error: "view_id_and_source_view_id_required" }; + } + if (fabricIds.length === 0) { + return { ok: false, error: "fabric_node_ids_required" }; + } + const copyPositions = bool2(params, "copy_positions") !== false && bool2(args, "copy_positions") !== false; + const removeFromSource = bool2(params, "remove_from_source") !== false; + const source = unwrap(await client.get(`/v1/topology/views/${encodeURIComponent(fromId)}`, undefined, signal, 120000)); + if (source.ok !== true) + return source; + const nodes = Array.isArray(source.nodes) ? source.nodes : []; + const wanted = new Set(fabricIds); + const selected = nodes.filter((n) => { + const row = asRecord2(n); + const id = str2(row, "id") || str2(row, "fabric_node_id"); + return wanted.has(id); + }); + const add = unwrap(await client.post(`/v1/topology/views/${encodeURIComponent(toId)}/nodes`, { fabric_node_ids: fabricIds, layout: copyPositions ? "keep" : "grid", managed_ne_ids: [] }, signal, 180000)); + if (add.ok !== true) + return add; + if (copyPositions && selected.length) { + const ox = Number(params.offset_x || 0); + const oy = Number(params.offset_y || 0); + const positions = selected.map((n) => { + const row = asRecord2(n); + return { + fabric_node_id: str2(row, "id") || str2(row, "fabric_node_id"), + x: Number(row.x || 0) + ox, + y: Number(row.y || 0) + oy + }; + }).filter((p) => p.fabric_node_id); + await client.patch(`/v1/topology/views/${encodeURIComponent(toId)}/positions`, { positions }, signal, 120000); + } + if (removeFromSource) { + await client.post(`/v1/topology/views/${encodeURIComponent(fromId)}/nodes/remove`, { fabric_node_ids: fabricIds }, signal); + } + return { + ok: true, + action, + moved: fabricIds.length, + source_view_id: fromId, + view_id: toId, + park: bool2(params, "park") === true || bool2(args, "park") === true + }; + } + return layoutRecipeUnavailable(action); +} + +// src/netx/tools.ts +var str3 = (description) => ({ type: "string", ...description ? { description } : {} }); +var num3 = (description) => ({ type: "number", ...description ? { description } : {} }); +var bool3 = (description) => ({ type: "boolean", ...description ? { description } : {} }); +var strArr = (description) => ({ + type: "array", + items: { type: "string" }, + ...description ? { description } : {} +}); +function renderJson(_args, value) { + return [{ type: "text", text: JSON.stringify(value, null, 0) }]; +} +var jsonOut = { + schema: { type: "json" }, + render: renderJson +}; +function tool(name, description, parameters, handler, getClient, timeoutMs) { + return defineTool({ + name, + description, + parameters, + output: jsonOut, + timeoutMs, + isConcurrencySafe: () => true, + async execute(args, exec) { + const result = await handler(getClient(), args, exec.signal); + if (result.ok === false) { + throw new Error(JSON.stringify(result)); + } + return result; + } + }); +} +function registerNetxTools(ctx, connection, options) { + const enabledGroups = options.forceGroups ?? groupsForPlane(connection.groups, options.plane, options.only); + const allow = toolNamesForGroups(enabledGroups); + if (allow.size === 0) + return () => {}; + const client = createNetxClient({ + apiUrl: connection.apiUrl, + lang: connection.lang, + timeoutMs: Math.min(connection.toolCallTimeoutMs, 45000), + getToken: () => getNetxConnection()?.token ?? "" + }); + const getClient = () => client; + const t = connection.toolCallTimeoutMs; + const catalog = [ + tool("netx__queryNmsAlarms", "Query NMS current alarms (each row includes host_name). Supports severity/ne_id/host_name/keyword, last_seen time_from/time_to, pagination. Prefer host_name for display; ne_id is for filters only.", { + severity: str3(), + ne_id: str3("Filter only; do not show UUID to users"), + host_name: str3("Filter by NE host_name"), + ne_name: str3("Legacy alias mapped to keyword"), + keyword: str3("Substring on cause/object/event. Examples: LOS, Fiber Break, bandwidth, CRC."), + time_from: str3("ISO time; filters last_seen_at >="), + time_to: str3("ISO time; filters last_seen_at <="), + page: num3(), + page_size: num3() + }, queryUmeAlarms, getClient, t), + tool("netx__aggregateNmsAlarms", "Aggregate NMS current alarms (by_severity + top by_ne). If group_by is set, routes to aggregateUmeAlarmsRaw. Always filter severity/keyword/time before paging.", { + severity: str3("Optional perceived_severity filter (critical/major/minor/warning)."), + top_ne: num3("Max NE buckets (default 50). Ignored when group_by is set."), + exclude_missing_host: bool3("Omit missing host_name from by_ne."), + time_from: str3(), + time_to: str3(), + group_by: str3("When set, routes to raw aggregation. Prefer alarm_host_name."), + group_by2: str3(), + is_cleared: str3(), + ne_id: str3(), + event_type: str3(), + keyword: str3(), + limit: num3() + }, aggregateUmeAlarms, getClient, t), + tool("netx__runNmsDiagnostics", "NMS alarm diagnostics: severity, top_event_types, top_alarm_codes, top_ne, protocol buckets, freshness meta.", {}, runUmeDiagnostics, getClient, t), + tool("netx__queryNmsNeInventory", "Paged NMS NE inventory synced in netx (keyword matches ne_id/ne_name/user_label/ip/host_name).", { + keyword: str3(), + page: num3(), + page_size: num3() + }, queryUmeNeInventory, getClient, t), + tool("netx__getNmsNe", "Get single NMS NE detail by ne_id (UUID).", { + ne_id: { type: "string", required: true, description: "NMS inventory ne_id (UUID)." } + }, getUmeNe, getClient, t), + tool("netx__queryNmsAlarmsRaw", "Power query NMS current alarms with full alarm_* + ne_* fields; optional field_preset or select_fields. Use field_preset=evidence for citations.", { + severity: str3(), + is_cleared: str3(), + ne_id: str3(), + event_type: str3(), + keyword: str3(), + time_from: str3(), + time_to: str3(), + order_by: str3("last_seen_at | time_created | perceived_severity | event_type | ne_id"), + order: str3("asc | desc"), + select_fields: strArr(), + field_preset: str3("brief | evidence | ne_debug"), + page: num3(), + page_size: num3() + }, queryUmeAlarmsRaw, getClient, t), + tool("netx__aggregateNmsAlarmsRaw", "Dynamic aggregation on NMS raw fields (group_by/group_by2); prefer alarm_host_name.", { + group_by: { type: "string", required: true }, + group_by2: str3(), + severity: str3(), + is_cleared: str3(), + ne_id: str3(), + event_type: str3(), + keyword: str3(), + time_from: str3(), + time_to: str3(), + exclude_missing_host: bool3(), + limit: num3() + }, aggregateUmeAlarmsRaw, getClient, t), + tool("netx__listNmsAlarmFields", "List available fields for NMS raw alarm queries.", {}, listUmeAlarmFields, getClient, t), + tool("netx__sqlQueryNms", "Read-only SELECT on NMS tables (ume_alarms_current/ume_inventory_ne); server enforces limits. Requires sql:query scope.", { + sql: { type: "string", required: true }, + limit: num3(), + statement_timeout_ms: num3() + }, sqlQueryUme, getClient, t), + tool("netx__listManagedNe", "List filtered netx managed NEs (keyword/vendor/connect_status required); use before execManagedNe.", { + keyword: str3(), + vendor: str3(), + connect_status: str3("unknown | testing | pass | fail"), + page: num3(), + page_size: num3() + }, listManagedNe, getClient, t), + tool("netx__getManagedNe", "Get one managed NE by managed ne_id (from listManagedNe / listCliTargets source=managed). Do NOT pass NMS inventory UUID here.", { + ne_id: str3("Managed NE id"), + managed_ne_id: str3("Alias for ne_id"), + id: str3("Alias for ne_id") + }, getManagedNe, getClient, t), + tool("netx__execManagedNe", "Run read-only CLI via netx (show/display/ping/traceroute). Single NE: ne_id OR ume_ne_id + commands. Many NEs: ne_ids[]/ume_ne_ids[] + shared commands, or targets[{ume_ne_id|ne_id, commands}]. Do NOT loop one-NE calls for multi-NE work.", { + ne_id: str3(), + nms_ne_id: str3("NMS inventory id; alias ume_ne_id"), + ume_ne_id: str3("Legacy alias of nms_ne_id"), + ne_ids: strArr("Managed NE ids for concurrent batch (shared commands)."), + nms_ne_ids: strArr("NMS inventory ids; alias ume_ne_ids"), + ume_ne_ids: strArr("Legacy alias of nms_ne_ids"), + targets: { + type: "array", + description: "Per-NE command sets: each item is one NE (ne_id OR ume_ne_id) with commands[].", + items: { + type: "object", + additionalProperties: false, + properties: { + ne_id: str3(), + ume_ne_id: str3(), + commands: strArr() + } + } + }, + commands: strArr("Commands for single NE, or shared commands for batch."), + read_timeout_sec: num3("Per-command read timeout (default 60; use 90–120 for slow show)."), + concurrency: num3("Parallel NEs for batch mode (1–8, default 4)."), + async: bool3("oclaw-only async hint; ignored by native REST client.") + }, execManagedNe, getClient, Math.max(t, 300000)), + tool("netx__listCliTargets", "List CLI-capable targets (managed NE and/or NMS inventory). Call once per session with keyword/source, cache ids, then execManagedNe.", { + source: str3("managed | nms | ume | all"), + keyword: str3(), + page: num3(), + page_size: num3() + }, listCliTargets, getClient, t), + tool("netx__findTopologyPaths", "Find up to max_paths simple paths between two fabric nodes (common group — native netx, not NMS-bound). For each endpoint provide exactly one of ume_ne_id or managed_ne_id.", { + from_nms_ne_id: str3("NMS inventory id"), + from_ume_ne_id: str3("Legacy alias of from_nms_ne_id"), + from_managed_ne_id: str3(), + to_nms_ne_id: str3("NMS inventory id"), + to_ume_ne_id: str3("Legacy alias of to_nms_ne_id"), + to_managed_ne_id: str3(), + max_paths: num3(), + max_hops: num3(), + layer: str3(), + detail: str3("summary | full") + }, findTopologyPaths, getClient, t), + tool("netx__getTopologyTree", "Get topology folder tree (nav roots + Root map canvases). Start here before createTopologyFolder.", { compact: bool3(), max_depth: num3() }, getTopologyTree, getClient, t), + tool("netx__getTopologyView", "Get a topology view by view_id. Default detail=summary with sample_nodes + links[].", { + view_id: { type: "string", required: true }, + detail: str3("summary | full"), + sample: num3() + }, getTopologyView, getClient, t), + tool("netx__createTopologyFolder", "Create topology folders / region canvases. Only way to create canvases (ne:write).", { + name: { type: "string", required: true }, + parent_id: str3(), + locale: str3("zh | en"), + sort_order: num3() + }, createTopologyFolder, getClient, t), + tool("netx__addTopologyViewNodes", "Bulk-place fabric nodes on a view via filters or fabric_node_ids (never managed/UME ids).", { + view_id: { type: "string", required: true }, + max_nodes: num3(), + keyword: str3(), + role: str3(), + vendor: str3(), + link_status: str3(), + limit: num3(), + offset: num3(), + fabric_node_ids: strArr(), + layout: str3("grid | keep") + }, addTopologyViewNodes, getClient, t), + tool("netx__removeTopologyViewNodes", "Remove placements from a view (not fabric) by filters or fabric_node_ids.", { + view_id: { type: "string", required: true }, + keyword: str3(), + role: str3(), + vendor: str3(), + link_status: str3(), + fabric_node_ids: strArr() + }, removeTopologyViewNodes, getClient, t), + tool("netx__copyTopologyViewNodes", "Clone fabric placements from source_view_id onto target_view_id (optional clear_target / copy_positions).", { + source_view_id: { type: "string", required: true }, + target_view_id: { type: "string", required: true }, + copy_positions: bool3(), + clear_target: bool3(), + offset_x: num3(), + offset_y: num3(), + limit: num3(), + dry_run: bool3() + }, copyTopologyViewNodes, getClient, t), + tool("netx__updateTopologyViewPositions", "Move nodes on a view via positions[] or layout=grid|offset|stack + filters.", { + view_id: { type: "string", required: true }, + positions: { type: "array", items: { type: "object" } }, + layout: str3("grid | offset | stack"), + keyword: str3(), + role: str3(), + vendor: str3(), + link_status: str3(), + fabric_node_ids: strArr(), + offset_x: num3(), + offset_y: num3() + }, updateTopologyViewPositions, getClient, t), + tool("netx__projectTopologyNeighbors", "Project existing fabric neighbors of on-view nodes onto the canvas.", { + view_id: { type: "string", required: true }, + seed_fabric_node_ids: strArr(), + managed_ne_ids: strArr(), + region_folder_id: str3(), + detail: str3("summary | full"), + sample: num3() + }, projectTopologyNeighbors, getClient, t), + tool("netx__queryTopologyFabricNodes", "Fabric inventory: mode=summary|list|search (keyword/role/level/region/link_status).", { + mode: str3("summary | list | search"), + q: str3(), + keyword: str3(), + role: str3(), + level: str3(), + level_major: str3(), + region_folder_id: str3(), + link_status: str3(), + page: num3(), + page_size: num3(), + limit: num3(), + summary: bool3() + }, queryTopologyFabricNodes, getClient, t), + tool("netx__classifyTopologyFabricNodes", "Classify fabric nodes: action=match|tag|patch|unmatched|preview_rules|apply_rules|list_rules.", { + action: { type: "string", required: true }, + pattern: str3(), + q: str3(), + match_field: str3(), + sample_limit: num3(), + fabric_node_ids: strArr(), + fabric_node_id: str3(), + node_id: str3(), + level: str3(), + role: str3(), + region_folder_id: str3(), + clear_region: bool3(), + dry_run: bool3(), + kind: str3(), + page: num3(), + page_size: num3(), + skip_manual: bool3(), + overwrite_manual: bool3(), + fill_empty_only: bool3() + }, classifyTopologyFabricNodes, getClient, t), + tool("netx__queryTopologyNeighborhood", "Neighborhood around a fabric node (depth 1–3) with compact links[].", { + node_id: { type: "string", required: true }, + depth: num3(), + layer: str3() + }, queryTopologyNeighborhood, getClient, t), + tool("netx__queryTopologyEdges", "Fabric adjacency links[] (detail=adjacency) or port rows (detail=ports).", { + node_id: str3(), + keyword: str3(), + layer: str3(), + status: str3(), + source: str3("lldp | ume | manual"), + detail: str3("adjacency | ports"), + page: num3(), + page_size: num3() + }, queryTopologyEdges, getClient, t), + tool("netx__suggestSinkHubs", "Rank hub territories on a source view for non-dual move_nodes(park) batches (degree stand-in in DSH).", { + source_view_id: str3(), + view_id: str3(), + pick: num3(), + exclude_portal_ids: strArr(), + exclude_fabric_node_ids: strArr() + }, suggestSinkHubs, getClient, t), + tool("netx__analyzeTopologyViewLayout", "Basic layout QA for a view (bbox/hubs). Full dual_unit/crossing scores need netx-topology MCP.", { + view_id: str3(), + folder_id: str3(), + detail: str3("summary | structure | hotspots | blocks | both"), + score_profile: str3(), + sight_limit: num3(), + max_views: num3(), + min_nodes: num3(), + max_nodes: num3(), + with_meta: bool3() + }, analyzeTopologyViewLayout, getClient, t), + tool("netx__sinkTopologyDualUnits", "Drain dual_unit eyes from source to sink (requires netx-topology MCP layout engine in DSH).", { + source_view_id: str3(), + sink_view_id: str3(), + max_units: num3(), + min_nodes: num3(), + max_nodes: num3(), + max_batch_nodes: num3(), + layout_batch: bool3(), + until_empty: bool3(), + dry_run: bool3() + }, sinkTopologyDualUnits, getClient, t), + tool("netx__layoutTopologyView", "Layout / polish a canvas. DSH supports catalog + move_nodes|sink_nodes over HTTP; dual_unit/orbit/polish recipes need netx-topology MCP.", { + view_id: str3(), + action: str3(), + source_view_id: str3(), + recipe: str3(), + preset: str3(), + mode: str3("preview | apply"), + tune: bool3(), + params: { type: "object" }, + catalog: bool3(), + fabric_node_ids: strArr(), + park: bool3(), + copy_positions: bool3() + }, layoutTopologyView, getClient, Math.max(t, 180000)) + ]; + const disposers = catalog.filter((entry) => allow.has(entry.name)).map((entry) => ctx.tools.register(entry)); + return () => { + for (const dispose of disposers) + dispose(); + }; +} + +// src/netx/group-tools-plugin.ts +function applyGroupToolsPlugin(ctx, options) { + let unregisterTools; + let unregisterSkills; + let skillGeneration = 0; + const resolveGroups = () => { + const connection = getNetxConnection(); + if (options.mode === "forced") { + return groupsForced(options.only ?? []); + } + return groupsForPlane(connection?.groups, "preset", options.only); + }; + const remountTools = () => { + unregisterTools?.(); + unregisterTools = undefined; + const connection = getNetxConnection(); + if (connection === undefined) { + ctx.logger.warn("%s: no connection yet — waiting for host settings bridge", options.name); + return; + } + const enabled = resolveGroups(); + if (enabled.length === 0) { + ctx.logger.info("%s: no groups enabled", options.name); + return; + } + unregisterTools = registerNetxTools(ctx, connection, { + plane: "preset", + only: options.mode === "forced" ? enabled : options.only, + forceGroups: options.mode === "forced" ? enabled : undefined + }); + ctx.logger.info("%s: groups=[%s] → %s tokenConfigured=%s", options.name, enabled.join(",") || "(none)", connection.apiUrl, connection.token.trim().length > 0); + }; + remountTools(); + const stopToolWatch = watchNetxConnection(() => { + remountTools(); + }); + ctx.inject(["skills"], (skillsCtx) => { + const remountSkills = () => { + const gen = ++skillGeneration; + unregisterSkills?.(); + unregisterSkills = undefined; + if (getNetxConnection() === undefined) + return; + const enabled = resolveGroups(); + registerGroupSkills(skillsCtx, enabled, options.name).then((dispose) => { + if (gen !== skillGeneration) { + dispose(); + return; + } + unregisterSkills = dispose; + }).catch((error) => { + skillsCtx.logger.warn("%s: skill register failed: %s", options.name, error); + }); + }; + remountSkills(); + const stopSkillWatch = watchNetxConnection(() => { + remountSkills(); + }); + skillsCtx.effect(() => () => { + skillGeneration += 1; + stopSkillWatch(); + unregisterSkills?.(); + unregisterSkills = undefined; + }, `${options.name}: dispose skills`); + }); + ctx.effect(() => () => { + stopToolWatch(); + unregisterTools?.(); + unregisterTools = undefined; + }, `${options.name}: dispose tools`); +} + +// src/agent-tools-nms.ts +var name = "netxops-tools-nms"; +var inject = ["tools"]; +function apply(ctx) { + applyGroupToolsPlugin(ctx, { name, mode: "forced", only: ["nms"] }); +} +export { + name, + inject, + apply +}; diff --git a/lib/agent-tools-topology.js b/lib/agent-tools-topology.js new file mode 100644 index 0000000..ce08e72 --- /dev/null +++ b/lib/agent-tools-topology.js @@ -0,0 +1,1783 @@ +// src/netx/capability-groups.ts +var DEFAULT_CAPABILITY_GROUPS = Object.freeze({ + nms: Object.freeze({ inPreset: true, public: false }), + common: Object.freeze({ inPreset: true, public: false }), + topology: Object.freeze({ inPreset: false, public: false }) +}); +var TOOLS_BY_GROUP = Object.freeze({ + nms: Object.freeze([ + "netx__queryNmsAlarms", + "netx__aggregateNmsAlarms", + "netx__runNmsDiagnostics", + "netx__queryNmsNeInventory", + "netx__getNmsNe", + "netx__queryNmsAlarmsRaw", + "netx__aggregateNmsAlarmsRaw", + "netx__listNmsAlarmFields", + "netx__sqlQueryNms" + ]), + common: Object.freeze([ + "netx__listManagedNe", + "netx__getManagedNe", + "netx__execManagedNe", + "netx__listCliTargets", + "netx__findTopologyPaths" + ]), + topology: Object.freeze([ + "netx__getTopologyTree", + "netx__getTopologyView", + "netx__createTopologyFolder", + "netx__addTopologyViewNodes", + "netx__removeTopologyViewNodes", + "netx__copyTopologyViewNodes", + "netx__updateTopologyViewPositions", + "netx__projectTopologyNeighbors", + "netx__queryTopologyFabricNodes", + "netx__classifyTopologyFabricNodes", + "netx__queryTopologyNeighborhood", + "netx__queryTopologyEdges", + "netx__layoutTopologyView", + "netx__suggestSinkHubs", + "netx__analyzeTopologyViewLayout", + "netx__sinkTopologyDualUnits" + ]) +}); +var SKILL_DIR_BY_GROUP = Object.freeze({ + nms: "nms", + common: "common", + topology: "topology" +}); +var CAPABILITY_GROUP_IDS = Object.freeze([ + "nms", + "common", + "topology" +]); +function groupsForPlane(groups, plane, only) { + const policy = groups ?? DEFAULT_CAPABILITY_GROUPS; + const enabled = CAPABILITY_GROUP_IDS.filter((id) => plane === "preset" ? policy[id].inPreset : policy[id].public); + if (!only || only.length === 0) + return enabled; + const allow = new Set(only); + return enabled.filter((id) => allow.has(id)); +} +function groupsForced(only) { + return CAPABILITY_GROUP_IDS.filter((id) => only.includes(id)); +} +function toolNamesForGroups(groupIds) { + const names = new Set; + for (const id of groupIds) { + for (const tool of TOOLS_BY_GROUP[id]) + names.add(tool); + } + return names; +} + +// src/netx/group-skills.ts +import { existsSync } from "node:fs"; +import { readdir, readFile, stat } from "node:fs/promises"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; +function opsSkillsRoot() { + const envRoot = process.env.NETX_SKILLS_ROOT?.trim(); + if (envRoot && existsSync(envRoot)) + return envRoot; + const here = dirname(fileURLToPath(import.meta.url)); + const siblingCandidates = [ + join(here, "..", "..", "..", "netx", "skills"), + join(here, "..", "..", "netx", "skills") + ]; + for (const candidate of siblingCandidates) { + if (existsSync(candidate)) + return candidate; + } + const packaged = [ + join(here, "..", "presets", "netxops", "skills"), + join(here, "..", "..", "presets", "netxops", "skills") + ]; + for (const candidate of packaged) { + if (existsSync(candidate)) + return candidate; + } + return packaged[0]; +} +function parseFrontmatter(raw) { + if (!raw.startsWith("---")) + return; + const end = raw.indexOf(` +---`, 3); + if (end < 0) + return; + const yaml = raw.slice(3, end).replace(/^\r?\n/, ""); + const body = raw.slice(end + 4).replace(/^\r?\n/, ""); + const data = {}; + const lines = yaml.split(/\r?\n/); + for (let i = 0;i < lines.length; i += 1) { + const line = lines[i]; + const nameMatch = /^name\s*:\s*(.+)\s*$/.exec(line); + if (nameMatch) { + data.name = stripQuotes(nameMatch[1].trim()); + continue; + } + const descMatch = /^description\s*:\s*(.*)$/.exec(line); + if (!descMatch) + continue; + const head = descMatch[1].trim(); + if (head === ">" || head === ">-" || head === "|" || head === "|-") { + const parts = []; + while (i + 1 < lines.length && /^[ \t]+/.test(lines[i + 1])) { + i += 1; + parts.push(lines[i].trim()); + } + data.description = parts.filter(Boolean).join(" "); + continue; + } + data.description = stripQuotes(head); + } + return { data, body }; +} +function stripQuotes(value) { + if (value.startsWith('"') && value.endsWith('"') || value.startsWith("'") && value.endsWith("'")) { + return value.slice(1, -1); + } + return value; +} +async function loadSkillBundle(dir) { + const skillPath = join(dir, "SKILL.md"); + let raw; + try { + raw = await readFile(skillPath, "utf8"); + } catch { + return null; + } + const parsed = parseFrontmatter(raw); + if (!parsed) + return null; + const name = typeof parsed.data.name === "string" ? parsed.data.name.trim() : ""; + const description = typeof parsed.data.description === "string" ? parsed.data.description.trim() : ""; + if (!name || !description) + return null; + return { + name, + description, + content: parsed.body.trimStart(), + path: skillPath, + directory: dir + }; +} +async function loadGroupSkills(skillsRoot, groupId) { + const groupDir = join(skillsRoot, SKILL_DIR_BY_GROUP[groupId]); + let entries; + try { + entries = await readdir(groupDir); + } catch { + return []; + } + const skills = []; + for (const entry of entries) { + const full = join(groupDir, entry); + let isDir = false; + try { + isDir = (await stat(full)).isDirectory(); + } catch { + continue; + } + if (!isDir) + continue; + const skill = await loadSkillBundle(full); + if (skill) + skills.push(skill); + } + return skills; +} +async function registerGroupSkills(ctx, groupIds, providerLabel) { + const skillsApi = ctx.skills; + if (!skillsApi || typeof skillsApi.register !== "function") { + return () => {}; + } + const root = opsSkillsRoot(); + const disposers = []; + const enabled = new Set(groupIds); + for (const groupId of CAPABILITY_GROUP_IDS) { + if (!enabled.has(groupId)) + continue; + const skills = await loadGroupSkills(root, groupId); + for (const skill of skills) { + disposers.push(skillsApi.register({ + name: skill.name, + description: skill.description, + content: skill.content, + path: skill.path, + resourceBase: { kind: "directory", path: skill.directory }, + provider: providerLabel, + source: "custom" + })); + } + } + return () => { + for (const dispose of disposers) + dispose(); + }; +} + +// src/netx/runtime.ts +var STORE_KEY = Symbol.for("dsh-netxops.connection-store"); +function store() { + const root = globalThis; + let current = root[STORE_KEY]; + if (current === undefined) { + current = { connection: undefined, listeners: new Set }; + root[STORE_KEY] = current; + } + return current; +} +function getNetxConnection() { + return store().connection; +} +function watchNetxConnection(listener) { + const state = store(); + state.listeners.add(listener); + return () => { + state.listeners.delete(listener); + }; +} + +// src/netx/tools.ts +import { defineTool } from "@deepseek-ai/dsh-tools"; + +// src/netx/http.ts +var PROTOCOL_KEY_ZH_TO_EN = { + 其他: "Other", + 时钟: "Clock", + "OTN/光": "OTN/Optical", + 电源: "Power" +}; +function localizePayload(lang, data) { + if (!lang.trim().toLowerCase().startsWith("en")) + return data; + const proto = data.protocol_summary; + if (!Array.isArray(proto)) + return data; + for (const row of proto) { + if (typeof row !== "object" || row === null || Array.isArray(row)) + continue; + const rec = row; + const key = typeof rec.key === "string" ? rec.key : ""; + const mapped = PROTOCOL_KEY_ZH_TO_EN[key]; + if (mapped !== undefined) + rec.key = mapped; + } + return data; +} +function encodeQuery(params) { + const sp = new URLSearchParams; + for (const [key, value] of Object.entries(params)) { + sp.set(key, String(value)); + } + const q = sp.toString(); + return q.length > 0 ? `?${q}` : ""; +} +function createNetxClient(connection) { + const base = connection.apiUrl.replace(/\/$/, ""); + const langParams = () => { + const lang = connection.lang.trim().toLowerCase(); + if (lang.startsWith("en")) + return { lang: "en" }; + return {}; + }; + async function request(method, path, options = {}) { + const token = connection.getToken().trim(); + if (token.length === 0) { + return { + ok: false, + error: "netx_token_missing", + detail: "Set credential NETX_API_TOKEN (Plugins → Netx Ops or scripts/set-netx-token)." + }; + } + const merged = { ...langParams(), ...options.params }; + const url = `${base}${path}${encodeQuery(merged)}`; + const timeoutMs = options.timeoutMs ?? connection.timeoutMs; + const controller = new AbortController; + const timer = setTimeout(() => { + controller.abort(); + }, timeoutMs); + const onOuterAbort = () => { + controller.abort(); + }; + options.signal?.addEventListener("abort", onOuterAbort, { once: true }); + try { + const headers = { + accept: "application/json", + authorization: `Bearer ${token}` + }; + if (options.body !== undefined) + headers["content-type"] = "application/json"; + const init = { + method, + headers, + signal: controller.signal + }; + if (options.body !== undefined) + init.body = JSON.stringify(options.body); + const resp = await fetch(url, init); + const text = await resp.text(); + if (!resp.ok) { + return { ok: false, error: `netx_http_${resp.status}`, detail: text.slice(0, 800) }; + } + const data = text.length > 0 ? JSON.parse(text) : {}; + if (typeof data === "object" && data !== null && !Array.isArray(data)) { + return { ok: true, data: localizePayload(connection.lang, data) }; + } + return { ok: true, data: { raw: data } }; + } catch (error) { + const detail = error instanceof Error ? error.message : String(error); + return { ok: false, error: "netx_request_failed", detail: detail.slice(0, 800) }; + } finally { + clearTimeout(timer); + options.signal?.removeEventListener("abort", onOuterAbort); + } + } + return { + get(path, params, signal, timeoutMs) { + return request("GET", path, { params, signal, timeoutMs }); + }, + post(path, body, signal, timeoutMs) { + return request("POST", path, { body, signal, timeoutMs }); + }, + patch(path, body, signal, timeoutMs) { + return request("PATCH", path, { body, signal, timeoutMs }); + } + }; +} +function quoteNeId(neId) { + return encodeURIComponent(neId.trim()); +} + +// src/netx/handlers.ts +var EXEC_MAX_COMMANDS = 5; +var UME_RAW_FIELD_PRESETS = { + brief: [ + "alarm_alarm_key", + "alarm_host_name", + "alarm_perceived_severity", + "alarm_event_type", + "alarm_last_seen_at", + "ne_host_name", + "ne_user_label", + "ne_ne_name", + "ne_ip_address", + "ne_exists" + ], + evidence: [ + "alarm_alarm_key", + "alarm_host_name", + "alarm_object_name", + "alarm_event_type", + "alarm_native_probable_cause", + "alarm_perceived_severity", + "alarm_is_cleared", + "alarm_time_created", + "alarm_last_seen_at", + "ne_host_name", + "ne_user_label", + "ne_ne_name", + "ne_ip_address", + "ne_connection_status", + "ne_exists" + ], + ne_debug: [ + "alarm_alarm_key", + "alarm_ne_id", + "alarm_perceived_severity", + "alarm_last_seen_at", + "ne_user_label", + "ne_ne_name", + "ne_ip_address", + "ne_ipv6_address", + "ne_device_level", + "ne_host_name", + "ne_connection_status", + "ne_admin_status", + "ne_address_type", + "ne_maintain_status", + "ne_exists" + ] +}; +function asRecord(value) { + return typeof value === "object" && value !== null && !Array.isArray(value) ? value : {}; +} +function str(args, key, fallback = "") { + const v = args[key]; + if (typeof v === "string") + return v; + if (typeof v === "number" || typeof v === "boolean") + return String(v); + return fallback; +} +function nmsOrUme(args, nmsKey, umeKey) { + return str(args, nmsKey).trim() || str(args, umeKey).trim(); +} +function nmsOrUmeList(args, nmsKey, umeKey) { + const primary = strList(args, nmsKey); + return primary.length > 0 ? primary : strList(args, umeKey); +} +function num(args, key) { + const v = args[key]; + return typeof v === "number" && Number.isFinite(v) ? v : undefined; +} +function bool(args, key) { + const v = args[key]; + return typeof v === "boolean" ? v : undefined; +} +function strList(args, key) { + const v = args[key]; + if (!Array.isArray(v)) + return []; + return v.map((x) => String(x).trim()).filter((x) => x.length > 0); +} +function clampInt(value, fallback, min, max) { + const n = value === undefined ? fallback : Math.trunc(value); + return Math.max(min, Math.min(max, n)); +} +function putStr(params, args, keys) { + for (const key of keys) { + const v = str(args, key).trim(); + if (v) + params[key] = v; + } +} +async function queryUmeAlarms(client, args, signal) { + let page = clampInt(num(args, "page"), 1, 1, 2); + const pageSize = clampInt(num(args, "page_size"), 50, 1, 500); + const params = { page, page_size: pageSize }; + putStr(params, args, ["severity", "ne_id", "host_name", "time_from", "time_to"]); + const keyword = str(args, "keyword").trim(); + const neName = str(args, "ne_name").trim(); + if (keyword) + params.keyword = keyword; + else if (neName) + params.keyword = neName; + return client.get("/v1/ume/alarms", params, signal); +} +async function aggregateUmeAlarmsRaw(client, args, signal) { + const params = {}; + putStr(params, args, [ + "group_by", + "group_by2", + "severity", + "is_cleared", + "ne_id", + "event_type", + "keyword", + "time_from", + "time_to", + "limit" + ]); + if ("exclude_missing_host" in args) { + const flag = bool(args, "exclude_missing_host"); + if (flag !== undefined) + params.exclude_missing_host = flag; + } + return client.get("/v1/ume/alarms/aggregate/raw", params, signal); +} +async function aggregateUmeAlarms(client, args, signal) { + if (str(args, "group_by").trim()) + return aggregateUmeAlarmsRaw(client, args, signal); + const topNe = clampInt(num(args, "top_ne"), 50, 0, 500); + const params = { top_ne: topNe }; + if ("exclude_missing_host" in args) { + const flag = bool(args, "exclude_missing_host"); + if (flag !== undefined) + params.exclude_missing_host = flag; + } + putStr(params, args, ["severity", "time_from", "time_to"]); + return client.get("/v1/ume/alarms/aggregate", params, signal); +} +async function runUmeDiagnostics(client, _args, signal) { + return client.get("/v1/ume/diagnostics", undefined, signal); +} +async function queryUmeNeInventory(client, args, signal) { + const params = { + page: clampInt(num(args, "page"), 1, 1, Number.MAX_SAFE_INTEGER), + page_size: clampInt(num(args, "page_size"), 50, 1, 500) + }; + putStr(params, args, ["keyword"]); + return client.get("/v1/ume/inventory/ne", params, signal); +} +async function getUmeNe(client, args, signal) { + const neId = str(args, "ne_id").trim(); + if (!neId) + return { ok: false, error: "ne_id_required", error_code: "ne_id_required" }; + return client.get(`/v1/ume/inventory/ne/${quoteNeId(neId)}`, undefined, signal); +} +async function queryUmeAlarmsRaw(client, args, signal) { + const params = { + page: clampInt(num(args, "page"), 1, 1, Number.MAX_SAFE_INTEGER), + page_size: clampInt(num(args, "page_size"), 50, 1, 500) + }; + putStr(params, args, [ + "severity", + "is_cleared", + "ne_id", + "event_type", + "keyword", + "time_from", + "time_to", + "order_by", + "order" + ]); + let fields = strList(args, "select_fields"); + if (fields.length === 0) { + const preset = str(args, "field_preset").trim().toLowerCase(); + fields = UME_RAW_FIELD_PRESETS[preset] ?? []; + } + if (fields.length > 0) + params.select_fields = fields.join(","); + return client.get("/v1/ume/alarms/raw", params, signal); +} +async function listUmeAlarmFields(client, _args, signal) { + return client.get("/v1/ume/alarms/fields", undefined, signal); +} +async function sqlQueryUme(client, args, signal) { + const sql = str(args, "sql").trim(); + if (!sql) + return { ok: false, error: "sql_required" }; + const limit = clampInt(num(args, "limit"), 200, 1, 2000); + const statementTimeoutMs = clampInt(num(args, "statement_timeout_ms"), 0, 0, 30000); + return client.post("/v1/sql/ume_query", { + sql, + limit, + statement_timeout_ms: statementTimeoutMs + }, signal, 60000); +} +async function listManagedNe(client, args, signal) { + const keyword = str(args, "keyword").trim(); + const vendor = str(args, "vendor").trim(); + const connectStatus = str(args, "connect_status").trim(); + if (!(keyword || vendor || connectStatus)) { + return { ok: false, error: "managed_ne_filter_required", error_code: "managed_ne_filter_required" }; + } + if (keyword && keyword.length < 2) { + return { ok: false, error: "managed_ne_keyword_too_short", error_code: "managed_ne_keyword_too_short" }; + } + const params = { + page: clampInt(num(args, "page"), 1, 1, Number.MAX_SAFE_INTEGER), + page_size: clampInt(num(args, "page_size"), 20, 1, 100) + }; + if (keyword) + params.keyword = keyword; + if (vendor) + params.vendor = vendor; + if (connectStatus) + params.connect_status = connectStatus; + return client.get("/v1/managed-ne", params, signal); +} +async function getManagedNe(client, args, signal) { + const neId = (str(args, "ne_id") || str(args, "managed_ne_id") || str(args, "id")).trim(); + if (!neId) { + return { + ok: false, + error: "ne_id_required", + error_code: "ne_id_required", + hint: "Pass managed NE id from listManagedNe/listCliTargets (source=managed). For NMS inventory UUIDs use execManagedNe(nms_ne_id=...) or getNmsNe, not getManagedNe.", + example: { ne_id: "" } + }; + } + const out = await client.get(`/v1/managed-ne/${quoteNeId(neId)}`, undefined, signal); + if (out.ok === false) { + const detail = `${str(out, "detail")}${str(out, "error")}`.toLowerCase(); + if (detail.includes("404") || detail.includes("not_found") || detail.includes("not found") || out.error === "netx_http_404") { + return { + ...out, + hint: "Managed NE not found for this ne_id. Call listManagedNe(keyword=...) or listCliTargets(source=managed) first. If this is an NMS inventory id, use execManagedNe(nms_ne_id=...) / getNmsNe instead of getManagedNe." + }; + } + } + return out; +} +async function execManagedNe(client, args, signal) { + const targetsRaw = args.targets; + const neIds = strList(args, "ne_ids"); + const umeNeIds = nmsOrUmeList(args, "nms_ne_ids", "ume_ne_ids"); + const sharedCommands = strList(args, "commands"); + const multi = Array.isArray(targetsRaw) && targetsRaw.length > 0 || neIds.length > 0 || umeNeIds.length > 0; + if (multi) { + const body2 = {}; + if (Array.isArray(targetsRaw) && targetsRaw.length > 0) { + const cleaned = []; + for (const t of targetsRaw) { + if (typeof t !== "object" || t === null || Array.isArray(t)) + continue; + const row = t; + const item = {}; + const neId2 = str(row, "ne_id").trim(); + const umeNeId2 = nmsOrUme(row, "nms_ne_id", "ume_ne_id"); + if (neId2) + item.ne_id = neId2; + if (umeNeId2) + item.ume_ne_id = umeNeId2; + const cmds = Array.isArray(row.commands) ? row.commands.map((c) => String(c).trim()).filter((c) => c.length > 0) : []; + if (cmds.length > 0) + item.commands = cmds; + if (Object.keys(item).length > 0) + cleaned.push(item); + } + body2.targets = cleaned; + } + if (neIds.length > 0) + body2.ne_ids = neIds; + if (umeNeIds.length > 0) + body2.ume_ne_ids = umeNeIds; + if (sharedCommands.length > 0) { + if (sharedCommands.length > EXEC_MAX_COMMANDS) { + return { ok: false, error: "too_many_commands", error_code: "too_many_commands" }; + } + body2.commands = sharedCommands; + } + body2.read_timeout_sec = clampInt(num(args, "read_timeout_sec"), 60, 10, 120); + const concurrency = num(args, "concurrency"); + if (concurrency !== undefined) + body2.concurrency = clampInt(concurrency, 4, 1, 8); + const out2 = await client.post("/v1/managed-ne/exec-batch", body2, signal, 600000); + if (out2.ok !== true) + return out2; + const data2 = asRecord(out2.data); + if (data2.ok === false) { + return { ok: false, data: data2, error: str(data2, "error", "exec_batch_failed") }; + } + return { ok: true, data: data2 }; + } + const neId = str(args, "ne_id").trim(); + const umeNeId = nmsOrUme(args, "nms_ne_id", "ume_ne_id"); + if (Boolean(neId) === Boolean(umeNeId)) { + return { + ok: false, + error: "exactly_one_of_ne_id_or_nms_ne_id_required", + error_code: "exactly_one_of_ne_id_or_nms_ne_id_required", + hint: "For one NE pass ne_id OR nms_ne_id (alias ume_ne_id). For many NEs pass ne_ids / nms_ne_ids with shared commands, or targets[] — one call, concurrent on server." + }; + } + if (sharedCommands.length === 0) { + return { ok: false, error: "commands_required", error_code: "commands_required" }; + } + if (sharedCommands.length > EXEC_MAX_COMMANDS) { + return { ok: false, error: "too_many_commands", error_code: "too_many_commands" }; + } + const body = { + commands: sharedCommands, + read_timeout_sec: clampInt(num(args, "read_timeout_sec"), 60, 10, 120) + }; + if (neId) + body.ne_id = neId; + if (umeNeId) + body.ume_ne_id = umeNeId; + const out = await client.post("/v1/managed-ne/exec", body, signal, 300000); + if (out.ok !== true) + return out; + const data = asRecord(out.data); + if (data.ok === false) { + return { ok: false, data, error: str(data, "error", "exec_failed") }; + } + return { ok: true, data }; +} +async function listCliTargets(client, args, signal) { + const params = { + page: clampInt(num(args, "page"), 1, 1, Number.MAX_SAFE_INTEGER), + page_size: clampInt(num(args, "page_size"), 50, 1, 500) + }; + putStr(params, args, ["source", "keyword"]); + return client.get("/v1/cli/targets", params, signal); +} +async function findTopologyPaths(client, args, signal) { + const fromUid = nmsOrUme(args, "from_nms_ne_id", "from_ume_ne_id"); + const fromMid = str(args, "from_managed_ne_id").trim(); + const toUid = nmsOrUme(args, "to_nms_ne_id", "to_ume_ne_id"); + const toMid = str(args, "to_managed_ne_id").trim(); + if (Boolean(fromUid) === Boolean(fromMid)) { + return { ok: false, error: "exactly_one_of_from_nms_ne_id_or_from_managed_ne_id_required" }; + } + if (Boolean(toUid) === Boolean(toMid)) { + return { ok: false, error: "exactly_one_of_to_nms_ne_id_or_to_managed_ne_id_required" }; + } + let detail = str(args, "detail", "summary").trim().toLowerCase() || "summary"; + if (detail !== "summary" && detail !== "full") + detail = "summary"; + const body = { + max_paths: clampInt(num(args, "max_paths"), 3, 1, 10), + max_hops: clampInt(num(args, "max_hops"), 6, 1, 12), + layer: str(args, "layer", "physical").trim() || "physical", + detail + }; + if (fromUid) + body.from_ume_ne_id = fromUid; + else + body.from_managed_ne_id = fromMid; + if (toUid) + body.to_ume_ne_id = toUid; + else + body.to_managed_ne_id = toMid; + return client.post("/v1/topology/fabric/paths", body, signal, 30000); +} + +// src/netx/topology-handlers.ts +function asRecord2(value) { + return typeof value === "object" && value !== null && !Array.isArray(value) ? value : {}; +} +function str2(args, key, fallback = "") { + const v = args[key]; + if (typeof v === "string") + return v; + if (typeof v === "number" || typeof v === "boolean") + return String(v); + return fallback; +} +function num2(args, key) { + const v = args[key]; + return typeof v === "number" && Number.isFinite(v) ? v : undefined; +} +function bool2(args, key) { + const v = args[key]; + return typeof v === "boolean" ? v : undefined; +} +function strList2(args, key) { + const v = args[key]; + if (!Array.isArray(v)) + return []; + return v.map((x) => String(x).trim()).filter((x) => x.length > 0); +} +function clampInt2(value, fallback, min, max) { + const n = value === undefined ? fallback : Math.trunc(value); + return Math.max(min, Math.min(max, n)); +} +function unwrap(out) { + if (out.ok !== true) + return out; + const data = asRecord2(out.data); + if (Object.keys(data).length === 0) + return { ok: true, data: out.data }; + return { ok: true, ...data, data }; +} +function filterFields(args) { + const out = {}; + for (const key of ["keyword", "role", "vendor", "link_status"]) { + const v = str2(args, key).trim(); + if (v) + out[key] = v; + } + return out; +} +function edgeEndpoints(edge) { + const a = str2(edge, "a_node_id") || str2(edge, "a"); + const b = str2(edge, "b_node_id") || str2(edge, "b"); + return [a.trim(), b.trim()]; +} +function collapseEdgesToLinks(edges, includeNames = false) { + const buckets = new Map; + for (const raw of edges) { + if (!raw || typeof raw !== "object" || Array.isArray(raw)) + continue; + const e = raw; + const [a, b] = edgeEndpoints(e); + if (!a || !b || a === b) + continue; + const lo = a < b ? a : b; + const hi = a < b ? b : a; + const key = `${lo}\x00${hi}`; + const row = buckets.get(key) ?? { + a_node_id: lo, + b_node_id: hi, + link_count: 0 + }; + row.link_count = Number(row.link_count || 0) + 1; + if (includeNames && !row.a_name) { + if (a === lo) { + row.a_name = str2(e, "a_name"); + row.b_name = str2(e, "b_name"); + } else { + row.a_name = str2(e, "b_name"); + row.b_name = str2(e, "a_name"); + } + } + buckets.set(key, row); + } + return [...buckets.values()]; +} +function summarizeViewGraph(graph, sample = 20) { + const nodes = Array.isArray(graph.nodes) ? graph.nodes : []; + const edges = Array.isArray(graph.edges) ? graph.edges : Array.isArray(graph.links) ? graph.links : []; + const sampleNodes = nodes.slice(0, Math.max(0, sample)).map((n) => { + const row = asRecord2(n); + return { + id: str2(row, "id") || str2(row, "fabric_node_id"), + name: str2(row, "name"), + x: row.x, + y: row.y, + level: row.level, + role: row.role + }; + }); + return { + ok: true, + view_id: str2(graph, "id") || str2(graph, "view_id"), + node_count: nodes.length, + edge_count: edges.length, + link_count: collapseEdgesToLinks(edges).length, + links: collapseEdgesToLinks(edges), + sample_nodes: sampleNodes, + detail: "summary" + }; +} +function layoutRecipeUnavailable(action) { + return { + ok: false, + error: "layout_recipe_requires_netx_topology_mcp", + action, + hint: "Canvas CRUD/query and move_nodes work in dsh-netxops. Dual-unit / orbit / polish recipes still need the netx-topology MCP layout engine (or enable that MCP alongside)." + }; +} +async function getTopologyTree(client, args, signal) { + const out = unwrap(await client.get("/v1/topology/tree", undefined, signal)); + if (out.ok !== true) + return out; + const compact = bool2(args, "compact") !== false; + const maxDepth = num2(args, "max_depth"); + if (!compact && maxDepth === undefined) + return out; + return { ...out, compact, max_depth: maxDepth ?? null }; +} +async function getTopologyView(client, args, signal) { + const viewId = str2(args, "view_id").trim(); + if (!viewId) + return { ok: false, error: "view_id_required" }; + const out = unwrap(await client.get(`/v1/topology/views/${encodeURIComponent(viewId)}`, undefined, signal, 120000)); + if (out.ok !== true) + return out; + const detail = str2(args, "detail", "summary").trim().toLowerCase() || "summary"; + if (detail === "full") + return { ...out, detail: "full" }; + return summarizeViewGraph(out, clampInt2(num2(args, "sample"), 20, 0, 200)); +} +async function createTopologyFolder(client, args, signal) { + const name = str2(args, "name").trim(); + if (!name) + return { ok: false, error: "name_required" }; + const body = { name }; + const parentId = str2(args, "parent_id").trim(); + if (parentId) + body.parent_id = parentId; + const locale = str2(args, "locale").trim(); + if (locale) + body.locale = locale; + if (num2(args, "sort_order") !== undefined) + body.sort_order = Math.trunc(num2(args, "sort_order")); + return unwrap(await client.post("/v1/topology/folders", body, signal)); +} +async function addTopologyViewNodes(client, args, signal) { + const viewId = str2(args, "view_id").trim(); + if (!viewId) + return { ok: false, error: "view_id_required" }; + if (strList2(args, "managed_ne_ids").length || strList2(args, "ume_ne_ids").length) { + return { + ok: false, + error: "fabric_nodes_only", + detail: "Use keyword/role/vendor/link_status or fabric_node_ids; never managed/UME ids." + }; + } + const filters = filterFields(args); + const fabricIds = strList2(args, "fabric_node_ids"); + if (Object.keys(filters).length === 0 && fabricIds.length === 0) { + return { + ok: false, + error: "filter_or_fabric_node_ids_required", + detail: "Pass keyword/role/vendor/link_status (preferred) or fabric_node_ids." + }; + } + if (num2(args, "max_nodes") !== undefined) { + const got = unwrap(await client.get(`/v1/topology/views/${encodeURIComponent(viewId)}`, undefined, signal)); + if (got.ok === true) { + const data = asRecord2(got.data); + const filt = asRecord2(got.filter ?? data.filter); + const membership = asRecord2(filt.membership ?? {}); + membership.max_nodes = clampInt2(num2(args, "max_nodes"), 2000, 1, 2000); + filt.membership = membership; + await client.patch(`/v1/topology/views/${encodeURIComponent(viewId)}`, { filter: filt }, signal); + } + } + const layout = str2(args, "layout", "grid").trim() || "grid"; + const body = { + managed_ne_ids: [], + layout, + ...filters + }; + if (fabricIds.length) + body.fabric_node_ids = fabricIds; + if (num2(args, "limit") !== undefined) + body.limit = clampInt2(num2(args, "limit"), 500, 1, 2000); + if (num2(args, "offset") !== undefined) + body.offset = clampInt2(num2(args, "offset"), 0, 0, Number.MAX_SAFE_INTEGER); + return unwrap(await client.post(`/v1/topology/views/${encodeURIComponent(viewId)}/nodes`, body, signal, 180000)); +} +async function removeTopologyViewNodes(client, args, signal) { + const viewId = str2(args, "view_id").trim(); + if (!viewId) + return { ok: false, error: "view_id_required" }; + const body = { ...filterFields(args) }; + const fabricIds = strList2(args, "fabric_node_ids"); + if (fabricIds.length) + body.fabric_node_ids = fabricIds; + return unwrap(await client.post(`/v1/topology/views/${encodeURIComponent(viewId)}/nodes/remove`, body, signal)); +} +async function copyTopologyViewNodes(client, args, signal) { + const sourceId = str2(args, "source_view_id").trim(); + const targetId = str2(args, "target_view_id").trim(); + if (!sourceId || !targetId) { + return { ok: false, error: "source_view_id_and_target_view_id_required" }; + } + const source = unwrap(await client.get(`/v1/topology/views/${encodeURIComponent(sourceId)}`, undefined, signal, 120000)); + if (source.ok !== true) + return source; + const nodes = Array.isArray(source.nodes) ? source.nodes : []; + const limit = num2(args, "limit"); + const selected = typeof limit === "number" ? nodes.slice(0, clampInt2(limit, nodes.length, 1, 2000)) : nodes; + const fabricIds = selected.map((n) => { + const row = asRecord2(n); + return str2(row, "id") || str2(row, "fabric_node_id"); + }).filter(Boolean); + if (bool2(args, "dry_run") === true) { + return { ok: true, dry_run: true, would_copy: fabricIds.length, source_view_id: sourceId, target_view_id: targetId }; + } + if (bool2(args, "clear_target") === true) { + await client.post(`/v1/topology/views/${encodeURIComponent(targetId)}/nodes/remove`, { + fabric_node_ids: "ALL", + clear_all: true + }, signal); + } + if (fabricIds.length === 0) { + return { ok: true, added: 0, source_view_id: sourceId, target_view_id: targetId }; + } + const add = unwrap(await client.post(`/v1/topology/views/${encodeURIComponent(targetId)}/nodes`, { fabric_node_ids: fabricIds, layout: "keep", managed_ne_ids: [] }, signal, 180000)); + if (add.ok !== true) + return add; + if (bool2(args, "copy_positions") !== false) { + const ox = num2(args, "offset_x") ?? 0; + const oy = num2(args, "offset_y") ?? 0; + const positions = selected.map((n) => { + const row = asRecord2(n); + const id = str2(row, "id") || str2(row, "fabric_node_id"); + return { + fabric_node_id: id, + x: Number(row.x || 0) + ox, + y: Number(row.y || 0) + oy + }; + }).filter((p) => p.fabric_node_id); + if (positions.length) { + await client.patch(`/v1/topology/views/${encodeURIComponent(targetId)}/positions`, { positions }, signal, 120000); + } + } + return { ok: true, added: fabricIds.length, source_view_id: sourceId, target_view_id: targetId, ...asRecord2(add) }; +} +async function updateTopologyViewPositions(client, args, signal) { + const viewId = str2(args, "view_id").trim(); + if (!viewId) + return { ok: false, error: "view_id_required" }; + const body = {}; + if (Array.isArray(args.positions)) + body.positions = args.positions; + const layout = str2(args, "layout").trim(); + if (layout) + body.layout = layout; + Object.assign(body, filterFields(args)); + for (const key of ["offset_x", "offset_y", "cols", "gap_x", "gap_y"]) { + if (num2(args, key) !== undefined) + body[key] = num2(args, key); + } + const fabricIds = strList2(args, "fabric_node_ids"); + if (fabricIds.length) + body.fabric_node_ids = fabricIds; + return unwrap(await client.patch(`/v1/topology/views/${encodeURIComponent(viewId)}/positions`, body, signal, 120000)); +} +async function projectTopologyNeighbors(client, args, signal) { + const viewId = str2(args, "view_id").trim(); + if (!viewId) + return { ok: false, error: "view_id_required" }; + const body = {}; + const seeds = strList2(args, "seed_fabric_node_ids").length ? strList2(args, "seed_fabric_node_ids") : strList2(args, "fabric_node_ids"); + if (seeds.length) + body.seed_fabric_node_ids = seeds; + const mids = strList2(args, "managed_ne_ids"); + if (mids.length) + body.managed_ne_ids = mids; + const region = str2(args, "region_folder_id").trim(); + if (region) + body.region_folder_id = region; + const out = unwrap(await client.post(`/v1/topology/views/${encodeURIComponent(viewId)}/project-neighbors`, body, signal, 180000)); + if (out.ok !== true) + return out; + const detail = str2(args, "detail", "summary").trim().toLowerCase() || "summary"; + if (detail === "full" || detail === "raw" || detail === "graph") { + return { ...out, detail: "full", projected: true }; + } + return { ...summarizeViewGraph(out, clampInt2(num2(args, "sample"), 20, 0, 100)), projected: true, view_id: viewId }; +} +async function queryTopologyFabricNodes(client, args, signal) { + let mode = str2(args, "mode").trim().toLowerCase(); + const q = str2(args, "q").trim(); + const keyword = str2(args, "keyword").trim(); + if (!mode) { + if (bool2(args, "summary") === true) + mode = "summary"; + else if (q) + mode = "search"; + else + mode = "list"; + } + if (mode === "summary" || mode === "stats" || mode === "count") { + const out2 = unwrap(await client.get("/v1/topology/fabric/summary", undefined, signal)); + return out2.ok === true ? { ...out2, mode: "summary" } : out2; + } + if (mode === "search" || mode === "find") { + const needle = q || keyword; + if (!needle) + return { ok: false, error: "q_required", hint: "mode=search needs q (or keyword)." }; + return unwrap(await client.get("/v1/topology/fabric/nodes/search", { + q: needle, + page: clampInt2(num2(args, "page"), 1, 1, Number.MAX_SAFE_INTEGER), + page_size: clampInt2(num2(args, "page_size") ?? num2(args, "limit"), 50, 1, 200) + }, signal)); + } + const params = { + page: clampInt2(num2(args, "page"), 1, 1, Number.MAX_SAFE_INTEGER), + page_size: clampInt2(num2(args, "page_size") ?? num2(args, "limit"), 50, 1, 500) + }; + for (const key of ["keyword", "role", "level", "level_major", "region_folder_id", "link_status"]) { + const v = str2(args, key).trim() || (key === "keyword" ? q : ""); + if (v) + params[key] = v; + } + const out = unwrap(await client.get("/v1/topology/fabric/nodes", params, signal)); + return out.ok === true ? { ...out, mode: "list" } : out; +} +async function classifyTopologyFabricNodes(client, args, signal) { + const action = str2(args, "action").trim().toLowerCase(); + if (!action) + return { ok: false, error: "action_required" }; + if (action === "match") { + const pattern = str2(args, "pattern").trim() || str2(args, "q").trim(); + if (!pattern) + return { ok: false, error: "pattern_required" }; + return unwrap(await client.post("/v1/topology/fabric/nodes/match", { + pattern, + match_field: str2(args, "match_field", "name").trim() || "name", + sample_limit: clampInt2(num2(args, "sample_limit"), 50, 1, 200) + }, signal)); + } + if (action === "tag") { + const body = { + fabric_node_ids: strList2(args, "fabric_node_ids"), + dry_run: bool2(args, "dry_run") === true + }; + const pattern = str2(args, "pattern").trim() || str2(args, "q").trim(); + if (pattern) + body.pattern = pattern; + if (args.level !== undefined) + body.level = args.level; + const role = str2(args, "role").trim(); + if (role) + body.role = role; + const region = str2(args, "region_folder_id").trim(); + if (region) + body.region_folder_id = region; + if (bool2(args, "clear_region") === true) + body.clear_region = true; + return unwrap(await client.post("/v1/topology/fabric/nodes/tags/bulk", body, signal)); + } + if (action === "patch") { + const nodeId = str2(args, "fabric_node_id").trim() || str2(args, "node_id").trim(); + if (!nodeId) + return { ok: false, error: "fabric_node_id_required" }; + const body = {}; + if (args.level !== undefined) + body.level = args.level; + const role = str2(args, "role").trim(); + if (role) + body.role = role; + const region = str2(args, "region_folder_id").trim(); + if (region) + body.region_folder_id = region; + if (bool2(args, "clear_region") === true) + body.clear_region = true; + return unwrap(await client.patch(`/v1/topology/fabric/nodes/${encodeURIComponent(nodeId)}/tags`, body, signal)); + } + if (action === "unmatched") { + return unwrap(await client.get("/v1/topology/classify/unmatched", { + kind: str2(args, "kind", "any").trim() || "any", + page: clampInt2(num2(args, "page"), 1, 1, Number.MAX_SAFE_INTEGER), + page_size: clampInt2(num2(args, "page_size") ?? num2(args, "limit"), 50, 1, 500) + }, signal)); + } + if (action === "preview_rules") { + return unwrap(await client.post("/v1/topology/classify/preview", {}, signal)); + } + if (action === "apply_rules") { + const params = {}; + if (bool2(args, "overwrite_manual") === true) + params.skip_manual = false; + else if (bool2(args, "skip_manual") !== undefined) + params.skip_manual = bool2(args, "skip_manual") !== false; + if (bool2(args, "fill_empty_only") === true) + params.fill_empty_only = true; + return unwrap(await client.post("/v1/topology/classify/apply", {}, signal)); + } + if (action === "list_rules") { + return unwrap(await client.get("/v1/topology/classify/rules", undefined, signal)); + } + return { ok: false, error: "unknown_action", action }; +} +async function queryTopologyNeighborhood(client, args, signal) { + const nodeId = str2(args, "node_id").trim(); + if (!nodeId) + return { ok: false, error: "node_id_required" }; + const out = unwrap(await client.get("/v1/topology/fabric/neighborhood", { + node_id: nodeId, + depth: clampInt2(num2(args, "depth"), 1, 1, 3), + layer: str2(args, "layer", "physical").trim() || "physical" + }, signal)); + if (out.ok !== true) + return out; + const edges = Array.isArray(out.edges) ? out.edges : []; + return { ...out, links: collapseEdgesToLinks(edges) }; +} +async function queryTopologyEdges(client, args, signal) { + const params = { + page: clampInt2(num2(args, "page"), 1, 1, Number.MAX_SAFE_INTEGER), + page_size: clampInt2(num2(args, "page_size"), 100, 1, 500), + layer: str2(args, "layer", "physical").trim() || "physical" + }; + for (const key of ["node_id", "keyword", "status", "source"]) { + const v = str2(args, key).trim(); + if (v) + params[key] = v; + } + const out = unwrap(await client.get("/v1/topology/fabric/edges", params, signal)); + if (out.ok !== true) + return out; + const detail = str2(args, "detail", "adjacency").trim().toLowerCase() || "adjacency"; + const items = Array.isArray(out.items) ? out.items : Array.isArray(out.edges) ? out.edges : []; + if (detail === "ports") + return { ...out, detail: "ports", items }; + const links = collapseEdgesToLinks(items, true); + return { ...out, detail: "adjacency", links, items: undefined }; +} +async function suggestSinkHubs(client, args, signal) { + const viewId = str2(args, "source_view_id").trim() || str2(args, "view_id").trim(); + if (!viewId) + return { ok: false, error: "source_view_id_required" }; + const out = unwrap(await client.get(`/v1/topology/views/${encodeURIComponent(viewId)}`, undefined, signal, 120000)); + if (out.ok !== true) + return out; + const nodes = Array.isArray(out.nodes) ? out.nodes : []; + const edges = Array.isArray(out.edges) ? out.edges : []; + const degree = new Map; + for (const raw of edges) { + const [a, b] = edgeEndpoints(asRecord2(raw)); + if (!a || !b) + continue; + degree.set(a, (degree.get(a) || 0) + 1); + degree.set(b, (degree.get(b) || 0) + 1); + } + const exclude = new Set([ + ...strList2(args, "exclude_portal_ids"), + ...strList2(args, "exclude_fabric_node_ids") + ]); + const pick = clampInt2(num2(args, "pick"), 5, 1, 50); + const hubs = [...degree.entries()].filter(([id]) => !exclude.has(id)).sort((a, b) => b[1] - a[1]).slice(0, pick).map(([fabric_node_id, deg]) => { + const node = nodes.find((n) => { + const row2 = asRecord2(n); + return (str2(row2, "id") || str2(row2, "fabric_node_id")) === fabric_node_id; + }); + const row = asRecord2(node); + return { + fabric_node_id, + degree: deg, + name: str2(row, "name"), + level: row.level, + role: row.role + }; + }); + return { + ok: true, + source_view_id: viewId, + hubs, + hint: "Degree ranking only — full dual_unit-aware suggestSinkHubs lives in netx-topology MCP." + }; +} +async function analyzeTopologyViewLayout(client, args, signal) { + const viewId = str2(args, "view_id").trim(); + if (!viewId) { + return { + ok: false, + error: "view_id_required", + hint: "Pass view_id. folder_id sampling without view_id needs netx-topology MCP structure analysis." + }; + } + const out = unwrap(await client.get(`/v1/topology/views/${encodeURIComponent(viewId)}`, undefined, signal, 120000)); + if (out.ok !== true) + return out; + const nodes = Array.isArray(out.nodes) ? out.nodes : []; + const edges = Array.isArray(out.edges) ? out.edges : []; + let minX = Infinity; + let minY = Infinity; + let maxX = -Infinity; + let maxY = -Infinity; + let missingxy = 0; + for (const raw of nodes) { + const row = asRecord2(raw); + const x = Number(row.x); + const y = Number(row.y); + if (!Number.isFinite(x) || !Number.isFinite(y)) { + missingxy += 1; + continue; + } + minX = Math.min(minX, x); + minY = Math.min(minY, y); + maxX = Math.max(maxX, x); + maxY = Math.max(maxY, y); + } + const degree = new Map; + for (const raw of edges) { + const [a, b] = edgeEndpoints(asRecord2(raw)); + if (!a || !b) + continue; + degree.set(a, (degree.get(a) || 0) + 1); + degree.set(b, (degree.get(b) || 0) + 1); + } + const hubs = [...degree.entries()].sort((a, b) => b[1] - a[1]).slice(0, 10).map(([id, deg]) => ({ fabric_node_id: id, degree: deg })); + return { + ok: true, + view_id: viewId, + detail: str2(args, "detail", "summary") || "summary", + node_count: nodes.length, + edge_count: edges.length, + link_count: collapseEdgesToLinks(edges).length, + missing_xy: missingxy, + bbox: Number.isFinite(minX) ? { minX, minY, maxX, maxY, width: maxX - minX, height: maxY - minY } : null, + hubs, + hint: "Basic graph stats only. Crossing/dual_unit/score.total analysis needs netx-topology MCP." + }; +} +async function sinkTopologyDualUnits(_client, args) { + return { + ok: false, + error: "sink_dual_units_requires_netx_topology_mcp", + source_view_id: str2(args, "source_view_id"), + sink_view_id: str2(args, "sink_view_id"), + hint: "sinkTopologyDualUnits needs the dual_unit layout engine from netx-topology MCP. Use layoutTopologyView(action=move_nodes, park=true) for HTTP-only parking, or enable the topology MCP." + }; +} +async function layoutTopologyView(client, args, signal) { + if (bool2(args, "catalog") === true) { + return { + ok: true, + catalog: true, + actions: [ + "layout", + "layout_dual_unit", + "move_nodes", + "sink_nodes", + "orbit_sweep", + "level_bands", + "polish_crossings", + "clear_edge_hits", + "compact_bbox", + "pull_far_chains", + "align_reference", + "fix_overlaps", + "resolve_overlaps", + "untangle", + "straighten_channels", + "job_status", + "job_cancel" + ], + dsh_supported: ["move_nodes", "sink_nodes", "catalog"], + recipes: ["rings", "corridor", "compact", "unstick"], + hint: "Only move_nodes/sink_nodes/catalog run in dsh-netxops; other actions need netx-topology MCP." + }; + } + const action = str2(args, "action", "layout").trim().toLowerCase() || "layout"; + if (action === "job_status" || action === "job_cancel") { + return { + ok: false, + error: "layout_jobs_not_in_dsh_host", + hint: "Background layout jobs are owned by netx-topology MCP." + }; + } + if (action === "move_nodes" || action === "sink_nodes") { + const toId = str2(args, "view_id").trim(); + const fromId = str2(args, "source_view_id").trim(); + const ids = strList2(args, "fabric_node_ids"); + const params = asRecord2(args.params); + const fabricIds = ids.length ? ids : strList2(params, "fabric_node_ids"); + if (!toId || !fromId) { + return { ok: false, error: "view_id_and_source_view_id_required" }; + } + if (fabricIds.length === 0) { + return { ok: false, error: "fabric_node_ids_required" }; + } + const copyPositions = bool2(params, "copy_positions") !== false && bool2(args, "copy_positions") !== false; + const removeFromSource = bool2(params, "remove_from_source") !== false; + const source = unwrap(await client.get(`/v1/topology/views/${encodeURIComponent(fromId)}`, undefined, signal, 120000)); + if (source.ok !== true) + return source; + const nodes = Array.isArray(source.nodes) ? source.nodes : []; + const wanted = new Set(fabricIds); + const selected = nodes.filter((n) => { + const row = asRecord2(n); + const id = str2(row, "id") || str2(row, "fabric_node_id"); + return wanted.has(id); + }); + const add = unwrap(await client.post(`/v1/topology/views/${encodeURIComponent(toId)}/nodes`, { fabric_node_ids: fabricIds, layout: copyPositions ? "keep" : "grid", managed_ne_ids: [] }, signal, 180000)); + if (add.ok !== true) + return add; + if (copyPositions && selected.length) { + const ox = Number(params.offset_x || 0); + const oy = Number(params.offset_y || 0); + const positions = selected.map((n) => { + const row = asRecord2(n); + return { + fabric_node_id: str2(row, "id") || str2(row, "fabric_node_id"), + x: Number(row.x || 0) + ox, + y: Number(row.y || 0) + oy + }; + }).filter((p) => p.fabric_node_id); + await client.patch(`/v1/topology/views/${encodeURIComponent(toId)}/positions`, { positions }, signal, 120000); + } + if (removeFromSource) { + await client.post(`/v1/topology/views/${encodeURIComponent(fromId)}/nodes/remove`, { fabric_node_ids: fabricIds }, signal); + } + return { + ok: true, + action, + moved: fabricIds.length, + source_view_id: fromId, + view_id: toId, + park: bool2(params, "park") === true || bool2(args, "park") === true + }; + } + return layoutRecipeUnavailable(action); +} + +// src/netx/tools.ts +var str3 = (description) => ({ type: "string", ...description ? { description } : {} }); +var num3 = (description) => ({ type: "number", ...description ? { description } : {} }); +var bool3 = (description) => ({ type: "boolean", ...description ? { description } : {} }); +var strArr = (description) => ({ + type: "array", + items: { type: "string" }, + ...description ? { description } : {} +}); +function renderJson(_args, value) { + return [{ type: "text", text: JSON.stringify(value, null, 0) }]; +} +var jsonOut = { + schema: { type: "json" }, + render: renderJson +}; +function tool(name, description, parameters, handler, getClient, timeoutMs) { + return defineTool({ + name, + description, + parameters, + output: jsonOut, + timeoutMs, + isConcurrencySafe: () => true, + async execute(args, exec) { + const result = await handler(getClient(), args, exec.signal); + if (result.ok === false) { + throw new Error(JSON.stringify(result)); + } + return result; + } + }); +} +function registerNetxTools(ctx, connection, options) { + const enabledGroups = options.forceGroups ?? groupsForPlane(connection.groups, options.plane, options.only); + const allow = toolNamesForGroups(enabledGroups); + if (allow.size === 0) + return () => {}; + const client = createNetxClient({ + apiUrl: connection.apiUrl, + lang: connection.lang, + timeoutMs: Math.min(connection.toolCallTimeoutMs, 45000), + getToken: () => getNetxConnection()?.token ?? "" + }); + const getClient = () => client; + const t = connection.toolCallTimeoutMs; + const catalog = [ + tool("netx__queryNmsAlarms", "Query NMS current alarms (each row includes host_name). Supports severity/ne_id/host_name/keyword, last_seen time_from/time_to, pagination. Prefer host_name for display; ne_id is for filters only.", { + severity: str3(), + ne_id: str3("Filter only; do not show UUID to users"), + host_name: str3("Filter by NE host_name"), + ne_name: str3("Legacy alias mapped to keyword"), + keyword: str3("Substring on cause/object/event. Examples: LOS, Fiber Break, bandwidth, CRC."), + time_from: str3("ISO time; filters last_seen_at >="), + time_to: str3("ISO time; filters last_seen_at <="), + page: num3(), + page_size: num3() + }, queryUmeAlarms, getClient, t), + tool("netx__aggregateNmsAlarms", "Aggregate NMS current alarms (by_severity + top by_ne). If group_by is set, routes to aggregateUmeAlarmsRaw. Always filter severity/keyword/time before paging.", { + severity: str3("Optional perceived_severity filter (critical/major/minor/warning)."), + top_ne: num3("Max NE buckets (default 50). Ignored when group_by is set."), + exclude_missing_host: bool3("Omit missing host_name from by_ne."), + time_from: str3(), + time_to: str3(), + group_by: str3("When set, routes to raw aggregation. Prefer alarm_host_name."), + group_by2: str3(), + is_cleared: str3(), + ne_id: str3(), + event_type: str3(), + keyword: str3(), + limit: num3() + }, aggregateUmeAlarms, getClient, t), + tool("netx__runNmsDiagnostics", "NMS alarm diagnostics: severity, top_event_types, top_alarm_codes, top_ne, protocol buckets, freshness meta.", {}, runUmeDiagnostics, getClient, t), + tool("netx__queryNmsNeInventory", "Paged NMS NE inventory synced in netx (keyword matches ne_id/ne_name/user_label/ip/host_name).", { + keyword: str3(), + page: num3(), + page_size: num3() + }, queryUmeNeInventory, getClient, t), + tool("netx__getNmsNe", "Get single NMS NE detail by ne_id (UUID).", { + ne_id: { type: "string", required: true, description: "NMS inventory ne_id (UUID)." } + }, getUmeNe, getClient, t), + tool("netx__queryNmsAlarmsRaw", "Power query NMS current alarms with full alarm_* + ne_* fields; optional field_preset or select_fields. Use field_preset=evidence for citations.", { + severity: str3(), + is_cleared: str3(), + ne_id: str3(), + event_type: str3(), + keyword: str3(), + time_from: str3(), + time_to: str3(), + order_by: str3("last_seen_at | time_created | perceived_severity | event_type | ne_id"), + order: str3("asc | desc"), + select_fields: strArr(), + field_preset: str3("brief | evidence | ne_debug"), + page: num3(), + page_size: num3() + }, queryUmeAlarmsRaw, getClient, t), + tool("netx__aggregateNmsAlarmsRaw", "Dynamic aggregation on NMS raw fields (group_by/group_by2); prefer alarm_host_name.", { + group_by: { type: "string", required: true }, + group_by2: str3(), + severity: str3(), + is_cleared: str3(), + ne_id: str3(), + event_type: str3(), + keyword: str3(), + time_from: str3(), + time_to: str3(), + exclude_missing_host: bool3(), + limit: num3() + }, aggregateUmeAlarmsRaw, getClient, t), + tool("netx__listNmsAlarmFields", "List available fields for NMS raw alarm queries.", {}, listUmeAlarmFields, getClient, t), + tool("netx__sqlQueryNms", "Read-only SELECT on NMS tables (ume_alarms_current/ume_inventory_ne); server enforces limits. Requires sql:query scope.", { + sql: { type: "string", required: true }, + limit: num3(), + statement_timeout_ms: num3() + }, sqlQueryUme, getClient, t), + tool("netx__listManagedNe", "List filtered netx managed NEs (keyword/vendor/connect_status required); use before execManagedNe.", { + keyword: str3(), + vendor: str3(), + connect_status: str3("unknown | testing | pass | fail"), + page: num3(), + page_size: num3() + }, listManagedNe, getClient, t), + tool("netx__getManagedNe", "Get one managed NE by managed ne_id (from listManagedNe / listCliTargets source=managed). Do NOT pass NMS inventory UUID here.", { + ne_id: str3("Managed NE id"), + managed_ne_id: str3("Alias for ne_id"), + id: str3("Alias for ne_id") + }, getManagedNe, getClient, t), + tool("netx__execManagedNe", "Run read-only CLI via netx (show/display/ping/traceroute). Single NE: ne_id OR ume_ne_id + commands. Many NEs: ne_ids[]/ume_ne_ids[] + shared commands, or targets[{ume_ne_id|ne_id, commands}]. Do NOT loop one-NE calls for multi-NE work.", { + ne_id: str3(), + nms_ne_id: str3("NMS inventory id; alias ume_ne_id"), + ume_ne_id: str3("Legacy alias of nms_ne_id"), + ne_ids: strArr("Managed NE ids for concurrent batch (shared commands)."), + nms_ne_ids: strArr("NMS inventory ids; alias ume_ne_ids"), + ume_ne_ids: strArr("Legacy alias of nms_ne_ids"), + targets: { + type: "array", + description: "Per-NE command sets: each item is one NE (ne_id OR ume_ne_id) with commands[].", + items: { + type: "object", + additionalProperties: false, + properties: { + ne_id: str3(), + ume_ne_id: str3(), + commands: strArr() + } + } + }, + commands: strArr("Commands for single NE, or shared commands for batch."), + read_timeout_sec: num3("Per-command read timeout (default 60; use 90–120 for slow show)."), + concurrency: num3("Parallel NEs for batch mode (1–8, default 4)."), + async: bool3("oclaw-only async hint; ignored by native REST client.") + }, execManagedNe, getClient, Math.max(t, 300000)), + tool("netx__listCliTargets", "List CLI-capable targets (managed NE and/or NMS inventory). Call once per session with keyword/source, cache ids, then execManagedNe.", { + source: str3("managed | nms | ume | all"), + keyword: str3(), + page: num3(), + page_size: num3() + }, listCliTargets, getClient, t), + tool("netx__findTopologyPaths", "Find up to max_paths simple paths between two fabric nodes (common group — native netx, not NMS-bound). For each endpoint provide exactly one of ume_ne_id or managed_ne_id.", { + from_nms_ne_id: str3("NMS inventory id"), + from_ume_ne_id: str3("Legacy alias of from_nms_ne_id"), + from_managed_ne_id: str3(), + to_nms_ne_id: str3("NMS inventory id"), + to_ume_ne_id: str3("Legacy alias of to_nms_ne_id"), + to_managed_ne_id: str3(), + max_paths: num3(), + max_hops: num3(), + layer: str3(), + detail: str3("summary | full") + }, findTopologyPaths, getClient, t), + tool("netx__getTopologyTree", "Get topology folder tree (nav roots + Root map canvases). Start here before createTopologyFolder.", { compact: bool3(), max_depth: num3() }, getTopologyTree, getClient, t), + tool("netx__getTopologyView", "Get a topology view by view_id. Default detail=summary with sample_nodes + links[].", { + view_id: { type: "string", required: true }, + detail: str3("summary | full"), + sample: num3() + }, getTopologyView, getClient, t), + tool("netx__createTopologyFolder", "Create topology folders / region canvases. Only way to create canvases (ne:write).", { + name: { type: "string", required: true }, + parent_id: str3(), + locale: str3("zh | en"), + sort_order: num3() + }, createTopologyFolder, getClient, t), + tool("netx__addTopologyViewNodes", "Bulk-place fabric nodes on a view via filters or fabric_node_ids (never managed/UME ids).", { + view_id: { type: "string", required: true }, + max_nodes: num3(), + keyword: str3(), + role: str3(), + vendor: str3(), + link_status: str3(), + limit: num3(), + offset: num3(), + fabric_node_ids: strArr(), + layout: str3("grid | keep") + }, addTopologyViewNodes, getClient, t), + tool("netx__removeTopologyViewNodes", "Remove placements from a view (not fabric) by filters or fabric_node_ids.", { + view_id: { type: "string", required: true }, + keyword: str3(), + role: str3(), + vendor: str3(), + link_status: str3(), + fabric_node_ids: strArr() + }, removeTopologyViewNodes, getClient, t), + tool("netx__copyTopologyViewNodes", "Clone fabric placements from source_view_id onto target_view_id (optional clear_target / copy_positions).", { + source_view_id: { type: "string", required: true }, + target_view_id: { type: "string", required: true }, + copy_positions: bool3(), + clear_target: bool3(), + offset_x: num3(), + offset_y: num3(), + limit: num3(), + dry_run: bool3() + }, copyTopologyViewNodes, getClient, t), + tool("netx__updateTopologyViewPositions", "Move nodes on a view via positions[] or layout=grid|offset|stack + filters.", { + view_id: { type: "string", required: true }, + positions: { type: "array", items: { type: "object" } }, + layout: str3("grid | offset | stack"), + keyword: str3(), + role: str3(), + vendor: str3(), + link_status: str3(), + fabric_node_ids: strArr(), + offset_x: num3(), + offset_y: num3() + }, updateTopologyViewPositions, getClient, t), + tool("netx__projectTopologyNeighbors", "Project existing fabric neighbors of on-view nodes onto the canvas.", { + view_id: { type: "string", required: true }, + seed_fabric_node_ids: strArr(), + managed_ne_ids: strArr(), + region_folder_id: str3(), + detail: str3("summary | full"), + sample: num3() + }, projectTopologyNeighbors, getClient, t), + tool("netx__queryTopologyFabricNodes", "Fabric inventory: mode=summary|list|search (keyword/role/level/region/link_status).", { + mode: str3("summary | list | search"), + q: str3(), + keyword: str3(), + role: str3(), + level: str3(), + level_major: str3(), + region_folder_id: str3(), + link_status: str3(), + page: num3(), + page_size: num3(), + limit: num3(), + summary: bool3() + }, queryTopologyFabricNodes, getClient, t), + tool("netx__classifyTopologyFabricNodes", "Classify fabric nodes: action=match|tag|patch|unmatched|preview_rules|apply_rules|list_rules.", { + action: { type: "string", required: true }, + pattern: str3(), + q: str3(), + match_field: str3(), + sample_limit: num3(), + fabric_node_ids: strArr(), + fabric_node_id: str3(), + node_id: str3(), + level: str3(), + role: str3(), + region_folder_id: str3(), + clear_region: bool3(), + dry_run: bool3(), + kind: str3(), + page: num3(), + page_size: num3(), + skip_manual: bool3(), + overwrite_manual: bool3(), + fill_empty_only: bool3() + }, classifyTopologyFabricNodes, getClient, t), + tool("netx__queryTopologyNeighborhood", "Neighborhood around a fabric node (depth 1–3) with compact links[].", { + node_id: { type: "string", required: true }, + depth: num3(), + layer: str3() + }, queryTopologyNeighborhood, getClient, t), + tool("netx__queryTopologyEdges", "Fabric adjacency links[] (detail=adjacency) or port rows (detail=ports).", { + node_id: str3(), + keyword: str3(), + layer: str3(), + status: str3(), + source: str3("lldp | ume | manual"), + detail: str3("adjacency | ports"), + page: num3(), + page_size: num3() + }, queryTopologyEdges, getClient, t), + tool("netx__suggestSinkHubs", "Rank hub territories on a source view for non-dual move_nodes(park) batches (degree stand-in in DSH).", { + source_view_id: str3(), + view_id: str3(), + pick: num3(), + exclude_portal_ids: strArr(), + exclude_fabric_node_ids: strArr() + }, suggestSinkHubs, getClient, t), + tool("netx__analyzeTopologyViewLayout", "Basic layout QA for a view (bbox/hubs). Full dual_unit/crossing scores need netx-topology MCP.", { + view_id: str3(), + folder_id: str3(), + detail: str3("summary | structure | hotspots | blocks | both"), + score_profile: str3(), + sight_limit: num3(), + max_views: num3(), + min_nodes: num3(), + max_nodes: num3(), + with_meta: bool3() + }, analyzeTopologyViewLayout, getClient, t), + tool("netx__sinkTopologyDualUnits", "Drain dual_unit eyes from source to sink (requires netx-topology MCP layout engine in DSH).", { + source_view_id: str3(), + sink_view_id: str3(), + max_units: num3(), + min_nodes: num3(), + max_nodes: num3(), + max_batch_nodes: num3(), + layout_batch: bool3(), + until_empty: bool3(), + dry_run: bool3() + }, sinkTopologyDualUnits, getClient, t), + tool("netx__layoutTopologyView", "Layout / polish a canvas. DSH supports catalog + move_nodes|sink_nodes over HTTP; dual_unit/orbit/polish recipes need netx-topology MCP.", { + view_id: str3(), + action: str3(), + source_view_id: str3(), + recipe: str3(), + preset: str3(), + mode: str3("preview | apply"), + tune: bool3(), + params: { type: "object" }, + catalog: bool3(), + fabric_node_ids: strArr(), + park: bool3(), + copy_positions: bool3() + }, layoutTopologyView, getClient, Math.max(t, 180000)) + ]; + const disposers = catalog.filter((entry) => allow.has(entry.name)).map((entry) => ctx.tools.register(entry)); + return () => { + for (const dispose of disposers) + dispose(); + }; +} + +// src/netx/group-tools-plugin.ts +function applyGroupToolsPlugin(ctx, options) { + let unregisterTools; + let unregisterSkills; + let skillGeneration = 0; + const resolveGroups = () => { + const connection = getNetxConnection(); + if (options.mode === "forced") { + return groupsForced(options.only ?? []); + } + return groupsForPlane(connection?.groups, "preset", options.only); + }; + const remountTools = () => { + unregisterTools?.(); + unregisterTools = undefined; + const connection = getNetxConnection(); + if (connection === undefined) { + ctx.logger.warn("%s: no connection yet — waiting for host settings bridge", options.name); + return; + } + const enabled = resolveGroups(); + if (enabled.length === 0) { + ctx.logger.info("%s: no groups enabled", options.name); + return; + } + unregisterTools = registerNetxTools(ctx, connection, { + plane: "preset", + only: options.mode === "forced" ? enabled : options.only, + forceGroups: options.mode === "forced" ? enabled : undefined + }); + ctx.logger.info("%s: groups=[%s] → %s tokenConfigured=%s", options.name, enabled.join(",") || "(none)", connection.apiUrl, connection.token.trim().length > 0); + }; + remountTools(); + const stopToolWatch = watchNetxConnection(() => { + remountTools(); + }); + ctx.inject(["skills"], (skillsCtx) => { + const remountSkills = () => { + const gen = ++skillGeneration; + unregisterSkills?.(); + unregisterSkills = undefined; + if (getNetxConnection() === undefined) + return; + const enabled = resolveGroups(); + registerGroupSkills(skillsCtx, enabled, options.name).then((dispose) => { + if (gen !== skillGeneration) { + dispose(); + return; + } + unregisterSkills = dispose; + }).catch((error) => { + skillsCtx.logger.warn("%s: skill register failed: %s", options.name, error); + }); + }; + remountSkills(); + const stopSkillWatch = watchNetxConnection(() => { + remountSkills(); + }); + skillsCtx.effect(() => () => { + skillGeneration += 1; + stopSkillWatch(); + unregisterSkills?.(); + unregisterSkills = undefined; + }, `${options.name}: dispose skills`); + }); + ctx.effect(() => () => { + stopToolWatch(); + unregisterTools?.(); + unregisterTools = undefined; + }, `${options.name}: dispose tools`); +} + +// src/agent-tools-topology.ts +var name = "netxops-tools-topology"; +var inject = ["tools"]; +function apply(ctx) { + applyGroupToolsPlugin(ctx, { name, mode: "forced", only: ["topology"] }); +} +export { + name, + inject, + apply +}; diff --git a/lib/agent-tools.js b/lib/agent-tools.js index eaf1982..25d8f36 100644 --- a/lib/agent-tools.js +++ b/lib/agent-tools.js @@ -1,3 +1,224 @@ +// src/netx/capability-groups.ts +var DEFAULT_CAPABILITY_GROUPS = Object.freeze({ + nms: Object.freeze({ inPreset: true, public: false }), + common: Object.freeze({ inPreset: true, public: false }), + topology: Object.freeze({ inPreset: false, public: false }) +}); +var TOOLS_BY_GROUP = Object.freeze({ + nms: Object.freeze([ + "netx__queryNmsAlarms", + "netx__aggregateNmsAlarms", + "netx__runNmsDiagnostics", + "netx__queryNmsNeInventory", + "netx__getNmsNe", + "netx__queryNmsAlarmsRaw", + "netx__aggregateNmsAlarmsRaw", + "netx__listNmsAlarmFields", + "netx__sqlQueryNms" + ]), + common: Object.freeze([ + "netx__listManagedNe", + "netx__getManagedNe", + "netx__execManagedNe", + "netx__listCliTargets", + "netx__findTopologyPaths" + ]), + topology: Object.freeze([ + "netx__getTopologyTree", + "netx__getTopologyView", + "netx__createTopologyFolder", + "netx__addTopologyViewNodes", + "netx__removeTopologyViewNodes", + "netx__copyTopologyViewNodes", + "netx__updateTopologyViewPositions", + "netx__projectTopologyNeighbors", + "netx__queryTopologyFabricNodes", + "netx__classifyTopologyFabricNodes", + "netx__queryTopologyNeighborhood", + "netx__queryTopologyEdges", + "netx__layoutTopologyView", + "netx__suggestSinkHubs", + "netx__analyzeTopologyViewLayout", + "netx__sinkTopologyDualUnits" + ]) +}); +var SKILL_DIR_BY_GROUP = Object.freeze({ + nms: "nms", + common: "common", + topology: "topology" +}); +var CAPABILITY_GROUP_IDS = Object.freeze([ + "nms", + "common", + "topology" +]); +function groupsForPlane(groups, plane, only) { + const policy = groups ?? DEFAULT_CAPABILITY_GROUPS; + const enabled = CAPABILITY_GROUP_IDS.filter((id) => plane === "preset" ? policy[id].inPreset : policy[id].public); + if (!only || only.length === 0) + return enabled; + const allow = new Set(only); + return enabled.filter((id) => allow.has(id)); +} +function groupsForced(only) { + return CAPABILITY_GROUP_IDS.filter((id) => only.includes(id)); +} +function toolNamesForGroups(groupIds) { + const names = new Set; + for (const id of groupIds) { + for (const tool of TOOLS_BY_GROUP[id]) + names.add(tool); + } + return names; +} + +// src/netx/group-skills.ts +import { existsSync } from "node:fs"; +import { readdir, readFile, stat } from "node:fs/promises"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; +function opsSkillsRoot() { + const envRoot = process.env.NETX_SKILLS_ROOT?.trim(); + if (envRoot && existsSync(envRoot)) + return envRoot; + const here = dirname(fileURLToPath(import.meta.url)); + const siblingCandidates = [ + join(here, "..", "..", "..", "netx", "skills"), + join(here, "..", "..", "netx", "skills") + ]; + for (const candidate of siblingCandidates) { + if (existsSync(candidate)) + return candidate; + } + const packaged = [ + join(here, "..", "presets", "netxops", "skills"), + join(here, "..", "..", "presets", "netxops", "skills") + ]; + for (const candidate of packaged) { + if (existsSync(candidate)) + return candidate; + } + return packaged[0]; +} +function parseFrontmatter(raw) { + if (!raw.startsWith("---")) + return; + const end = raw.indexOf(` +---`, 3); + if (end < 0) + return; + const yaml = raw.slice(3, end).replace(/^\r?\n/, ""); + const body = raw.slice(end + 4).replace(/^\r?\n/, ""); + const data = {}; + const lines = yaml.split(/\r?\n/); + for (let i = 0;i < lines.length; i += 1) { + const line = lines[i]; + const nameMatch = /^name\s*:\s*(.+)\s*$/.exec(line); + if (nameMatch) { + data.name = stripQuotes(nameMatch[1].trim()); + continue; + } + const descMatch = /^description\s*:\s*(.*)$/.exec(line); + if (!descMatch) + continue; + const head = descMatch[1].trim(); + if (head === ">" || head === ">-" || head === "|" || head === "|-") { + const parts = []; + while (i + 1 < lines.length && /^[ \t]+/.test(lines[i + 1])) { + i += 1; + parts.push(lines[i].trim()); + } + data.description = parts.filter(Boolean).join(" "); + continue; + } + data.description = stripQuotes(head); + } + return { data, body }; +} +function stripQuotes(value) { + if (value.startsWith('"') && value.endsWith('"') || value.startsWith("'") && value.endsWith("'")) { + return value.slice(1, -1); + } + return value; +} +async function loadSkillBundle(dir) { + const skillPath = join(dir, "SKILL.md"); + let raw; + try { + raw = await readFile(skillPath, "utf8"); + } catch { + return null; + } + const parsed = parseFrontmatter(raw); + if (!parsed) + return null; + const name = typeof parsed.data.name === "string" ? parsed.data.name.trim() : ""; + const description = typeof parsed.data.description === "string" ? parsed.data.description.trim() : ""; + if (!name || !description) + return null; + return { + name, + description, + content: parsed.body.trimStart(), + path: skillPath, + directory: dir + }; +} +async function loadGroupSkills(skillsRoot, groupId) { + const groupDir = join(skillsRoot, SKILL_DIR_BY_GROUP[groupId]); + let entries; + try { + entries = await readdir(groupDir); + } catch { + return []; + } + const skills = []; + for (const entry of entries) { + const full = join(groupDir, entry); + let isDir = false; + try { + isDir = (await stat(full)).isDirectory(); + } catch { + continue; + } + if (!isDir) + continue; + const skill = await loadSkillBundle(full); + if (skill) + skills.push(skill); + } + return skills; +} +async function registerGroupSkills(ctx, groupIds, providerLabel) { + const skillsApi = ctx.skills; + if (!skillsApi || typeof skillsApi.register !== "function") { + return () => {}; + } + const root = opsSkillsRoot(); + const disposers = []; + const enabled = new Set(groupIds); + for (const groupId of CAPABILITY_GROUP_IDS) { + if (!enabled.has(groupId)) + continue; + const skills = await loadGroupSkills(root, groupId); + for (const skill of skills) { + disposers.push(skillsApi.register({ + name: skill.name, + description: skill.description, + content: skill.content, + path: skill.path, + resourceBase: { kind: "directory", path: skill.directory }, + provider: providerLabel, + source: "custom" + })); + } + } + return () => { + for (const dispose of disposers) + dispose(); + }; +} + // src/netx/runtime.ts var STORE_KEY = Symbol.for("dsh-netxops.connection-store"); function store() { @@ -121,6 +342,9 @@ function createNetxClient(connection) { }, post(path, body, signal, timeoutMs) { return request("POST", path, { body, signal, timeoutMs }); + }, + patch(path, body, signal, timeoutMs) { + return request("PATCH", path, { body, signal, timeoutMs }); } }; } @@ -189,6 +413,13 @@ function str(args, key, fallback = "") { return String(v); return fallback; } +function nmsOrUme(args, nmsKey, umeKey) { + return str(args, nmsKey).trim() || str(args, umeKey).trim(); +} +function nmsOrUmeList(args, nmsKey, umeKey) { + const primary = strList(args, nmsKey); + return primary.length > 0 ? primary : strList(args, umeKey); +} function num(args, key) { const v = args[key]; return typeof v === "number" && Number.isFinite(v) ? v : undefined; @@ -347,7 +578,7 @@ async function getManagedNe(client, args, signal) { ok: false, error: "ne_id_required", error_code: "ne_id_required", - hint: "Pass managed NE id from listManagedNe/listCliTargets (source=managed). For UME inventory UUIDs use execManagedNe(ume_ne_id=...) or getUmeNe, not getManagedNe.", + hint: "Pass managed NE id from listManagedNe/listCliTargets (source=managed). For NMS inventory UUIDs use execManagedNe(nms_ne_id=...) or getNmsNe, not getManagedNe.", example: { ne_id: "" } }; } @@ -357,7 +588,7 @@ async function getManagedNe(client, args, signal) { if (detail.includes("404") || detail.includes("not_found") || detail.includes("not found") || out.error === "netx_http_404") { return { ...out, - hint: "Managed NE not found for this ne_id. Call listManagedNe(keyword=...) or listCliTargets(source=managed) first. If this is a UME ne_id, use execManagedNe(ume_ne_id=...) / getUmeNe instead of getManagedNe." + hint: "Managed NE not found for this ne_id. Call listManagedNe(keyword=...) or listCliTargets(source=managed) first. If this is an NMS inventory id, use execManagedNe(nms_ne_id=...) / getNmsNe instead of getManagedNe." }; } } @@ -366,7 +597,7 @@ async function getManagedNe(client, args, signal) { async function execManagedNe(client, args, signal) { const targetsRaw = args.targets; const neIds = strList(args, "ne_ids"); - const umeNeIds = strList(args, "ume_ne_ids"); + const umeNeIds = nmsOrUmeList(args, "nms_ne_ids", "ume_ne_ids"); const sharedCommands = strList(args, "commands"); const multi = Array.isArray(targetsRaw) && targetsRaw.length > 0 || neIds.length > 0 || umeNeIds.length > 0; if (multi) { @@ -379,7 +610,7 @@ async function execManagedNe(client, args, signal) { const row = t; const item = {}; const neId2 = str(row, "ne_id").trim(); - const umeNeId2 = str(row, "ume_ne_id").trim(); + const umeNeId2 = nmsOrUme(row, "nms_ne_id", "ume_ne_id"); if (neId2) item.ne_id = neId2; if (umeNeId2) @@ -416,13 +647,13 @@ async function execManagedNe(client, args, signal) { return { ok: true, data: data2 }; } const neId = str(args, "ne_id").trim(); - const umeNeId = str(args, "ume_ne_id").trim(); + const umeNeId = nmsOrUme(args, "nms_ne_id", "ume_ne_id"); if (Boolean(neId) === Boolean(umeNeId)) { return { ok: false, - error: "exactly_one_of_ne_id_or_ume_ne_id_required", - error_code: "exactly_one_of_ne_id_or_ume_ne_id_required", - hint: "For one NE pass ne_id OR ume_ne_id. For many NEs pass ne_ids / ume_ne_ids with shared commands, or targets[] with per-NE commands — one call, concurrent on server." + error: "exactly_one_of_ne_id_or_nms_ne_id_required", + error_code: "exactly_one_of_ne_id_or_nms_ne_id_required", + hint: "For one NE pass ne_id OR nms_ne_id (alias ume_ne_id). For many NEs pass ne_ids / nms_ne_ids with shared commands, or targets[] — one call, concurrent on server." }; } if (sharedCommands.length === 0) { @@ -457,15 +688,15 @@ async function listCliTargets(client, args, signal) { return client.get("/v1/cli/targets", params, signal); } async function findTopologyPaths(client, args, signal) { - const fromUid = str(args, "from_ume_ne_id").trim(); + const fromUid = nmsOrUme(args, "from_nms_ne_id", "from_ume_ne_id"); const fromMid = str(args, "from_managed_ne_id").trim(); - const toUid = str(args, "to_ume_ne_id").trim(); + const toUid = nmsOrUme(args, "to_nms_ne_id", "to_ume_ne_id"); const toMid = str(args, "to_managed_ne_id").trim(); if (Boolean(fromUid) === Boolean(fromMid)) { - return { ok: false, error: "exactly_one_of_from_ume_ne_id_or_from_managed_ne_id_required" }; + return { ok: false, error: "exactly_one_of_from_nms_ne_id_or_from_managed_ne_id_required" }; } if (Boolean(toUid) === Boolean(toMid)) { - return { ok: false, error: "exactly_one_of_to_ume_ne_id_or_to_managed_ne_id_required" }; + return { ok: false, error: "exactly_one_of_to_nms_ne_id_or_to_managed_ne_id_required" }; } let detail = str(args, "detail", "summary").trim().toLowerCase() || "summary"; if (detail !== "summary" && detail !== "full") @@ -487,10 +718,657 @@ async function findTopologyPaths(client, args, signal) { return client.post("/v1/topology/fabric/paths", body, signal, 30000); } +// src/netx/topology-handlers.ts +function asRecord2(value) { + return typeof value === "object" && value !== null && !Array.isArray(value) ? value : {}; +} +function str2(args, key, fallback = "") { + const v = args[key]; + if (typeof v === "string") + return v; + if (typeof v === "number" || typeof v === "boolean") + return String(v); + return fallback; +} +function num2(args, key) { + const v = args[key]; + return typeof v === "number" && Number.isFinite(v) ? v : undefined; +} +function bool2(args, key) { + const v = args[key]; + return typeof v === "boolean" ? v : undefined; +} +function strList2(args, key) { + const v = args[key]; + if (!Array.isArray(v)) + return []; + return v.map((x) => String(x).trim()).filter((x) => x.length > 0); +} +function clampInt2(value, fallback, min, max) { + const n = value === undefined ? fallback : Math.trunc(value); + return Math.max(min, Math.min(max, n)); +} +function unwrap(out) { + if (out.ok !== true) + return out; + const data = asRecord2(out.data); + if (Object.keys(data).length === 0) + return { ok: true, data: out.data }; + return { ok: true, ...data, data }; +} +function filterFields(args) { + const out = {}; + for (const key of ["keyword", "role", "vendor", "link_status"]) { + const v = str2(args, key).trim(); + if (v) + out[key] = v; + } + return out; +} +function edgeEndpoints(edge) { + const a = str2(edge, "a_node_id") || str2(edge, "a"); + const b = str2(edge, "b_node_id") || str2(edge, "b"); + return [a.trim(), b.trim()]; +} +function collapseEdgesToLinks(edges, includeNames = false) { + const buckets = new Map; + for (const raw of edges) { + if (!raw || typeof raw !== "object" || Array.isArray(raw)) + continue; + const e = raw; + const [a, b] = edgeEndpoints(e); + if (!a || !b || a === b) + continue; + const lo = a < b ? a : b; + const hi = a < b ? b : a; + const key = `${lo}\x00${hi}`; + const row = buckets.get(key) ?? { + a_node_id: lo, + b_node_id: hi, + link_count: 0 + }; + row.link_count = Number(row.link_count || 0) + 1; + if (includeNames && !row.a_name) { + if (a === lo) { + row.a_name = str2(e, "a_name"); + row.b_name = str2(e, "b_name"); + } else { + row.a_name = str2(e, "b_name"); + row.b_name = str2(e, "a_name"); + } + } + buckets.set(key, row); + } + return [...buckets.values()]; +} +function summarizeViewGraph(graph, sample = 20) { + const nodes = Array.isArray(graph.nodes) ? graph.nodes : []; + const edges = Array.isArray(graph.edges) ? graph.edges : Array.isArray(graph.links) ? graph.links : []; + const sampleNodes = nodes.slice(0, Math.max(0, sample)).map((n) => { + const row = asRecord2(n); + return { + id: str2(row, "id") || str2(row, "fabric_node_id"), + name: str2(row, "name"), + x: row.x, + y: row.y, + level: row.level, + role: row.role + }; + }); + return { + ok: true, + view_id: str2(graph, "id") || str2(graph, "view_id"), + node_count: nodes.length, + edge_count: edges.length, + link_count: collapseEdgesToLinks(edges).length, + links: collapseEdgesToLinks(edges), + sample_nodes: sampleNodes, + detail: "summary" + }; +} +function layoutRecipeUnavailable(action) { + return { + ok: false, + error: "layout_recipe_requires_netx_topology_mcp", + action, + hint: "Canvas CRUD/query and move_nodes work in dsh-netxops. Dual-unit / orbit / polish recipes still need the netx-topology MCP layout engine (or enable that MCP alongside)." + }; +} +async function getTopologyTree(client, args, signal) { + const out = unwrap(await client.get("/v1/topology/tree", undefined, signal)); + if (out.ok !== true) + return out; + const compact = bool2(args, "compact") !== false; + const maxDepth = num2(args, "max_depth"); + if (!compact && maxDepth === undefined) + return out; + return { ...out, compact, max_depth: maxDepth ?? null }; +} +async function getTopologyView(client, args, signal) { + const viewId = str2(args, "view_id").trim(); + if (!viewId) + return { ok: false, error: "view_id_required" }; + const out = unwrap(await client.get(`/v1/topology/views/${encodeURIComponent(viewId)}`, undefined, signal, 120000)); + if (out.ok !== true) + return out; + const detail = str2(args, "detail", "summary").trim().toLowerCase() || "summary"; + if (detail === "full") + return { ...out, detail: "full" }; + return summarizeViewGraph(out, clampInt2(num2(args, "sample"), 20, 0, 200)); +} +async function createTopologyFolder(client, args, signal) { + const name = str2(args, "name").trim(); + if (!name) + return { ok: false, error: "name_required" }; + const body = { name }; + const parentId = str2(args, "parent_id").trim(); + if (parentId) + body.parent_id = parentId; + const locale = str2(args, "locale").trim(); + if (locale) + body.locale = locale; + if (num2(args, "sort_order") !== undefined) + body.sort_order = Math.trunc(num2(args, "sort_order")); + return unwrap(await client.post("/v1/topology/folders", body, signal)); +} +async function addTopologyViewNodes(client, args, signal) { + const viewId = str2(args, "view_id").trim(); + if (!viewId) + return { ok: false, error: "view_id_required" }; + if (strList2(args, "managed_ne_ids").length || strList2(args, "ume_ne_ids").length) { + return { + ok: false, + error: "fabric_nodes_only", + detail: "Use keyword/role/vendor/link_status or fabric_node_ids; never managed/UME ids." + }; + } + const filters = filterFields(args); + const fabricIds = strList2(args, "fabric_node_ids"); + if (Object.keys(filters).length === 0 && fabricIds.length === 0) { + return { + ok: false, + error: "filter_or_fabric_node_ids_required", + detail: "Pass keyword/role/vendor/link_status (preferred) or fabric_node_ids." + }; + } + if (num2(args, "max_nodes") !== undefined) { + const got = unwrap(await client.get(`/v1/topology/views/${encodeURIComponent(viewId)}`, undefined, signal)); + if (got.ok === true) { + const data = asRecord2(got.data); + const filt = asRecord2(got.filter ?? data.filter); + const membership = asRecord2(filt.membership ?? {}); + membership.max_nodes = clampInt2(num2(args, "max_nodes"), 2000, 1, 2000); + filt.membership = membership; + await client.patch(`/v1/topology/views/${encodeURIComponent(viewId)}`, { filter: filt }, signal); + } + } + const layout = str2(args, "layout", "grid").trim() || "grid"; + const body = { + managed_ne_ids: [], + layout, + ...filters + }; + if (fabricIds.length) + body.fabric_node_ids = fabricIds; + if (num2(args, "limit") !== undefined) + body.limit = clampInt2(num2(args, "limit"), 500, 1, 2000); + if (num2(args, "offset") !== undefined) + body.offset = clampInt2(num2(args, "offset"), 0, 0, Number.MAX_SAFE_INTEGER); + return unwrap(await client.post(`/v1/topology/views/${encodeURIComponent(viewId)}/nodes`, body, signal, 180000)); +} +async function removeTopologyViewNodes(client, args, signal) { + const viewId = str2(args, "view_id").trim(); + if (!viewId) + return { ok: false, error: "view_id_required" }; + const body = { ...filterFields(args) }; + const fabricIds = strList2(args, "fabric_node_ids"); + if (fabricIds.length) + body.fabric_node_ids = fabricIds; + return unwrap(await client.post(`/v1/topology/views/${encodeURIComponent(viewId)}/nodes/remove`, body, signal)); +} +async function copyTopologyViewNodes(client, args, signal) { + const sourceId = str2(args, "source_view_id").trim(); + const targetId = str2(args, "target_view_id").trim(); + if (!sourceId || !targetId) { + return { ok: false, error: "source_view_id_and_target_view_id_required" }; + } + const source = unwrap(await client.get(`/v1/topology/views/${encodeURIComponent(sourceId)}`, undefined, signal, 120000)); + if (source.ok !== true) + return source; + const nodes = Array.isArray(source.nodes) ? source.nodes : []; + const limit = num2(args, "limit"); + const selected = typeof limit === "number" ? nodes.slice(0, clampInt2(limit, nodes.length, 1, 2000)) : nodes; + const fabricIds = selected.map((n) => { + const row = asRecord2(n); + return str2(row, "id") || str2(row, "fabric_node_id"); + }).filter(Boolean); + if (bool2(args, "dry_run") === true) { + return { ok: true, dry_run: true, would_copy: fabricIds.length, source_view_id: sourceId, target_view_id: targetId }; + } + if (bool2(args, "clear_target") === true) { + await client.post(`/v1/topology/views/${encodeURIComponent(targetId)}/nodes/remove`, { + fabric_node_ids: "ALL", + clear_all: true + }, signal); + } + if (fabricIds.length === 0) { + return { ok: true, added: 0, source_view_id: sourceId, target_view_id: targetId }; + } + const add = unwrap(await client.post(`/v1/topology/views/${encodeURIComponent(targetId)}/nodes`, { fabric_node_ids: fabricIds, layout: "keep", managed_ne_ids: [] }, signal, 180000)); + if (add.ok !== true) + return add; + if (bool2(args, "copy_positions") !== false) { + const ox = num2(args, "offset_x") ?? 0; + const oy = num2(args, "offset_y") ?? 0; + const positions = selected.map((n) => { + const row = asRecord2(n); + const id = str2(row, "id") || str2(row, "fabric_node_id"); + return { + fabric_node_id: id, + x: Number(row.x || 0) + ox, + y: Number(row.y || 0) + oy + }; + }).filter((p) => p.fabric_node_id); + if (positions.length) { + await client.patch(`/v1/topology/views/${encodeURIComponent(targetId)}/positions`, { positions }, signal, 120000); + } + } + return { ok: true, added: fabricIds.length, source_view_id: sourceId, target_view_id: targetId, ...asRecord2(add) }; +} +async function updateTopologyViewPositions(client, args, signal) { + const viewId = str2(args, "view_id").trim(); + if (!viewId) + return { ok: false, error: "view_id_required" }; + const body = {}; + if (Array.isArray(args.positions)) + body.positions = args.positions; + const layout = str2(args, "layout").trim(); + if (layout) + body.layout = layout; + Object.assign(body, filterFields(args)); + for (const key of ["offset_x", "offset_y", "cols", "gap_x", "gap_y"]) { + if (num2(args, key) !== undefined) + body[key] = num2(args, key); + } + const fabricIds = strList2(args, "fabric_node_ids"); + if (fabricIds.length) + body.fabric_node_ids = fabricIds; + return unwrap(await client.patch(`/v1/topology/views/${encodeURIComponent(viewId)}/positions`, body, signal, 120000)); +} +async function projectTopologyNeighbors(client, args, signal) { + const viewId = str2(args, "view_id").trim(); + if (!viewId) + return { ok: false, error: "view_id_required" }; + const body = {}; + const seeds = strList2(args, "seed_fabric_node_ids").length ? strList2(args, "seed_fabric_node_ids") : strList2(args, "fabric_node_ids"); + if (seeds.length) + body.seed_fabric_node_ids = seeds; + const mids = strList2(args, "managed_ne_ids"); + if (mids.length) + body.managed_ne_ids = mids; + const region = str2(args, "region_folder_id").trim(); + if (region) + body.region_folder_id = region; + const out = unwrap(await client.post(`/v1/topology/views/${encodeURIComponent(viewId)}/project-neighbors`, body, signal, 180000)); + if (out.ok !== true) + return out; + const detail = str2(args, "detail", "summary").trim().toLowerCase() || "summary"; + if (detail === "full" || detail === "raw" || detail === "graph") { + return { ...out, detail: "full", projected: true }; + } + return { ...summarizeViewGraph(out, clampInt2(num2(args, "sample"), 20, 0, 100)), projected: true, view_id: viewId }; +} +async function queryTopologyFabricNodes(client, args, signal) { + let mode = str2(args, "mode").trim().toLowerCase(); + const q = str2(args, "q").trim(); + const keyword = str2(args, "keyword").trim(); + if (!mode) { + if (bool2(args, "summary") === true) + mode = "summary"; + else if (q) + mode = "search"; + else + mode = "list"; + } + if (mode === "summary" || mode === "stats" || mode === "count") { + const out2 = unwrap(await client.get("/v1/topology/fabric/summary", undefined, signal)); + return out2.ok === true ? { ...out2, mode: "summary" } : out2; + } + if (mode === "search" || mode === "find") { + const needle = q || keyword; + if (!needle) + return { ok: false, error: "q_required", hint: "mode=search needs q (or keyword)." }; + return unwrap(await client.get("/v1/topology/fabric/nodes/search", { + q: needle, + page: clampInt2(num2(args, "page"), 1, 1, Number.MAX_SAFE_INTEGER), + page_size: clampInt2(num2(args, "page_size") ?? num2(args, "limit"), 50, 1, 200) + }, signal)); + } + const params = { + page: clampInt2(num2(args, "page"), 1, 1, Number.MAX_SAFE_INTEGER), + page_size: clampInt2(num2(args, "page_size") ?? num2(args, "limit"), 50, 1, 500) + }; + for (const key of ["keyword", "role", "level", "level_major", "region_folder_id", "link_status"]) { + const v = str2(args, key).trim() || (key === "keyword" ? q : ""); + if (v) + params[key] = v; + } + const out = unwrap(await client.get("/v1/topology/fabric/nodes", params, signal)); + return out.ok === true ? { ...out, mode: "list" } : out; +} +async function classifyTopologyFabricNodes(client, args, signal) { + const action = str2(args, "action").trim().toLowerCase(); + if (!action) + return { ok: false, error: "action_required" }; + if (action === "match") { + const pattern = str2(args, "pattern").trim() || str2(args, "q").trim(); + if (!pattern) + return { ok: false, error: "pattern_required" }; + return unwrap(await client.post("/v1/topology/fabric/nodes/match", { + pattern, + match_field: str2(args, "match_field", "name").trim() || "name", + sample_limit: clampInt2(num2(args, "sample_limit"), 50, 1, 200) + }, signal)); + } + if (action === "tag") { + const body = { + fabric_node_ids: strList2(args, "fabric_node_ids"), + dry_run: bool2(args, "dry_run") === true + }; + const pattern = str2(args, "pattern").trim() || str2(args, "q").trim(); + if (pattern) + body.pattern = pattern; + if (args.level !== undefined) + body.level = args.level; + const role = str2(args, "role").trim(); + if (role) + body.role = role; + const region = str2(args, "region_folder_id").trim(); + if (region) + body.region_folder_id = region; + if (bool2(args, "clear_region") === true) + body.clear_region = true; + return unwrap(await client.post("/v1/topology/fabric/nodes/tags/bulk", body, signal)); + } + if (action === "patch") { + const nodeId = str2(args, "fabric_node_id").trim() || str2(args, "node_id").trim(); + if (!nodeId) + return { ok: false, error: "fabric_node_id_required" }; + const body = {}; + if (args.level !== undefined) + body.level = args.level; + const role = str2(args, "role").trim(); + if (role) + body.role = role; + const region = str2(args, "region_folder_id").trim(); + if (region) + body.region_folder_id = region; + if (bool2(args, "clear_region") === true) + body.clear_region = true; + return unwrap(await client.patch(`/v1/topology/fabric/nodes/${encodeURIComponent(nodeId)}/tags`, body, signal)); + } + if (action === "unmatched") { + return unwrap(await client.get("/v1/topology/classify/unmatched", { + kind: str2(args, "kind", "any").trim() || "any", + page: clampInt2(num2(args, "page"), 1, 1, Number.MAX_SAFE_INTEGER), + page_size: clampInt2(num2(args, "page_size") ?? num2(args, "limit"), 50, 1, 500) + }, signal)); + } + if (action === "preview_rules") { + return unwrap(await client.post("/v1/topology/classify/preview", {}, signal)); + } + if (action === "apply_rules") { + const params = {}; + if (bool2(args, "overwrite_manual") === true) + params.skip_manual = false; + else if (bool2(args, "skip_manual") !== undefined) + params.skip_manual = bool2(args, "skip_manual") !== false; + if (bool2(args, "fill_empty_only") === true) + params.fill_empty_only = true; + return unwrap(await client.post("/v1/topology/classify/apply", {}, signal)); + } + if (action === "list_rules") { + return unwrap(await client.get("/v1/topology/classify/rules", undefined, signal)); + } + return { ok: false, error: "unknown_action", action }; +} +async function queryTopologyNeighborhood(client, args, signal) { + const nodeId = str2(args, "node_id").trim(); + if (!nodeId) + return { ok: false, error: "node_id_required" }; + const out = unwrap(await client.get("/v1/topology/fabric/neighborhood", { + node_id: nodeId, + depth: clampInt2(num2(args, "depth"), 1, 1, 3), + layer: str2(args, "layer", "physical").trim() || "physical" + }, signal)); + if (out.ok !== true) + return out; + const edges = Array.isArray(out.edges) ? out.edges : []; + return { ...out, links: collapseEdgesToLinks(edges) }; +} +async function queryTopologyEdges(client, args, signal) { + const params = { + page: clampInt2(num2(args, "page"), 1, 1, Number.MAX_SAFE_INTEGER), + page_size: clampInt2(num2(args, "page_size"), 100, 1, 500), + layer: str2(args, "layer", "physical").trim() || "physical" + }; + for (const key of ["node_id", "keyword", "status", "source"]) { + const v = str2(args, key).trim(); + if (v) + params[key] = v; + } + const out = unwrap(await client.get("/v1/topology/fabric/edges", params, signal)); + if (out.ok !== true) + return out; + const detail = str2(args, "detail", "adjacency").trim().toLowerCase() || "adjacency"; + const items = Array.isArray(out.items) ? out.items : Array.isArray(out.edges) ? out.edges : []; + if (detail === "ports") + return { ...out, detail: "ports", items }; + const links = collapseEdgesToLinks(items, true); + return { ...out, detail: "adjacency", links, items: undefined }; +} +async function suggestSinkHubs(client, args, signal) { + const viewId = str2(args, "source_view_id").trim() || str2(args, "view_id").trim(); + if (!viewId) + return { ok: false, error: "source_view_id_required" }; + const out = unwrap(await client.get(`/v1/topology/views/${encodeURIComponent(viewId)}`, undefined, signal, 120000)); + if (out.ok !== true) + return out; + const nodes = Array.isArray(out.nodes) ? out.nodes : []; + const edges = Array.isArray(out.edges) ? out.edges : []; + const degree = new Map; + for (const raw of edges) { + const [a, b] = edgeEndpoints(asRecord2(raw)); + if (!a || !b) + continue; + degree.set(a, (degree.get(a) || 0) + 1); + degree.set(b, (degree.get(b) || 0) + 1); + } + const exclude = new Set([ + ...strList2(args, "exclude_portal_ids"), + ...strList2(args, "exclude_fabric_node_ids") + ]); + const pick = clampInt2(num2(args, "pick"), 5, 1, 50); + const hubs = [...degree.entries()].filter(([id]) => !exclude.has(id)).sort((a, b) => b[1] - a[1]).slice(0, pick).map(([fabric_node_id, deg]) => { + const node = nodes.find((n) => { + const row2 = asRecord2(n); + return (str2(row2, "id") || str2(row2, "fabric_node_id")) === fabric_node_id; + }); + const row = asRecord2(node); + return { + fabric_node_id, + degree: deg, + name: str2(row, "name"), + level: row.level, + role: row.role + }; + }); + return { + ok: true, + source_view_id: viewId, + hubs, + hint: "Degree ranking only — full dual_unit-aware suggestSinkHubs lives in netx-topology MCP." + }; +} +async function analyzeTopologyViewLayout(client, args, signal) { + const viewId = str2(args, "view_id").trim(); + if (!viewId) { + return { + ok: false, + error: "view_id_required", + hint: "Pass view_id. folder_id sampling without view_id needs netx-topology MCP structure analysis." + }; + } + const out = unwrap(await client.get(`/v1/topology/views/${encodeURIComponent(viewId)}`, undefined, signal, 120000)); + if (out.ok !== true) + return out; + const nodes = Array.isArray(out.nodes) ? out.nodes : []; + const edges = Array.isArray(out.edges) ? out.edges : []; + let minX = Infinity; + let minY = Infinity; + let maxX = -Infinity; + let maxY = -Infinity; + let missingxy = 0; + for (const raw of nodes) { + const row = asRecord2(raw); + const x = Number(row.x); + const y = Number(row.y); + if (!Number.isFinite(x) || !Number.isFinite(y)) { + missingxy += 1; + continue; + } + minX = Math.min(minX, x); + minY = Math.min(minY, y); + maxX = Math.max(maxX, x); + maxY = Math.max(maxY, y); + } + const degree = new Map; + for (const raw of edges) { + const [a, b] = edgeEndpoints(asRecord2(raw)); + if (!a || !b) + continue; + degree.set(a, (degree.get(a) || 0) + 1); + degree.set(b, (degree.get(b) || 0) + 1); + } + const hubs = [...degree.entries()].sort((a, b) => b[1] - a[1]).slice(0, 10).map(([id, deg]) => ({ fabric_node_id: id, degree: deg })); + return { + ok: true, + view_id: viewId, + detail: str2(args, "detail", "summary") || "summary", + node_count: nodes.length, + edge_count: edges.length, + link_count: collapseEdgesToLinks(edges).length, + missing_xy: missingxy, + bbox: Number.isFinite(minX) ? { minX, minY, maxX, maxY, width: maxX - minX, height: maxY - minY } : null, + hubs, + hint: "Basic graph stats only. Crossing/dual_unit/score.total analysis needs netx-topology MCP." + }; +} +async function sinkTopologyDualUnits(_client, args) { + return { + ok: false, + error: "sink_dual_units_requires_netx_topology_mcp", + source_view_id: str2(args, "source_view_id"), + sink_view_id: str2(args, "sink_view_id"), + hint: "sinkTopologyDualUnits needs the dual_unit layout engine from netx-topology MCP. Use layoutTopologyView(action=move_nodes, park=true) for HTTP-only parking, or enable the topology MCP." + }; +} +async function layoutTopologyView(client, args, signal) { + if (bool2(args, "catalog") === true) { + return { + ok: true, + catalog: true, + actions: [ + "layout", + "layout_dual_unit", + "move_nodes", + "sink_nodes", + "orbit_sweep", + "level_bands", + "polish_crossings", + "clear_edge_hits", + "compact_bbox", + "pull_far_chains", + "align_reference", + "fix_overlaps", + "resolve_overlaps", + "untangle", + "straighten_channels", + "job_status", + "job_cancel" + ], + dsh_supported: ["move_nodes", "sink_nodes", "catalog"], + recipes: ["rings", "corridor", "compact", "unstick"], + hint: "Only move_nodes/sink_nodes/catalog run in dsh-netxops; other actions need netx-topology MCP." + }; + } + const action = str2(args, "action", "layout").trim().toLowerCase() || "layout"; + if (action === "job_status" || action === "job_cancel") { + return { + ok: false, + error: "layout_jobs_not_in_dsh_host", + hint: "Background layout jobs are owned by netx-topology MCP." + }; + } + if (action === "move_nodes" || action === "sink_nodes") { + const toId = str2(args, "view_id").trim(); + const fromId = str2(args, "source_view_id").trim(); + const ids = strList2(args, "fabric_node_ids"); + const params = asRecord2(args.params); + const fabricIds = ids.length ? ids : strList2(params, "fabric_node_ids"); + if (!toId || !fromId) { + return { ok: false, error: "view_id_and_source_view_id_required" }; + } + if (fabricIds.length === 0) { + return { ok: false, error: "fabric_node_ids_required" }; + } + const copyPositions = bool2(params, "copy_positions") !== false && bool2(args, "copy_positions") !== false; + const removeFromSource = bool2(params, "remove_from_source") !== false; + const source = unwrap(await client.get(`/v1/topology/views/${encodeURIComponent(fromId)}`, undefined, signal, 120000)); + if (source.ok !== true) + return source; + const nodes = Array.isArray(source.nodes) ? source.nodes : []; + const wanted = new Set(fabricIds); + const selected = nodes.filter((n) => { + const row = asRecord2(n); + const id = str2(row, "id") || str2(row, "fabric_node_id"); + return wanted.has(id); + }); + const add = unwrap(await client.post(`/v1/topology/views/${encodeURIComponent(toId)}/nodes`, { fabric_node_ids: fabricIds, layout: copyPositions ? "keep" : "grid", managed_ne_ids: [] }, signal, 180000)); + if (add.ok !== true) + return add; + if (copyPositions && selected.length) { + const ox = Number(params.offset_x || 0); + const oy = Number(params.offset_y || 0); + const positions = selected.map((n) => { + const row = asRecord2(n); + return { + fabric_node_id: str2(row, "id") || str2(row, "fabric_node_id"), + x: Number(row.x || 0) + ox, + y: Number(row.y || 0) + oy + }; + }).filter((p) => p.fabric_node_id); + await client.patch(`/v1/topology/views/${encodeURIComponent(toId)}/positions`, { positions }, signal, 120000); + } + if (removeFromSource) { + await client.post(`/v1/topology/views/${encodeURIComponent(fromId)}/nodes/remove`, { fabric_node_ids: fabricIds }, signal); + } + return { + ok: true, + action, + moved: fabricIds.length, + source_view_id: fromId, + view_id: toId, + park: bool2(params, "park") === true || bool2(args, "park") === true + }; + } + return layoutRecipeUnavailable(action); +} + // src/netx/tools.ts -var str2 = (description) => ({ type: "string", ...description ? { description } : {} }); -var num2 = (description) => ({ type: "number", ...description ? { description } : {} }); -var bool2 = (description) => ({ type: "boolean", ...description ? { description } : {} }); +var str3 = (description) => ({ type: "string", ...description ? { description } : {} }); +var num3 = (description) => ({ type: "number", ...description ? { description } : {} }); +var bool3 = (description) => ({ type: "boolean", ...description ? { description } : {} }); var strArr = (description) => ({ type: "array", items: { type: "string" }, @@ -520,7 +1398,11 @@ function tool(name, description, parameters, handler, getClient, timeoutMs) { } }); } -function registerNetxTools(ctx, connection) { +function registerNetxTools(ctx, connection, options) { + const enabledGroups = options.forceGroups ?? groupsForPlane(connection.groups, options.plane, options.only); + const allow = toolNamesForGroups(enabledGroups); + if (allow.size === 0) + return () => {}; const client = createNetxClient({ apiUrl: connection.apiUrl, lang: connection.lang, @@ -529,92 +1411,94 @@ function registerNetxTools(ctx, connection) { }); const getClient = () => client; const t = connection.toolCallTimeoutMs; - const disposers = [ - ctx.tools.register(tool("netx__queryUmeAlarms", "Query UME current alarms (each row includes host_name). Supports severity/ne_id/host_name/keyword, last_seen time_from/time_to, pagination. Prefer host_name for display; ne_id is for filters only.", { - severity: str2(), - ne_id: str2("Filter only; do not show UUID to users"), - host_name: str2("Filter by NE host_name"), - ne_name: str2("Legacy alias mapped to keyword"), - keyword: str2("Substring on cause/object/event. Examples: LOS, Fiber Break, bandwidth, CRC."), - time_from: str2("ISO time; filters last_seen_at >="), - time_to: str2("ISO time; filters last_seen_at <="), - page: num2(), - page_size: num2() - }, queryUmeAlarms, getClient, t)), - ctx.tools.register(tool("netx__aggregateUmeAlarms", "Aggregate UME current alarms (by_severity + top by_ne). If group_by is set, routes to aggregateUmeAlarmsRaw. Always filter severity/keyword/time before paging.", { - severity: str2("Optional perceived_severity filter (critical/major/minor/warning)."), - top_ne: num2("Max NE buckets (default 50). Ignored when group_by is set."), - exclude_missing_host: bool2("Omit missing host_name from by_ne."), - time_from: str2(), - time_to: str2(), - group_by: str2("When set, routes to raw aggregation. Prefer alarm_host_name."), - group_by2: str2(), - is_cleared: str2(), - ne_id: str2(), - event_type: str2(), - keyword: str2(), - limit: num2() - }, aggregateUmeAlarms, getClient, t)), - ctx.tools.register(tool("netx__runUmeDiagnostics", "UME alarm diagnostics: severity, top_event_types, top_alarm_codes, top_ne, protocol buckets, freshness meta.", {}, runUmeDiagnostics, getClient, t)), - ctx.tools.register(tool("netx__queryUmeNeInventory", "Paged UME NE inventory synced in netx (keyword matches ne_id/ne_name/user_label/ip/host_name).", { - keyword: str2(), - page: num2(), - page_size: num2() - }, queryUmeNeInventory, getClient, t)), - ctx.tools.register(tool("netx__getUmeNe", "Get single UME NE detail by ne_id (UUID).", { - ne_id: { type: "string", required: true, description: "UME inventory ne_id (UUID)." } - }, getUmeNe, getClient, t)), - ctx.tools.register(tool("netx__queryUmeAlarmsRaw", "Power query UME current alarms with full alarm_* + ne_* fields; optional field_preset or select_fields. Use field_preset=evidence for citations.", { - severity: str2(), - is_cleared: str2(), - ne_id: str2(), - event_type: str2(), - keyword: str2(), - time_from: str2(), - time_to: str2(), - order_by: str2("last_seen_at | time_created | perceived_severity | event_type | ne_id"), - order: str2("asc | desc"), + const catalog = [ + tool("netx__queryNmsAlarms", "Query NMS current alarms (each row includes host_name). Supports severity/ne_id/host_name/keyword, last_seen time_from/time_to, pagination. Prefer host_name for display; ne_id is for filters only.", { + severity: str3(), + ne_id: str3("Filter only; do not show UUID to users"), + host_name: str3("Filter by NE host_name"), + ne_name: str3("Legacy alias mapped to keyword"), + keyword: str3("Substring on cause/object/event. Examples: LOS, Fiber Break, bandwidth, CRC."), + time_from: str3("ISO time; filters last_seen_at >="), + time_to: str3("ISO time; filters last_seen_at <="), + page: num3(), + page_size: num3() + }, queryUmeAlarms, getClient, t), + tool("netx__aggregateNmsAlarms", "Aggregate NMS current alarms (by_severity + top by_ne). If group_by is set, routes to aggregateUmeAlarmsRaw. Always filter severity/keyword/time before paging.", { + severity: str3("Optional perceived_severity filter (critical/major/minor/warning)."), + top_ne: num3("Max NE buckets (default 50). Ignored when group_by is set."), + exclude_missing_host: bool3("Omit missing host_name from by_ne."), + time_from: str3(), + time_to: str3(), + group_by: str3("When set, routes to raw aggregation. Prefer alarm_host_name."), + group_by2: str3(), + is_cleared: str3(), + ne_id: str3(), + event_type: str3(), + keyword: str3(), + limit: num3() + }, aggregateUmeAlarms, getClient, t), + tool("netx__runNmsDiagnostics", "NMS alarm diagnostics: severity, top_event_types, top_alarm_codes, top_ne, protocol buckets, freshness meta.", {}, runUmeDiagnostics, getClient, t), + tool("netx__queryNmsNeInventory", "Paged NMS NE inventory synced in netx (keyword matches ne_id/ne_name/user_label/ip/host_name).", { + keyword: str3(), + page: num3(), + page_size: num3() + }, queryUmeNeInventory, getClient, t), + tool("netx__getNmsNe", "Get single NMS NE detail by ne_id (UUID).", { + ne_id: { type: "string", required: true, description: "NMS inventory ne_id (UUID)." } + }, getUmeNe, getClient, t), + tool("netx__queryNmsAlarmsRaw", "Power query NMS current alarms with full alarm_* + ne_* fields; optional field_preset or select_fields. Use field_preset=evidence for citations.", { + severity: str3(), + is_cleared: str3(), + ne_id: str3(), + event_type: str3(), + keyword: str3(), + time_from: str3(), + time_to: str3(), + order_by: str3("last_seen_at | time_created | perceived_severity | event_type | ne_id"), + order: str3("asc | desc"), select_fields: strArr(), - field_preset: str2("brief | evidence | ne_debug"), - page: num2(), - page_size: num2() - }, queryUmeAlarmsRaw, getClient, t)), - ctx.tools.register(tool("netx__aggregateUmeAlarmsRaw", "Dynamic aggregation on UME raw fields (group_by/group_by2); prefer alarm_host_name.", { + field_preset: str3("brief | evidence | ne_debug"), + page: num3(), + page_size: num3() + }, queryUmeAlarmsRaw, getClient, t), + tool("netx__aggregateNmsAlarmsRaw", "Dynamic aggregation on NMS raw fields (group_by/group_by2); prefer alarm_host_name.", { group_by: { type: "string", required: true }, - group_by2: str2(), - severity: str2(), - is_cleared: str2(), - ne_id: str2(), - event_type: str2(), - keyword: str2(), - time_from: str2(), - time_to: str2(), - exclude_missing_host: bool2(), - limit: num2() - }, aggregateUmeAlarmsRaw, getClient, t)), - ctx.tools.register(tool("netx__listUmeAlarmFields", "List available fields for UME raw alarm queries.", {}, listUmeAlarmFields, getClient, t)), - ctx.tools.register(tool("netx__sqlQueryUme", "Read-only SELECT on UME tables (ume_alarms_current/ume_inventory_ne); server enforces limits. Requires sql:query scope.", { + group_by2: str3(), + severity: str3(), + is_cleared: str3(), + ne_id: str3(), + event_type: str3(), + keyword: str3(), + time_from: str3(), + time_to: str3(), + exclude_missing_host: bool3(), + limit: num3() + }, aggregateUmeAlarmsRaw, getClient, t), + tool("netx__listNmsAlarmFields", "List available fields for NMS raw alarm queries.", {}, listUmeAlarmFields, getClient, t), + tool("netx__sqlQueryNms", "Read-only SELECT on NMS tables (ume_alarms_current/ume_inventory_ne); server enforces limits. Requires sql:query scope.", { sql: { type: "string", required: true }, - limit: num2(), - statement_timeout_ms: num2() - }, sqlQueryUme, getClient, t)), - ctx.tools.register(tool("netx__listManagedNe", "List filtered netx managed NEs (keyword/vendor/connect_status required); use before execManagedNe.", { - keyword: str2(), - vendor: str2(), - connect_status: str2("unknown | testing | pass | fail"), - page: num2(), - page_size: num2() - }, listManagedNe, getClient, t)), - ctx.tools.register(tool("netx__getManagedNe", "Get one managed NE by managed ne_id (from listManagedNe / listCliTargets source=managed). Do NOT pass UME inventory UUID here.", { - ne_id: str2("Managed NE id"), - managed_ne_id: str2("Alias for ne_id"), - id: str2("Alias for ne_id") - }, getManagedNe, getClient, t)), - ctx.tools.register(tool("netx__execManagedNe", "Run read-only CLI via netx (show/display/ping/traceroute). Single NE: ne_id OR ume_ne_id + commands. Many NEs: ne_ids[]/ume_ne_ids[] + shared commands, or targets[{ume_ne_id|ne_id, commands}]. Do NOT loop one-NE calls for multi-NE work.", { - ne_id: str2(), - ume_ne_id: str2(), + limit: num3(), + statement_timeout_ms: num3() + }, sqlQueryUme, getClient, t), + tool("netx__listManagedNe", "List filtered netx managed NEs (keyword/vendor/connect_status required); use before execManagedNe.", { + keyword: str3(), + vendor: str3(), + connect_status: str3("unknown | testing | pass | fail"), + page: num3(), + page_size: num3() + }, listManagedNe, getClient, t), + tool("netx__getManagedNe", "Get one managed NE by managed ne_id (from listManagedNe / listCliTargets source=managed). Do NOT pass NMS inventory UUID here.", { + ne_id: str3("Managed NE id"), + managed_ne_id: str3("Alias for ne_id"), + id: str3("Alias for ne_id") + }, getManagedNe, getClient, t), + tool("netx__execManagedNe", "Run read-only CLI via netx (show/display/ping/traceroute). Single NE: ne_id OR ume_ne_id + commands. Many NEs: ne_ids[]/ume_ne_ids[] + shared commands, or targets[{ume_ne_id|ne_id, commands}]. Do NOT loop one-NE calls for multi-NE work.", { + ne_id: str3(), + nms_ne_id: str3("NMS inventory id; alias ume_ne_id"), + ume_ne_id: str3("Legacy alias of nms_ne_id"), ne_ids: strArr("Managed NE ids for concurrent batch (shared commands)."), - ume_ne_ids: strArr("UME inventory ne_ids for concurrent batch (shared commands)."), + nms_ne_ids: strArr("NMS inventory ids; alias ume_ne_ids"), + ume_ne_ids: strArr("Legacy alias of nms_ne_ids"), targets: { type: "array", description: "Per-NE command sets: each item is one NE (ne_id OR ume_ne_id) with commands[].", @@ -622,66 +1506,275 @@ function registerNetxTools(ctx, connection) { type: "object", additionalProperties: false, properties: { - ne_id: str2(), - ume_ne_id: str2(), + ne_id: str3(), + ume_ne_id: str3(), commands: strArr() } } }, commands: strArr("Commands for single NE, or shared commands for batch."), - read_timeout_sec: num2("Per-command read timeout (default 60; use 90–120 for slow show)."), - concurrency: num2("Parallel NEs for batch mode (1–8, default 4)."), - async: bool2("oclaw-only async hint; ignored by native REST client.") - }, execManagedNe, getClient, Math.max(t, 300000))), - ctx.tools.register(tool("netx__listCliTargets", "List CLI-capable targets (managed NE and/or UME inventory). Call once per session with keyword/source, cache ids, then execManagedNe.", { - source: str2("managed | ume | all"), - keyword: str2(), - page: num2(), - page_size: num2() - }, listCliTargets, getClient, t)), - ctx.tools.register(tool("netx__findTopologyPaths", "Find up to max_paths simple paths between two fabric nodes. For each endpoint provide exactly one of ume_ne_id or managed_ne_id.", { - from_ume_ne_id: str2(), - from_managed_ne_id: str2(), - to_ume_ne_id: str2(), - to_managed_ne_id: str2(), - max_paths: num2(), - max_hops: num2(), - layer: str2(), - detail: str2("summary | full") - }, findTopologyPaths, getClient, t)) + read_timeout_sec: num3("Per-command read timeout (default 60; use 90–120 for slow show)."), + concurrency: num3("Parallel NEs for batch mode (1–8, default 4)."), + async: bool3("oclaw-only async hint; ignored by native REST client.") + }, execManagedNe, getClient, Math.max(t, 300000)), + tool("netx__listCliTargets", "List CLI-capable targets (managed NE and/or NMS inventory). Call once per session with keyword/source, cache ids, then execManagedNe.", { + source: str3("managed | nms | ume | all"), + keyword: str3(), + page: num3(), + page_size: num3() + }, listCliTargets, getClient, t), + tool("netx__findTopologyPaths", "Find up to max_paths simple paths between two fabric nodes (common group — native netx, not NMS-bound). For each endpoint provide exactly one of ume_ne_id or managed_ne_id.", { + from_nms_ne_id: str3("NMS inventory id"), + from_ume_ne_id: str3("Legacy alias of from_nms_ne_id"), + from_managed_ne_id: str3(), + to_nms_ne_id: str3("NMS inventory id"), + to_ume_ne_id: str3("Legacy alias of to_nms_ne_id"), + to_managed_ne_id: str3(), + max_paths: num3(), + max_hops: num3(), + layer: str3(), + detail: str3("summary | full") + }, findTopologyPaths, getClient, t), + tool("netx__getTopologyTree", "Get topology folder tree (nav roots + Root map canvases). Start here before createTopologyFolder.", { compact: bool3(), max_depth: num3() }, getTopologyTree, getClient, t), + tool("netx__getTopologyView", "Get a topology view by view_id. Default detail=summary with sample_nodes + links[].", { + view_id: { type: "string", required: true }, + detail: str3("summary | full"), + sample: num3() + }, getTopologyView, getClient, t), + tool("netx__createTopologyFolder", "Create topology folders / region canvases. Only way to create canvases (ne:write).", { + name: { type: "string", required: true }, + parent_id: str3(), + locale: str3("zh | en"), + sort_order: num3() + }, createTopologyFolder, getClient, t), + tool("netx__addTopologyViewNodes", "Bulk-place fabric nodes on a view via filters or fabric_node_ids (never managed/UME ids).", { + view_id: { type: "string", required: true }, + max_nodes: num3(), + keyword: str3(), + role: str3(), + vendor: str3(), + link_status: str3(), + limit: num3(), + offset: num3(), + fabric_node_ids: strArr(), + layout: str3("grid | keep") + }, addTopologyViewNodes, getClient, t), + tool("netx__removeTopologyViewNodes", "Remove placements from a view (not fabric) by filters or fabric_node_ids.", { + view_id: { type: "string", required: true }, + keyword: str3(), + role: str3(), + vendor: str3(), + link_status: str3(), + fabric_node_ids: strArr() + }, removeTopologyViewNodes, getClient, t), + tool("netx__copyTopologyViewNodes", "Clone fabric placements from source_view_id onto target_view_id (optional clear_target / copy_positions).", { + source_view_id: { type: "string", required: true }, + target_view_id: { type: "string", required: true }, + copy_positions: bool3(), + clear_target: bool3(), + offset_x: num3(), + offset_y: num3(), + limit: num3(), + dry_run: bool3() + }, copyTopologyViewNodes, getClient, t), + tool("netx__updateTopologyViewPositions", "Move nodes on a view via positions[] or layout=grid|offset|stack + filters.", { + view_id: { type: "string", required: true }, + positions: { type: "array", items: { type: "object" } }, + layout: str3("grid | offset | stack"), + keyword: str3(), + role: str3(), + vendor: str3(), + link_status: str3(), + fabric_node_ids: strArr(), + offset_x: num3(), + offset_y: num3() + }, updateTopologyViewPositions, getClient, t), + tool("netx__projectTopologyNeighbors", "Project existing fabric neighbors of on-view nodes onto the canvas.", { + view_id: { type: "string", required: true }, + seed_fabric_node_ids: strArr(), + managed_ne_ids: strArr(), + region_folder_id: str3(), + detail: str3("summary | full"), + sample: num3() + }, projectTopologyNeighbors, getClient, t), + tool("netx__queryTopologyFabricNodes", "Fabric inventory: mode=summary|list|search (keyword/role/level/region/link_status).", { + mode: str3("summary | list | search"), + q: str3(), + keyword: str3(), + role: str3(), + level: str3(), + level_major: str3(), + region_folder_id: str3(), + link_status: str3(), + page: num3(), + page_size: num3(), + limit: num3(), + summary: bool3() + }, queryTopologyFabricNodes, getClient, t), + tool("netx__classifyTopologyFabricNodes", "Classify fabric nodes: action=match|tag|patch|unmatched|preview_rules|apply_rules|list_rules.", { + action: { type: "string", required: true }, + pattern: str3(), + q: str3(), + match_field: str3(), + sample_limit: num3(), + fabric_node_ids: strArr(), + fabric_node_id: str3(), + node_id: str3(), + level: str3(), + role: str3(), + region_folder_id: str3(), + clear_region: bool3(), + dry_run: bool3(), + kind: str3(), + page: num3(), + page_size: num3(), + skip_manual: bool3(), + overwrite_manual: bool3(), + fill_empty_only: bool3() + }, classifyTopologyFabricNodes, getClient, t), + tool("netx__queryTopologyNeighborhood", "Neighborhood around a fabric node (depth 1–3) with compact links[].", { + node_id: { type: "string", required: true }, + depth: num3(), + layer: str3() + }, queryTopologyNeighborhood, getClient, t), + tool("netx__queryTopologyEdges", "Fabric adjacency links[] (detail=adjacency) or port rows (detail=ports).", { + node_id: str3(), + keyword: str3(), + layer: str3(), + status: str3(), + source: str3("lldp | ume | manual"), + detail: str3("adjacency | ports"), + page: num3(), + page_size: num3() + }, queryTopologyEdges, getClient, t), + tool("netx__suggestSinkHubs", "Rank hub territories on a source view for non-dual move_nodes(park) batches (degree stand-in in DSH).", { + source_view_id: str3(), + view_id: str3(), + pick: num3(), + exclude_portal_ids: strArr(), + exclude_fabric_node_ids: strArr() + }, suggestSinkHubs, getClient, t), + tool("netx__analyzeTopologyViewLayout", "Basic layout QA for a view (bbox/hubs). Full dual_unit/crossing scores need netx-topology MCP.", { + view_id: str3(), + folder_id: str3(), + detail: str3("summary | structure | hotspots | blocks | both"), + score_profile: str3(), + sight_limit: num3(), + max_views: num3(), + min_nodes: num3(), + max_nodes: num3(), + with_meta: bool3() + }, analyzeTopologyViewLayout, getClient, t), + tool("netx__sinkTopologyDualUnits", "Drain dual_unit eyes from source to sink (requires netx-topology MCP layout engine in DSH).", { + source_view_id: str3(), + sink_view_id: str3(), + max_units: num3(), + min_nodes: num3(), + max_nodes: num3(), + max_batch_nodes: num3(), + layout_batch: bool3(), + until_empty: bool3(), + dry_run: bool3() + }, sinkTopologyDualUnits, getClient, t), + tool("netx__layoutTopologyView", "Layout / polish a canvas. DSH supports catalog + move_nodes|sink_nodes over HTTP; dual_unit/orbit/polish recipes need netx-topology MCP.", { + view_id: str3(), + action: str3(), + source_view_id: str3(), + recipe: str3(), + preset: str3(), + mode: str3("preview | apply"), + tune: bool3(), + params: { type: "object" }, + catalog: bool3(), + fabric_node_ids: strArr(), + park: bool3(), + copy_positions: bool3() + }, layoutTopologyView, getClient, Math.max(t, 180000)) ]; + const disposers = catalog.filter((entry) => allow.has(entry.name)).map((entry) => ctx.tools.register(entry)); return () => { for (const dispose of disposers) dispose(); }; } +// src/netx/group-tools-plugin.ts +function applyGroupToolsPlugin(ctx, options) { + let unregisterTools; + let unregisterSkills; + let skillGeneration = 0; + const resolveGroups = () => { + const connection = getNetxConnection(); + if (options.mode === "forced") { + return groupsForced(options.only ?? []); + } + return groupsForPlane(connection?.groups, "preset", options.only); + }; + const remountTools = () => { + unregisterTools?.(); + unregisterTools = undefined; + const connection = getNetxConnection(); + if (connection === undefined) { + ctx.logger.warn("%s: no connection yet — waiting for host settings bridge", options.name); + return; + } + const enabled = resolveGroups(); + if (enabled.length === 0) { + ctx.logger.info("%s: no groups enabled", options.name); + return; + } + unregisterTools = registerNetxTools(ctx, connection, { + plane: "preset", + only: options.mode === "forced" ? enabled : options.only, + forceGroups: options.mode === "forced" ? enabled : undefined + }); + ctx.logger.info("%s: groups=[%s] → %s tokenConfigured=%s", options.name, enabled.join(",") || "(none)", connection.apiUrl, connection.token.trim().length > 0); + }; + remountTools(); + const stopToolWatch = watchNetxConnection(() => { + remountTools(); + }); + ctx.inject(["skills"], (skillsCtx) => { + const remountSkills = () => { + const gen = ++skillGeneration; + unregisterSkills?.(); + unregisterSkills = undefined; + if (getNetxConnection() === undefined) + return; + const enabled = resolveGroups(); + registerGroupSkills(skillsCtx, enabled, options.name).then((dispose) => { + if (gen !== skillGeneration) { + dispose(); + return; + } + unregisterSkills = dispose; + }).catch((error) => { + skillsCtx.logger.warn("%s: skill register failed: %s", options.name, error); + }); + }; + remountSkills(); + const stopSkillWatch = watchNetxConnection(() => { + remountSkills(); + }); + skillsCtx.effect(() => () => { + skillGeneration += 1; + stopSkillWatch(); + unregisterSkills?.(); + unregisterSkills = undefined; + }, `${options.name}: dispose skills`); + }); + ctx.effect(() => () => { + stopToolWatch(); + unregisterTools?.(); + unregisterTools = undefined; + }, `${options.name}: dispose tools`); +} + // src/agent-tools.ts var name = "netxops-tools"; var inject = ["tools"]; function apply(ctx) { - let unregister; - const remount = () => { - unregister?.(); - unregister = undefined; - const connection = getNetxConnection(); - if (connection === undefined) { - ctx.logger.warn("netxops-tools: no connection yet — waiting for host settings bridge"); - return; - } - unregister = registerNetxTools(ctx, connection); - const tokenConfigured = connection.token.trim().length > 0; - ctx.logger.info("netxops-tools: registered netx__* for Ops preset → %s tokenConfigured=%s", connection.apiUrl, tokenConfigured); - }; - remount(); - const stopWatch = watchNetxConnection(() => { - remount(); - }); - ctx.effect(() => () => { - stopWatch(); - unregister?.(); - unregister = undefined; - }, "netxops-tools: dispose"); + applyGroupToolsPlugin(ctx, { name, mode: "settings" }); } export { name, diff --git a/lib/client.js b/lib/client.js index 5f64dad..82b0f59 100644 --- a/lib/client.js +++ b/lib/client.js @@ -112,6 +112,68 @@ function alarmPushTone(phase) { } } +// src/client/im-delivery-catalog.ts +var IM_DELIVERY_CATALOG_ENDPOINT = "im-delivery.catalog"; +var EMPTY_IM_DELIVERY_CATALOG = { + available: true, + options: [], + hint: "", + loading: false +}; +function asImDeliveryCatalog(value) { + if (value === null || typeof value !== "object" || Array.isArray(value)) { + return { ...EMPTY_IM_DELIVERY_CATALOG, available: false }; + } + const row = value; + const raw = Array.isArray(row.options) ? row.options : []; + const options = []; + for (const entry of raw) { + if (entry === null || typeof entry !== "object" || Array.isArray(entry)) + continue; + const item = entry; + const botId = typeof item.botId === "string" ? item.botId.trim() : ""; + const targetId = typeof item.targetId === "string" ? item.targetId.trim() : ""; + if (!botId || !targetId) + continue; + options.push({ + botId, + targetId, + name: typeof item.name === "string" && item.name.trim() ? item.name.trim() : targetId, + kind: typeof item.kind === "string" ? item.kind : "", + channel: typeof item.channel === "string" ? item.channel : "im" + }); + } + return { + available: row.available !== false, + options, + hint: typeof row.hint === "string" ? row.hint : "", + loading: false + }; +} +async function fetchImDeliveryCatalog(call, signal) { + const result = await call(NETXOPS_RPC_CHANNEL, IM_DELIVERY_CATALOG_ENDPOINT, {}, signal); + if (result !== null && typeof result === "object" && result.ok === true) { + return asImDeliveryCatalog(result.value); + } + if (result !== null && typeof result === "object" && result.ok === false) { + return { + ...EMPTY_IM_DELIVERY_CATALOG, + available: false, + hint: String(result.error?.message ?? "rpc failed") + }; + } + return asImDeliveryCatalog(result); +} +function imCatalogOptionKey(botId, targetId) { + return `${botId}::${targetId}`; +} +function parseImCatalogOptionKey(value) { + const at = value.indexOf("::"); + if (at <= 0) + return { botId: "", targetId: "" }; + return { botId: value.slice(0, at), targetId: value.slice(at + 2) }; +} + // src/client/styles.ts var STYLE_ID = "dsh-netxops-card-css"; var CSS = ` @@ -142,11 +204,15 @@ var CSS = ` .dsh-nx-reset{border:none;background:none;padding:0;font:inherit;font-size:12px;line-height:1.5;color:var(--dsw-alias-label-secondary);cursor:pointer} .dsh-nx-reset:hover:not(:disabled){color:var(--dsw-alias-label-primary)} .dsh-nx-input{height:34px;padding:0 12px;border:.5px solid var(--dsw-alias-border-l4);border-radius:8px;background:var(--dsw-alias-bg-layer-3);font:inherit;font-size:13px;line-height:1.5;color:var(--dsw-alias-label-primary)} +.dsh-nx-select{appearance:auto;cursor:pointer} .dsh-nx-input:focus-visible{outline:none;border-color:var(--dsw-alias-brand-primary)} .dsh-nx-inputInvalid{border-color:var(--dsw-alias-label-error)} .dsh-nx-hint{margin:0;font-size:12px;line-height:1.5;color:var(--dsw-alias-label-tertiary)} .dsh-nx-checkRow{display:flex;align-items:flex-start;gap:10px;font-size:12px;line-height:1.5;color:var(--dsw-alias-label-secondary);cursor:pointer} .dsh-nx-checkRow input{margin-top:2px;flex:none} +.dsh-nx-groupBlock{display:flex;flex-direction:column;gap:8px;padding:8px 0 4px} +.dsh-nx-groupTitle{font-size:12px;font-weight:600;line-height:1.5;color:var(--dsw-alias-label-primary)} +.dsh-nx-groupChecks{display:flex;flex-direction:column;gap:6px;padding-left:2px} .dsh-nx-invalid{margin:0;font-size:12px;line-height:1.5;color:var(--dsw-alias-label-error)} .dsh-nx-footer{display:flex;align-items:center;justify-content:flex-end;gap:8px;padding:12px 0 4px;border-top:.5px solid var(--dsw-alias-border-l2)} .dsh-nx-failed{flex:1;min-width:0;margin:0;font-size:12px;line-height:1.5;color:var(--dsw-alias-label-error)} @@ -247,6 +313,151 @@ function ValueField(props) { ] }); } +function ImDeliveryPicker(props) { + const options = props.catalog.options; + const selectedKey = props.botId && props.targetId ? imCatalogOptionKey(props.botId, props.targetId) : ""; + const matched = options.some((row) => imCatalogOptionKey(row.botId, row.targetId) === selectedKey); + const [manual, setManual] = import_react.useState(() => !!(selectedKey && !matched)); + import_react.useEffect(() => { + if (selectedKey && matched) + setManual(false); + else if (selectedKey && !matched) + setManual(true); + }, [selectedKey, matched]); + const selectValue = manual ? "__manual__" : matched ? selectedKey : ""; + const showManual = manual || selectedKey && !matched; + return /* @__PURE__ */ jsx_runtime.jsxs(jsx_runtime.Fragment, { + children: [ + /* @__PURE__ */ jsx_runtime.jsxs("div", { + className: "dsh-nx-field", + children: [ + /* @__PURE__ */ jsx_runtime.jsx("div", { + className: "dsh-nx-fieldHead", + children: /* @__PURE__ */ jsx_runtime.jsx("label", { + className: "dsh-nx-label", + htmlFor: "netxops-im-target", + children: props.labels.target + }) + }), + /* @__PURE__ */ jsx_runtime.jsxs("select", { + id: "netxops-im-target", + className: "dsh-nx-input dsh-nx-select", + value: selectValue, + disabled: props.disabled, + onChange: (event) => { + const value = event.target.value; + if (value === "__manual__") { + setManual(true); + return; + } + setManual(false); + if (!value) { + props.onPick("", ""); + return; + } + const next = parseImCatalogOptionKey(value); + props.onPick(next.botId, next.targetId); + }, + children: [ + /* @__PURE__ */ jsx_runtime.jsx("option", { + value: "", + children: options.length === 0 ? props.labels.none : `— ${props.labels.target} —` + }), + options.map((row) => /* @__PURE__ */ jsx_runtime.jsx("option", { + value: imCatalogOptionKey(row.botId, row.targetId), + children: `${row.name} · ${row.channel || "im"} · ${row.targetId}` + }, imCatalogOptionKey(row.botId, row.targetId))), + /* @__PURE__ */ jsx_runtime.jsx("option", { + value: "__manual__", + children: props.labels.manual + }) + ] + }), + !props.catalog.available || options.length === 0 ? /* @__PURE__ */ jsx_runtime.jsx("p", { + className: "dsh-nx-hint", + children: props.catalog.hint || props.labels.unavailable + }) : null + ] + }), + showManual ? /* @__PURE__ */ jsx_runtime.jsxs(jsx_runtime.Fragment, { + children: [ + /* @__PURE__ */ jsx_runtime.jsx(ValueField, { + id: "netxops-im-bot-id", + label: props.labels.botId, + hint: props.labels.botHint, + field: props.botField, + overriddenLabel: props.labels.overridden, + resetLabel: props.labels.reset, + invalidLabel: props.labels.invalid, + disabled: props.disabled, + onEdit: props.onEditBot, + onReset: props.onResetBot + }), + /* @__PURE__ */ jsx_runtime.jsx(ValueField, { + id: "netxops-im-target-id", + label: props.labels.targetId, + hint: props.labels.targetHint, + field: props.targetField, + overriddenLabel: props.labels.overridden, + resetLabel: props.labels.reset, + invalidLabel: props.labels.invalid, + disabled: props.disabled, + onEdit: props.onEditTarget, + onReset: props.onResetTarget + }) + ] + }) : null + ] + }); +} +function CapabilityGroupBlock(props) { + return /* @__PURE__ */ jsx_runtime.jsxs("div", { + className: "dsh-nx-groupBlock", + children: [ + /* @__PURE__ */ jsx_runtime.jsx("div", { + className: "dsh-nx-groupTitle", + children: props.title + }), + /* @__PURE__ */ jsx_runtime.jsxs("div", { + className: "dsh-nx-groupChecks", + children: [ + /* @__PURE__ */ jsx_runtime.jsxs("label", { + className: "dsh-nx-checkRow", + children: [ + /* @__PURE__ */ jsx_runtime.jsx("input", { + type: "checkbox", + checked: props.inPreset.text === "true", + disabled: props.disabled, + onChange: (event) => { + props.onEditInPreset(event.target.checked); + } + }), + /* @__PURE__ */ jsx_runtime.jsx("span", { + children: props.inPresetLabel + }) + ] + }), + /* @__PURE__ */ jsx_runtime.jsxs("label", { + className: "dsh-nx-checkRow", + children: [ + /* @__PURE__ */ jsx_runtime.jsx("input", { + type: "checkbox", + checked: props.published.text === "true", + disabled: props.disabled, + onChange: (event) => { + props.onEditPublic(event.target.checked); + } + }), + /* @__PURE__ */ jsx_runtime.jsx("span", { + children: props.publicLabel + }) + ] + }) + ] + }) + ] + }); +} function NetxopsCard(props) { ensureStyles(); const { t } = props; @@ -388,6 +599,80 @@ function NetxopsCard(props) { props.resetField("lang"); } }), + /* @__PURE__ */ jsx_runtime.jsx(ValueField, { + id: "netxops-nms-provider", + label: t("nmsProvider"), + hint: t("nmsProviderHint"), + field: state.nmsProvider, + overriddenLabel: t("overridden"), + resetLabel: t("reset"), + invalidLabel: t("invalid"), + disabled, + onEdit: (text) => { + props.edit("nmsProvider", text); + }, + onReset: () => { + props.resetField("nmsProvider"); + } + }), + /* @__PURE__ */ jsx_runtime.jsxs("div", { + className: "dsh-nx-field", + children: [ + /* @__PURE__ */ jsx_runtime.jsx("div", { + className: "dsh-nx-fieldHead", + children: /* @__PURE__ */ jsx_runtime.jsx("span", { + className: "dsh-nx-label", + children: t("capabilityGroups") + }) + }), + /* @__PURE__ */ jsx_runtime.jsx("p", { + className: "dsh-nx-hint", + children: t("capabilityGroupsHint") + }), + /* @__PURE__ */ jsx_runtime.jsx(CapabilityGroupBlock, { + title: t("groupNms"), + inPresetLabel: t("groupInPreset"), + publicLabel: t("groupPublic"), + inPreset: state.groupNmsInPreset, + published: state.groupNmsPublic, + disabled, + onEditInPreset: (checked) => { + props.edit("groupNmsInPreset", checked ? "true" : "false"); + }, + onEditPublic: (checked) => { + props.edit("groupNmsPublic", checked ? "true" : "false"); + } + }), + /* @__PURE__ */ jsx_runtime.jsx(CapabilityGroupBlock, { + title: t("groupCommon"), + inPresetLabel: t("groupInPreset"), + publicLabel: t("groupPublic"), + inPreset: state.groupCommonInPreset, + published: state.groupCommonPublic, + disabled, + onEditInPreset: (checked) => { + props.edit("groupCommonInPreset", checked ? "true" : "false"); + }, + onEditPublic: (checked) => { + props.edit("groupCommonPublic", checked ? "true" : "false"); + } + }), + /* @__PURE__ */ jsx_runtime.jsx(CapabilityGroupBlock, { + title: t("groupTopology"), + inPresetLabel: t("groupInPreset"), + publicLabel: t("groupPublic"), + inPreset: state.groupTopologyInPreset, + published: state.groupTopologyPublic, + disabled, + onEditInPreset: (checked) => { + props.edit("groupTopologyInPreset", checked ? "true" : "false"); + }, + onEditPublic: (checked) => { + props.edit("groupTopologyPublic", checked ? "true" : "false"); + } + }) + ] + }), /* @__PURE__ */ jsx_runtime.jsxs("div", { className: "dsh-nx-field", children: [ @@ -558,35 +843,40 @@ function NetxopsCard(props) { }) ] }), - /* @__PURE__ */ jsx_runtime.jsx(ValueField, { - id: "netxops-im-bot-id", - label: t("imBotId"), - hint: t("imBotIdHint"), - field: state.imBotId, - overriddenLabel: t("overridden"), - resetLabel: t("reset"), - invalidLabel: t("invalid"), + /* @__PURE__ */ jsx_runtime.jsx(ImDeliveryPicker, { + catalog: state.imDeliveryCatalog, + botId: state.imBotId.text, + targetId: state.imTargetId.text, disabled, - onEdit: (text) => { + labels: { + target: t("imTarget"), + none: t("imTargetNone"), + manual: t("imTargetManual"), + unavailable: t("imCatalogUnavailable"), + botId: t("imBotId"), + botHint: t("imBotIdHint"), + targetId: t("imTargetId"), + targetHint: t("imTargetIdHint"), + overridden: t("overridden"), + reset: t("reset"), + invalid: t("invalid") + }, + botField: state.imBotId, + targetField: state.imTargetId, + onPick: (botId, targetId) => { + props.edit("imBotId", botId); + props.edit("imTargetId", targetId); + }, + onEditBot: (text) => { props.edit("imBotId", text); }, - onReset: () => { - props.resetField("imBotId"); - } - }), - /* @__PURE__ */ jsx_runtime.jsx(ValueField, { - id: "netxops-im-target-id", - label: t("imTargetId"), - hint: t("imTargetIdHint"), - field: state.imTargetId, - overriddenLabel: t("overridden"), - resetLabel: t("reset"), - invalidLabel: t("invalid"), - disabled, - onEdit: (text) => { + onEditTarget: (text) => { props.edit("imTargetId", text); }, - onReset: () => { + onResetBot: () => { + props.resetField("imBotId"); + }, + onResetTarget: () => { props.resetField("imTargetId"); } }), @@ -861,14 +1151,23 @@ class NetxopsCardController { }; rpcCall; alarmPushStatus = null; + imDeliveryCatalog = { ...EMPTY_IM_DELIVERY_CATALOG }; pollTimer; pollInFlight = false; + catalogInFlight = false; constructor(scope, ctx) { this.scope = scope; this.ctx = ctx; this.form = new CardForm(scope, [ textField("apiUrl"), textField("lang"), + textField("nmsProvider"), + booleanFieldPersistFalse("groupNmsInPreset"), + booleanField("groupNmsPublic"), + booleanFieldPersistFalse("groupCommonInPreset"), + booleanField("groupCommonPublic"), + booleanField("groupTopologyInPreset"), + booleanField("groupTopologyPublic"), booleanField("alarmPushEnabled"), booleanFieldPersistFalse("alarmDeliverDsh"), booleanField("alarmDeliverIm"), @@ -897,20 +1196,29 @@ class NetxopsCardController { this.rpcCall = call; if (call === undefined) { this.stopStatusPoll(); + let changed = false; if (this.alarmPushStatus !== null) { this.alarmPushStatus = null; - this.store.set(this.projection()); + changed = true; } + if (this.imDeliveryCatalog.options.length > 0 || this.imDeliveryCatalog.available !== true) { + this.imDeliveryCatalog = { ...EMPTY_IM_DELIVERY_CATALOG }; + changed = true; + } + if (changed) + this.store.set(this.projection()); return; } this.startStatusPoll(); this.refreshAlarmPushStatus(); + this.refreshImDeliveryCatalog(); } startStatusPoll() { if (this.pollTimer !== undefined) return; this.pollTimer = setInterval(() => { this.refreshAlarmPushStatus(); + this.refreshImDeliveryCatalog(); }, STATUS_POLL_MS); } stopStatusPoll() { @@ -935,11 +1243,38 @@ class NetxopsCardController { this.pollInFlight = false; } } + async refreshImDeliveryCatalog() { + const call = this.rpcCall; + if (call === undefined || this.catalogInFlight) + return; + this.catalogInFlight = true; + try { + const next = await fetchImDeliveryCatalog(call); + const prev = this.imDeliveryCatalog; + const sameOptions = prev.options.length === next.options.length && prev.options.every((row, index) => { + const other = next.options[index]; + return other && row.botId === other.botId && row.targetId === other.targetId && row.name === other.name && row.channel === other.channel; + }); + if (prev.available === next.available && prev.hint === next.hint && sameOptions) + return; + this.imDeliveryCatalog = next; + this.store.set(this.projection()); + } catch {} finally { + this.catalogInFlight = false; + } + } projection() { return { ...this.form.shell(), apiUrl: this.form.field("apiUrl"), lang: this.form.field("lang"), + nmsProvider: this.form.field("nmsProvider"), + groupNmsInPreset: this.form.field("groupNmsInPreset"), + groupNmsPublic: this.form.field("groupNmsPublic"), + groupCommonInPreset: this.form.field("groupCommonInPreset"), + groupCommonPublic: this.form.field("groupCommonPublic"), + groupTopologyInPreset: this.form.field("groupTopologyInPreset"), + groupTopologyPublic: this.form.field("groupTopologyPublic"), alarmPushEnabled: this.form.field("alarmPushEnabled"), alarmDeliverDsh: this.form.field("alarmDeliverDsh"), alarmDeliverIm: this.form.field("alarmDeliverIm"), @@ -949,7 +1284,8 @@ class NetxopsCardController { apiTokenConfigured: this.credential.configured, apiTokenWritable: this.credential.remoteReady && this.credential.writable, apiTokenRemoteReady: this.credential.remoteReady, - alarmPushStatus: this.alarmPushStatus + alarmPushStatus: this.alarmPushStatus, + imDeliveryCatalog: this.imDeliveryCatalog }; } credentials() { @@ -1014,11 +1350,20 @@ function refOf(snapshot) { // src/client/locales.ts var en = { title: "Netx Ops", - description: "UME API endpoint and bearer token for netx__* tools.", + description: "netx API, bearer token, and capability groups (nms / common / topology).", apiUrl: "API URL", apiUrlHint: "netx REST root, e.g. http://127.0.0.1:8890 (also used for alarm subscribe)", lang: "Language", langHint: "Response language hint (zh / en).", + capabilityGroups: "Capability groups", + capabilityGroupsHint: "One group ↔ one skill (netx-nms / netx-common / netx-topology). Defaults: nms + common in preset; topology and all public off. Save, then open a new session. Other agents can mount dsh-netxops/tools-nms|common|topology.", + nmsProvider: "nms provider", + nmsProviderHint: "Vendor adapter id. Supported today: zte-ume (REST still /v1/ume/*; model tools are netx__*Nms*).", + groupNms: "nms — skill netx-nms (alarms / inventory / SQL)", + groupCommon: "common — skill netx-common (managed CLI + findTopologyPaths)", + groupTopology: "topology — skill netx-topology (canvas / fabric / dual_unit)", + groupInPreset: "In Netx Ops preset", + groupPublic: "Publish to other presets", alarmPushEnabled: "Key-alarm push", alarmPushEnabledHint: "Dial out to netx when a matched key alarm arrives. Choose DSH and/or IM sinks below.", alarmPushStatus: "Push link", @@ -1033,11 +1378,15 @@ var en = { alarmDeliverDsh: "Deliver to DSH session", alarmDeliverDshHint: "Open/follow the sticky「Netx 关键告警」session on this Host.", alarmDeliverIm: "Deliver to WhatsApp / IM", - alarmDeliverImHint: "Requires dsh-im-ops. Paste botId + targetId from IM → 投递设置 → 复制调用参数.", + alarmDeliverImHint: "Requires dsh-im-ops ≥ops.24. Pick a saved delivery target below (create targets under IM → Delivery settings).", imBotId: "IM Bot ID", - imBotIdHint: "Opaque botId from IM delivery settings (not a phone number).", + imBotIdHint: "Filled from the picker, or enter manually.", imTargetId: "IM Target ID", - imTargetIdHint: "Opaque targetId alias (e.g. release-alerts). Create the target in IM first.", + imTargetIdHint: "Filled from the picker, or enter manually.", + imTarget: "Delivery target", + imTargetNone: "(Create a target in IM delivery settings first)", + imTargetManual: "Enter manually…", + imCatalogUnavailable: "Cannot load targets — install/update dsh-im-ops (≥ops.24) and save at least one delivery target.", apiToken: "API token", apiTokenHint: "Stored as credential NETX_API_TOKEN (never written into settings). Leave blank to keep the current token.", apiTokenUnavailable: "This DSH build does not expose remote.credentials. Set the token with scripts/set-netx-token.ps1 (or .sh), then restart is not required if credentials are watched.", @@ -1057,11 +1406,20 @@ var en = { }; var zh = { title: "Netx Ops", - description: "UME API 地址与 Bearer Token,供 netx__* 工具使用。", + description: "netx API、Bearer Token,以及能力组(nms / common / topology)。", apiUrl: "API 地址", apiUrlHint: "netx REST 根地址,例如 http://127.0.0.1:8890(告警订阅复用同一地址)", lang: "语言", langHint: "响应语言提示(zh / en)。", + capabilityGroups: "能力组", + capabilityGroupsHint: "一组一个 skill(netx-nms / netx-common / netx-topology)。默认:nms + common 进入预设;topology 与全部公开关闭。保存后请新开会话。其他 Agent 可挂 dsh-netxops/tools-nms|common|topology。", + nmsProvider: "nms provider", + nmsProviderHint: "厂商适配器 id。当前支持:zte-ume(REST 仍为 /v1/ume/*;模型工具名为 netx__*Nms*)。", + groupNms: "nms — skill netx-nms(告警 / 库存 / SQL)", + groupCommon: "common — skill netx-common(纳管 CLI + findTopologyPaths)", + groupTopology: "topology — skill netx-topology(画布 / Fabric / dual_unit)", + groupInPreset: "在 Netx Ops 预设中启用", + groupPublic: "对其他预设公开", alarmPushEnabled: "关键告警推送", alarmPushEnabledHint: "主动连接 netx;匹配到关键告警后按下述开关投递。可只开 DSH、只开 IM,或两者都开。", alarmPushStatus: "推送链路", @@ -1076,11 +1434,15 @@ var zh = { alarmDeliverDsh: "投递到 DSH 会话", alarmDeliverDshHint: "在本机打开/续写 sticky「Netx 关键告警」会话。", alarmDeliverIm: "投递到 WhatsApp / IM", - alarmDeliverImHint: "需要已安装 dsh-im-ops。请先在 IM「投递设置」新建目标,再粘贴「复制调用参数」里的 botId / targetId。", + alarmDeliverImHint: "需要 dsh-im-ops ≥ops.24。请在下方下拉选择已保存的投递目标(先在 IM「投递设置」新建)。", imBotId: "IM Bot ID", - imBotIdHint: "来自 IM 投递设置的不透明 botId(不是手机号)。", + imBotIdHint: "由下拉自动填入,也可手动改。", imTargetId: "IM Target ID", - imTargetIdHint: "投递目标别名(如 release-alerts)。请先在 IM 侧创建目标。", + imTargetIdHint: "由下拉自动填入,也可手动改。", + imTarget: "投递目标", + imTargetNone: "(请先在 IM「投递设置」新建目标)", + imTargetManual: "手动填写…", + imCatalogUnavailable: "无法加载投递目标 — 请安装/更新 dsh-im-ops(≥ops.24)并至少保存一个投递目标。", apiToken: "API Token", apiTokenHint: "写入凭据 NETX_API_TOKEN(不会进 settings)。留空表示保留已有 token。", apiTokenUnavailable: "当前 DSH 未提供 remote.credentials。请用 scripts/set-netx-token.ps1(或 .sh)写入 token;若 harness 在监视凭据文件则无需重启。", diff --git a/lib/index.js b/lib/index.js index 5811244..d194782 100644 --- a/lib/index.js +++ b/lib/index.js @@ -2,10 +2,10 @@ import { createRequire } from "node:module"; var __require = /* @__PURE__ */ createRequire(import.meta.url); // src/index.ts -import { cpSync, existsSync, mkdirSync, rmSync, writeFileSync } from "node:fs"; +import { cpSync, existsSync as existsSync2, mkdirSync, rmSync, writeFileSync } from "node:fs"; import { homedir as homedir2 } from "node:os"; -import { dirname, join as join2 } from "node:path"; -import { fileURLToPath } from "node:url"; +import { dirname as dirname2, join as join3 } from "node:path"; +import { fileURLToPath as fileURLToPath2 } from "node:url"; import z from "@deepseek-ai/schemastery"; import { credentialRef } from "@deepseek-ai/dsh-credentials"; import * as DshSettings from "@deepseek-ai/dsh-settings"; @@ -89,7 +89,7 @@ function formatAlarmPrompt(payload, lang = "zh") { const label = String(payload.rule_label ?? payload.native_probable_cause ?? "关键告警").trim(); if (lang.startsWith("en")) { return [ - `[UME ${actionEn[action] ?? (action || "Alarm")}] ${label}`, + `[NMS ${actionEn[action] ?? (action || "Alarm")}] ${label}`, `Device: ${device}`, `Object: ${String(payload.object_name ?? "-").trim()}`, `Severity: ${String(payload.perceived_severity ?? "-").trim()}`, @@ -103,7 +103,7 @@ function formatAlarmPrompt(payload, lang = "zh") { `); } return [ - `[UME ${actionZh[action] ?? (action || "告警")}] ${label}`, + `[NMS ${actionZh[action] ?? (action || "告警")}] ${label}`, `设备: ${device}`, `对象: ${String(payload.object_name ?? "-").trim()}`, `级别: ${String(payload.perceived_severity ?? "-").trim()}`, @@ -393,6 +393,243 @@ function resetAlarmSession() { sticky = null; } +// src/netx/capability-groups.ts +var DEFAULT_CAPABILITY_GROUPS = Object.freeze({ + nms: Object.freeze({ inPreset: true, public: false }), + common: Object.freeze({ inPreset: true, public: false }), + topology: Object.freeze({ inPreset: false, public: false }) +}); +var TOOLS_BY_GROUP = Object.freeze({ + nms: Object.freeze([ + "netx__queryNmsAlarms", + "netx__aggregateNmsAlarms", + "netx__runNmsDiagnostics", + "netx__queryNmsNeInventory", + "netx__getNmsNe", + "netx__queryNmsAlarmsRaw", + "netx__aggregateNmsAlarmsRaw", + "netx__listNmsAlarmFields", + "netx__sqlQueryNms" + ]), + common: Object.freeze([ + "netx__listManagedNe", + "netx__getManagedNe", + "netx__execManagedNe", + "netx__listCliTargets", + "netx__findTopologyPaths" + ]), + topology: Object.freeze([ + "netx__getTopologyTree", + "netx__getTopologyView", + "netx__createTopologyFolder", + "netx__addTopologyViewNodes", + "netx__removeTopologyViewNodes", + "netx__copyTopologyViewNodes", + "netx__updateTopologyViewPositions", + "netx__projectTopologyNeighbors", + "netx__queryTopologyFabricNodes", + "netx__classifyTopologyFabricNodes", + "netx__queryTopologyNeighborhood", + "netx__queryTopologyEdges", + "netx__layoutTopologyView", + "netx__suggestSinkHubs", + "netx__analyzeTopologyViewLayout", + "netx__sinkTopologyDualUnits" + ]) +}); +var SKILL_DIR_BY_GROUP = Object.freeze({ + nms: "nms", + common: "common", + topology: "topology" +}); +var CAPABILITY_GROUP_IDS = Object.freeze([ + "nms", + "common", + "topology" +]); +function capabilityGroupsFromSettings(fields) { + const src = fields ?? {}; + const commonInPreset = src.groupCommonInPreset !== undefined ? src.groupCommonInPreset !== false : src.groupManagedNeInPreset !== false; + const commonPublic = src.groupCommonPublic === true || src.groupManagedNePublic === true; + return { + nms: { + inPreset: src.groupNmsInPreset !== false, + public: src.groupNmsPublic === true + }, + common: { + inPreset: commonInPreset, + public: commonPublic + }, + topology: { + inPreset: src.groupTopologyInPreset === true || src.groupTopologyLayoutInPreset === true, + public: src.groupTopologyPublic === true || src.groupTopologyLayoutPublic === true + } + }; +} +function groupsForPlane(groups, plane, only) { + const policy = groups ?? DEFAULT_CAPABILITY_GROUPS; + const enabled = CAPABILITY_GROUP_IDS.filter((id) => plane === "preset" ? policy[id].inPreset : policy[id].public); + if (!only || only.length === 0) + return enabled; + const allow = new Set(only); + return enabled.filter((id) => allow.has(id)); +} +function toolNamesForGroups(groupIds) { + const names = new Set; + for (const id of groupIds) { + for (const tool of TOOLS_BY_GROUP[id]) + names.add(tool); + } + return names; +} + +// src/netx/group-skills.ts +import { existsSync } from "node:fs"; +import { readdir, readFile, stat } from "node:fs/promises"; +import { dirname, join as join2 } from "node:path"; +import { fileURLToPath } from "node:url"; +function opsSkillsRoot() { + const envRoot = process.env.NETX_SKILLS_ROOT?.trim(); + if (envRoot && existsSync(envRoot)) + return envRoot; + const here = dirname(fileURLToPath(import.meta.url)); + const siblingCandidates = [ + join2(here, "..", "..", "..", "netx", "skills"), + join2(here, "..", "..", "netx", "skills") + ]; + for (const candidate of siblingCandidates) { + if (existsSync(candidate)) + return candidate; + } + const packaged = [ + join2(here, "..", "presets", "netxops", "skills"), + join2(here, "..", "..", "presets", "netxops", "skills") + ]; + for (const candidate of packaged) { + if (existsSync(candidate)) + return candidate; + } + return packaged[0]; +} +function parseFrontmatter(raw) { + if (!raw.startsWith("---")) + return; + const end = raw.indexOf(` +---`, 3); + if (end < 0) + return; + const yaml = raw.slice(3, end).replace(/^\r?\n/, ""); + const body = raw.slice(end + 4).replace(/^\r?\n/, ""); + const data = {}; + const lines = yaml.split(/\r?\n/); + for (let i = 0;i < lines.length; i += 1) { + const line = lines[i]; + const nameMatch = /^name\s*:\s*(.+)\s*$/.exec(line); + if (nameMatch) { + data.name = stripQuotes(nameMatch[1].trim()); + continue; + } + const descMatch = /^description\s*:\s*(.*)$/.exec(line); + if (!descMatch) + continue; + const head = descMatch[1].trim(); + if (head === ">" || head === ">-" || head === "|" || head === "|-") { + const parts = []; + while (i + 1 < lines.length && /^[ \t]+/.test(lines[i + 1])) { + i += 1; + parts.push(lines[i].trim()); + } + data.description = parts.filter(Boolean).join(" "); + continue; + } + data.description = stripQuotes(head); + } + return { data, body }; +} +function stripQuotes(value) { + if (value.startsWith('"') && value.endsWith('"') || value.startsWith("'") && value.endsWith("'")) { + return value.slice(1, -1); + } + return value; +} +async function loadSkillBundle(dir) { + const skillPath = join2(dir, "SKILL.md"); + let raw; + try { + raw = await readFile(skillPath, "utf8"); + } catch { + return null; + } + const parsed = parseFrontmatter(raw); + if (!parsed) + return null; + const name = typeof parsed.data.name === "string" ? parsed.data.name.trim() : ""; + const description = typeof parsed.data.description === "string" ? parsed.data.description.trim() : ""; + if (!name || !description) + return null; + return { + name, + description, + content: parsed.body.trimStart(), + path: skillPath, + directory: dir + }; +} +async function loadGroupSkills(skillsRoot, groupId) { + const groupDir = join2(skillsRoot, SKILL_DIR_BY_GROUP[groupId]); + let entries; + try { + entries = await readdir(groupDir); + } catch { + return []; + } + const skills = []; + for (const entry of entries) { + const full = join2(groupDir, entry); + let isDir = false; + try { + isDir = (await stat(full)).isDirectory(); + } catch { + continue; + } + if (!isDir) + continue; + const skill = await loadSkillBundle(full); + if (skill) + skills.push(skill); + } + return skills; +} +async function registerGroupSkills(ctx, groupIds, providerLabel) { + const skillsApi = ctx.skills; + if (!skillsApi || typeof skillsApi.register !== "function") { + return () => {}; + } + const root = opsSkillsRoot(); + const disposers = []; + const enabled = new Set(groupIds); + for (const groupId of CAPABILITY_GROUP_IDS) { + if (!enabled.has(groupId)) + continue; + const skills = await loadGroupSkills(root, groupId); + for (const skill of skills) { + disposers.push(skillsApi.register({ + name: skill.name, + description: skill.description, + content: skill.content, + path: skill.path, + resourceBase: { kind: "directory", path: skill.directory }, + provider: providerLabel, + source: "custom" + })); + } + } + return () => { + for (const dispose of disposers) + dispose(); + }; +} + // src/netx/runtime.ts var STORE_KEY2 = Symbol.for("dsh-netxops.connection-store"); function store2() { @@ -410,6 +647,1473 @@ function publishNetxConnection(next) { for (const listener of state.listeners) listener(); } +function getNetxConnection() { + return store2().connection; +} +function watchNetxConnection(listener) { + const state = store2(); + state.listeners.add(listener); + return () => { + state.listeners.delete(listener); + }; +} + +// src/netx/tools.ts +import { defineTool } from "@deepseek-ai/dsh-tools"; + +// src/netx/http.ts +var PROTOCOL_KEY_ZH_TO_EN = { + 其他: "Other", + 时钟: "Clock", + "OTN/光": "OTN/Optical", + 电源: "Power" +}; +function localizePayload(lang, data) { + if (!lang.trim().toLowerCase().startsWith("en")) + return data; + const proto = data.protocol_summary; + if (!Array.isArray(proto)) + return data; + for (const row of proto) { + if (typeof row !== "object" || row === null || Array.isArray(row)) + continue; + const rec = row; + const key = typeof rec.key === "string" ? rec.key : ""; + const mapped = PROTOCOL_KEY_ZH_TO_EN[key]; + if (mapped !== undefined) + rec.key = mapped; + } + return data; +} +function encodeQuery(params) { + const sp = new URLSearchParams; + for (const [key, value] of Object.entries(params)) { + sp.set(key, String(value)); + } + const q = sp.toString(); + return q.length > 0 ? `?${q}` : ""; +} +function createNetxClient(connection) { + const base = connection.apiUrl.replace(/\/$/, ""); + const langParams = () => { + const lang = connection.lang.trim().toLowerCase(); + if (lang.startsWith("en")) + return { lang: "en" }; + return {}; + }; + async function request(method, path, options = {}) { + const token = connection.getToken().trim(); + if (token.length === 0) { + return { + ok: false, + error: "netx_token_missing", + detail: "Set credential NETX_API_TOKEN (Plugins → Netx Ops or scripts/set-netx-token)." + }; + } + const merged = { ...langParams(), ...options.params }; + const url = `${base}${path}${encodeQuery(merged)}`; + const timeoutMs = options.timeoutMs ?? connection.timeoutMs; + const controller = new AbortController; + const timer = setTimeout(() => { + controller.abort(); + }, timeoutMs); + const onOuterAbort = () => { + controller.abort(); + }; + options.signal?.addEventListener("abort", onOuterAbort, { once: true }); + try { + const headers = { + accept: "application/json", + authorization: `Bearer ${token}` + }; + if (options.body !== undefined) + headers["content-type"] = "application/json"; + const init = { + method, + headers, + signal: controller.signal + }; + if (options.body !== undefined) + init.body = JSON.stringify(options.body); + const resp = await fetch(url, init); + const text = await resp.text(); + if (!resp.ok) { + return { ok: false, error: `netx_http_${resp.status}`, detail: text.slice(0, 800) }; + } + const data = text.length > 0 ? JSON.parse(text) : {}; + if (typeof data === "object" && data !== null && !Array.isArray(data)) { + return { ok: true, data: localizePayload(connection.lang, data) }; + } + return { ok: true, data: { raw: data } }; + } catch (error) { + const detail = error instanceof Error ? error.message : String(error); + return { ok: false, error: "netx_request_failed", detail: detail.slice(0, 800) }; + } finally { + clearTimeout(timer); + options.signal?.removeEventListener("abort", onOuterAbort); + } + } + return { + get(path, params, signal, timeoutMs) { + return request("GET", path, { params, signal, timeoutMs }); + }, + post(path, body, signal, timeoutMs) { + return request("POST", path, { body, signal, timeoutMs }); + }, + patch(path, body, signal, timeoutMs) { + return request("PATCH", path, { body, signal, timeoutMs }); + } + }; +} +function quoteNeId(neId) { + return encodeURIComponent(neId.trim()); +} + +// src/netx/handlers.ts +var EXEC_MAX_COMMANDS = 5; +var UME_RAW_FIELD_PRESETS = { + brief: [ + "alarm_alarm_key", + "alarm_host_name", + "alarm_perceived_severity", + "alarm_event_type", + "alarm_last_seen_at", + "ne_host_name", + "ne_user_label", + "ne_ne_name", + "ne_ip_address", + "ne_exists" + ], + evidence: [ + "alarm_alarm_key", + "alarm_host_name", + "alarm_object_name", + "alarm_event_type", + "alarm_native_probable_cause", + "alarm_perceived_severity", + "alarm_is_cleared", + "alarm_time_created", + "alarm_last_seen_at", + "ne_host_name", + "ne_user_label", + "ne_ne_name", + "ne_ip_address", + "ne_connection_status", + "ne_exists" + ], + ne_debug: [ + "alarm_alarm_key", + "alarm_ne_id", + "alarm_perceived_severity", + "alarm_last_seen_at", + "ne_user_label", + "ne_ne_name", + "ne_ip_address", + "ne_ipv6_address", + "ne_device_level", + "ne_host_name", + "ne_connection_status", + "ne_admin_status", + "ne_address_type", + "ne_maintain_status", + "ne_exists" + ] +}; +function asRecord(value) { + return typeof value === "object" && value !== null && !Array.isArray(value) ? value : {}; +} +function str(args, key, fallback = "") { + const v = args[key]; + if (typeof v === "string") + return v; + if (typeof v === "number" || typeof v === "boolean") + return String(v); + return fallback; +} +function nmsOrUme(args, nmsKey, umeKey) { + return str(args, nmsKey).trim() || str(args, umeKey).trim(); +} +function nmsOrUmeList(args, nmsKey, umeKey) { + const primary = strList(args, nmsKey); + return primary.length > 0 ? primary : strList(args, umeKey); +} +function num(args, key) { + const v = args[key]; + return typeof v === "number" && Number.isFinite(v) ? v : undefined; +} +function bool(args, key) { + const v = args[key]; + return typeof v === "boolean" ? v : undefined; +} +function strList(args, key) { + const v = args[key]; + if (!Array.isArray(v)) + return []; + return v.map((x) => String(x).trim()).filter((x) => x.length > 0); +} +function clampInt(value, fallback, min, max) { + const n = value === undefined ? fallback : Math.trunc(value); + return Math.max(min, Math.min(max, n)); +} +function putStr(params, args, keys) { + for (const key of keys) { + const v = str(args, key).trim(); + if (v) + params[key] = v; + } +} +async function queryUmeAlarms(client, args, signal) { + let page = clampInt(num(args, "page"), 1, 1, 2); + const pageSize = clampInt(num(args, "page_size"), 50, 1, 500); + const params = { page, page_size: pageSize }; + putStr(params, args, ["severity", "ne_id", "host_name", "time_from", "time_to"]); + const keyword = str(args, "keyword").trim(); + const neName = str(args, "ne_name").trim(); + if (keyword) + params.keyword = keyword; + else if (neName) + params.keyword = neName; + return client.get("/v1/ume/alarms", params, signal); +} +async function aggregateUmeAlarmsRaw(client, args, signal) { + const params = {}; + putStr(params, args, [ + "group_by", + "group_by2", + "severity", + "is_cleared", + "ne_id", + "event_type", + "keyword", + "time_from", + "time_to", + "limit" + ]); + if ("exclude_missing_host" in args) { + const flag = bool(args, "exclude_missing_host"); + if (flag !== undefined) + params.exclude_missing_host = flag; + } + return client.get("/v1/ume/alarms/aggregate/raw", params, signal); +} +async function aggregateUmeAlarms(client, args, signal) { + if (str(args, "group_by").trim()) + return aggregateUmeAlarmsRaw(client, args, signal); + const topNe = clampInt(num(args, "top_ne"), 50, 0, 500); + const params = { top_ne: topNe }; + if ("exclude_missing_host" in args) { + const flag = bool(args, "exclude_missing_host"); + if (flag !== undefined) + params.exclude_missing_host = flag; + } + putStr(params, args, ["severity", "time_from", "time_to"]); + return client.get("/v1/ume/alarms/aggregate", params, signal); +} +async function runUmeDiagnostics(client, _args, signal) { + return client.get("/v1/ume/diagnostics", undefined, signal); +} +async function queryUmeNeInventory(client, args, signal) { + const params = { + page: clampInt(num(args, "page"), 1, 1, Number.MAX_SAFE_INTEGER), + page_size: clampInt(num(args, "page_size"), 50, 1, 500) + }; + putStr(params, args, ["keyword"]); + return client.get("/v1/ume/inventory/ne", params, signal); +} +async function getUmeNe(client, args, signal) { + const neId = str(args, "ne_id").trim(); + if (!neId) + return { ok: false, error: "ne_id_required", error_code: "ne_id_required" }; + return client.get(`/v1/ume/inventory/ne/${quoteNeId(neId)}`, undefined, signal); +} +async function queryUmeAlarmsRaw(client, args, signal) { + const params = { + page: clampInt(num(args, "page"), 1, 1, Number.MAX_SAFE_INTEGER), + page_size: clampInt(num(args, "page_size"), 50, 1, 500) + }; + putStr(params, args, [ + "severity", + "is_cleared", + "ne_id", + "event_type", + "keyword", + "time_from", + "time_to", + "order_by", + "order" + ]); + let fields = strList(args, "select_fields"); + if (fields.length === 0) { + const preset = str(args, "field_preset").trim().toLowerCase(); + fields = UME_RAW_FIELD_PRESETS[preset] ?? []; + } + if (fields.length > 0) + params.select_fields = fields.join(","); + return client.get("/v1/ume/alarms/raw", params, signal); +} +async function listUmeAlarmFields(client, _args, signal) { + return client.get("/v1/ume/alarms/fields", undefined, signal); +} +async function sqlQueryUme(client, args, signal) { + const sql = str(args, "sql").trim(); + if (!sql) + return { ok: false, error: "sql_required" }; + const limit = clampInt(num(args, "limit"), 200, 1, 2000); + const statementTimeoutMs = clampInt(num(args, "statement_timeout_ms"), 0, 0, 30000); + return client.post("/v1/sql/ume_query", { + sql, + limit, + statement_timeout_ms: statementTimeoutMs + }, signal, 60000); +} +async function listManagedNe(client, args, signal) { + const keyword = str(args, "keyword").trim(); + const vendor = str(args, "vendor").trim(); + const connectStatus = str(args, "connect_status").trim(); + if (!(keyword || vendor || connectStatus)) { + return { ok: false, error: "managed_ne_filter_required", error_code: "managed_ne_filter_required" }; + } + if (keyword && keyword.length < 2) { + return { ok: false, error: "managed_ne_keyword_too_short", error_code: "managed_ne_keyword_too_short" }; + } + const params = { + page: clampInt(num(args, "page"), 1, 1, Number.MAX_SAFE_INTEGER), + page_size: clampInt(num(args, "page_size"), 20, 1, 100) + }; + if (keyword) + params.keyword = keyword; + if (vendor) + params.vendor = vendor; + if (connectStatus) + params.connect_status = connectStatus; + return client.get("/v1/managed-ne", params, signal); +} +async function getManagedNe(client, args, signal) { + const neId = (str(args, "ne_id") || str(args, "managed_ne_id") || str(args, "id")).trim(); + if (!neId) { + return { + ok: false, + error: "ne_id_required", + error_code: "ne_id_required", + hint: "Pass managed NE id from listManagedNe/listCliTargets (source=managed). For NMS inventory UUIDs use execManagedNe(nms_ne_id=...) or getNmsNe, not getManagedNe.", + example: { ne_id: "" } + }; + } + const out = await client.get(`/v1/managed-ne/${quoteNeId(neId)}`, undefined, signal); + if (out.ok === false) { + const detail = `${str(out, "detail")}${str(out, "error")}`.toLowerCase(); + if (detail.includes("404") || detail.includes("not_found") || detail.includes("not found") || out.error === "netx_http_404") { + return { + ...out, + hint: "Managed NE not found for this ne_id. Call listManagedNe(keyword=...) or listCliTargets(source=managed) first. If this is an NMS inventory id, use execManagedNe(nms_ne_id=...) / getNmsNe instead of getManagedNe." + }; + } + } + return out; +} +async function execManagedNe(client, args, signal) { + const targetsRaw = args.targets; + const neIds = strList(args, "ne_ids"); + const umeNeIds = nmsOrUmeList(args, "nms_ne_ids", "ume_ne_ids"); + const sharedCommands = strList(args, "commands"); + const multi = Array.isArray(targetsRaw) && targetsRaw.length > 0 || neIds.length > 0 || umeNeIds.length > 0; + if (multi) { + const body2 = {}; + if (Array.isArray(targetsRaw) && targetsRaw.length > 0) { + const cleaned = []; + for (const t of targetsRaw) { + if (typeof t !== "object" || t === null || Array.isArray(t)) + continue; + const row = t; + const item = {}; + const neId2 = str(row, "ne_id").trim(); + const umeNeId2 = nmsOrUme(row, "nms_ne_id", "ume_ne_id"); + if (neId2) + item.ne_id = neId2; + if (umeNeId2) + item.ume_ne_id = umeNeId2; + const cmds = Array.isArray(row.commands) ? row.commands.map((c) => String(c).trim()).filter((c) => c.length > 0) : []; + if (cmds.length > 0) + item.commands = cmds; + if (Object.keys(item).length > 0) + cleaned.push(item); + } + body2.targets = cleaned; + } + if (neIds.length > 0) + body2.ne_ids = neIds; + if (umeNeIds.length > 0) + body2.ume_ne_ids = umeNeIds; + if (sharedCommands.length > 0) { + if (sharedCommands.length > EXEC_MAX_COMMANDS) { + return { ok: false, error: "too_many_commands", error_code: "too_many_commands" }; + } + body2.commands = sharedCommands; + } + body2.read_timeout_sec = clampInt(num(args, "read_timeout_sec"), 60, 10, 120); + const concurrency = num(args, "concurrency"); + if (concurrency !== undefined) + body2.concurrency = clampInt(concurrency, 4, 1, 8); + const out2 = await client.post("/v1/managed-ne/exec-batch", body2, signal, 600000); + if (out2.ok !== true) + return out2; + const data2 = asRecord(out2.data); + if (data2.ok === false) { + return { ok: false, data: data2, error: str(data2, "error", "exec_batch_failed") }; + } + return { ok: true, data: data2 }; + } + const neId = str(args, "ne_id").trim(); + const umeNeId = nmsOrUme(args, "nms_ne_id", "ume_ne_id"); + if (Boolean(neId) === Boolean(umeNeId)) { + return { + ok: false, + error: "exactly_one_of_ne_id_or_nms_ne_id_required", + error_code: "exactly_one_of_ne_id_or_nms_ne_id_required", + hint: "For one NE pass ne_id OR nms_ne_id (alias ume_ne_id). For many NEs pass ne_ids / nms_ne_ids with shared commands, or targets[] — one call, concurrent on server." + }; + } + if (sharedCommands.length === 0) { + return { ok: false, error: "commands_required", error_code: "commands_required" }; + } + if (sharedCommands.length > EXEC_MAX_COMMANDS) { + return { ok: false, error: "too_many_commands", error_code: "too_many_commands" }; + } + const body = { + commands: sharedCommands, + read_timeout_sec: clampInt(num(args, "read_timeout_sec"), 60, 10, 120) + }; + if (neId) + body.ne_id = neId; + if (umeNeId) + body.ume_ne_id = umeNeId; + const out = await client.post("/v1/managed-ne/exec", body, signal, 300000); + if (out.ok !== true) + return out; + const data = asRecord(out.data); + if (data.ok === false) { + return { ok: false, data, error: str(data, "error", "exec_failed") }; + } + return { ok: true, data }; +} +async function listCliTargets(client, args, signal) { + const params = { + page: clampInt(num(args, "page"), 1, 1, Number.MAX_SAFE_INTEGER), + page_size: clampInt(num(args, "page_size"), 50, 1, 500) + }; + putStr(params, args, ["source", "keyword"]); + return client.get("/v1/cli/targets", params, signal); +} +async function findTopologyPaths(client, args, signal) { + const fromUid = nmsOrUme(args, "from_nms_ne_id", "from_ume_ne_id"); + const fromMid = str(args, "from_managed_ne_id").trim(); + const toUid = nmsOrUme(args, "to_nms_ne_id", "to_ume_ne_id"); + const toMid = str(args, "to_managed_ne_id").trim(); + if (Boolean(fromUid) === Boolean(fromMid)) { + return { ok: false, error: "exactly_one_of_from_nms_ne_id_or_from_managed_ne_id_required" }; + } + if (Boolean(toUid) === Boolean(toMid)) { + return { ok: false, error: "exactly_one_of_to_nms_ne_id_or_to_managed_ne_id_required" }; + } + let detail = str(args, "detail", "summary").trim().toLowerCase() || "summary"; + if (detail !== "summary" && detail !== "full") + detail = "summary"; + const body = { + max_paths: clampInt(num(args, "max_paths"), 3, 1, 10), + max_hops: clampInt(num(args, "max_hops"), 6, 1, 12), + layer: str(args, "layer", "physical").trim() || "physical", + detail + }; + if (fromUid) + body.from_ume_ne_id = fromUid; + else + body.from_managed_ne_id = fromMid; + if (toUid) + body.to_ume_ne_id = toUid; + else + body.to_managed_ne_id = toMid; + return client.post("/v1/topology/fabric/paths", body, signal, 30000); +} + +// src/netx/topology-handlers.ts +function asRecord2(value) { + return typeof value === "object" && value !== null && !Array.isArray(value) ? value : {}; +} +function str2(args, key, fallback = "") { + const v = args[key]; + if (typeof v === "string") + return v; + if (typeof v === "number" || typeof v === "boolean") + return String(v); + return fallback; +} +function num2(args, key) { + const v = args[key]; + return typeof v === "number" && Number.isFinite(v) ? v : undefined; +} +function bool2(args, key) { + const v = args[key]; + return typeof v === "boolean" ? v : undefined; +} +function strList2(args, key) { + const v = args[key]; + if (!Array.isArray(v)) + return []; + return v.map((x) => String(x).trim()).filter((x) => x.length > 0); +} +function clampInt2(value, fallback, min, max) { + const n = value === undefined ? fallback : Math.trunc(value); + return Math.max(min, Math.min(max, n)); +} +function unwrap(out) { + if (out.ok !== true) + return out; + const data = asRecord2(out.data); + if (Object.keys(data).length === 0) + return { ok: true, data: out.data }; + return { ok: true, ...data, data }; +} +function filterFields(args) { + const out = {}; + for (const key of ["keyword", "role", "vendor", "link_status"]) { + const v = str2(args, key).trim(); + if (v) + out[key] = v; + } + return out; +} +function edgeEndpoints(edge) { + const a = str2(edge, "a_node_id") || str2(edge, "a"); + const b = str2(edge, "b_node_id") || str2(edge, "b"); + return [a.trim(), b.trim()]; +} +function collapseEdgesToLinks(edges, includeNames = false) { + const buckets = new Map; + for (const raw of edges) { + if (!raw || typeof raw !== "object" || Array.isArray(raw)) + continue; + const e = raw; + const [a, b] = edgeEndpoints(e); + if (!a || !b || a === b) + continue; + const lo = a < b ? a : b; + const hi = a < b ? b : a; + const key = `${lo}\x00${hi}`; + const row = buckets.get(key) ?? { + a_node_id: lo, + b_node_id: hi, + link_count: 0 + }; + row.link_count = Number(row.link_count || 0) + 1; + if (includeNames && !row.a_name) { + if (a === lo) { + row.a_name = str2(e, "a_name"); + row.b_name = str2(e, "b_name"); + } else { + row.a_name = str2(e, "b_name"); + row.b_name = str2(e, "a_name"); + } + } + buckets.set(key, row); + } + return [...buckets.values()]; +} +function summarizeViewGraph(graph, sample = 20) { + const nodes = Array.isArray(graph.nodes) ? graph.nodes : []; + const edges = Array.isArray(graph.edges) ? graph.edges : Array.isArray(graph.links) ? graph.links : []; + const sampleNodes = nodes.slice(0, Math.max(0, sample)).map((n) => { + const row = asRecord2(n); + return { + id: str2(row, "id") || str2(row, "fabric_node_id"), + name: str2(row, "name"), + x: row.x, + y: row.y, + level: row.level, + role: row.role + }; + }); + return { + ok: true, + view_id: str2(graph, "id") || str2(graph, "view_id"), + node_count: nodes.length, + edge_count: edges.length, + link_count: collapseEdgesToLinks(edges).length, + links: collapseEdgesToLinks(edges), + sample_nodes: sampleNodes, + detail: "summary" + }; +} +function layoutRecipeUnavailable(action) { + return { + ok: false, + error: "layout_recipe_requires_netx_topology_mcp", + action, + hint: "Canvas CRUD/query and move_nodes work in dsh-netxops. Dual-unit / orbit / polish recipes still need the netx-topology MCP layout engine (or enable that MCP alongside)." + }; +} +async function getTopologyTree(client, args, signal) { + const out = unwrap(await client.get("/v1/topology/tree", undefined, signal)); + if (out.ok !== true) + return out; + const compact = bool2(args, "compact") !== false; + const maxDepth = num2(args, "max_depth"); + if (!compact && maxDepth === undefined) + return out; + return { ...out, compact, max_depth: maxDepth ?? null }; +} +async function getTopologyView(client, args, signal) { + const viewId = str2(args, "view_id").trim(); + if (!viewId) + return { ok: false, error: "view_id_required" }; + const out = unwrap(await client.get(`/v1/topology/views/${encodeURIComponent(viewId)}`, undefined, signal, 120000)); + if (out.ok !== true) + return out; + const detail = str2(args, "detail", "summary").trim().toLowerCase() || "summary"; + if (detail === "full") + return { ...out, detail: "full" }; + return summarizeViewGraph(out, clampInt2(num2(args, "sample"), 20, 0, 200)); +} +async function createTopologyFolder(client, args, signal) { + const name = str2(args, "name").trim(); + if (!name) + return { ok: false, error: "name_required" }; + const body = { name }; + const parentId = str2(args, "parent_id").trim(); + if (parentId) + body.parent_id = parentId; + const locale = str2(args, "locale").trim(); + if (locale) + body.locale = locale; + if (num2(args, "sort_order") !== undefined) + body.sort_order = Math.trunc(num2(args, "sort_order")); + return unwrap(await client.post("/v1/topology/folders", body, signal)); +} +async function addTopologyViewNodes(client, args, signal) { + const viewId = str2(args, "view_id").trim(); + if (!viewId) + return { ok: false, error: "view_id_required" }; + if (strList2(args, "managed_ne_ids").length || strList2(args, "ume_ne_ids").length) { + return { + ok: false, + error: "fabric_nodes_only", + detail: "Use keyword/role/vendor/link_status or fabric_node_ids; never managed/UME ids." + }; + } + const filters = filterFields(args); + const fabricIds = strList2(args, "fabric_node_ids"); + if (Object.keys(filters).length === 0 && fabricIds.length === 0) { + return { + ok: false, + error: "filter_or_fabric_node_ids_required", + detail: "Pass keyword/role/vendor/link_status (preferred) or fabric_node_ids." + }; + } + if (num2(args, "max_nodes") !== undefined) { + const got = unwrap(await client.get(`/v1/topology/views/${encodeURIComponent(viewId)}`, undefined, signal)); + if (got.ok === true) { + const data = asRecord2(got.data); + const filt = asRecord2(got.filter ?? data.filter); + const membership = asRecord2(filt.membership ?? {}); + membership.max_nodes = clampInt2(num2(args, "max_nodes"), 2000, 1, 2000); + filt.membership = membership; + await client.patch(`/v1/topology/views/${encodeURIComponent(viewId)}`, { filter: filt }, signal); + } + } + const layout = str2(args, "layout", "grid").trim() || "grid"; + const body = { + managed_ne_ids: [], + layout, + ...filters + }; + if (fabricIds.length) + body.fabric_node_ids = fabricIds; + if (num2(args, "limit") !== undefined) + body.limit = clampInt2(num2(args, "limit"), 500, 1, 2000); + if (num2(args, "offset") !== undefined) + body.offset = clampInt2(num2(args, "offset"), 0, 0, Number.MAX_SAFE_INTEGER); + return unwrap(await client.post(`/v1/topology/views/${encodeURIComponent(viewId)}/nodes`, body, signal, 180000)); +} +async function removeTopologyViewNodes(client, args, signal) { + const viewId = str2(args, "view_id").trim(); + if (!viewId) + return { ok: false, error: "view_id_required" }; + const body = { ...filterFields(args) }; + const fabricIds = strList2(args, "fabric_node_ids"); + if (fabricIds.length) + body.fabric_node_ids = fabricIds; + return unwrap(await client.post(`/v1/topology/views/${encodeURIComponent(viewId)}/nodes/remove`, body, signal)); +} +async function copyTopologyViewNodes(client, args, signal) { + const sourceId = str2(args, "source_view_id").trim(); + const targetId = str2(args, "target_view_id").trim(); + if (!sourceId || !targetId) { + return { ok: false, error: "source_view_id_and_target_view_id_required" }; + } + const source = unwrap(await client.get(`/v1/topology/views/${encodeURIComponent(sourceId)}`, undefined, signal, 120000)); + if (source.ok !== true) + return source; + const nodes = Array.isArray(source.nodes) ? source.nodes : []; + const limit = num2(args, "limit"); + const selected = typeof limit === "number" ? nodes.slice(0, clampInt2(limit, nodes.length, 1, 2000)) : nodes; + const fabricIds = selected.map((n) => { + const row = asRecord2(n); + return str2(row, "id") || str2(row, "fabric_node_id"); + }).filter(Boolean); + if (bool2(args, "dry_run") === true) { + return { ok: true, dry_run: true, would_copy: fabricIds.length, source_view_id: sourceId, target_view_id: targetId }; + } + if (bool2(args, "clear_target") === true) { + await client.post(`/v1/topology/views/${encodeURIComponent(targetId)}/nodes/remove`, { + fabric_node_ids: "ALL", + clear_all: true + }, signal); + } + if (fabricIds.length === 0) { + return { ok: true, added: 0, source_view_id: sourceId, target_view_id: targetId }; + } + const add = unwrap(await client.post(`/v1/topology/views/${encodeURIComponent(targetId)}/nodes`, { fabric_node_ids: fabricIds, layout: "keep", managed_ne_ids: [] }, signal, 180000)); + if (add.ok !== true) + return add; + if (bool2(args, "copy_positions") !== false) { + const ox = num2(args, "offset_x") ?? 0; + const oy = num2(args, "offset_y") ?? 0; + const positions = selected.map((n) => { + const row = asRecord2(n); + const id = str2(row, "id") || str2(row, "fabric_node_id"); + return { + fabric_node_id: id, + x: Number(row.x || 0) + ox, + y: Number(row.y || 0) + oy + }; + }).filter((p) => p.fabric_node_id); + if (positions.length) { + await client.patch(`/v1/topology/views/${encodeURIComponent(targetId)}/positions`, { positions }, signal, 120000); + } + } + return { ok: true, added: fabricIds.length, source_view_id: sourceId, target_view_id: targetId, ...asRecord2(add) }; +} +async function updateTopologyViewPositions(client, args, signal) { + const viewId = str2(args, "view_id").trim(); + if (!viewId) + return { ok: false, error: "view_id_required" }; + const body = {}; + if (Array.isArray(args.positions)) + body.positions = args.positions; + const layout = str2(args, "layout").trim(); + if (layout) + body.layout = layout; + Object.assign(body, filterFields(args)); + for (const key of ["offset_x", "offset_y", "cols", "gap_x", "gap_y"]) { + if (num2(args, key) !== undefined) + body[key] = num2(args, key); + } + const fabricIds = strList2(args, "fabric_node_ids"); + if (fabricIds.length) + body.fabric_node_ids = fabricIds; + return unwrap(await client.patch(`/v1/topology/views/${encodeURIComponent(viewId)}/positions`, body, signal, 120000)); +} +async function projectTopologyNeighbors(client, args, signal) { + const viewId = str2(args, "view_id").trim(); + if (!viewId) + return { ok: false, error: "view_id_required" }; + const body = {}; + const seeds = strList2(args, "seed_fabric_node_ids").length ? strList2(args, "seed_fabric_node_ids") : strList2(args, "fabric_node_ids"); + if (seeds.length) + body.seed_fabric_node_ids = seeds; + const mids = strList2(args, "managed_ne_ids"); + if (mids.length) + body.managed_ne_ids = mids; + const region = str2(args, "region_folder_id").trim(); + if (region) + body.region_folder_id = region; + const out = unwrap(await client.post(`/v1/topology/views/${encodeURIComponent(viewId)}/project-neighbors`, body, signal, 180000)); + if (out.ok !== true) + return out; + const detail = str2(args, "detail", "summary").trim().toLowerCase() || "summary"; + if (detail === "full" || detail === "raw" || detail === "graph") { + return { ...out, detail: "full", projected: true }; + } + return { ...summarizeViewGraph(out, clampInt2(num2(args, "sample"), 20, 0, 100)), projected: true, view_id: viewId }; +} +async function queryTopologyFabricNodes(client, args, signal) { + let mode = str2(args, "mode").trim().toLowerCase(); + const q = str2(args, "q").trim(); + const keyword = str2(args, "keyword").trim(); + if (!mode) { + if (bool2(args, "summary") === true) + mode = "summary"; + else if (q) + mode = "search"; + else + mode = "list"; + } + if (mode === "summary" || mode === "stats" || mode === "count") { + const out2 = unwrap(await client.get("/v1/topology/fabric/summary", undefined, signal)); + return out2.ok === true ? { ...out2, mode: "summary" } : out2; + } + if (mode === "search" || mode === "find") { + const needle = q || keyword; + if (!needle) + return { ok: false, error: "q_required", hint: "mode=search needs q (or keyword)." }; + return unwrap(await client.get("/v1/topology/fabric/nodes/search", { + q: needle, + page: clampInt2(num2(args, "page"), 1, 1, Number.MAX_SAFE_INTEGER), + page_size: clampInt2(num2(args, "page_size") ?? num2(args, "limit"), 50, 1, 200) + }, signal)); + } + const params = { + page: clampInt2(num2(args, "page"), 1, 1, Number.MAX_SAFE_INTEGER), + page_size: clampInt2(num2(args, "page_size") ?? num2(args, "limit"), 50, 1, 500) + }; + for (const key of ["keyword", "role", "level", "level_major", "region_folder_id", "link_status"]) { + const v = str2(args, key).trim() || (key === "keyword" ? q : ""); + if (v) + params[key] = v; + } + const out = unwrap(await client.get("/v1/topology/fabric/nodes", params, signal)); + return out.ok === true ? { ...out, mode: "list" } : out; +} +async function classifyTopologyFabricNodes(client, args, signal) { + const action = str2(args, "action").trim().toLowerCase(); + if (!action) + return { ok: false, error: "action_required" }; + if (action === "match") { + const pattern = str2(args, "pattern").trim() || str2(args, "q").trim(); + if (!pattern) + return { ok: false, error: "pattern_required" }; + return unwrap(await client.post("/v1/topology/fabric/nodes/match", { + pattern, + match_field: str2(args, "match_field", "name").trim() || "name", + sample_limit: clampInt2(num2(args, "sample_limit"), 50, 1, 200) + }, signal)); + } + if (action === "tag") { + const body = { + fabric_node_ids: strList2(args, "fabric_node_ids"), + dry_run: bool2(args, "dry_run") === true + }; + const pattern = str2(args, "pattern").trim() || str2(args, "q").trim(); + if (pattern) + body.pattern = pattern; + if (args.level !== undefined) + body.level = args.level; + const role = str2(args, "role").trim(); + if (role) + body.role = role; + const region = str2(args, "region_folder_id").trim(); + if (region) + body.region_folder_id = region; + if (bool2(args, "clear_region") === true) + body.clear_region = true; + return unwrap(await client.post("/v1/topology/fabric/nodes/tags/bulk", body, signal)); + } + if (action === "patch") { + const nodeId = str2(args, "fabric_node_id").trim() || str2(args, "node_id").trim(); + if (!nodeId) + return { ok: false, error: "fabric_node_id_required" }; + const body = {}; + if (args.level !== undefined) + body.level = args.level; + const role = str2(args, "role").trim(); + if (role) + body.role = role; + const region = str2(args, "region_folder_id").trim(); + if (region) + body.region_folder_id = region; + if (bool2(args, "clear_region") === true) + body.clear_region = true; + return unwrap(await client.patch(`/v1/topology/fabric/nodes/${encodeURIComponent(nodeId)}/tags`, body, signal)); + } + if (action === "unmatched") { + return unwrap(await client.get("/v1/topology/classify/unmatched", { + kind: str2(args, "kind", "any").trim() || "any", + page: clampInt2(num2(args, "page"), 1, 1, Number.MAX_SAFE_INTEGER), + page_size: clampInt2(num2(args, "page_size") ?? num2(args, "limit"), 50, 1, 500) + }, signal)); + } + if (action === "preview_rules") { + return unwrap(await client.post("/v1/topology/classify/preview", {}, signal)); + } + if (action === "apply_rules") { + const params = {}; + if (bool2(args, "overwrite_manual") === true) + params.skip_manual = false; + else if (bool2(args, "skip_manual") !== undefined) + params.skip_manual = bool2(args, "skip_manual") !== false; + if (bool2(args, "fill_empty_only") === true) + params.fill_empty_only = true; + return unwrap(await client.post("/v1/topology/classify/apply", {}, signal)); + } + if (action === "list_rules") { + return unwrap(await client.get("/v1/topology/classify/rules", undefined, signal)); + } + return { ok: false, error: "unknown_action", action }; +} +async function queryTopologyNeighborhood(client, args, signal) { + const nodeId = str2(args, "node_id").trim(); + if (!nodeId) + return { ok: false, error: "node_id_required" }; + const out = unwrap(await client.get("/v1/topology/fabric/neighborhood", { + node_id: nodeId, + depth: clampInt2(num2(args, "depth"), 1, 1, 3), + layer: str2(args, "layer", "physical").trim() || "physical" + }, signal)); + if (out.ok !== true) + return out; + const edges = Array.isArray(out.edges) ? out.edges : []; + return { ...out, links: collapseEdgesToLinks(edges) }; +} +async function queryTopologyEdges(client, args, signal) { + const params = { + page: clampInt2(num2(args, "page"), 1, 1, Number.MAX_SAFE_INTEGER), + page_size: clampInt2(num2(args, "page_size"), 100, 1, 500), + layer: str2(args, "layer", "physical").trim() || "physical" + }; + for (const key of ["node_id", "keyword", "status", "source"]) { + const v = str2(args, key).trim(); + if (v) + params[key] = v; + } + const out = unwrap(await client.get("/v1/topology/fabric/edges", params, signal)); + if (out.ok !== true) + return out; + const detail = str2(args, "detail", "adjacency").trim().toLowerCase() || "adjacency"; + const items = Array.isArray(out.items) ? out.items : Array.isArray(out.edges) ? out.edges : []; + if (detail === "ports") + return { ...out, detail: "ports", items }; + const links = collapseEdgesToLinks(items, true); + return { ...out, detail: "adjacency", links, items: undefined }; +} +async function suggestSinkHubs(client, args, signal) { + const viewId = str2(args, "source_view_id").trim() || str2(args, "view_id").trim(); + if (!viewId) + return { ok: false, error: "source_view_id_required" }; + const out = unwrap(await client.get(`/v1/topology/views/${encodeURIComponent(viewId)}`, undefined, signal, 120000)); + if (out.ok !== true) + return out; + const nodes = Array.isArray(out.nodes) ? out.nodes : []; + const edges = Array.isArray(out.edges) ? out.edges : []; + const degree = new Map; + for (const raw of edges) { + const [a, b] = edgeEndpoints(asRecord2(raw)); + if (!a || !b) + continue; + degree.set(a, (degree.get(a) || 0) + 1); + degree.set(b, (degree.get(b) || 0) + 1); + } + const exclude = new Set([ + ...strList2(args, "exclude_portal_ids"), + ...strList2(args, "exclude_fabric_node_ids") + ]); + const pick = clampInt2(num2(args, "pick"), 5, 1, 50); + const hubs = [...degree.entries()].filter(([id]) => !exclude.has(id)).sort((a, b) => b[1] - a[1]).slice(0, pick).map(([fabric_node_id, deg]) => { + const node = nodes.find((n) => { + const row2 = asRecord2(n); + return (str2(row2, "id") || str2(row2, "fabric_node_id")) === fabric_node_id; + }); + const row = asRecord2(node); + return { + fabric_node_id, + degree: deg, + name: str2(row, "name"), + level: row.level, + role: row.role + }; + }); + return { + ok: true, + source_view_id: viewId, + hubs, + hint: "Degree ranking only — full dual_unit-aware suggestSinkHubs lives in netx-topology MCP." + }; +} +async function analyzeTopologyViewLayout(client, args, signal) { + const viewId = str2(args, "view_id").trim(); + if (!viewId) { + return { + ok: false, + error: "view_id_required", + hint: "Pass view_id. folder_id sampling without view_id needs netx-topology MCP structure analysis." + }; + } + const out = unwrap(await client.get(`/v1/topology/views/${encodeURIComponent(viewId)}`, undefined, signal, 120000)); + if (out.ok !== true) + return out; + const nodes = Array.isArray(out.nodes) ? out.nodes : []; + const edges = Array.isArray(out.edges) ? out.edges : []; + let minX = Infinity; + let minY = Infinity; + let maxX = -Infinity; + let maxY = -Infinity; + let missingxy = 0; + for (const raw of nodes) { + const row = asRecord2(raw); + const x = Number(row.x); + const y = Number(row.y); + if (!Number.isFinite(x) || !Number.isFinite(y)) { + missingxy += 1; + continue; + } + minX = Math.min(minX, x); + minY = Math.min(minY, y); + maxX = Math.max(maxX, x); + maxY = Math.max(maxY, y); + } + const degree = new Map; + for (const raw of edges) { + const [a, b] = edgeEndpoints(asRecord2(raw)); + if (!a || !b) + continue; + degree.set(a, (degree.get(a) || 0) + 1); + degree.set(b, (degree.get(b) || 0) + 1); + } + const hubs = [...degree.entries()].sort((a, b) => b[1] - a[1]).slice(0, 10).map(([id, deg]) => ({ fabric_node_id: id, degree: deg })); + return { + ok: true, + view_id: viewId, + detail: str2(args, "detail", "summary") || "summary", + node_count: nodes.length, + edge_count: edges.length, + link_count: collapseEdgesToLinks(edges).length, + missing_xy: missingxy, + bbox: Number.isFinite(minX) ? { minX, minY, maxX, maxY, width: maxX - minX, height: maxY - minY } : null, + hubs, + hint: "Basic graph stats only. Crossing/dual_unit/score.total analysis needs netx-topology MCP." + }; +} +async function sinkTopologyDualUnits(_client, args) { + return { + ok: false, + error: "sink_dual_units_requires_netx_topology_mcp", + source_view_id: str2(args, "source_view_id"), + sink_view_id: str2(args, "sink_view_id"), + hint: "sinkTopologyDualUnits needs the dual_unit layout engine from netx-topology MCP. Use layoutTopologyView(action=move_nodes, park=true) for HTTP-only parking, or enable the topology MCP." + }; +} +async function layoutTopologyView(client, args, signal) { + if (bool2(args, "catalog") === true) { + return { + ok: true, + catalog: true, + actions: [ + "layout", + "layout_dual_unit", + "move_nodes", + "sink_nodes", + "orbit_sweep", + "level_bands", + "polish_crossings", + "clear_edge_hits", + "compact_bbox", + "pull_far_chains", + "align_reference", + "fix_overlaps", + "resolve_overlaps", + "untangle", + "straighten_channels", + "job_status", + "job_cancel" + ], + dsh_supported: ["move_nodes", "sink_nodes", "catalog"], + recipes: ["rings", "corridor", "compact", "unstick"], + hint: "Only move_nodes/sink_nodes/catalog run in dsh-netxops; other actions need netx-topology MCP." + }; + } + const action = str2(args, "action", "layout").trim().toLowerCase() || "layout"; + if (action === "job_status" || action === "job_cancel") { + return { + ok: false, + error: "layout_jobs_not_in_dsh_host", + hint: "Background layout jobs are owned by netx-topology MCP." + }; + } + if (action === "move_nodes" || action === "sink_nodes") { + const toId = str2(args, "view_id").trim(); + const fromId = str2(args, "source_view_id").trim(); + const ids = strList2(args, "fabric_node_ids"); + const params = asRecord2(args.params); + const fabricIds = ids.length ? ids : strList2(params, "fabric_node_ids"); + if (!toId || !fromId) { + return { ok: false, error: "view_id_and_source_view_id_required" }; + } + if (fabricIds.length === 0) { + return { ok: false, error: "fabric_node_ids_required" }; + } + const copyPositions = bool2(params, "copy_positions") !== false && bool2(args, "copy_positions") !== false; + const removeFromSource = bool2(params, "remove_from_source") !== false; + const source = unwrap(await client.get(`/v1/topology/views/${encodeURIComponent(fromId)}`, undefined, signal, 120000)); + if (source.ok !== true) + return source; + const nodes = Array.isArray(source.nodes) ? source.nodes : []; + const wanted = new Set(fabricIds); + const selected = nodes.filter((n) => { + const row = asRecord2(n); + const id = str2(row, "id") || str2(row, "fabric_node_id"); + return wanted.has(id); + }); + const add = unwrap(await client.post(`/v1/topology/views/${encodeURIComponent(toId)}/nodes`, { fabric_node_ids: fabricIds, layout: copyPositions ? "keep" : "grid", managed_ne_ids: [] }, signal, 180000)); + if (add.ok !== true) + return add; + if (copyPositions && selected.length) { + const ox = Number(params.offset_x || 0); + const oy = Number(params.offset_y || 0); + const positions = selected.map((n) => { + const row = asRecord2(n); + return { + fabric_node_id: str2(row, "id") || str2(row, "fabric_node_id"), + x: Number(row.x || 0) + ox, + y: Number(row.y || 0) + oy + }; + }).filter((p) => p.fabric_node_id); + await client.patch(`/v1/topology/views/${encodeURIComponent(toId)}/positions`, { positions }, signal, 120000); + } + if (removeFromSource) { + await client.post(`/v1/topology/views/${encodeURIComponent(fromId)}/nodes/remove`, { fabric_node_ids: fabricIds }, signal); + } + return { + ok: true, + action, + moved: fabricIds.length, + source_view_id: fromId, + view_id: toId, + park: bool2(params, "park") === true || bool2(args, "park") === true + }; + } + return layoutRecipeUnavailable(action); +} + +// src/netx/tools.ts +var str3 = (description) => ({ type: "string", ...description ? { description } : {} }); +var num3 = (description) => ({ type: "number", ...description ? { description } : {} }); +var bool3 = (description) => ({ type: "boolean", ...description ? { description } : {} }); +var strArr = (description) => ({ + type: "array", + items: { type: "string" }, + ...description ? { description } : {} +}); +function renderJson(_args, value) { + return [{ type: "text", text: JSON.stringify(value, null, 0) }]; +} +var jsonOut = { + schema: { type: "json" }, + render: renderJson +}; +function tool(name, description, parameters, handler, getClient, timeoutMs) { + return defineTool({ + name, + description, + parameters, + output: jsonOut, + timeoutMs, + isConcurrencySafe: () => true, + async execute(args, exec) { + const result = await handler(getClient(), args, exec.signal); + if (result.ok === false) { + throw new Error(JSON.stringify(result)); + } + return result; + } + }); +} +function registerNetxTools(ctx, connection, options) { + const enabledGroups = options.forceGroups ?? groupsForPlane(connection.groups, options.plane, options.only); + const allow = toolNamesForGroups(enabledGroups); + if (allow.size === 0) + return () => {}; + const client = createNetxClient({ + apiUrl: connection.apiUrl, + lang: connection.lang, + timeoutMs: Math.min(connection.toolCallTimeoutMs, 45000), + getToken: () => getNetxConnection()?.token ?? "" + }); + const getClient = () => client; + const t = connection.toolCallTimeoutMs; + const catalog = [ + tool("netx__queryNmsAlarms", "Query NMS current alarms (each row includes host_name). Supports severity/ne_id/host_name/keyword, last_seen time_from/time_to, pagination. Prefer host_name for display; ne_id is for filters only.", { + severity: str3(), + ne_id: str3("Filter only; do not show UUID to users"), + host_name: str3("Filter by NE host_name"), + ne_name: str3("Legacy alias mapped to keyword"), + keyword: str3("Substring on cause/object/event. Examples: LOS, Fiber Break, bandwidth, CRC."), + time_from: str3("ISO time; filters last_seen_at >="), + time_to: str3("ISO time; filters last_seen_at <="), + page: num3(), + page_size: num3() + }, queryUmeAlarms, getClient, t), + tool("netx__aggregateNmsAlarms", "Aggregate NMS current alarms (by_severity + top by_ne). If group_by is set, routes to aggregateUmeAlarmsRaw. Always filter severity/keyword/time before paging.", { + severity: str3("Optional perceived_severity filter (critical/major/minor/warning)."), + top_ne: num3("Max NE buckets (default 50). Ignored when group_by is set."), + exclude_missing_host: bool3("Omit missing host_name from by_ne."), + time_from: str3(), + time_to: str3(), + group_by: str3("When set, routes to raw aggregation. Prefer alarm_host_name."), + group_by2: str3(), + is_cleared: str3(), + ne_id: str3(), + event_type: str3(), + keyword: str3(), + limit: num3() + }, aggregateUmeAlarms, getClient, t), + tool("netx__runNmsDiagnostics", "NMS alarm diagnostics: severity, top_event_types, top_alarm_codes, top_ne, protocol buckets, freshness meta.", {}, runUmeDiagnostics, getClient, t), + tool("netx__queryNmsNeInventory", "Paged NMS NE inventory synced in netx (keyword matches ne_id/ne_name/user_label/ip/host_name).", { + keyword: str3(), + page: num3(), + page_size: num3() + }, queryUmeNeInventory, getClient, t), + tool("netx__getNmsNe", "Get single NMS NE detail by ne_id (UUID).", { + ne_id: { type: "string", required: true, description: "NMS inventory ne_id (UUID)." } + }, getUmeNe, getClient, t), + tool("netx__queryNmsAlarmsRaw", "Power query NMS current alarms with full alarm_* + ne_* fields; optional field_preset or select_fields. Use field_preset=evidence for citations.", { + severity: str3(), + is_cleared: str3(), + ne_id: str3(), + event_type: str3(), + keyword: str3(), + time_from: str3(), + time_to: str3(), + order_by: str3("last_seen_at | time_created | perceived_severity | event_type | ne_id"), + order: str3("asc | desc"), + select_fields: strArr(), + field_preset: str3("brief | evidence | ne_debug"), + page: num3(), + page_size: num3() + }, queryUmeAlarmsRaw, getClient, t), + tool("netx__aggregateNmsAlarmsRaw", "Dynamic aggregation on NMS raw fields (group_by/group_by2); prefer alarm_host_name.", { + group_by: { type: "string", required: true }, + group_by2: str3(), + severity: str3(), + is_cleared: str3(), + ne_id: str3(), + event_type: str3(), + keyword: str3(), + time_from: str3(), + time_to: str3(), + exclude_missing_host: bool3(), + limit: num3() + }, aggregateUmeAlarmsRaw, getClient, t), + tool("netx__listNmsAlarmFields", "List available fields for NMS raw alarm queries.", {}, listUmeAlarmFields, getClient, t), + tool("netx__sqlQueryNms", "Read-only SELECT on NMS tables (ume_alarms_current/ume_inventory_ne); server enforces limits. Requires sql:query scope.", { + sql: { type: "string", required: true }, + limit: num3(), + statement_timeout_ms: num3() + }, sqlQueryUme, getClient, t), + tool("netx__listManagedNe", "List filtered netx managed NEs (keyword/vendor/connect_status required); use before execManagedNe.", { + keyword: str3(), + vendor: str3(), + connect_status: str3("unknown | testing | pass | fail"), + page: num3(), + page_size: num3() + }, listManagedNe, getClient, t), + tool("netx__getManagedNe", "Get one managed NE by managed ne_id (from listManagedNe / listCliTargets source=managed). Do NOT pass NMS inventory UUID here.", { + ne_id: str3("Managed NE id"), + managed_ne_id: str3("Alias for ne_id"), + id: str3("Alias for ne_id") + }, getManagedNe, getClient, t), + tool("netx__execManagedNe", "Run read-only CLI via netx (show/display/ping/traceroute). Single NE: ne_id OR ume_ne_id + commands. Many NEs: ne_ids[]/ume_ne_ids[] + shared commands, or targets[{ume_ne_id|ne_id, commands}]. Do NOT loop one-NE calls for multi-NE work.", { + ne_id: str3(), + nms_ne_id: str3("NMS inventory id; alias ume_ne_id"), + ume_ne_id: str3("Legacy alias of nms_ne_id"), + ne_ids: strArr("Managed NE ids for concurrent batch (shared commands)."), + nms_ne_ids: strArr("NMS inventory ids; alias ume_ne_ids"), + ume_ne_ids: strArr("Legacy alias of nms_ne_ids"), + targets: { + type: "array", + description: "Per-NE command sets: each item is one NE (ne_id OR ume_ne_id) with commands[].", + items: { + type: "object", + additionalProperties: false, + properties: { + ne_id: str3(), + ume_ne_id: str3(), + commands: strArr() + } + } + }, + commands: strArr("Commands for single NE, or shared commands for batch."), + read_timeout_sec: num3("Per-command read timeout (default 60; use 90–120 for slow show)."), + concurrency: num3("Parallel NEs for batch mode (1–8, default 4)."), + async: bool3("oclaw-only async hint; ignored by native REST client.") + }, execManagedNe, getClient, Math.max(t, 300000)), + tool("netx__listCliTargets", "List CLI-capable targets (managed NE and/or NMS inventory). Call once per session with keyword/source, cache ids, then execManagedNe.", { + source: str3("managed | nms | ume | all"), + keyword: str3(), + page: num3(), + page_size: num3() + }, listCliTargets, getClient, t), + tool("netx__findTopologyPaths", "Find up to max_paths simple paths between two fabric nodes (common group — native netx, not NMS-bound). For each endpoint provide exactly one of ume_ne_id or managed_ne_id.", { + from_nms_ne_id: str3("NMS inventory id"), + from_ume_ne_id: str3("Legacy alias of from_nms_ne_id"), + from_managed_ne_id: str3(), + to_nms_ne_id: str3("NMS inventory id"), + to_ume_ne_id: str3("Legacy alias of to_nms_ne_id"), + to_managed_ne_id: str3(), + max_paths: num3(), + max_hops: num3(), + layer: str3(), + detail: str3("summary | full") + }, findTopologyPaths, getClient, t), + tool("netx__getTopologyTree", "Get topology folder tree (nav roots + Root map canvases). Start here before createTopologyFolder.", { compact: bool3(), max_depth: num3() }, getTopologyTree, getClient, t), + tool("netx__getTopologyView", "Get a topology view by view_id. Default detail=summary with sample_nodes + links[].", { + view_id: { type: "string", required: true }, + detail: str3("summary | full"), + sample: num3() + }, getTopologyView, getClient, t), + tool("netx__createTopologyFolder", "Create topology folders / region canvases. Only way to create canvases (ne:write).", { + name: { type: "string", required: true }, + parent_id: str3(), + locale: str3("zh | en"), + sort_order: num3() + }, createTopologyFolder, getClient, t), + tool("netx__addTopologyViewNodes", "Bulk-place fabric nodes on a view via filters or fabric_node_ids (never managed/UME ids).", { + view_id: { type: "string", required: true }, + max_nodes: num3(), + keyword: str3(), + role: str3(), + vendor: str3(), + link_status: str3(), + limit: num3(), + offset: num3(), + fabric_node_ids: strArr(), + layout: str3("grid | keep") + }, addTopologyViewNodes, getClient, t), + tool("netx__removeTopologyViewNodes", "Remove placements from a view (not fabric) by filters or fabric_node_ids.", { + view_id: { type: "string", required: true }, + keyword: str3(), + role: str3(), + vendor: str3(), + link_status: str3(), + fabric_node_ids: strArr() + }, removeTopologyViewNodes, getClient, t), + tool("netx__copyTopologyViewNodes", "Clone fabric placements from source_view_id onto target_view_id (optional clear_target / copy_positions).", { + source_view_id: { type: "string", required: true }, + target_view_id: { type: "string", required: true }, + copy_positions: bool3(), + clear_target: bool3(), + offset_x: num3(), + offset_y: num3(), + limit: num3(), + dry_run: bool3() + }, copyTopologyViewNodes, getClient, t), + tool("netx__updateTopologyViewPositions", "Move nodes on a view via positions[] or layout=grid|offset|stack + filters.", { + view_id: { type: "string", required: true }, + positions: { type: "array", items: { type: "object" } }, + layout: str3("grid | offset | stack"), + keyword: str3(), + role: str3(), + vendor: str3(), + link_status: str3(), + fabric_node_ids: strArr(), + offset_x: num3(), + offset_y: num3() + }, updateTopologyViewPositions, getClient, t), + tool("netx__projectTopologyNeighbors", "Project existing fabric neighbors of on-view nodes onto the canvas.", { + view_id: { type: "string", required: true }, + seed_fabric_node_ids: strArr(), + managed_ne_ids: strArr(), + region_folder_id: str3(), + detail: str3("summary | full"), + sample: num3() + }, projectTopologyNeighbors, getClient, t), + tool("netx__queryTopologyFabricNodes", "Fabric inventory: mode=summary|list|search (keyword/role/level/region/link_status).", { + mode: str3("summary | list | search"), + q: str3(), + keyword: str3(), + role: str3(), + level: str3(), + level_major: str3(), + region_folder_id: str3(), + link_status: str3(), + page: num3(), + page_size: num3(), + limit: num3(), + summary: bool3() + }, queryTopologyFabricNodes, getClient, t), + tool("netx__classifyTopologyFabricNodes", "Classify fabric nodes: action=match|tag|patch|unmatched|preview_rules|apply_rules|list_rules.", { + action: { type: "string", required: true }, + pattern: str3(), + q: str3(), + match_field: str3(), + sample_limit: num3(), + fabric_node_ids: strArr(), + fabric_node_id: str3(), + node_id: str3(), + level: str3(), + role: str3(), + region_folder_id: str3(), + clear_region: bool3(), + dry_run: bool3(), + kind: str3(), + page: num3(), + page_size: num3(), + skip_manual: bool3(), + overwrite_manual: bool3(), + fill_empty_only: bool3() + }, classifyTopologyFabricNodes, getClient, t), + tool("netx__queryTopologyNeighborhood", "Neighborhood around a fabric node (depth 1–3) with compact links[].", { + node_id: { type: "string", required: true }, + depth: num3(), + layer: str3() + }, queryTopologyNeighborhood, getClient, t), + tool("netx__queryTopologyEdges", "Fabric adjacency links[] (detail=adjacency) or port rows (detail=ports).", { + node_id: str3(), + keyword: str3(), + layer: str3(), + status: str3(), + source: str3("lldp | ume | manual"), + detail: str3("adjacency | ports"), + page: num3(), + page_size: num3() + }, queryTopologyEdges, getClient, t), + tool("netx__suggestSinkHubs", "Rank hub territories on a source view for non-dual move_nodes(park) batches (degree stand-in in DSH).", { + source_view_id: str3(), + view_id: str3(), + pick: num3(), + exclude_portal_ids: strArr(), + exclude_fabric_node_ids: strArr() + }, suggestSinkHubs, getClient, t), + tool("netx__analyzeTopologyViewLayout", "Basic layout QA for a view (bbox/hubs). Full dual_unit/crossing scores need netx-topology MCP.", { + view_id: str3(), + folder_id: str3(), + detail: str3("summary | structure | hotspots | blocks | both"), + score_profile: str3(), + sight_limit: num3(), + max_views: num3(), + min_nodes: num3(), + max_nodes: num3(), + with_meta: bool3() + }, analyzeTopologyViewLayout, getClient, t), + tool("netx__sinkTopologyDualUnits", "Drain dual_unit eyes from source to sink (requires netx-topology MCP layout engine in DSH).", { + source_view_id: str3(), + sink_view_id: str3(), + max_units: num3(), + min_nodes: num3(), + max_nodes: num3(), + max_batch_nodes: num3(), + layout_batch: bool3(), + until_empty: bool3(), + dry_run: bool3() + }, sinkTopologyDualUnits, getClient, t), + tool("netx__layoutTopologyView", "Layout / polish a canvas. DSH supports catalog + move_nodes|sink_nodes over HTTP; dual_unit/orbit/polish recipes need netx-topology MCP.", { + view_id: str3(), + action: str3(), + source_view_id: str3(), + recipe: str3(), + preset: str3(), + mode: str3("preview | apply"), + tune: bool3(), + params: { type: "object" }, + catalog: bool3(), + fabric_node_ids: strArr(), + park: bool3(), + copy_positions: bool3() + }, layoutTopologyView, getClient, Math.max(t, 180000)) + ]; + const disposers = catalog.filter((entry) => allow.has(entry.name)).map((entry) => ctx.tools.register(entry)); + return () => { + for (const dispose of disposers) + dispose(); + }; +} // src/index.ts var name = "netxops"; @@ -428,33 +2132,40 @@ var Config = z.object({ alarmDeliverDsh: z.boolean().default(true), alarmDeliverIm: z.boolean().default(false), imBotId: z.string().default(""), - imTargetId: z.string().default("") + imTargetId: z.string().default(""), + nmsProvider: z.string().default("zte-ume"), + groupNmsInPreset: z.boolean().default(true), + groupNmsPublic: z.boolean().default(false), + groupCommonInPreset: z.boolean().default(true), + groupCommonPublic: z.boolean().default(false), + groupTopologyInPreset: z.boolean().default(false), + groupTopologyPublic: z.boolean().default(false) }); function packageRoot() { - return join2(dirname(fileURLToPath(import.meta.url)), ".."); + return join3(dirname2(fileURLToPath2(import.meta.url)), ".."); } function resolveDshHome() { const fromEnv = process.env.DSH_HOME?.trim(); if (fromEnv !== undefined && fromEnv.length > 0) return fromEnv; - return join2(homedir2(), ".dsh"); + return join3(homedir2(), ".dsh"); } function ensureAgentPresetInstalled(logger) { - const src = join2(packageRoot(), "presets", NETXOPS_PRESET_ID); - const composition = join2(src, "agent.cordis.yml"); - if (!existsSync(composition)) { + const src = join3(packageRoot(), "presets", NETXOPS_PRESET_ID); + const composition = join3(src, "agent.cordis.yml"); + if (!existsSync2(composition)) { logger.warn("netxops: bundled preset missing at %s — skip user-preset install", src); return; } - const destParent = join2(resolveDshHome(), ".agent-presets"); - const dest = join2(destParent, NETXOPS_PRESET_ID); + const destParent = join3(resolveDshHome(), ".agent-presets"); + const dest = join3(destParent, NETXOPS_PRESET_ID); try { mkdirSync(destParent, { recursive: true }); - if (existsSync(dest)) { + if (existsSync2(dest)) { rmSync(dest, { recursive: true, force: true }); } cpSync(src, dest, { recursive: true }); - writeFileSync(join2(dest, ".dsh-netxops-managed"), `${new Date().toISOString()} + writeFileSync(join3(dest, ".dsh-netxops-managed"), `${new Date().toISOString()} `, "utf8"); logger.info("netxops: agent preset installed at %s", dest); } catch (error) { @@ -533,17 +2244,22 @@ function apply(ctx, config = Config({})) { return; const apiUrl = current.apiUrl.replace(/\/$/, ""); const tokenConfigured = token.trim().length > 0; + const groups = capabilityGroupsFromSettings(current); + if (current.nmsProvider && current.nmsProvider !== "zte-ume") { + ctx.logger.warn("netxops: nmsProvider=%s is not implemented yet; using zte-ume REST adapter", current.nmsProvider); + } publishNetxConnection({ apiUrl, token, lang: current.lang, - toolCallTimeoutMs: current.toolCallTimeoutMs + toolCallTimeoutMs: current.toolCallTimeoutMs, + groups }); restartAlarmPush(current, apiUrl, token); if (!tokenConfigured) { ctx.logger.warn("netxops: published connection → %s tokenConfigured=false (set credential %s)", apiUrl, current.tokenCredentialRef); } else { - ctx.logger.info("netxops: published connection → %s tokenConfigured=true alarmPush=%s dsh=%s im=%s", apiUrl, current.alarmPushEnabled === true, current.alarmDeliverDsh !== false, current.alarmDeliverIm === true); + ctx.logger.info("netxops: published connection → %s tokenConfigured=true alarmPush=%s dsh=%s im=%s public=[%s]", apiUrl, current.alarmPushEnabled === true, current.alarmDeliverDsh !== false, current.alarmDeliverIm === true, groupsForPlane(groups, "public").join(",") || "(none)"); } }).catch((error) => { ctx.logger.error("netxops: connection publish error: %s", error); @@ -562,6 +2278,59 @@ function apply(ctx, config = Config({})) { if (String(ref) === source().tokenCredentialRef) publish(); }); + ctx.inject(["tools"], (toolsCtx) => { + let unregisterTools; + const remountPublicTools = () => { + unregisterTools?.(); + unregisterTools = undefined; + const connection = getNetxConnection(); + if (!connection) + return; + const enabled = groupsForPlane(connection.groups, "public"); + unregisterTools = registerNetxTools(toolsCtx, connection, { plane: "public" }); + toolsCtx.logger.info("netxops: host public tools groups=[%s]", enabled.join(",") || "(none)"); + }; + remountPublicTools(); + const stopWatch = watchNetxConnection(() => { + remountPublicTools(); + }); + toolsCtx.effect(() => () => { + stopWatch(); + unregisterTools?.(); + }, "netxops: dispose public tools"); + }); + ctx.inject(["skills"], (skillsCtx) => { + let unregisterSkills; + let generation2 = 0; + const remountPublicSkills = () => { + const gen = ++generation2; + unregisterSkills?.(); + unregisterSkills = undefined; + const connection = getNetxConnection(); + if (!connection) + return; + const enabled = groupsForPlane(connection.groups, "public"); + registerGroupSkills(skillsCtx, enabled, "netxops-public").then((dispose) => { + if (gen !== generation2) { + dispose(); + return; + } + unregisterSkills = dispose; + skillsCtx.logger.info("netxops: host public skills groups=[%s]", enabled.join(",") || "(none)"); + }).catch((error) => { + skillsCtx.logger.warn("netxops: public skill register failed: %s", error); + }); + }; + remountPublicSkills(); + const stopWatch = watchNetxConnection(() => { + remountPublicSkills(); + }); + skillsCtx.effect(() => () => { + generation2 += 1; + stopWatch(); + unregisterSkills?.(); + }, "netxops: dispose public skills"); + }); ctx.inject(["connection"], (connCtx) => { const rpc = connCtx.connection?.rpc; if (!rpc || typeof rpc.handle !== "function") { @@ -570,10 +2339,43 @@ function apply(ctx, config = Config({})) { } connCtx.effect(() => { const dispose = rpc.handle(NETXOPS_RPC_CHANNEL, async (endpoint) => { - if (endpoint !== "alarm-push.status") { - return { ok: false, error: { code: "bad-request", message: "Unknown endpoint." } }; + if (endpoint === "alarm-push.status") { + return { ok: true, value: getAlarmPushStatus() }; } - return { ok: true, value: getAlarmPushStatus() }; + if (endpoint === "im-delivery.catalog") { + const fromGet = typeof ctx.get === "function" ? ctx.get("dshIm") : undefined; + const im = fromGet ?? ctx.dshIm; + if (!im || typeof im.listDeliveryCatalog !== "function") { + return { + ok: true, + value: { + available: false, + options: [], + hint: "dsh-im-ops missing or outdated — install ≥ops.24 for delivery picker" + } + }; + } + try { + const options = await im.listDeliveryCatalog(); + return { + ok: true, + value: { + available: true, + options: Array.isArray(options) ? options : [] + } + }; + } catch (error) { + return { + ok: true, + value: { + available: false, + options: [], + hint: error instanceof Error ? error.message : String(error) + } + }; + } + } + return { ok: false, error: { code: "bad-request", message: "Unknown endpoint." } }; }); return () => { dispose(); diff --git a/package.json b/package.json index 39651f2..d23b58c 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "dsh-netxops", - "version": "0.1.17", - "description": "DeepSeek Harness Netx Ops: Ops-scoped netx__* REST tools + key-alarm push to DSH sessions + Plugins settings card + agent preset", + "version": "0.1.21", + "description": "DeepSeek Harness Netx Ops: capability groups nms/common/topology (one group ↔ one skill)", "license": "MIT", "type": "module", "private": false, @@ -9,6 +9,9 @@ "exports": { ".": "./lib/index.js", "./tools": "./lib/agent-tools.js", + "./tools-nms": "./lib/agent-tools-nms.js", + "./tools-common": "./lib/agent-tools-common.js", + "./tools-topology": "./lib/agent-tools-topology.js", "./client": "./lib/client.js", "./package.json": "./package.json" }, @@ -16,6 +19,9 @@ "src/", "lib/index.js", "lib/agent-tools.js", + "lib/agent-tools-nms.js", + "lib/agent-tools-common.js", + "lib/agent-tools-topology.js", "lib/client.js", "presets/", "cordis.patch.yml", @@ -30,7 +36,7 @@ "deepseek-harness", "netx", "ops", - "ume", + "nms", "agent-preset" ], "repository": { diff --git a/presets/netxops/PERSONA.md b/presets/netxops/PERSONA.md index 44b8ad2..ecab381 100644 --- a/presets/netxops/PERSONA.md +++ b/presets/netxops/PERSONA.md @@ -1,6 +1,6 @@ # Netx Ops persona (source for `agent.cordis.yml` → `@deepseek-ai/dsh-persona`) -You are **Netx Ops**, a network operations specialist for ZTE UME / netx. +You are **Netx Ops**, a network operations specialist for NMS / netx. ## Identity - If asked who you are or which model you use: answer only that you are **Netx Ops**. @@ -25,9 +25,10 @@ You are **Netx Ops**, a network operations specialist for ZTE UME / netx. - Severity: Critical / Major / Minor / Warning. - Display NEs by **host_name** only — never bare UUID to the user. -## Skills -- `ops-netx-ume-playbook` — UME alarms / inventory / SQL / path query -- `ops-netx-managed-ne-playbook` — managed / UME CLI batch +## Skills (by capability group) +- `netx-nms` — **nms** +- `netx-common` — **common** +- `netx-topology` — **topology** (canvas / dual_unit / layout) ## Tools `netx__*` only. Multi-NE CLI = one `execManagedNe` batch. diff --git a/presets/netxops/agent.cordis.yml b/presets/netxops/agent.cordis.yml index ec71f1a..eabf3cc 100644 --- a/presets/netxops/agent.cordis.yml +++ b/presets/netxops/agent.cordis.yml @@ -1,6 +1,7 @@ -# Netx Ops agent preset — UME alarms / NE inventory / managed CLI. +# Netx Ops agent preset — NMS alarms / NE inventory / managed CLI. # Host keeps registries, sandbox, model route, settings; this file owns -# persona, skills, and Ops-scoped `netx__*` tools. +# persona and Ops-scoped `netx__*` tools. Playbook skills are registered by +# `dsh-netxops/tools` according to Settings → capability groups (not customSkillDirs). # ── identity ──────────────────────────────────────────────────────────────── @@ -8,7 +9,7 @@ name: '@deepseek-ai/dsh-persona' config: text: |- - You are **Netx Ops**, a network operations specialist for ZTE UME / netx. + You are **Netx Ops**, a network operations specialist for NMS / netx. ## Identity - If asked who you are or which model you use: answer only that you are **Netx Ops**. @@ -32,13 +33,14 @@ - Display NEs by **host_name** only — never bare UUID `ne_id` to the user. - No evidence → say evidence insufficient; do not invent root cause. - ## Skills (load before acting) - - UME alarms / inventory → `ops-netx-ume-playbook` - - Managed SSH/Telnet CLI → `ops-netx-managed-ne-playbook` + ## Skills (load before acting; only when the capability group is enabled) + - nms alarms / inventory → `netx-nms` + - common managed CLI + paths → `netx-common` + - topology canvas / dual_unit / layout → `netx-topology` ## Tools Call netx as `netx__*` (camelCase tool names). See skill bodies for decision trees. - Batch multi-NE CLI in **one** `execManagedNe` call (`ne_ids` / `ume_ne_ids` / `targets`). + Batch multi-NE CLI in **one** `execManagedNe` call (`ne_ids` / `nms_ne_ids` / `targets`). - id: agent-instructions name: '@deepseek-ai/dsh-agent-instructions' @@ -58,13 +60,14 @@ - id: skill-filesystem name: '@deepseek-ai/dsh-skill-filesystem' config: - customSkillDirs: - - !!js "process.getBuiltinModule('node:url').fileURLToPath(new URL('skills/', baseUrl))" + # Ops playbooks are registered by netxops-tools from capability groups. + includeDefaultRoots: true + customSkillDirs: [] - id: tool-skill name: '@deepseek-ai/dsh-tool-skill' -# Ops-only tools: register into this preset's tool scope (not the host global layer). +# Ops-only tools (+ gated skills): register into this preset's scope. - id: netxops-tools name: dsh-netxops/tools diff --git a/presets/netxops/preset.yml b/presets/netxops/preset.yml index f02f4f5..9f0c42b 100644 --- a/presets/netxops/preset.yml +++ b/presets/netxops/preset.yml @@ -1,3 +1,3 @@ name: Netx Ops -description: UME 告警 / 网元清单 / 纳管 CLI 运维 Agent。证据优先,短回复,host_name 主键。 +description: NMS 告警 / 网元清单 / 纳管 CLI 运维 Agent。证据优先,短回复,host_name 主键。 order: 50 diff --git a/presets/netxops/skills/README.md b/presets/netxops/skills/README.md new file mode 100644 index 0000000..bb51024 --- /dev/null +++ b/presets/netxops/skills/README.md @@ -0,0 +1,10 @@ +# Mirrored from netx/skills 鈥?do not long-edit here + +Canonical: `D:\project\chatgpt\netx\skills` + +`powershell +powershell -File .\scripts\sync-skills-from-netx.ps1 +` + +Runtime prefers `NETX_SKILLS_ROOT` or sibling `../netx/skills`. +One topology skill covers canvas + dual_unit / layout recipes; DSH may still gate **tools** via topology vs topology-layout groups. diff --git a/presets/netxops/skills/common/netx-common/SKILL.md b/presets/netxops/skills/common/netx-common/SKILL.md new file mode 100644 index 0000000..18b5d16 --- /dev/null +++ b/presets/netxops/skills/common/netx-common/SKILL.md @@ -0,0 +1,91 @@ +--- +name: netx-common +description: >- + netx common playbook (MCP + DSH): managed-NE CLI (SSH/Telnet batch-first) plus + native findTopologyPaths. Not NMS REST alarms/inventory. Trigger: execManagedNe, + show/display, optical, capacity A<>B, path between sites. +--- + +# netx-common (managed CLI + paths) + +## Naming (MCP + DSH) + +Canonical: **`netx/skills/common/netx-common/`**. Mirrored into MCP / dsh-netxops — edit once. + +| Host | How tools appear | +|------|------------------| +| **MCP** (`netx-mcp`) | Bare names: `execManagedNe`, `findTopologyPaths`, … | +| **DSH** (`dsh-netxops`) | Prefixed: `netx__execManagedNe`, … | + +NMS alarms/inventory → **netx-nms**. Canvas / dual_unit → **netx-topology**. + +## Tools + +| Purpose | Tool | +|---------|------| +| List managed NEs | `listManagedNe` | +| Managed detail | `getManagedNe` | +| Read-only CLI (batch-first) | `execManagedNe` | +| CLI target index | `listCliTargets` | +| Fabric paths | `findTopologyPaths` | + +Prefer `nms_ne_id` / `nms_ne_ids`; legacy `ume_*` accepted. `listCliTargets(source=nms)` preferred (`ume` alias). + +## CLI order + +1. `listManagedNe` (`connect_status=pass`) or `listCliTargets` (**once** per session, cache ids) +2. Multi-NE → **one** `execManagedNe` with `ne_ids` / `nms_ne_ids` / `targets` +3. Paths → `findTopologyPaths` (`nms_ne_id` **or** `managed_ne_id` per end) + +### Batch examples + +**Same commands, many NEs** + +```json +{ + "nms_ne_ids": ["uuid-a", "uuid-b", "uuid-c"], + "commands": ["show version"], + "read_timeout_sec": 60, + "concurrency": 4 +} +``` + +**Different commands per NE (still one call)** + +```json +{ + "targets": [ + {"nms_ne_id": "uuid-zte", "commands": ["show opticalinfo brief"]}, + {"nms_ne_id": "uuid-hw", "commands": ["display optical-module brief"]}, + {"nms_ne_id": "uuid-cisco", "commands": ["show interface transceiver"]} + ], + "read_timeout_sec": 90 +} +``` + +**Wrong:** N× single-NE `execManagedNe` in one turn (stdio serial). + +## Field recipes + +### Capacity / optical A<>B + +1. Resolve nicknames → `host_name` +2. `findTopologyPaths` and/or LLDP → both ports +3. Optics CLI on **both** ends; summarize RX/TX / thresholds +4. Do not answer with only NMS bandwidth/optical-power-threshold alarm tallies unless asked + +### ZTE optical CLI + +Try in order; one failure → switch spelling (do not blind-retry): + +| Prefer | Fallback | +|--------|----------| +| `show opticalinfo brief` | Field-confirmed on many ZXR10 | +| `show optical brief` | Some EN platforms | +| `show opticalinfo brief \| begin ` | After port known | + +## Guardrails + +- Never pass NMS alarm UUIDs as managed `ne_id` for `getManagedNe` +- Allowlist prefixes: `show ` / `display ` / `ping ` / `traceroute` … +- Prefer `host_name` for users; keep ids for tool params diff --git a/presets/netxops/skills/nms/netx-nms/SKILL.md b/presets/netxops/skills/nms/netx-nms/SKILL.md new file mode 100644 index 0000000..5839103 --- /dev/null +++ b/presets/netxops/skills/nms/netx-nms/SKILL.md @@ -0,0 +1,70 @@ +--- +name: netx-nms +description: >- + netx NMS playbook (MCP + DSH): vendor NMS adapter (zte-ume) for alarm + query/aggregate/diagnostics, NE inventory, raw fields, and read-only SQL. + Trigger: NMS alarms, host_name, Critical Top, LOS, BN EMS, netx ops. +--- + +# netx-nms (vendor NMS adapter) + +## Naming (MCP + DSH) + +Canonical playbooks live in **`netx/skills/`** (this file is mirrored into MCP packages / dsh-netxops). +Edit the netx repo copy; run sync scripts — do not maintain divergent forks. + +| Host | How tools appear | +|------|------------------| +| **MCP** (`netx-mcp`) | Bare names: `queryNmsAlarms`, … | +| **DSH** (`dsh-netxops`) | Prefixed: `netx__queryNmsAlarms`, … (same camelCase stem) | + +REST still `/v1/ume/*` for provider `zte-ume`. +Prefer `nms_ne_id` / `nms_ne_ids`; legacy `ume_*` aliases still work. + +Path lookup → **common** skill `netx-common` (`findTopologyPaths`). +Canvas → **netx-topology**(含 dual_unit / 布图;DSH 可把部分布局工具放在实验 tool group)。 + +## Tool names + +| Purpose | Tool | +|---------|------| +| Alarm list | `queryNmsAlarms` | +| Alarm aggregate | `aggregateNmsAlarms` | +| Diagnostics | `runNmsDiagnostics` | +| NE inventory | `queryNmsNeInventory` | +| NE detail | `getNmsNe` | +| Field list | `listNmsAlarmFields` | +| Raw rows | `queryNmsAlarmsRaw` | +| Dynamic aggregate | `aggregateNmsAlarmsRaw` | +| SQL | `sqlQueryNms` | +| Managed CLI (other skill) | `listManagedNe` / `getManagedNe` / `execManagedNe` / `listCliTargets` | + +## Tool order + +1. **Freshness first**: `runNmsDiagnostics` or `aggregateNmsAlarms` → `meta.last_seen_min` / `last_seen_max`. +2. Overview: `aggregateNmsAlarms` + `runNmsDiagnostics`; samples via `queryNmsAlarms` (one page). +3. Evidence: `listNmsAlarmFields` → `queryNmsAlarmsRaw` (`field_preset=evidence`). +4. Custom aggregate: `aggregateNmsAlarmsRaw`. +5. SQL: `sqlQueryNms` (SELECT only; `statement_timeout_ms`). +6. Paths: `findTopologyPaths` via **netx-common**. +7. Device CLI: **netx-common** — multi-NE = **one** `execManagedNe` batch. + +## Short-intent recipes + +| User says | Recipe | +|-----------|--------| +| fiber cut / LOS / 断纤 | `queryNmsAlarmsRaw(keyword=LOS)` and/or `Fiber Break` → **host_name** list | +| offline / BN EMS | keyword=`BN EMS` | +| Critical Top | `aggregateNmsAlarms(severity=critical, top_ne=20)` | +| CRC in area | Raw `keyword=CRC` + `AREA-` hostname prefix | +| optical power **threshold** | keyword=`optical power` + area — **not** fiber-cut | +| one hostname | host-scoped `queryNmsAlarms` / Raw only | + +## Guardrails + +- Prefer non-SQL; filter severity → keyword/host → time → ne_id. +- Lists ≤2 pages; `page_size` default 50. +- Display **host_name** only; never bare UUID to users. +- `getManagedNe` needs managed id; NMS UUID → `getNmsNe` / `execManagedNe(nms_ne_id=...)`. + +See [reference.md](reference.md). diff --git a/presets/netxops/skills/nms/netx-nms/reference.md b/presets/netxops/skills/nms/netx-nms/reference.md new file mode 100644 index 0000000..7da47eb --- /dev/null +++ b/presets/netxops/skills/nms/netx-nms/reference.md @@ -0,0 +1,20 @@ +# netx-nms quick reference + +## Freshness + +- `runNmsDiagnostics` / `aggregateNmsAlarms` → `meta.last_seen_min` / `last_seen_max` +- Snapshot: windows inside min~max — do not default to `now()-30m` + +## Tools + +| Intent | Call | +|--------|------| +| Critical Top | `aggregateNmsAlarms(severity=critical, top_ne=20)` | +| Fiber / LOS | Raw `keyword=LOS` / `Fiber Break` | +| Offline / BN EMS | Raw keyword=`BN EMS` | +| Single host | `queryNmsAlarms(host_name=…)` | +| Inventory | `queryNmsNeInventory(keyword=…)` / `getNmsNe` | +| Paths | `findTopologyPaths(from_nms_ne_id, to_nms_ne_id)` — **common** skill | +| SQL | `sqlQueryNms` SELECT; `statement_timeout_ms=8000` | + +DSH: prefix every tool with `netx__`. diff --git a/presets/netxops/skills/ops-netx-managed-ne-playbook/SKILL.md b/presets/netxops/skills/ops-netx-managed-ne-playbook/SKILL.md deleted file mode 100644 index e12a8df..0000000 --- a/presets/netxops/skills/ops-netx-managed-ne-playbook/SKILL.md +++ /dev/null @@ -1,107 +0,0 @@ ---- -name: ops-netx-managed-ne-playbook -description: >- - Netx Ops managed-NE playbook: device list, connect status, read-only CLI - via SSH/Telnet (batch-first). Trigger: execManagedNe, show/display, optical, capacity A<>B. ---- - -# Ops Netx managed NE playbook - -## Scope - -Load this skill whenever you must **log into** devices registered under netx **managed NE** (or UME CLI profiles) to run show/display/ping. - -Differs from UME inventory (`ops-netx-ume-playbook`): this is **SSH/Telnet** (ZTE/Huawei/Cisco hops, Linux tunnels, bastion), not UME REST sync alone. - -## Tool order - -Use `netx__*`. - -1. **Locate** - - `listManagedNe`: `keyword`, `connect_status=pass` (preferred) - - `getManagedNe`: only for `connect_detail`; **managed `ne_id` only** - - Do **not** pass UME alarm UUIDs as managed `ne_id`; on failure follow returned `hint` - - UME without per-device managed rows: `listCliTargets(source=ume)` or inventory → `execManagedNe(ume_ne_id=…)` (requires netx **UME → CLI** profile) -2. **Execute** - - `execManagedNe`: `ne_id` **or** `ume_ne_id` + `commands` (default max 5; `NETX_NE_EXEC_MAX_COMMANDS`, hard cap 50) - - **Multi-NE = batch-first (one tool call; server concurrency default 4, max 20)**: - - Same commands: `ne_ids` / `ume_ne_ids` + shared `commands` - - Different commands per NE: `targets=[{ume_ne_id|ne_id, commands:[…]}, …]` - - Many single-NE calls in one turn are **serial** — forbidden for multi-NE work - - Per session: call `listCliTargets` at most once; merge shows into each target's `commands[]` - - Timeouts: raise `read_timeout_sec` (default 60; slow 90–120) or fewer commands — no blind retry - -## Field link recipes - -### Capacity / optical between two names - -User says **capacity**, **bandwidth between A and B**, **optical power A <> B**, or site pairs: - -1. Resolve nicknames → real `host_name` via inventory. -2. Interconnect: `findTopologyPaths` and/or LLDP — both ports. -3. Optics on **both** ends with correct vendor command. Summarize interface, RX/TX, thresholds, link up. -4. Do not answer with only UME bandwidth-usage or optical-threshold **alarm** tallies unless asked. -5. Prefer one batch (`targets` if vendors differ). - -### Area optical-power **alarm** list (UME only) - -Use UME keyword=`optical power` + hostname prefix — **not** this CLI recipe. - -### ZTE optical CLI - -Try in order; one failure → switch command (do not retry same spelling): - -| Prefer | Notes | -|--------|-------| -| `show opticalinfo brief` | Field-confirmed on many ZXR10 | -| `show optical brief` | Some EN platforms | -| `show opticalinfo brief \| begin ` | After port known | - -Cisco/Huawei: allowlisted `show interface transceiver` / `display optical-module` style. - -### Multi-NE examples - -**Same command, many NEs (one call):** - -```json -{ - "ume_ne_ids": ["uuid-a", "uuid-b", "uuid-c"], - "commands": ["show version"], - "read_timeout_sec": 60, - "concurrency": 4 -} -``` - -**Different commands (still one call):** - -```json -{ - "targets": [ - {"ume_ne_id": "uuid-zte", "commands": ["show opticalinfo brief"]}, - {"ume_ne_id": "uuid-hw", "commands": ["display optical-module brief"]}, - {"ume_ne_id": "uuid-cisco", "commands": ["show interface transceiver"]} - ], - "read_timeout_sec": 90 -} -``` - -**Wrong:** N× single-NE `execManagedNe` in one turn (serial + budget burn). - -## CLI constraints (server-enforced) - -- Allowed prefixes: `show `, `display `, `ping `, `ping6 `, `traceroute `, `tracert `, `trace `, `trace6 ` -- Pipes: whitelist filters only (`include`/`exclude`/`begin`/…); no `redirect`/`tee` -- Forbidden: `;`, newlines, config/write/reload/delete -- Examples: `show version`, `display current-configuration | include sysname`, `ping 192.168.0.1` - -## Troubleshooting - -1. `connect_status=fail`: read `getManagedNe` `connect_detail` — do not blind-exec. -2. Hop / bastion: verify `hop_enabled`, `hop_vendor`, templates. -3. Timeout: raise `read_timeout_sec` (max 120) or fewer commands. - -## Output - -- Conclusion + **tool output excerpts** (never invent CLI). -- Prefer name/IP for users; keep `ne_id` as correlation key only. -- English sessions: no Chinese in user-visible prose (device output may be quoted as device text). diff --git a/presets/netxops/skills/ops-netx-ume-playbook/SKILL.md b/presets/netxops/skills/ops-netx-ume-playbook/SKILL.md deleted file mode 100644 index c3a8351..0000000 --- a/presets/netxops/skills/ops-netx-ume-playbook/SKILL.md +++ /dev/null @@ -1,116 +0,0 @@ ---- -name: ops-netx-ume-playbook -description: >- - Netx Ops UME playbook: alarm query/aggregate/diagnostics, NE inventory, - raw fields, read-only SQL, and alarm-related topology path lookup. - Trigger: UME alarms, host_name, Critical Top, LOS, BN EMS, netx ops. ---- - -# Ops Netx UME playbook - -## Scope - -For any Netx Ops request about UME **alarms** or **NE inventory**, load this skill first. - -## Tool names - -Host tool names are `netx__`: - -| Purpose | Tool | -|---------|------| -| Alarm list | `queryUmeAlarms` | -| Alarm aggregate | `aggregateUmeAlarms` | -| Diagnostics | `runUmeDiagnostics` | -| NE inventory | `queryUmeNeInventory` | -| NE detail | `getUmeNe` | -| Field list | `listUmeAlarmFields` | -| Raw rows | `queryUmeAlarmsRaw` | -| Dynamic aggregate | `aggregateUmeAlarmsRaw` | -| SQL | `sqlQueryUme` | -| Topology paths | `findTopologyPaths` | -| Managed CLI (other skill) | `listManagedNe` / `getManagedNe` / `execManagedNe` / `listCliTargets` | - -Do not use removed inline `netx_*` names. - -## Tool order - -1. **Freshness first**: `runUmeDiagnostics` or `aggregateUmeAlarms` → `meta.last_seen_min` / `last_seen_max`. - - If max is far from now, treat as **snapshot**: time windows must fall inside that range — never blind `now()-30m`. -2. Overview: `aggregateUmeAlarms` + `runUmeDiagnostics`; samples via `queryUmeAlarms` (one page). -3. Evidence: `listUmeAlarmFields` → `queryUmeAlarmsRaw` (`field_preset=evidence` / `select_fields`). -4. Custom aggregate: `aggregateUmeAlarmsRaw` (`group_by=alarm_host_name`, …). -5. SQL: `sqlQueryUme` (SELECT only; set `statement_timeout_ms`). -6. Related path: take `ne_id` from alarms → `findTopologyPaths` (shortest first). -7. Device CLI: `ops-netx-managed-ne-playbook` — multi-NE must be **one** `execManagedNe` batch. - -## Decision tree - -- **Fleet / Top risk**: `runUmeDiagnostics` + `aggregateUmeAlarms` (missing host excluded by default; check `by_ne_missing`). - - Critical Top-N: `aggregateUmeAlarms(severity=critical, top_ne=10)`. - - Group by host: `aggregateUmeAlarms(group_by=alarm_host_name, …)` or `aggregateUmeAlarmsRaw`. -- **Time window**: `time_from` / `time_to` = `last_seen_at`; check freshness first. -- **Citeable rows**: `queryUmeAlarmsRaw` + `field_preset=evidence`. -- **Complex filters**: `sqlQueryUme`. -- **Critical port / fiber**: sample 1–2 `ne_id` → `findTopologyPaths` → then CLI if needed. -- **NE identity**: `queryUmeNeInventory(keyword=host_name)`; full raw via `getUmeNe`. - -## Short-intent recipes (≤3 tool calls) - -| User says | Recipe | -|-----------|--------| -| fiber cut / LOS / 断纤 / sitelist | `queryUmeAlarmsRaw(keyword=LOS)` and/or `keyword=Fiber Break`; reply **host_name** list + counts (not optical-power threshold) | -| offline / unmanaged / 离线 | keyword=`BN EMS` / NE communication failure; clarify unmanaged vs unreachable | -| Critical Top / tally | `aggregateUmeAlarms(severity=critical, top_ne=20)` | -| how many alarms / 现网告警数量 | `runUmeDiagnostics` or `aggregateUmeAlarms` → by_severity + freshness | -| CRC in area PAD / ACH / … | `queryUmeAlarmsRaw(keyword=CRC)` then keep `AREA-` hostname prefix | -| bandwidth / congestion (+ area) | keyword=`bandwidth`; filter hostname prefix; CLI confirm = top 3–5 NEs **one** batch | -| optical power **threshold** in area | keyword=`optical power`; keep `AREA-` hosts — **not** fiber-cut | -| dying gasp / BN EMS | See correlation below — do not stop at one NE | -| power / temperature / fan / undervoltage | matching keyword; scope host or area prefix | -| license | keyword=`License` | -| BGP / OSPF / ISIS / LDP / PW / Tunnel on host | host-scoped `queryUmeAlarmsRaw` | -| Port down / which segment | keyword=`Port down` or `LOS`; `object_name` + `findTopologyPaths` / LLDP | -| alarm on **one hostname** | `queryUmeAlarms` / Raw with that host only — never hijack unrelated playbooks | -| history / time range (`17.50-18.15`) | Resolve **WIB (UTC+7)** → `time_from`/`time_to`; check freshness first | - -Confirm replies (`YES` / `confirm` / `继续`): continue the prior task; do not restart the query. - -### Field vocabulary - -- **Area** = hostname prefix before first `-` (`MDN-`, `ACH-`, …), case-insensitive starts-with. -- **Capacity A<>B / optical between sites** = interconnect SFP/optics CLI (managed-ne skill), not bandwidth-usage alarms alone. -- **Optical power threshold** ≠ fiber cut / LOS sitelist. -- **Local clock phrases**: Asia/Jakarta (WIB, UTC+7) unless user says otherwise. - -### Dying gasp / BN EMS - -1. Named NE: Raw keyword=`dying gasp` — note `object_name` / times. -2. Peer via `findTopologyPaths` and/or LLDP. -3. Peer: BN EMS / communication failure near that timestamp. -4. Reply both sides + times. - -### Anti-patterns - -1. Wrong playbook hijack (single-host ask → do not run license/daily scripts). -2. Narration-only final replies. -3. Dense Markdown pipe tables in chat-style channels — prefer `*bold*` + `-` lists. -4. Blind CLI retries with the same failed command. -5. Unfiltered dump of huge uncleared sets — always severity/keyword/host/area/time. - -## Guardrails - -- Prefer non-SQL; use SQL only when parameters cannot express the filter. -- Filter order: `severity` → `keyword`/`host_name` → time → `event_type`/`ne_id`. -- Lists ≤2 pages by default; `page_size` default 50; aggregate `top_ne` default 50; dynamic aggregate `limit≤200`. -- Top NEs: ignore `(host_name missing)` in rankings; report missing count separately. -- SQL: `statement_timeout_ms=8000`; no `WITH RECURSIVE`. -- `getManagedNe` needs **managed** `ne_id` only; UME UUID → `getUmeNe` / `execManagedNe(ume_ne_id=...)`. - -## Display - -- Primary NE key for users = **`host_name`** (`alarm_host_name` in raw). -- Never show bare `ne_id` UUID; use it only for filters / `findTopologyPaths`. - -## Templates - -See [reference.md](reference.md). diff --git a/presets/netxops/skills/ops-netx-ume-playbook/reference.md b/presets/netxops/skills/ops-netx-ume-playbook/reference.md deleted file mode 100644 index a41b718..0000000 --- a/presets/netxops/skills/ops-netx-ume-playbook/reference.md +++ /dev/null @@ -1,58 +0,0 @@ -# Ops Netx UME quick reference - -## 0) Freshness (required) - -- `runUmeDiagnostics` / `aggregateUmeAlarms` → `meta.last_seen_min` / `last_seen_max` -- Snapshot data: windows inside min~max — **do not** default to `now()-30 minutes` - -## 1) Current alarms (light) - -- Tool: `queryUmeAlarms` -- Params: `severity`, `host_name`, `ne_id` (filter only), `keyword`, `time_from`, `time_to`, `page`, `page_size` -- Suggest: `page_size=50`; at most 2 pages by default - -## 2) Raw evidence - -- Tool: `queryUmeAlarmsRaw` -- Optional: `listUmeAlarmFields` -- `field_preset`: `brief` / `evidence` / `ne_debug` -- Display key: `alarm_host_name` - -## 3) Aggregate - -- `aggregateUmeAlarms`: `severity`, `top_ne`, `exclude_missing_host`, time window - - Critical Top: `severity=critical` - - `group_by=alarm_host_name` → dynamic aggregate path -- `aggregateUmeAlarmsRaw`: custom `group_by` - -## 3b) Short paths - -| Intent | Call | -|--------|------| -| Critical Top | `aggregateUmeAlarms(severity=critical, top_ne=20)` | -| Fiber / LOS sitelist | Raw `keyword=LOS` and/or `Fiber Break` → host list | -| Offline / BN EMS | Raw keyword=`BN EMS` | -| Area optical threshold | Raw `keyword=optical power` + `AREA-` prefix — **not** fiber cut | -| Single host alarms | `queryUmeAlarms(host_name=…)` | -| dying gasp | local dying gasp → peer BN EMS near time | -| Capacity A<>B | resolve hosts → `findTopologyPaths` / LLDP → optic CLI (managed-ne) | -| History (WIB) | freshness → `time_from`/`time_to` | - -## 4) Diagnostics - -- `runUmeDiagnostics` -- `top_event_types`, `top_alarm_codes`, `top_ne`, `meta.last_seen_*` - -## 4b) Inventory - -- `queryUmeNeInventory(keyword=…)` -- `getUmeNe` for full `raw_json` - -## 5) Paths - -- `findTopologyPaths(from_ume_ne_id, to_ume_ne_id)` — default `detail=summary` - -## 6) SQL - -- `sqlQueryUme` SELECT only; `statement_timeout_ms=8000` -- Prefer aggregate/raw when SQL scope is insufficient diff --git a/presets/netxops/skills/topology/netx-topology/SKILL.md b/presets/netxops/skills/topology/netx-topology/SKILL.md new file mode 100644 index 0000000..43c6da7 --- /dev/null +++ b/presets/netxops/skills/topology/netx-topology/SKILL.md @@ -0,0 +1,378 @@ +--- +name: netx-topology +description: >- + 用 netx-topology MCP / DSH topology 组查邻接、分类打标;先 dual_unit 一次抽最大核心眼, + 再换其它算法下沉/布图(不污染 Fabric)。触发:画拓扑、布图、拖图、分类、LLDP、Fabric、netx-topology。 +--- + +# netx 拓扑(通用) + +## Hosts(MCP + DSH) + +| Host | Tools | +|------|--------| +| **MCP** `netx-topology-mcp` | 裸名(`sinkTopologyDualUnits` …);安装见 [`docs/MCP_TOPOLOGY.md`](../../../docs/MCP_TOPOLOGY.md) | +| **DSH** `dsh-netxops` | `netx__` + 同 stem。整组工具 + skill **`netx-topology`**(能力组 **topology**,默认关)。缺布局引擎能力时用 MCP。 | + +Canonical: `netx/skills/topology/netx-topology/`(唯一正文)。 + +**原则**: +1. **第一步用 dual_units**:从**核心**出发,选**覆盖网元最多**的那一只眼(`prefer_top_eye` + max cover)。 +2. **`sinkTopologyDualUnits` / `layout_dual_unit` 整图流程只用一次**——只沉这一只最大眼;**禁止**再连抽下一批 dual_unit。 +3. **后续下沉换算法**:`suggestSinkHubs` → `move_nodes(park)` / `projectTopologyNeighbors`;**不要**再 `sinkTopologyDualUnits`。 +4. **眼图已定型 → 门控精修**:对该 sink 只用下表「允许」动作;禁止全局拆眼工具。 +5. 布眼目标:少交叉、眼心空旷、少重叠(椭圆弧带;长链在眼外)。对照人工金标时还要看:**紧凑度、正交边、贴边清开**(见「算法天花板」)。 + +**禁止**写临时 py 穷举坐标或直接调 HTTP;验证与压交叉**只调 MCP**(或 DSH 已实现的同名工具)。不造 Fabric 边。 +**脱敏(硬)**:勿把客户网元名、站点/区域名、具体交叉数、具体 view_id 写进本 skill。角色只用通用词:门户 / 枢纽 / 汇聚 / 接入 / 末梢。**禁止**在 skill 正文写站点缩写或设备角色缩写当专名举例。 + +--- + +## 完整处理思路(一次大区接入眼) + +按阶段执行;每阶段只调 MCP,读返回再决定下一步。 + +``` +① 认眼 + analyze(structure) → dual_units 里取覆盖最大的核心门户对 +② 一次 sink + sinkTopologyDualUnits(max_units=1, prefer_top_eye, layout_batch) + → 同一 sink 贯穿全程;眼形不对只 layout_dual_unit 重布同一对门户 +③ 换算法下沉(禁 dual) + 循环:suggestSinkHubs(pick:1) → move_nodes(park) → until_limit(crossing) + 空批次后:orphan_batch / 对根−sink−门户 分批 park;仍禁 dual +④ 眼定型精修(门控序列,见下) + until_limit(crossing) → until_limit(total) + → clear_edge_hits(portal_ids) → pull_far_chains(portal_ids) + → compact_bbox(portal_ids, outlier_only) → 可选再 clear / total +⑤ 停手(算法天花板,**默认无范本**) + overlaps=0;until_limit stall;pull/compact/clear 均 moved≈0 + → 交付:眼可读 + ov=0 + 交叉可接受;util/正交/贴边缺口 → 手拖或改初布 + → 勿再发明无门控全局工具硬挤 +``` + +**范本现实(硬)**: +- **日常默认没有范本**;有人工图也几乎不是同一张网 → **禁止**把 `align_reference` / 抄坐标当主路径。 +- `align_reference` **仅**用于:同网同成员画布的 A/B 对照、调试、回放(共享 `fabric_node_id`)。跨网 ID 对不上,抄了也无意义。 +- 生产要缩短与人工观感差距 → **改初布配方**(正交/紧凑从 dual 一次就偏地铁风),不是现场找金标对齐。 + +**从零重布(无范本算力路径)**: +- 眼已在 sink、只需甩掉坏坐标 / 金标残留:对本 sink **`layout_dual_unit`(同一门户对)→ 立刻门控精修**。这**不算**再 sink 一批 dual。 +- 初布后交叉会先飙高、贴边变差属正常;**不要**因此改走 `polish_crossings` / `fix_overlaps`。 +- 纯算力压交叉后 util/bbox 常崩(枢纽被 `until_limit` 甩远)→ **必须**接 `pull_far_chains` → `compact_bbox`;缺这两个 action 就先重启 MCP,勿半截交付。 +- 无范本时:交叉可压到可读,但仍常高于「抄同网坐标」路径;util/正交缺口靠 pull + 初布,**不是**再堆 until_limit。 + +**开局核对 catalog(硬)**: +- 精修前先 `layoutTopologyView(catalog=true)`:须见 `pull_far_chains`、`compact_bbox`,且 `version`/`rev` 为当前包。 +- 缺 action 或 `rev` 过旧 → **重启** netx-topology MCP 再干;禁止用旧进程硬跑、禁止改走 HTTP/临时 py。 + +**精修硬门控(已写进算法,skill 须遵守)**: +- 任何落笔:**全局交叉不得升高**;升高 → 弃该候选。 +- **最小化展开**(bundle):从小压缩步长试探;步长须清开 footprint(成员互叠也算失败)。 +- **侵入** = 展开后踏入外人框 / 留下任意 footprint 重叠 → 换 tip,勿硬塞。 +- `until_limit` 只接受 `overlaps=0`;apply 遇 `overlaps_remain` → 修算法,**勿**对眼 sink 跑 `fix_overlaps`。 +- `portal_ids` **只钉眼门户**;`freeze_layers=["core"]` 会冻住 sink 上所有核心(含旁路枢纽)——慎用。 +- 旁路枢纽也要动时:`protect_rigid=off`(仍受 `portal_ids` 约束)。 +- `clear_edge_hits`:**禁止**放宽交叉(含 `preserve_axis`);交叉升高一律拒。 +- `pull_far_chains`:相对**门户中点**缩放远走廊/孤立点/远叶(不是朝外圈枢纽);枢纽本身在外围时朝枢纽缩**收不了 bbox**。 +- `compact_bbox`:默认 **farthest-K**(`outlier_only`);全图/分位均匀缩易叠点 → 拒。 +- `until_limit`:`stop_reason=max_moves` **≠** stall → **再调**直到 `stall`(可提高 `max_moves`,但仍以 stall 为准)。 +- 大眼 `max_jump` 用 4k–8k;勿收到 <2k。大图会 background → `job_status`(可数分钟);勿中途改走拆眼工具。 + +--- + +## 算法天花板(经验固化) + +大区接入眼跑完门控序列后,常见结果: + +| 维度 | 算法能做到 | 再挤会怎样 | 交付策略 | +|------|------------|------------|----------| +| 交叉 | 从零 dual 后 `until_limit` 可大幅压下;无范本时仍常高于抄坐标路径 | 再挪极值尖端易抬交叉;`max_moves` 停了要续跑到 stall | **停**于 stall;残余当弦交叉可接受 | +| 重叠 | 硬零 | — | 必须保持 | +| 紧凑 util / bbox | 压交叉常先**炸开** bbox;`pull_far_chains` 多轮收远走廊 | 缺 pull 就半截交付;强缩常 `Δg>0` 拒 | **必跑** pull→compact 到 `moved=0`;其余手拖 | +| 贴边 clearance | `clear` + `objective=total` 能降一批 | 与交叉/紧凑拉扯,清到个位数很难 | stall 后手拖擦边点 | +| 正交 axis | `total` 略抬;远低于人工地铁风 | `preserve_axis` 清边若放宽交叉会毁掉前面成果(已禁 slack) | 手拖走廊;勿为轴牺牲交叉 | +| 直链 / 环穿 | bundle 后仍有折角与穿环 | 全局 straighten 拆眼 | 勿清零;结构优先 | + +**判定「算法到头」**(门控精修,无范本): +1. `overlaps=0` +2. `until_limit`(crossing 与 total)均 stall +3. pull/compact/clear `moved≈0` +4. 目视:眼心空、双门户可辨 + +**可选对照**(仅评测):对**同网**人工画布与算法 sink 各 `analyze`,只比 `summary`;**勿**因此去 `align_reference` 当交付手段。 + +**明确无效 / 禁止再试**(眼 sink): +- 再次 `sinkTopologyDualUnits` / `until_empty` / 新块画布抽 dual +- `polish_crossings` / `straighten_channels` / `untangle` / `fix_overlaps` / `orbit_sweep(round=true)` +- 临时 py 穷举坐标、无门控的整图缩放/平移极值点 +- 为追 util 关掉交叉门控或给 `clear` 开交叉 slack +- **把跨网/他网人工图当范本抄坐标**(含滥用 `align_reference`) + +--- + +## 主路径(必循) + +``` +analyze(structure) → 读 dual_units(认核心最大眼) +→ 有 level:先打标;巨图勿先全量 level_bands +→ 【仅一次】sinkTopologyDualUnits( + max_units=1, prefer_top_eye=true, prefer_pure=false, + sink_layers=[access], keep_layers=[core,agg], layout_batch=true, + max_nodes/max_batch_nodes 放行大眼 + ) +→ 眼形不对:可对本 sink 重跑 layout_dual_unit(仍算同一只眼;勿再 sink 下一批 dual) +→ 【换算法】suggestSinkHubs → move_nodes(park) 分批迁入同一 sink;每批后 until_limit;禁止再 dual +→ 【眼定型】门控精修序列(下节);禁止全局拆眼 +→ 根图可 level_bands(只动根上残留,不动已定型的眼 sink) +``` + +停手:最大眼可读、眼心空、`overlaps≈0`、门户清晰;其余节点用非 dual 手段靠上去即可。交叉允许来自跨走廊弦边。 + +**dual 一次用尽**:`until_empty`、再次 `sinkTopologyDualUnits`、为下一批 dual 新建块画布 —— **一律禁止**(日常)。 + +**布眼几何**(仅那一次):嵌套半椭圆;中轴只有双门户;长链/尾巴放眼外禁穿环。眼坏了重跑 `layout_dual_unit`,勿全局拆眼。 + +--- + +## 眼图定型后的精修(门控序列) + +眼 sink 一经验收,**禁止**再对该画布跑会整图拆眼的工具。 + +| 允许 | 禁止(眼 sink 上) | +|------|-------------------| +| `analyze(detail=hotspots\|both)` 只读 | `polish_crossings` / `straighten_channels` | +| `orbit_sweep`:**`until_limit=true`** 或 `node_id` preview→pick | `orbit_sweep(round=true)` 全图轮扫 | +| `clear_edge_hits`(**须** `portal_ids`;交叉不升) | `fix_overlaps` / `untangle` 批处理整图 | +| `pull_far_chains`(朝**门户中点**收远走廊/孤立点;同上门控) | `layout` / 再次 `sinkTopologyDualUnits` | +| `compact_bbox`(farthest-K;交叉不升、ov=0) | 临时 py 穷举坐标 | +| `updateTopologyViewPositions` 手拖 1~少数点 | `align_reference` 当无同网范本时的交付手段 | +| 眼形整体崩了才 `layout_dual_unit` 重布同一门户对 | | + +**推荐节奏**(一次序列,读返回再决定是否重复某一环): + +``` +0) catalog=true 核对 pull_far_chains / compact_bbox(缺则重启 MCP) +0b) 从零重布时:layout_dual_unit(同一门户对, apply) // 交叉飙高正常 +1) until_limit + objective=crossing + portal_ids + bundle + → stop_reason=max_moves 则续跑,直到 stall +2) until_limit + objective=total // 交叉不升;抬贴边/正交;亦可 max_moves→续跑 +3) clear_edge_hits(portal_ids, top_n/max_moves 可加大;勿 preserve_axis 除非确认不抬交叉) +4) pull_far_chains(portal_ids) // 可 2~3 轮直到 moved=0;压交叉后必做 +5) compact_bbox(portal_ids, outlier_only) // 常 accepted=false,正常 +6) 可选:再 clear 或 total;再 stall → 停手 +``` + +`until_limit` 调用模板: + +``` +layoutTopologyView({ + action: "orbit_sweep", mode: "apply", + params: { + until_limit: true, + objective: "crossing", // 再一轮改 total + portal_ids: [<门户A>, <门户B>], + protect_rigid: "off", + max_degree: 14, + max_jump: 8000, // 大眼用 4k–8k;勿 <2k + max_stretch: 32, + max_moves: 40, // 触顶只说明配额满,不是算法到头 + bundle: true + } +}) +→ 读 local.op:start/end_crossings、moves[]、stop_reason +→ max_moves → 再调;stall → 进入下一步 +``` + +`round=true` ≠ `until_limit`:前者一批 top_n 死拿 pick#1;后者循环到 stall。 +勿为清零交叉拆弧带;以 `verdict.total` + 目视眼形 +「算法到头」四条为准。 + +--- + +## 阶段 0 — 画布与成员 + +1. `getTopologyTree` → **`view_id`**(文件夹 physical 画布)。 +2. 建根/子区域只用 `createTopologyFolder`。 +3. 缺层级/区域时:`classifyTopologyFabricNodes`(`match` → `tag(level, dry_run)` → `tag`;或 `unmatched` / `apply_rules`)。**level** 越小越靠外(0 外部 / 1 核心 / 2 汇聚 / 3 接入;可用 1.1、2.1 子层)。**勿**切片建图。 +4. `analyzeTopologyViewLayout({ view_id, detail: "structure" })` 读 `dual_units` / `shape` / `layers` —— **先认核心最大眼**(`unit_count` / 各 unit `node_count`)。 +5. `addTopologyViewNodes`(可按 `role` 大档或后续 level)/ `projectTopologyNeighbors`(区域画布务必 `region_folder_id`)。 +6. 多余点用 `removeTopologyViewNodes` **移出画布**(不删 Fabric)。`region:…` 幽灵点勿当网元拖。 + +--- + +## 形状与 dual_units(只服务「第一只眼」) + +``` +analyzeTopologyViewLayout({ view_id, detail: "structure" }) +``` + +| 字段 | 用途 | +|------|------| +| `shape.primary` | `chains` / `star` / `mesh` / `mixed_blocks` | +| `dual_units` | **从核心出发**:core–core → core–agg → …,再取 **node_count 最大**的一只做唯一 sink | +| `advice.block_plan` | 其余块怎么靠(非 dual 连抽) | +| `gravity.type` | 链图勿当 hub 花瓣 | + +- **第一步必选最大核心眼**:`prefer_top_eye=true`,`prefer_pure=false`,`max_units=1`;目标是**一块吃进尽量多网元**,不要求交叉=0。 +- **禁止**把 dual_units 当成「抽干源图」的循环泵;剩余网元见下方「后续下沉」。 +- **禁止**再用互斥 soft_block 把通路切开。 +- **链图 / 小图**:可不走 dual;preview `corridor`/`compact`。 + +--- + +## 根图 → 子区域:第一次(dual,仅一次) + +``` +sinkTopologyDualUnits({ + source_view_id: <根图>, + sink_view_id: <子区域>, // 同一张接入画布贯穿全程 + max_units: 1, + max_nodes: 300, + max_batch_nodes: 400, + layout_batch: true, + sink_layers: ["access"], + keep_layers: ["core", "agg"], + prefer_top_eye: true, + prefer_pure: false, + // 勿 until_empty;勿随后再调本工具 +}) +→ 眼形验收 / 手拖;必要时 layout_dual_unit 重布同一眼 +``` + +- **同一 sink**:后续非 dual 迁入也进**同一个** `sink_view_id`;禁止块1/块2/块3。 +- **眼定型后**:对该 sink **只门控精修**(见上节)。 + +--- + +## 后续下沉(换算法,禁用 dual) + +源图仍有接入 / 旁路网元时,**不要**再 `sinkTopologyDualUnits`。 + +0. **先问算法要批次**(推荐) + ``` + suggestSinkHubs({ + source_view_id: <根图>, + sink_view_id: <眼 sink>, + pick: 1, + }) + → batch.fabric_node_ids / move_nodes 可直接套用 + ``` + 排序:剩余领地大优先、agg 优于 core;眼门户不当 batch 头。 + + **每批迁完立刻** `until_limit(crossing, portal_ids)`,再取下一批;勿攒多批再一起 polish。 + + `suggestSinkHubs` 空了但根上还有孤立末梢/旁路:清掉已在 sink 的重复点,再对 `root−sink−眼门户` 分批 `move_nodes(park)`(每批后 `until_limit`)。也会直接吐 `orphan_batch_*`。勿再 dual。 + +1. **指定迁移 + 扫角停靠** + ``` + layoutTopologyView({ + action: "move_nodes", + source_view_id: , view_id: , + mode: "apply", + params: { + fabric_node_ids: [...], + park: true, + remove_from_source: true, + }, + }) + ``` + +2. **投影邻居**:`projectTopologyNeighbors`(区域画布带 `region_folder_id`),再手拖或 `orbit_sweep`。 + +3. **根图收尾**:`level_bands` 只钉**根图**上残留的 core/agg;**勿**对已定型眼 sink 跑全局带。 + +4. **小连通块**:源上剩余短链/星,可对**源图**或迁入后**单点**处理;勿对眼 sink `layout(recipe=…)`。 + +--- + +## 分层布局(特殊场景) + +当网络需呈现层级结构(外部→接入→核心→汇聚→客户等)时: + +### 分层通用规则 +1. **顶层:外部/对接网络** +2. **次顶层:终端客户接入层** +3. **中间层:核心层** +4. **核心下层:汇聚层** +5. **底层:接入层/孤立层** + +### 优化流程 +1. **先手动后算法**:用户手动拖拽保证业务结构,再用算法优化 +2. **分层约束**:`updateTopologyViewPositions` 按层钉 y;`orbit_sweep` 可传 `y_min/y_max` +3. **分步(非眼画布 / 根图)**:可贴边、分层钉 y;**已定型眼 sink 除外**——只允许门控精修 / 手拖 + +### 冲突处理 +| 冲突场景 | 处理方式 | +|---------|---------| +| 交叉 vs 结构 | **结构优先**(最大眼 / 分层) | +| 算法 vs 手动 | **手动优先** | +| polish 拆眼 | **禁止**对 dual 眼走 straighten 主路径 | +| 算法 vs 金标观感 | 交叉可赢;紧凑/正交/贴边交给手拖 | + +--- + +## layoutTopologyView(精简) + +| action | 用途 | +|--------|------| +| `layout` | 小图 / 剩余块:`compact` / `corridor` / `rings` / `unstick` | +| `layout_dual_unit` | **仅**重布已沉的那一只眼;椭圆弧带 + 空心眼 | +| `move_nodes` / `sink_nodes` | **后续下沉主路径**;`park` 块扫 | +| `orbit_sweep` | **眼后精修主工具**:`until_limit` 或 `node_id`;`objective=crossing\|total` | +| `clear_edge_hits` | 眼 sink **可**(须 `portal_ids`;交叉不升) | +| `pull_far_chains` | 眼 sink **可**:朝门户中点收远走廊/孤立点 | +| `align_reference` | **非主路径**:仅同网同成员 A/B 调试;跨网禁用 | +| `compact_bbox` | 眼 sink **可**:farthest-K 收 bbox | +| `level_bands` | 仅根图/未定型画布;巨图在 dual 一次之后钉根 | +| `polish_crossings` | **眼 sink 禁用** | +| `fix_overlaps` / `untangle` / `straighten_channels` | **眼 sink 禁用** | +| `job_status` / `job_cancel` | 后台 job | + +眼 sink 精修 = 门控序列 + 手拖补观感;不要排全局流水线,也不要在 stall 后继续发明新全局动作。 + +--- + +## 验收 + +| 块 | 看什么 | +|----|--------| +| `overlap` | 硬零(权重 0.24) | +| `crossing` | crossings/cpl;`top_nodes` / `top_edges` | +| `sparsity` / `edge_axis` / `edge_clearance` | 对照人工:紧凑、正交、贴边(算法常弱于此) | +| `rings` / `chains` | 环被穿、走廊折角 | +| `verdict.total` | ≈70 可交付(ov=0);结构可读优先于刷分 | +| `score_profile` | `auto`(默认)/ `default` / `eye`;大稀疏斜边画布自动走 eye | + +**评分修订(相对旧权)**:util 甜区下限 0.08;贴边分=节点命中∪hits/link;rings 降权;eye 再降 axis、抬 util/clearance;`compactness` 仅诊断不加权。 + +图标 25px;推荐中心距 Δx≥200、Δy≥170。 + +--- + +## 工具速查 + +| 工具 | 作用 | +|------|------| +| `getTopologyTree` / `getTopologyView` | 树与画布 | +| `createTopologyFolder` | 新建根/区域 | +| `add` / `remove` / `updateTopologyViewPositions` | 成员与手拖 | +| `sinkTopologyDualUnits` | **仅一次**:最大核心眼 → sink | +| `suggestSinkHubs` | **后续下沉批次**(含 orphan_batch) | +| `move_nodes`(经 layoutTopologyView) | **后续下沉** | +| `projectTopologyNeighbors` | 投影邻居 | +| `copyTopologyViewNodes` | 克隆沙箱 | +| `queryTopologyFabricNodes` | 库存 | +| neighborhood / edges | 邻接 | +| `layoutTopologyView` | 上表 action | +| `analyzeTopologyViewLayout` | structure + 验收 | + +--- + +## 代码热更 + +1. 本仓 MCP 用 `PYTHONPATH=…/src`(见用户 `~/.cursor/mcp.json`),改源码后不必为加载而 pip install。 +2. **必须重启** stdio 进程(杀旧 `python -m netx_topology_mcp` 后由 Cursor 拉起;或 MCP 面板禁用/启用)。 +3. `layoutTopologyView(catalog=true)` 核对:`version` / `rev`(`NETX_MCP_REV`)+ action 含 `pull_far_chains` / `compact_bbox`。 +4. 机器上常残留多份旧 MCP 进程 → 精修前 catalog 若仍缺 action,**杀光再启**;勿用旧进程半截交付,勿改走 HTTP/临时 py。 + +拓扑页开「实时同步」可看落笔。勿用告警/CLI MCP 写拓扑。 diff --git a/scripts/build-host.mjs b/scripts/build-host.mjs index e60e6ff..d17d07c 100644 --- a/scripts/build-host.mjs +++ b/scripts/build-host.mjs @@ -30,6 +30,9 @@ const external = [ const entries = [ { entry: join(root, 'src/index.ts'), out: join(outDir, 'index.js') }, { entry: join(root, 'src/agent-tools.ts'), out: join(outDir, 'agent-tools.js') }, + { entry: join(root, 'src/agent-tools-nms.ts'), out: join(outDir, 'agent-tools-nms.js') }, + { entry: join(root, 'src/agent-tools-common.ts'), out: join(outDir, 'agent-tools-common.js') }, + { entry: join(root, 'src/agent-tools-topology.ts'), out: join(outDir, 'agent-tools-topology.js') }, ] mkdirSync(outDir, { recursive: true }) diff --git a/scripts/sync-skills-from-netx.ps1 b/scripts/sync-skills-from-netx.ps1 new file mode 100644 index 0000000..b12f221 --- /dev/null +++ b/scripts/sync-skills-from-netx.ps1 @@ -0,0 +1,47 @@ +# Sync canonical netx/skills → dsh-netxops presets (npm embed only). +# MCP packages do NOT carry a skills/ mirror — point Cursor/hosts at netx/skills/. +param( + [string]$NetxRoot = "" +) +$ErrorActionPreference = "Stop" +$opsRoot = Resolve-Path (Join-Path $PSScriptRoot "..") +if (-not $NetxRoot) { + $sibling = Join-Path $opsRoot "..\netx" + if (Test-Path (Join-Path $sibling "skills")) { + $NetxRoot = (Resolve-Path $sibling).Path + } else { + throw "Pass -NetxRoot or place netx checkout next to netxops (../netx/skills)" + } +} +$src = Join-Path $NetxRoot "skills" +$dst = Join-Path $opsRoot "presets\netxops\skills" +if (-not (Test-Path $src)) { throw "missing $src" } + +foreach ($group in @("nms", "common", "topology")) { + $from = Join-Path $src $group + $to = Join-Path $dst $group + if (-not (Test-Path $from)) { throw "missing group $from" } + if (Test-Path $to) { Remove-Item $to -Recurse -Force } + New-Item -ItemType Directory -Force -Path (Split-Path $to) | Out-Null + Copy-Item $from $to -Recurse -Force +} + +# Drop legacy separate layout skill if present +$legacyLayout = Join-Path $dst "topology-layout" +if (Test-Path $legacyLayout) { Remove-Item $legacyLayout -Recurse -Force } + +@" +# Mirrored from netx/skills — do not long-edit here + +Canonical: ``$NetxRoot\skills`` + +```powershell +powershell -File .\scripts\sync-skills-from-netx.ps1 +``` + +Runtime prefers ``NETX_SKILLS_ROOT`` or sibling ``../netx/skills``. +One topology skill covers canvas + dual_unit / layout recipes; DSH may still gate **tools** via topology vs topology-layout groups. +"@ | Set-Content -Encoding utf8 (Join-Path $dst "README.md") + +Write-Host "Synced $src -> $dst" +Get-ChildItem -Recurse $dst -Filter SKILL.md | ForEach-Object { $_.FullName.Substring($opsRoot.Path.Length) } diff --git a/src/agent-tools-common.ts b/src/agent-tools-common.ts new file mode 100644 index 0000000..d156d00 --- /dev/null +++ b/src/agent-tools-common.ts @@ -0,0 +1,15 @@ +/** + * Forced common-group tools + skill (native managed CLI + findTopologyPaths). + * + * @module dsh-netxops/tools-common + */ + +import type { Context } from '@deepseek-ai/cordis' +import { applyGroupToolsPlugin } from './netx/group-tools-plugin.ts' + +export const name = 'netxops-tools-common' +export const inject = ['tools'] + +export function apply(ctx: Context): void { + applyGroupToolsPlugin(ctx, { name, mode: 'forced', only: ['common'] }) +} diff --git a/src/agent-tools-nms.ts b/src/agent-tools-nms.ts new file mode 100644 index 0000000..4f65540 --- /dev/null +++ b/src/agent-tools-nms.ts @@ -0,0 +1,16 @@ +/** + * Forced nms-group tools + skill (vendor NMS adapter; current provider zte-ume). + * Mount this export in any agent preset that needs NMS alarms/inventory. + * + * @module dsh-netxops/tools-nms + */ + +import type { Context } from '@deepseek-ai/cordis' +import { applyGroupToolsPlugin } from './netx/group-tools-plugin.ts' + +export const name = 'netxops-tools-nms' +export const inject = ['tools'] + +export function apply(ctx: Context): void { + applyGroupToolsPlugin(ctx, { name, mode: 'forced', only: ['nms'] }) +} diff --git a/src/agent-tools-topology.ts b/src/agent-tools-topology.ts new file mode 100644 index 0000000..6250756 --- /dev/null +++ b/src/agent-tools-topology.ts @@ -0,0 +1,15 @@ +/** + * Forced topology-group tools + skill (canvas / fabric / dual_unit / layout). + * + * @module dsh-netxops/tools-topology + */ + +import type { Context } from '@deepseek-ai/cordis' +import { applyGroupToolsPlugin } from './netx/group-tools-plugin.ts' + +export const name = 'netxops-tools-topology' +export const inject = ['tools'] + +export function apply(ctx: Context): void { + applyGroupToolsPlugin(ctx, { name, mode: 'forced', only: ['topology'] }) +} diff --git a/src/agent-tools.ts b/src/agent-tools.ts index 2e9ec65..abd9e69 100644 --- a/src/agent-tools.ts +++ b/src/agent-tools.ts @@ -1,14 +1,11 @@ /** - * Agent-plane Netx Ops tools: register `netx__*` into the calling context's - * tool scope (the Netx Ops preset standing mount), so other presets do not see them. + * Agent-plane aggregate mount: all capability groups gated by Settings inPreset. * * @module dsh-netxops/tools */ import type { Context } from '@deepseek-ai/cordis' -import type {} from '@deepseek-ai/dsh-tools' -import { getNetxConnection, watchNetxConnection } from './netx/runtime.ts' -import { registerNetxTools } from './netx/tools.ts' +import { applyGroupToolsPlugin } from './netx/group-tools-plugin.ts' /** Cordis plugin name. */ export const name = 'netxops-tools' @@ -16,35 +13,7 @@ export const name = 'netxops-tools' /** Tool registry must exist in this (preset-scoped) context. */ export const inject = ['tools'] -/** - * Mount netx REST tools for the Netx Ops agent preset only. - */ +/** Mount enabled groups for the Netx Ops preset. */ export function apply(ctx: Context): void { - let unregister: (() => void) | undefined - - const remount = (): void => { - unregister?.() - unregister = undefined - const connection = getNetxConnection() - if (connection === undefined) { - ctx.logger.warn('netxops-tools: no connection yet — waiting for host settings bridge') - return - } - unregister = registerNetxTools(ctx, connection) - const tokenConfigured = connection.token.trim().length > 0 - ctx.logger.info( - 'netxops-tools: registered netx__* for Ops preset → %s tokenConfigured=%s', - connection.apiUrl, - tokenConfigured, - ) - } - - remount() - const stopWatch = watchNetxConnection(() => { remount() }) - - ctx.effect(() => () => { - stopWatch() - unregister?.() - unregister = undefined - }, 'netxops-tools: dispose') + applyGroupToolsPlugin(ctx, { name, mode: 'settings' }) } diff --git a/src/client/NetxopsCard.tsx b/src/client/NetxopsCard.tsx index f2de017..bacaf3a 100644 --- a/src/client/NetxopsCard.tsx +++ b/src/client/NetxopsCard.tsx @@ -7,6 +7,11 @@ import type { InjectFace, PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-cli import type { NetxopsCardFace, NetxopsCardState } from './controller.ts' import type { NetxopsLocaleKey } from './locales.ts' import type { CardFieldState } from './card-form.ts' +import { + imCatalogOptionKey, + parseImCatalogOptionKey, + type ImDeliveryCatalog, +} from './im-delivery-catalog.ts' import { alarmPushTone, type AlarmPushPhase } from './alarm-push-status-view.ts' import { ensureStyles } from './styles.ts' import type {} from '@deepseek-ai/dsh-client-ui-settings-plugins/client' @@ -88,6 +93,159 @@ function ValueField(props: { ) } +function ImDeliveryPicker(props: { + catalog: ImDeliveryCatalog + botId: string + targetId: string + disabled: boolean + labels: { + target: string + none: string + manual: string + unavailable: string + botId: string + botHint: string + targetId: string + targetHint: string + overridden: string + reset: string + invalid: string + } + botField: CardFieldState + targetField: CardFieldState + onPick: (botId: string, targetId: string) => void + onEditBot: (text: string) => void + onEditTarget: (text: string) => void + onResetBot: () => void + onResetTarget: () => void +}) { + const options = props.catalog.options + const selectedKey = props.botId && props.targetId + ? imCatalogOptionKey(props.botId, props.targetId) + : '' + const matched = options.some((row) => imCatalogOptionKey(row.botId, row.targetId) === selectedKey) + const [manual, setManual] = useState(() => !!(selectedKey && !matched)) + useEffect(() => { + if (selectedKey && matched) setManual(false) + else if (selectedKey && !matched) setManual(true) + }, [selectedKey, matched]) + const selectValue = manual ? '__manual__' : (matched ? selectedKey : '') + const showManual = manual || (selectedKey && !matched) + + return ( + <> +
+
+ +
+ + {!props.catalog.available || options.length === 0 + ?

{props.catalog.hint || props.labels.unavailable}

+ : null} +
+ {showManual + ? ( + <> + + + + ) + : null} + + ) +} + +function CapabilityGroupBlock(props: { + title: string + inPresetLabel: string + publicLabel: string + inPreset: CardFieldState + published: CardFieldState + disabled: boolean + onEditInPreset: (checked: boolean) => void + onEditPublic: (checked: boolean) => void +}) { + return ( +
+
{props.title}
+
+ + +
+
+ ) +} + export function NetxopsCard(props: NetxopsCardProps) { ensureStyles() const { t } = props @@ -188,6 +346,66 @@ export function NetxopsCard(props: NetxopsCardProps) { onEdit={(text) => { props.edit('lang', text) }} onReset={() => { props.resetField('lang') }} /> + { props.edit('nmsProvider', text) }} + onReset={() => { props.resetField('nmsProvider') }} + /> +
+
+ {t('capabilityGroups')} +
+

{t('capabilityGroupsHint')}

+ { + props.edit('groupNmsInPreset', checked ? 'true' : 'false') + }} + onEditPublic={(checked) => { + props.edit('groupNmsPublic', checked ? 'true' : 'false') + }} + /> + { + props.edit('groupCommonInPreset', checked ? 'true' : 'false') + }} + onEditPublic={(checked) => { + props.edit('groupCommonPublic', checked ? 'true' : 'false') + }} + /> + { + props.edit('groupTopologyInPreset', checked ? 'true' : 'false') + }} + onEditPublic={(checked) => { + props.edit('groupTopologyPublic', checked ? 'true' : 'false') + }} + /> +
@@ -290,29 +508,34 @@ export function NetxopsCard(props: NetxopsCardProps) { {t('alarmDeliverImHint')}
- { props.edit('imBotId', text) }} - onReset={() => { props.resetField('imBotId') }} - /> - { props.edit('imTargetId', text) }} - onReset={() => { props.resetField('imTargetId') }} + labels={{ + target: t('imTarget'), + none: t('imTargetNone'), + manual: t('imTargetManual'), + unavailable: t('imCatalogUnavailable'), + botId: t('imBotId'), + botHint: t('imBotIdHint'), + targetId: t('imTargetId'), + targetHint: t('imTargetIdHint'), + overridden: t('overridden'), + reset: t('reset'), + invalid: t('invalid'), + }} + botField={state.imBotId} + targetField={state.imTargetId} + onPick={(botId, targetId) => { + props.edit('imBotId', botId) + props.edit('imTargetId', targetId) + }} + onEditBot={(text) => { props.edit('imBotId', text) }} + onEditTarget={(text) => { props.edit('imTargetId', text) }} + onResetBot={() => { props.resetField('imBotId') }} + onResetTarget={() => { props.resetField('imTargetId') }} />
{state.failed ?

{t('saveFailed')}

: null} diff --git a/src/client/controller.ts b/src/client/controller.ts index 0909cc1..6d984f4 100644 --- a/src/client/controller.ts +++ b/src/client/controller.ts @@ -15,6 +15,11 @@ import { type AlarmPushRpcCall, type AlarmPushStatus, } from './alarm-push-status-view.ts' +import { + EMPTY_IM_DELIVERY_CATALOG, + fetchImDeliveryCatalog, + type ImDeliveryCatalog, +} from './im-delivery-catalog.ts' export const NETXOPS_NS = 'netxops' const DEFAULT_TOKEN_REF = 'NETX_API_TOKEN' @@ -30,6 +35,13 @@ export interface NetxopsSettings { alarmDeliverIm?: boolean imBotId?: string imTargetId?: string + groupNmsInPreset?: boolean + groupNmsPublic?: boolean + groupCommonInPreset?: boolean + groupCommonPublic?: boolean + groupTopologyInPreset?: boolean + groupTopologyPublic?: boolean + nmsProvider?: string } interface CredentialState { @@ -42,6 +54,13 @@ interface CredentialState { export interface NetxopsCardState extends CardShell { apiUrl: CardFieldState lang: CardFieldState + groupNmsInPreset: CardFieldState + groupNmsPublic: CardFieldState + groupCommonInPreset: CardFieldState + groupCommonPublic: CardFieldState + groupTopologyInPreset: CardFieldState + groupTopologyPublic: CardFieldState + nmsProvider: CardFieldState alarmPushEnabled: CardFieldState alarmDeliverDsh: CardFieldState alarmDeliverIm: CardFieldState @@ -53,6 +72,8 @@ export interface NetxopsCardState extends CardShell { apiTokenRemoteReady: boolean /** Host WSS status when Connection RPC is available; otherwise null. */ alarmPushStatus: AlarmPushStatus | null + /** Saved IM delivery targets for the picker (soft-depends on dsh-im-ops). */ + imDeliveryCatalog: ImDeliveryCatalog } export interface NetxopsCardFace extends CardActions { @@ -77,8 +98,10 @@ export class NetxopsCardController { } private rpcCall: AlarmPushRpcCall | undefined private alarmPushStatus: AlarmPushStatus | null = null + private imDeliveryCatalog: ImDeliveryCatalog = { ...EMPTY_IM_DELIVERY_CATALOG } private pollTimer: ReturnType | undefined private pollInFlight = false + private catalogInFlight = false constructor( private readonly scope: SettingsScope, @@ -89,6 +112,13 @@ export class NetxopsCardController { [ textField('apiUrl'), textField('lang'), + textField('nmsProvider'), + booleanFieldPersistFalse('groupNmsInPreset'), + booleanField('groupNmsPublic'), + booleanFieldPersistFalse('groupCommonInPreset'), + booleanField('groupCommonPublic'), + booleanField('groupTopologyInPreset'), + booleanField('groupTopologyPublic'), booleanField('alarmPushEnabled'), booleanFieldPersistFalse('alarmDeliverDsh'), booleanField('alarmDeliverIm'), @@ -122,19 +152,29 @@ export class NetxopsCardController { this.rpcCall = call if (call === undefined) { this.stopStatusPoll() + let changed = false if (this.alarmPushStatus !== null) { this.alarmPushStatus = null - this.store.set(this.projection()) + changed = true } + if (this.imDeliveryCatalog.options.length > 0 || this.imDeliveryCatalog.available !== true) { + this.imDeliveryCatalog = { ...EMPTY_IM_DELIVERY_CATALOG } + changed = true + } + if (changed) this.store.set(this.projection()) return } this.startStatusPoll() void this.refreshAlarmPushStatus() + void this.refreshImDeliveryCatalog() } private startStatusPoll(): void { if (this.pollTimer !== undefined) return - this.pollTimer = setInterval(() => { void this.refreshAlarmPushStatus() }, STATUS_POLL_MS) + this.pollTimer = setInterval(() => { + void this.refreshAlarmPushStatus() + void this.refreshImDeliveryCatalog() + }, STATUS_POLL_MS) } private stopStatusPoll(): void { @@ -168,11 +208,48 @@ export class NetxopsCardController { } } + private async refreshImDeliveryCatalog(): Promise { + const call = this.rpcCall + if (call === undefined || this.catalogInFlight) return + this.catalogInFlight = true + try { + const next = await fetchImDeliveryCatalog(call) + const prev = this.imDeliveryCatalog + const sameOptions = prev.options.length === next.options.length + && prev.options.every((row, index) => { + const other = next.options[index] + return other + && row.botId === other.botId + && row.targetId === other.targetId + && row.name === other.name + && row.channel === other.channel + }) + if ( + prev.available === next.available + && prev.hint === next.hint + && sameOptions + ) return + this.imDeliveryCatalog = next + this.store.set(this.projection()) + } catch { + // Keep last good snapshot; next poll retries. + } finally { + this.catalogInFlight = false + } + } + private projection(): NetxopsCardState { return { ...this.form.shell(), apiUrl: this.form.field('apiUrl'), lang: this.form.field('lang'), + nmsProvider: this.form.field('nmsProvider'), + groupNmsInPreset: this.form.field('groupNmsInPreset'), + groupNmsPublic: this.form.field('groupNmsPublic'), + groupCommonInPreset: this.form.field('groupCommonInPreset'), + groupCommonPublic: this.form.field('groupCommonPublic'), + groupTopologyInPreset: this.form.field('groupTopologyInPreset'), + groupTopologyPublic: this.form.field('groupTopologyPublic'), alarmPushEnabled: this.form.field('alarmPushEnabled'), alarmDeliverDsh: this.form.field('alarmDeliverDsh'), alarmDeliverIm: this.form.field('alarmDeliverIm'), @@ -183,6 +260,7 @@ export class NetxopsCardController { apiTokenWritable: this.credential.remoteReady && this.credential.writable, apiTokenRemoteReady: this.credential.remoteReady, alarmPushStatus: this.alarmPushStatus, + imDeliveryCatalog: this.imDeliveryCatalog, } } diff --git a/src/client/im-delivery-catalog.ts b/src/client/im-delivery-catalog.ts new file mode 100644 index 0000000..60dc9eb --- /dev/null +++ b/src/client/im-delivery-catalog.ts @@ -0,0 +1,98 @@ +/** + * Browser helper: load saved IM delivery targets via Netx Ops Connection RPC. + */ + +import { + NETXOPS_RPC_CHANNEL, + type AlarmPushRpcCall, +} from './alarm-push-status-view.ts' + +export const IM_DELIVERY_CATALOG_ENDPOINT = 'im-delivery.catalog' + +export interface ImDeliveryCatalogOption { + botId: string + targetId: string + name: string + kind: string + channel: string +} + +export interface ImDeliveryCatalog { + available: boolean + options: ImDeliveryCatalogOption[] + hint: string + loading: boolean +} + +export const EMPTY_IM_DELIVERY_CATALOG: ImDeliveryCatalog = { + available: true, + options: [], + hint: '', + loading: false, +} + +/** + * @param value - RPC catalog payload. + */ +export function asImDeliveryCatalog(value: unknown): ImDeliveryCatalog { + if (value === null || typeof value !== 'object' || Array.isArray(value)) { + return { ...EMPTY_IM_DELIVERY_CATALOG, available: false } + } + const row = value as Record + const raw = Array.isArray(row.options) ? row.options : [] + const options: ImDeliveryCatalogOption[] = [] + for (const entry of raw) { + if (entry === null || typeof entry !== 'object' || Array.isArray(entry)) continue + const item = entry as Record + const botId = typeof item.botId === 'string' ? item.botId.trim() : '' + const targetId = typeof item.targetId === 'string' ? item.targetId.trim() : '' + if (!botId || !targetId) continue + options.push({ + botId, + targetId, + name: typeof item.name === 'string' && item.name.trim() ? item.name.trim() : targetId, + kind: typeof item.kind === 'string' ? item.kind : '', + channel: typeof item.channel === 'string' ? item.channel : 'im', + }) + } + return { + available: row.available !== false, + options, + hint: typeof row.hint === 'string' ? row.hint : '', + loading: false, + } +} + +/** + * @param call - `ctx.connection.rpc.call`. + * @param signal - optional abort. + */ +export async function fetchImDeliveryCatalog( + call: AlarmPushRpcCall, + signal?: AbortSignal, +): Promise { + const result = await call(NETXOPS_RPC_CHANNEL, IM_DELIVERY_CATALOG_ENDPOINT, {}, signal) + if (result !== null && typeof result === 'object' && (result as { ok?: boolean }).ok === true) { + return asImDeliveryCatalog((result as { value?: unknown }).value) + } + if (result !== null && typeof result === 'object' && (result as { ok?: boolean }).ok === false) { + return { + ...EMPTY_IM_DELIVERY_CATALOG, + available: false, + hint: String((result as { error?: { message?: string } }).error?.message ?? 'rpc failed'), + } + } + return asImDeliveryCatalog(result) +} + +/** Stable select value for one catalog row. */ +export function imCatalogOptionKey(botId: string, targetId: string): string { + return `${botId}::${targetId}` +} + +/** Parse {@link imCatalogOptionKey}. */ +export function parseImCatalogOptionKey(value: string): { botId: string, targetId: string } { + const at = value.indexOf('::') + if (at <= 0) return { botId: '', targetId: '' } + return { botId: value.slice(0, at), targetId: value.slice(at + 2) } +} diff --git a/src/client/locales.ts b/src/client/locales.ts index 2b6a091..56ece2a 100644 --- a/src/client/locales.ts +++ b/src/client/locales.ts @@ -7,6 +7,15 @@ export type NetxopsLocaleKey = | 'apiUrlHint' | 'lang' | 'langHint' + | 'capabilityGroups' + | 'capabilityGroupsHint' + | 'nmsProvider' + | 'nmsProviderHint' + | 'groupNms' + | 'groupCommon' + | 'groupTopology' + | 'groupInPreset' + | 'groupPublic' | 'alarmPushEnabled' | 'alarmPushEnabledHint' | 'alarmPushStatus' @@ -26,6 +35,10 @@ export type NetxopsLocaleKey = | 'imBotIdHint' | 'imTargetId' | 'imTargetIdHint' + | 'imTarget' + | 'imTargetNone' + | 'imTargetManual' + | 'imCatalogUnavailable' | 'apiToken' | 'apiTokenHint' | 'apiTokenUnavailable' @@ -45,11 +58,20 @@ export type NetxopsLocaleKey = export const en: Record = { title: 'Netx Ops', - description: 'UME API endpoint and bearer token for netx__* tools.', + description: 'netx API, bearer token, and capability groups (nms / common / topology).', apiUrl: 'API URL', apiUrlHint: 'netx REST root, e.g. http://127.0.0.1:8890 (also used for alarm subscribe)', lang: 'Language', langHint: 'Response language hint (zh / en).', + capabilityGroups: 'Capability groups', + capabilityGroupsHint: 'One group ↔ one skill (netx-nms / netx-common / netx-topology). Defaults: nms + common in preset; topology and all public off. Save, then open a new session. Other agents can mount dsh-netxops/tools-nms|common|topology.', + nmsProvider: 'nms provider', + nmsProviderHint: 'Vendor adapter id. Supported today: zte-ume (REST still /v1/ume/*; model tools are netx__*Nms*).', + groupNms: 'nms — skill netx-nms (alarms / inventory / SQL)', + groupCommon: 'common — skill netx-common (managed CLI + findTopologyPaths)', + groupTopology: 'topology — skill netx-topology (canvas / fabric / dual_unit)', + groupInPreset: 'In Netx Ops preset', + groupPublic: 'Publish to other presets', alarmPushEnabled: 'Key-alarm push', alarmPushEnabledHint: 'Dial out to netx when a matched key alarm arrives. Choose DSH and/or IM sinks below.', alarmPushStatus: 'Push link', @@ -64,11 +86,15 @@ export const en: Record = { alarmDeliverDsh: 'Deliver to DSH session', alarmDeliverDshHint: 'Open/follow the sticky「Netx 关键告警」session on this Host.', alarmDeliverIm: 'Deliver to WhatsApp / IM', - alarmDeliverImHint: 'Requires dsh-im-ops. Paste botId + targetId from IM → 投递设置 → 复制调用参数.', + alarmDeliverImHint: 'Requires dsh-im-ops ≥ops.24. Pick a saved delivery target below (create targets under IM → Delivery settings).', imBotId: 'IM Bot ID', - imBotIdHint: 'Opaque botId from IM delivery settings (not a phone number).', + imBotIdHint: 'Filled from the picker, or enter manually.', imTargetId: 'IM Target ID', - imTargetIdHint: 'Opaque targetId alias (e.g. release-alerts). Create the target in IM first.', + imTargetIdHint: 'Filled from the picker, or enter manually.', + imTarget: 'Delivery target', + imTargetNone: '(Create a target in IM delivery settings first)', + imTargetManual: 'Enter manually…', + imCatalogUnavailable: 'Cannot load targets — install/update dsh-im-ops (≥ops.24) and save at least one delivery target.', apiToken: 'API token', apiTokenHint: 'Stored as credential NETX_API_TOKEN (never written into settings). Leave blank to keep the current token.', apiTokenUnavailable: 'This DSH build does not expose remote.credentials. Set the token with scripts/set-netx-token.ps1 (or .sh), then restart is not required if credentials are watched.', @@ -89,11 +115,20 @@ export const en: Record = { export const zh: Record = { title: 'Netx Ops', - description: 'UME API 地址与 Bearer Token,供 netx__* 工具使用。', + description: 'netx API、Bearer Token,以及能力组(nms / common / topology)。', apiUrl: 'API 地址', apiUrlHint: 'netx REST 根地址,例如 http://127.0.0.1:8890(告警订阅复用同一地址)', lang: '语言', langHint: '响应语言提示(zh / en)。', + capabilityGroups: '能力组', + capabilityGroupsHint: '一组一个 skill(netx-nms / netx-common / netx-topology)。默认:nms + common 进入预设;topology 与全部公开关闭。保存后请新开会话。其他 Agent 可挂 dsh-netxops/tools-nms|common|topology。', + nmsProvider: 'nms provider', + nmsProviderHint: '厂商适配器 id。当前支持:zte-ume(REST 仍为 /v1/ume/*;模型工具名为 netx__*Nms*)。', + groupNms: 'nms — skill netx-nms(告警 / 库存 / SQL)', + groupCommon: 'common — skill netx-common(纳管 CLI + findTopologyPaths)', + groupTopology: 'topology — skill netx-topology(画布 / Fabric / dual_unit)', + groupInPreset: '在 Netx Ops 预设中启用', + groupPublic: '对其他预设公开', alarmPushEnabled: '关键告警推送', alarmPushEnabledHint: '主动连接 netx;匹配到关键告警后按下述开关投递。可只开 DSH、只开 IM,或两者都开。', alarmPushStatus: '推送链路', @@ -108,11 +143,15 @@ export const zh: Record = { alarmDeliverDsh: '投递到 DSH 会话', alarmDeliverDshHint: '在本机打开/续写 sticky「Netx 关键告警」会话。', alarmDeliverIm: '投递到 WhatsApp / IM', - alarmDeliverImHint: '需要已安装 dsh-im-ops。请先在 IM「投递设置」新建目标,再粘贴「复制调用参数」里的 botId / targetId。', + alarmDeliverImHint: '需要 dsh-im-ops ≥ops.24。请在下方下拉选择已保存的投递目标(先在 IM「投递设置」新建)。', imBotId: 'IM Bot ID', - imBotIdHint: '来自 IM 投递设置的不透明 botId(不是手机号)。', + imBotIdHint: '由下拉自动填入,也可手动改。', imTargetId: 'IM Target ID', - imTargetIdHint: '投递目标别名(如 release-alerts)。请先在 IM 侧创建目标。', + imTargetIdHint: '由下拉自动填入,也可手动改。', + imTarget: '投递目标', + imTargetNone: '(请先在 IM「投递设置」新建目标)', + imTargetManual: '手动填写…', + imCatalogUnavailable: '无法加载投递目标 — 请安装/更新 dsh-im-ops(≥ops.24)并至少保存一个投递目标。', apiToken: 'API Token', apiTokenHint: '写入凭据 NETX_API_TOKEN(不会进 settings)。留空表示保留已有 token。', apiTokenUnavailable: '当前 DSH 未提供 remote.credentials。请用 scripts/set-netx-token.ps1(或 .sh)写入 token;若 harness 在监视凭据文件则无需重启。', diff --git a/src/client/styles.ts b/src/client/styles.ts index ae0d10c..40fedd3 100644 --- a/src/client/styles.ts +++ b/src/client/styles.ts @@ -30,11 +30,15 @@ const CSS = ` .dsh-nx-reset{border:none;background:none;padding:0;font:inherit;font-size:12px;line-height:1.5;color:var(--dsw-alias-label-secondary);cursor:pointer} .dsh-nx-reset:hover:not(:disabled){color:var(--dsw-alias-label-primary)} .dsh-nx-input{height:34px;padding:0 12px;border:.5px solid var(--dsw-alias-border-l4);border-radius:8px;background:var(--dsw-alias-bg-layer-3);font:inherit;font-size:13px;line-height:1.5;color:var(--dsw-alias-label-primary)} +.dsh-nx-select{appearance:auto;cursor:pointer} .dsh-nx-input:focus-visible{outline:none;border-color:var(--dsw-alias-brand-primary)} .dsh-nx-inputInvalid{border-color:var(--dsw-alias-label-error)} .dsh-nx-hint{margin:0;font-size:12px;line-height:1.5;color:var(--dsw-alias-label-tertiary)} .dsh-nx-checkRow{display:flex;align-items:flex-start;gap:10px;font-size:12px;line-height:1.5;color:var(--dsw-alias-label-secondary);cursor:pointer} .dsh-nx-checkRow input{margin-top:2px;flex:none} +.dsh-nx-groupBlock{display:flex;flex-direction:column;gap:8px;padding:8px 0 4px} +.dsh-nx-groupTitle{font-size:12px;font-weight:600;line-height:1.5;color:var(--dsw-alias-label-primary)} +.dsh-nx-groupChecks{display:flex;flex-direction:column;gap:6px;padding-left:2px} .dsh-nx-invalid{margin:0;font-size:12px;line-height:1.5;color:var(--dsw-alias-label-error)} .dsh-nx-footer{display:flex;align-items:center;justify-content:flex-end;gap:8px;padding:12px 0 4px;border-top:.5px solid var(--dsw-alias-border-l2)} .dsh-nx-failed{flex:1;min-width:0;margin:0;font-size:12px;line-height:1.5;color:var(--dsw-alias-label-error)} diff --git a/src/index.ts b/src/index.ts index 25f5784..f2a1a06 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,7 +1,8 @@ /** - * Host-plane Netx Ops: settings (apiUrl / lang / alarm push) + credentials - * (NETX_API_TOKEN) publish a connection snapshot; the Ops preset mounts - * `netx__*` into its own tool scope (`dsh-netxops/tools`). + * Host-plane Netx Ops: settings (apiUrl / lang / alarm push / capability groups) + * + credentials (NETX_API_TOKEN) publish a connection snapshot. The Ops preset + * mounts selected `netx__*` groups via `dsh-netxops/tools`; groups marked public + * also register on the host tool/skill layer for other presets. * * When「关键告警推送」is on, this host dials out to netx's fixed-IP alarm hub and * opens/follows a sticky DSH session (im / WhatsApp is optional and separate). @@ -27,7 +28,14 @@ import { } from './netx/alarm-push-status.ts' import { deliverAlarmToIm } from './netx/alarm-im.ts' import { deliverAlarmToSession, resetAlarmSession } from './netx/alarm-session.ts' -import { publishNetxConnection } from './netx/runtime.ts' +import { + capabilityGroupsFromSettings, + groupsForPlane, + type NetxCapabilityGroupSettingsFields, +} from './netx/capability-groups.ts' +import { registerGroupSkills } from './netx/group-skills.ts' +import { publishNetxConnection, getNetxConnection, watchNetxConnection } from './netx/runtime.ts' +import { registerNetxTools } from './netx/tools.ts' /** Cordis plugin name. */ export const name = 'netxops' @@ -78,6 +86,23 @@ export interface Config { imBotId: string /** Opaque target id from the same copy payload. */ imTargetId: string + /** + * NMS provider adapter id. Supported today: `zte-ume` + * (REST `/v1/ume/*`; model tools are generic `netx__*Nms*`). + */ + nmsProvider: string + /** nms tools/skills in the Netx Ops preset (default on). */ + groupNmsInPreset: boolean + /** Publish nms tools/skills to other presets via the host layer (default off). */ + groupNmsPublic: boolean + /** common tools/skills in the Netx Ops preset (default on). */ + groupCommonInPreset: boolean + /** Publish common tools/skills to other presets (default off). */ + groupCommonPublic: boolean + /** topology canvas / layout tools in the Netx Ops preset (default off). */ + groupTopologyInPreset: boolean + /** Publish topology tools/skills to other presets (default off). */ + groupTopologyPublic: boolean } export const Config: z = z.object({ @@ -91,6 +116,13 @@ export const Config: z = z.object({ alarmDeliverIm: z.boolean().default(false), imBotId: z.string().default(''), imTargetId: z.string().default(''), + nmsProvider: z.string().default('zte-ume'), + groupNmsInPreset: z.boolean().default(true), + groupNmsPublic: z.boolean().default(false), + groupCommonInPreset: z.boolean().default(true), + groupCommonPublic: z.boolean().default(false), + groupTopologyInPreset: z.boolean().default(false), + groupTopologyPublic: z.boolean().default(false), }) /** Package root (parent of `lib/` or `src/` depending on launch). */ @@ -238,11 +270,19 @@ export function apply(ctx: Context, config: Config = Config({})): void { const apiUrl = current.apiUrl.replace(/\/$/, '') const tokenConfigured = token.trim().length > 0 + const groups = capabilityGroupsFromSettings(current as NetxCapabilityGroupSettingsFields) + if (current.nmsProvider && current.nmsProvider !== 'zte-ume') { + ctx.logger.warn( + 'netxops: nmsProvider=%s is not implemented yet; using zte-ume REST adapter', + current.nmsProvider, + ) + } publishNetxConnection({ apiUrl, token, lang: current.lang, toolCallTimeoutMs: current.toolCallTimeoutMs, + groups, }) restartAlarmPush(current, apiUrl, token) if (!tokenConfigured) { @@ -253,11 +293,12 @@ export function apply(ctx: Context, config: Config = Config({})): void { ) } else { ctx.logger.info( - 'netxops: published connection → %s tokenConfigured=true alarmPush=%s dsh=%s im=%s', + 'netxops: published connection → %s tokenConfigured=true alarmPush=%s dsh=%s im=%s public=[%s]', apiUrl, current.alarmPushEnabled === true, current.alarmDeliverDsh !== false, current.alarmDeliverIm === true, + groupsForPlane(groups, 'public').join(',') || '(none)', ) } }).catch((error) => { @@ -280,7 +321,63 @@ export function apply(ctx: Context, config: Config = Config({})): void { if (String(ref) === source().tokenCredentialRef) publish() }) - // Browser card polls alarm-push WSS status through Connection RPC. + // Optional host-layer publish: groups with `public=true` become visible to other presets. + ctx.inject(['tools'], (toolsCtx) => { + let unregisterTools: (() => void) | undefined + const remountPublicTools = (): void => { + unregisterTools?.() + unregisterTools = undefined + const connection = getNetxConnection() + if (!connection) return + const enabled = groupsForPlane(connection.groups, 'public') + unregisterTools = registerNetxTools(toolsCtx, connection, { plane: 'public' }) + toolsCtx.logger.info( + 'netxops: host public tools groups=[%s]', + enabled.join(',') || '(none)', + ) + } + remountPublicTools() + const stopWatch = watchNetxConnection(() => { remountPublicTools() }) + toolsCtx.effect(() => () => { + stopWatch() + unregisterTools?.() + }, 'netxops: dispose public tools') + }) + + ctx.inject(['skills'], (skillsCtx) => { + let unregisterSkills: (() => void) | undefined + let generation = 0 + const remountPublicSkills = (): void => { + const gen = ++generation + unregisterSkills?.() + unregisterSkills = undefined + const connection = getNetxConnection() + if (!connection) return + const enabled = groupsForPlane(connection.groups, 'public') + void registerGroupSkills(skillsCtx, enabled, 'netxops-public').then((dispose) => { + if (gen !== generation) { + dispose() + return + } + unregisterSkills = dispose + skillsCtx.logger.info( + 'netxops: host public skills groups=[%s]', + enabled.join(',') || '(none)', + ) + }).catch((error) => { + skillsCtx.logger.warn('netxops: public skill register failed: %s', error) + }) + } + remountPublicSkills() + const stopWatch = watchNetxConnection(() => { remountPublicSkills() }) + skillsCtx.effect(() => () => { + generation += 1 + stopWatch() + unregisterSkills?.() + }, 'netxops: dispose public skills') + }) + + // Browser card polls alarm-push WSS status + IM delivery catalog through Connection RPC. ctx.inject(['connection'], (connCtx) => { const rpc = connCtx.connection?.rpc if (!rpc || typeof rpc.handle !== 'function') { @@ -291,10 +388,46 @@ export function apply(ctx: Context, config: Config = Config({})): void { const dispose = rpc.handle( NETXOPS_RPC_CHANNEL, async (endpoint: string) => { - if (endpoint !== 'alarm-push.status') { - return { ok: false, error: { code: 'bad-request', message: 'Unknown endpoint.' } } + if (endpoint === 'alarm-push.status') { + return { ok: true, value: getAlarmPushStatus() } } - return { ok: true, value: getAlarmPushStatus() } + if (endpoint === 'im-delivery.catalog') { + type DshImCatalog = { listDeliveryCatalog?: () => Promise } + const fromGet = typeof (ctx as { get?: (name: string) => unknown }).get === 'function' + ? (ctx as { get: (name: string) => unknown }).get('dshIm') as DshImCatalog | undefined + : undefined + const im = fromGet ?? (ctx as { dshIm?: DshImCatalog }).dshIm + if (!im || typeof im.listDeliveryCatalog !== 'function') { + return { + ok: true, + value: { + available: false, + options: [], + hint: 'dsh-im-ops missing or outdated — install ≥ops.24 for delivery picker', + }, + } + } + try { + const options = await im.listDeliveryCatalog() + return { + ok: true, + value: { + available: true, + options: Array.isArray(options) ? options : [], + }, + } + } catch (error) { + return { + ok: true, + value: { + available: false, + options: [], + hint: error instanceof Error ? error.message : String(error), + }, + } + } + } + return { ok: false, error: { code: 'bad-request', message: 'Unknown endpoint.' } } }, ) return () => { void dispose() } diff --git a/src/netx/alarm-push.ts b/src/netx/alarm-push.ts index d0fef7b..9c7b793 100644 --- a/src/netx/alarm-push.ts +++ b/src/netx/alarm-push.ts @@ -62,7 +62,7 @@ export function formatAlarmPrompt(payload: KeyAlarmPayload, lang = 'zh'): string const label = String(payload.rule_label ?? payload.native_probable_cause ?? '关键告警').trim() if (lang.startsWith('en')) { return [ - `[UME ${actionEn[action] ?? (action || 'Alarm')}] ${label}`, + `[NMS ${actionEn[action] ?? (action || 'Alarm')}] ${label}`, `Device: ${device}`, `Object: ${String(payload.object_name ?? '-').trim()}`, `Severity: ${String(payload.perceived_severity ?? '-').trim()}`, @@ -75,7 +75,7 @@ export function formatAlarmPrompt(payload: KeyAlarmPayload, lang = 'zh'): string ].join('\n') } return [ - `[UME ${actionZh[action] ?? (action || '告警')}] ${label}`, + `[NMS ${actionZh[action] ?? (action || '告警')}] ${label}`, `设备: ${device}`, `对象: ${String(payload.object_name ?? '-').trim()}`, `级别: ${String(payload.perceived_severity ?? '-').trim()}`, diff --git a/src/netx/capability-groups.ts b/src/netx/capability-groups.ts new file mode 100644 index 0000000..299fe3d --- /dev/null +++ b/src/netx/capability-groups.ts @@ -0,0 +1,176 @@ +/** + * Netx Ops capability groups: selective tool/skill exposure. + * + * Rule: **one group ↔ one skill** (dirs under `netx/skills//`). + * + * - `nms` → skill `netx-nms` — vendor NMS adapter (zte-ume behind generic names) + * - `common` → skill `netx-common` — managed CLI + findTopologyPaths + * - `topology` → skill `netx-topology` — canvas / fabric / dual_unit / layout recipes + */ + +/** Stable group ids used in settings, skill dirs, and registration filters. */ +export type NetxCapabilityGroupId = 'nms' | 'common' | 'topology' + +/** Per-group exposure knobs. */ +export interface NetxGroupExposure { + /** Mount into the Netx Ops agent preset scope. */ + inPreset: boolean + /** Mount on the host tool/skill layer so other presets can see the group. */ + public: boolean +} + +/** Full capability-group policy published with the connection snapshot. */ +export type NetxCapabilityGroups = Record + +/** Flat settings fields (Plugins card + schemastery Config). */ +export interface NetxCapabilityGroupSettingsFields { + groupNmsInPreset: boolean + groupNmsPublic: boolean + groupCommonInPreset: boolean + groupCommonPublic: boolean + groupTopologyInPreset: boolean + groupTopologyPublic: boolean +} + +/** + * Default: nms + common in Ops preset; topology and all public off. + */ +export const DEFAULT_CAPABILITY_GROUPS: NetxCapabilityGroups = Object.freeze({ + nms: Object.freeze({ inPreset: true, public: false }), + common: Object.freeze({ inPreset: true, public: false }), + topology: Object.freeze({ inPreset: false, public: false }), +}) + +/** + * Model-facing tool names (`netx__*`) owned by each group. + * nms tools use generic `Nms` names; HTTP still hits the configured NMS provider adapter. + */ +export const TOOLS_BY_GROUP: Readonly> = Object.freeze({ + nms: Object.freeze([ + 'netx__queryNmsAlarms', + 'netx__aggregateNmsAlarms', + 'netx__runNmsDiagnostics', + 'netx__queryNmsNeInventory', + 'netx__getNmsNe', + 'netx__queryNmsAlarmsRaw', + 'netx__aggregateNmsAlarmsRaw', + 'netx__listNmsAlarmFields', + 'netx__sqlQueryNms', + ]), + common: Object.freeze([ + 'netx__listManagedNe', + 'netx__getManagedNe', + 'netx__execManagedNe', + 'netx__listCliTargets', + 'netx__findTopologyPaths', + ]), + topology: Object.freeze([ + 'netx__getTopologyTree', + 'netx__getTopologyView', + 'netx__createTopologyFolder', + 'netx__addTopologyViewNodes', + 'netx__removeTopologyViewNodes', + 'netx__copyTopologyViewNodes', + 'netx__updateTopologyViewPositions', + 'netx__projectTopologyNeighbors', + 'netx__queryTopologyFabricNodes', + 'netx__classifyTopologyFabricNodes', + 'netx__queryTopologyNeighborhood', + 'netx__queryTopologyEdges', + 'netx__layoutTopologyView', + 'netx__suggestSinkHubs', + 'netx__analyzeTopologyViewLayout', + 'netx__sinkTopologyDualUnits', + ]), +}) + +/** Skill directory name under skills root / `presets/netxops/skills//`. */ +export const SKILL_DIR_BY_GROUP: Readonly> = Object.freeze({ + nms: 'nms', + common: 'common', + topology: 'topology', +}) + +export const CAPABILITY_GROUP_IDS: readonly NetxCapabilityGroupId[] = Object.freeze([ + 'nms', + 'common', + 'topology', +]) + +/** + * Build group policy from flat settings / Config fields. + * Accepts legacy `groupManagedNe*` as aliases of `groupCommon*`. + * Legacy `groupTopologyLayout*` ORs into `topology` (layout tools now live in topology). + */ +export function capabilityGroupsFromSettings( + fields: Partial | null | undefined, +): NetxCapabilityGroups { + const src = fields ?? {} + const commonInPreset = src.groupCommonInPreset !== undefined + ? src.groupCommonInPreset !== false + : src.groupManagedNeInPreset !== false + const commonPublic = src.groupCommonPublic === true + || src.groupManagedNePublic === true + return { + nms: { + inPreset: src.groupNmsInPreset !== false, + public: src.groupNmsPublic === true, + }, + common: { + inPreset: commonInPreset, + public: commonPublic, + }, + topology: { + inPreset: src.groupTopologyInPreset === true + || src.groupTopologyLayoutInPreset === true, + public: src.groupTopologyPublic === true + || src.groupTopologyLayoutPublic === true, + }, + } +} + +/** + * Groups enabled for one registration plane. + * @param groups - published policy. + * @param plane - preset scope vs host public layer. + * @param only - when set, intersect with this allow-list (per-export tools packages). + */ +export function groupsForPlane( + groups: NetxCapabilityGroups | undefined, + plane: 'preset' | 'public', + only?: readonly NetxCapabilityGroupId[], +): NetxCapabilityGroupId[] { + const policy = groups ?? DEFAULT_CAPABILITY_GROUPS + const enabled = CAPABILITY_GROUP_IDS.filter((id) => ( + plane === 'preset' ? policy[id].inPreset : policy[id].public + )) + if (!only || only.length === 0) return enabled + const allow = new Set(only) + return enabled.filter((id) => allow.has(id)) +} + +/** + * Force-enable the listed groups (for `dsh-netxops/tools-` mounts that + * intentionally ignore Ops-preset inPreset flags). + */ +export function groupsForced( + only: readonly NetxCapabilityGroupId[], +): NetxCapabilityGroupId[] { + return CAPABILITY_GROUP_IDS.filter((id) => only.includes(id)) +} + +/** + * Tool name allow-list for the given groups. + */ +export function toolNamesForGroups(groupIds: readonly NetxCapabilityGroupId[]): Set { + const names = new Set() + for (const id of groupIds) { + for (const tool of TOOLS_BY_GROUP[id]) names.add(tool) + } + return names +} diff --git a/src/netx/group-skills.ts b/src/netx/group-skills.ts new file mode 100644 index 0000000..3f28441 --- /dev/null +++ b/src/netx/group-skills.ts @@ -0,0 +1,196 @@ +/** + * Register Ops playbook skills for enabled capability groups from disk. + */ + +import { existsSync } from 'node:fs' +import { readdir, readFile, stat } from 'node:fs/promises' +import { dirname, join } from 'node:path' +import { fileURLToPath } from 'node:url' +import type { Context } from '@deepseek-ai/cordis' +import { + CAPABILITY_GROUP_IDS, + SKILL_DIR_BY_GROUP, + type NetxCapabilityGroupId, +} from './capability-groups.ts' + +interface ParsedSkill { + name: string + description: string + content: string + path: string + directory: string +} + +/** + * Package `presets/netxops/skills` root, or a shared netx skills checkout. + * + * Resolution order: + * 1. `NETX_SKILLS_ROOT` (canonical `netx/skills`) + * 2. Sibling `../netx/skills` when developing next to the netx repo + * 3. Bundled `presets/netxops/skills` inside this package + */ +export function opsSkillsRoot(): string { + const envRoot = process.env.NETX_SKILLS_ROOT?.trim() + if (envRoot && existsSync(envRoot)) return envRoot + + const here = dirname(fileURLToPath(import.meta.url)) + const siblingCandidates = [ + // src/netx → ../../../netx/skills ; lib → ../../netx/skills + join(here, '..', '..', '..', 'netx', 'skills'), + join(here, '..', '..', 'netx', 'skills'), + ] + for (const candidate of siblingCandidates) { + if (existsSync(candidate)) return candidate + } + + const packaged = [ + join(here, '..', 'presets', 'netxops', 'skills'), + join(here, '..', '..', 'presets', 'netxops', 'skills'), + ] + for (const candidate of packaged) { + if (existsSync(candidate)) return candidate + } + return packaged[0] +} + +function parseFrontmatter(raw: string): { data: Record; body: string } | undefined { + if (!raw.startsWith('---')) return undefined + const end = raw.indexOf('\n---', 3) + if (end < 0) return undefined + const yaml = raw.slice(3, end).replace(/^\r?\n/, '') + const body = raw.slice(end + 4).replace(/^\r?\n/, '') + const data: Record = {} + const lines = yaml.split(/\r?\n/) + for (let i = 0; i < lines.length; i += 1) { + const line = lines[i] + const nameMatch = /^name\s*:\s*(.+)\s*$/.exec(line) + if (nameMatch) { + data.name = stripQuotes(nameMatch[1].trim()) + continue + } + const descMatch = /^description\s*:\s*(.*)$/.exec(line) + if (!descMatch) continue + const head = descMatch[1].trim() + if (head === '>' || head === '>-' || head === '|' || head === '|-') { + const parts: string[] = [] + while (i + 1 < lines.length && /^[ \t]+/.test(lines[i + 1])) { + i += 1 + parts.push(lines[i].trim()) + } + data.description = parts.filter(Boolean).join(' ') + continue + } + data.description = stripQuotes(head) + } + return { data, body } +} + +function stripQuotes(value: string): string { + if ( + (value.startsWith('"') && value.endsWith('"')) + || (value.startsWith("'") && value.endsWith("'")) + ) { + return value.slice(1, -1) + } + return value +} + +async function loadSkillBundle(dir: string): Promise { + const skillPath = join(dir, 'SKILL.md') + let raw: string + try { + raw = await readFile(skillPath, 'utf8') + } catch { + return null + } + const parsed = parseFrontmatter(raw) + if (!parsed) return null + const name = typeof parsed.data.name === 'string' ? parsed.data.name.trim() : '' + const description = typeof parsed.data.description === 'string' + ? parsed.data.description.trim() + : '' + if (!name || !description) return null + return { + name, + description, + content: parsed.body.trimStart(), + path: skillPath, + directory: dir, + } +} + +async function loadGroupSkills( + skillsRoot: string, + groupId: NetxCapabilityGroupId, +): Promise { + const groupDir = join(skillsRoot, SKILL_DIR_BY_GROUP[groupId]) + let entries: string[] + try { + entries = await readdir(groupDir) + } catch { + return [] + } + const skills: ParsedSkill[] = [] + for (const entry of entries) { + const full = join(groupDir, entry) + let isDir = false + try { + isDir = (await stat(full)).isDirectory() + } catch { + continue + } + if (!isDir) continue + const skill = await loadSkillBundle(full) + if (skill) skills.push(skill) + } + return skills +} + +/** + * Register playbook skills for the given capability groups into `ctx.skills`. + * @param ctx - context with a skills registry (preset or host layer). + * @param groupIds - enabled groups for this plane. + * @param providerLabel - `provider` stamp on each registration. + * @returns disposer that unregisters every skill. + */ +export async function registerGroupSkills( + ctx: Context, + groupIds: readonly NetxCapabilityGroupId[], + providerLabel: string, +): Promise<() => void> { + const skillsApi = (ctx as { skills?: { register: (skill: { + name: string + description: string + content: string + path?: string + resourceBase?: { kind: 'directory'; path: string } + provider?: string + source: string + }) => () => void } }).skills + if (!skillsApi || typeof skillsApi.register !== 'function') { + return () => {} + } + + const root = opsSkillsRoot() + const disposers: Array<() => void> = [] + const enabled = new Set(groupIds) + for (const groupId of CAPABILITY_GROUP_IDS) { + if (!enabled.has(groupId)) continue + const skills = await loadGroupSkills(root, groupId) + for (const skill of skills) { + disposers.push(skillsApi.register({ + name: skill.name, + description: skill.description, + content: skill.content, + path: skill.path, + resourceBase: { kind: 'directory', path: skill.directory }, + provider: providerLabel, + source: 'custom', + })) + } + } + + return () => { + for (const dispose of disposers) dispose() + } +} diff --git a/src/netx/group-tools-plugin.ts b/src/netx/group-tools-plugin.ts new file mode 100644 index 0000000..ea678b2 --- /dev/null +++ b/src/netx/group-tools-plugin.ts @@ -0,0 +1,107 @@ +/** + * Shared agent-plane mount: tools + skills for one or more capability groups. + */ + +import type { Context } from '@deepseek-ai/cordis' +import type {} from '@deepseek-ai/dsh-tools' +import { + groupsForced, + groupsForPlane, + type NetxCapabilityGroupId, +} from './capability-groups.ts' +import { registerGroupSkills } from './group-skills.ts' +import { getNetxConnection, watchNetxConnection } from './runtime.ts' +import { registerNetxTools } from './tools.ts' + +export interface GroupToolsPluginOptions { + /** Cordis plugin name. */ + name: string + /** + * `settings` — honor inPreset/public from connection (Ops aggregate mount). + * `forced` — always register `only` groups when connection exists (per-group export). + */ + mode: 'settings' | 'forced' + /** Group allow-list; omit in settings mode to use all groups. */ + only?: readonly NetxCapabilityGroupId[] +} + +/** + * Apply a group-scoped tools/skills plugin on the calling (usually preset) context. + */ +export function applyGroupToolsPlugin(ctx: Context, options: GroupToolsPluginOptions): void { + let unregisterTools: (() => void) | undefined + let unregisterSkills: (() => void) | undefined + let skillGeneration = 0 + + const resolveGroups = (): NetxCapabilityGroupId[] => { + const connection = getNetxConnection() + if (options.mode === 'forced') { + return groupsForced(options.only ?? []) + } + return groupsForPlane(connection?.groups, 'preset', options.only) + } + + const remountTools = (): void => { + unregisterTools?.() + unregisterTools = undefined + const connection = getNetxConnection() + if (connection === undefined) { + ctx.logger.warn('%s: no connection yet — waiting for host settings bridge', options.name) + return + } + const enabled = resolveGroups() + if (enabled.length === 0) { + ctx.logger.info('%s: no groups enabled', options.name) + return + } + unregisterTools = registerNetxTools(ctx, connection, { + plane: 'preset', + only: options.mode === 'forced' ? enabled : options.only, + forceGroups: options.mode === 'forced' ? enabled : undefined, + }) + ctx.logger.info( + '%s: groups=[%s] → %s tokenConfigured=%s', + options.name, + enabled.join(',') || '(none)', + connection.apiUrl, + connection.token.trim().length > 0, + ) + } + + remountTools() + const stopToolWatch = watchNetxConnection(() => { remountTools() }) + + ctx.inject(['skills'], (skillsCtx) => { + const remountSkills = (): void => { + const gen = ++skillGeneration + unregisterSkills?.() + unregisterSkills = undefined + if (getNetxConnection() === undefined) return + const enabled = resolveGroups() + void registerGroupSkills(skillsCtx, enabled, options.name).then((dispose) => { + if (gen !== skillGeneration) { + dispose() + return + } + unregisterSkills = dispose + }).catch((error) => { + skillsCtx.logger.warn('%s: skill register failed: %s', options.name, error) + }) + } + + remountSkills() + const stopSkillWatch = watchNetxConnection(() => { remountSkills() }) + skillsCtx.effect(() => () => { + skillGeneration += 1 + stopSkillWatch() + unregisterSkills?.() + unregisterSkills = undefined + }, `${options.name}: dispose skills`) + }) + + ctx.effect(() => () => { + stopToolWatch() + unregisterTools?.() + unregisterTools = undefined + }, `${options.name}: dispose tools`) +} diff --git a/src/netx/handlers.ts b/src/netx/handlers.ts index b0dbff5..c8f4108 100644 --- a/src/netx/handlers.ts +++ b/src/netx/handlers.ts @@ -38,6 +38,16 @@ function str(args: NetxJson, key: string, fallback = ''): string { return fallback } +/** Prefer generic `nms_*` aliases; fall back to historical `ume_*` wire names. */ +function nmsOrUme(args: NetxJson, nmsKey: string, umeKey: string): string { + return str(args, nmsKey).trim() || str(args, umeKey).trim() +} + +function nmsOrUmeList(args: NetxJson, nmsKey: string, umeKey: string): string[] { + const primary = strList(args, nmsKey) + return primary.length > 0 ? primary : strList(args, umeKey) +} + function num(args: NetxJson, key: string): number | undefined { const v = args[key] return typeof v === 'number' && Number.isFinite(v) ? v : undefined @@ -189,7 +199,7 @@ export async function getManagedNe(client: NetxClient, args: NetxJson, signal?: ok: false, error: 'ne_id_required', error_code: 'ne_id_required', - hint: 'Pass managed NE id from listManagedNe/listCliTargets (source=managed). For UME inventory UUIDs use execManagedNe(ume_ne_id=...) or getUmeNe, not getManagedNe.', + hint: 'Pass managed NE id from listManagedNe/listCliTargets (source=managed). For NMS inventory UUIDs use execManagedNe(nms_ne_id=...) or getNmsNe, not getManagedNe.', example: { ne_id: '' }, } } @@ -199,7 +209,7 @@ export async function getManagedNe(client: NetxClient, args: NetxJson, signal?: if (detail.includes('404') || detail.includes('not_found') || detail.includes('not found') || out.error === 'netx_http_404') { return { ...out, - hint: 'Managed NE not found for this ne_id. Call listManagedNe(keyword=...) or listCliTargets(source=managed) first. If this is a UME ne_id, use execManagedNe(ume_ne_id=...) / getUmeNe instead of getManagedNe.', + hint: 'Managed NE not found for this ne_id. Call listManagedNe(keyword=...) or listCliTargets(source=managed) first. If this is an NMS inventory id, use execManagedNe(nms_ne_id=...) / getNmsNe instead of getManagedNe.', } } } @@ -209,7 +219,7 @@ export async function getManagedNe(client: NetxClient, args: NetxJson, signal?: export async function execManagedNe(client: NetxClient, args: NetxJson, signal?: AbortSignal): Promise { const targetsRaw = args.targets const neIds = strList(args, 'ne_ids') - const umeNeIds = strList(args, 'ume_ne_ids') + const umeNeIds = nmsOrUmeList(args, 'nms_ne_ids', 'ume_ne_ids') const sharedCommands = strList(args, 'commands') const multi = (Array.isArray(targetsRaw) && targetsRaw.length > 0) || neIds.length > 0 @@ -224,7 +234,7 @@ export async function execManagedNe(client: NetxClient, args: NetxJson, signal?: const row = t as NetxJson const item: NetxJson = {} const neId = str(row, 'ne_id').trim() - const umeNeId = str(row, 'ume_ne_id').trim() + const umeNeId = nmsOrUme(row, 'nms_ne_id', 'ume_ne_id') if (neId) item.ne_id = neId if (umeNeId) item.ume_ne_id = umeNeId const cmds = Array.isArray(row.commands) @@ -256,13 +266,13 @@ export async function execManagedNe(client: NetxClient, args: NetxJson, signal?: } const neId = str(args, 'ne_id').trim() - const umeNeId = str(args, 'ume_ne_id').trim() + const umeNeId = nmsOrUme(args, 'nms_ne_id', 'ume_ne_id') if (Boolean(neId) === Boolean(umeNeId)) { return { ok: false, - error: 'exactly_one_of_ne_id_or_ume_ne_id_required', - error_code: 'exactly_one_of_ne_id_or_ume_ne_id_required', - hint: 'For one NE pass ne_id OR ume_ne_id. For many NEs pass ne_ids / ume_ne_ids with shared commands, or targets[] with per-NE commands — one call, concurrent on server.', + error: 'exactly_one_of_ne_id_or_nms_ne_id_required', + error_code: 'exactly_one_of_ne_id_or_nms_ne_id_required', + hint: 'For one NE pass ne_id OR nms_ne_id (alias ume_ne_id). For many NEs pass ne_ids / nms_ne_ids with shared commands, or targets[] — one call, concurrent on server.', } } if (sharedCommands.length === 0) { @@ -296,15 +306,15 @@ export async function listCliTargets(client: NetxClient, args: NetxJson, signal? } export async function findTopologyPaths(client: NetxClient, args: NetxJson, signal?: AbortSignal): Promise { - const fromUid = str(args, 'from_ume_ne_id').trim() + const fromUid = nmsOrUme(args, 'from_nms_ne_id', 'from_ume_ne_id') const fromMid = str(args, 'from_managed_ne_id').trim() - const toUid = str(args, 'to_ume_ne_id').trim() + const toUid = nmsOrUme(args, 'to_nms_ne_id', 'to_ume_ne_id') const toMid = str(args, 'to_managed_ne_id').trim() if (Boolean(fromUid) === Boolean(fromMid)) { - return { ok: false, error: 'exactly_one_of_from_ume_ne_id_or_from_managed_ne_id_required' } + return { ok: false, error: 'exactly_one_of_from_nms_ne_id_or_from_managed_ne_id_required' } } if (Boolean(toUid) === Boolean(toMid)) { - return { ok: false, error: 'exactly_one_of_to_ume_ne_id_or_to_managed_ne_id_required' } + return { ok: false, error: 'exactly_one_of_to_nms_ne_id_or_to_managed_ne_id_required' } } let detail = str(args, 'detail', 'summary').trim().toLowerCase() || 'summary' if (detail !== 'summary' && detail !== 'full') detail = 'summary' diff --git a/src/netx/http.ts b/src/netx/http.ts index a1d4f5b..4e589bf 100644 --- a/src/netx/http.ts +++ b/src/netx/http.ts @@ -121,6 +121,9 @@ export function createNetxClient(connection: NetxConnection) { post(path: string, body: NetxJson, signal?: AbortSignal, timeoutMs?: number) { return request('POST', path, { body, signal, timeoutMs }) }, + patch(path: string, body: NetxJson, signal?: AbortSignal, timeoutMs?: number) { + return request('PATCH', path, { body, signal, timeoutMs }) + }, } } diff --git a/src/netx/tools.ts b/src/netx/tools.ts index 768772a..ff1364e 100644 --- a/src/netx/tools.ts +++ b/src/netx/tools.ts @@ -4,15 +4,24 @@ import type { Context } from '@deepseek-ai/cordis' import { defineTool } from '@deepseek-ai/dsh-tools' +import { + groupsForPlane, + toolNamesForGroups, + type NetxCapabilityGroupId, + type NetxCapabilityGroups, +} from './capability-groups.ts' import { createNetxClient, type NetxClient, type NetxJson } from './http.ts' import { getNetxConnection } from './runtime.ts' import * as H from './handlers.ts' +import * as T from './topology-handlers.ts' export interface NetxToolConnection { apiUrl: string token: string lang: string toolCallTimeoutMs: number + /** Capability-group exposure; omitted → package defaults. */ + groups?: NetxCapabilityGroups } type Handler = (client: NetxClient, args: NetxJson, signal?: AbortSignal) => Promise @@ -60,14 +69,36 @@ function tool( }) } +export interface RegisterNetxToolsOptions { + /** + * `preset` — groups with `inPreset` (Netx Ops preset scope). + * `public` — groups with `public` (host layer for other presets). + */ + plane: 'preset' | 'public' + /** Optional intersect with settings-enabled groups. */ + only?: readonly NetxCapabilityGroupId[] + /** When set, register exactly these groups (ignore plane flags). */ + forceGroups?: readonly NetxCapabilityGroupId[] +} + /** - * Register all Netx Ops tools against the current connection snapshot. + * Register Netx Ops tools for one exposure plane against the connection snapshot. * Bearer is read from the live process store on each request (not frozen here). - * @param ctx - host context with `tools`. - * @param connection - apiUrl / token / lang / timeout (apiUrl/lang/timeout used for client base). + * @param ctx - context with `tools`. + * @param connection - apiUrl / token / lang / timeout / groups. + * @param options - which plane's group flags to honor. * @returns disposer that unregisters every tool. */ -export function registerNetxTools(ctx: Context, connection: NetxToolConnection): () => void { +export function registerNetxTools( + ctx: Context, + connection: NetxToolConnection, + options: RegisterNetxToolsOptions, +): () => void { + const enabledGroups = options.forceGroups + ?? groupsForPlane(connection.groups, options.plane, options.only) + const allow = toolNamesForGroups(enabledGroups) + if (allow.size === 0) return () => {} + const client = createNetxClient({ apiUrl: connection.apiUrl, lang: connection.lang, @@ -77,10 +108,10 @@ export function registerNetxTools(ctx: Context, connection: NetxToolConnection): const getClient = () => client const t = connection.toolCallTimeoutMs - const disposers = [ - ctx.tools.register(tool( - 'netx__queryUmeAlarms', - 'Query UME current alarms (each row includes host_name). Supports severity/ne_id/host_name/keyword, last_seen time_from/time_to, pagination. Prefer host_name for display; ne_id is for filters only.', + const catalog = [ + tool( + 'netx__queryNmsAlarms', + 'Query NMS current alarms (each row includes host_name). Supports severity/ne_id/host_name/keyword, last_seen time_from/time_to, pagination. Prefer host_name for display; ne_id is for filters only.', { severity: str(), ne_id: str('Filter only; do not show UUID to users'), @@ -93,10 +124,10 @@ export function registerNetxTools(ctx: Context, connection: NetxToolConnection): page_size: num(), }, H.queryUmeAlarms, getClient, t, - )), - ctx.tools.register(tool( - 'netx__aggregateUmeAlarms', - 'Aggregate UME current alarms (by_severity + top by_ne). If group_by is set, routes to aggregateUmeAlarmsRaw. Always filter severity/keyword/time before paging.', + ), + tool( + 'netx__aggregateNmsAlarms', + 'Aggregate NMS current alarms (by_severity + top by_ne). If group_by is set, routes to aggregateUmeAlarmsRaw. Always filter severity/keyword/time before paging.', { severity: str('Optional perceived_severity filter (critical/major/minor/warning).'), top_ne: num('Max NE buckets (default 50). Ignored when group_by is set.'), @@ -112,34 +143,34 @@ export function registerNetxTools(ctx: Context, connection: NetxToolConnection): limit: num(), }, H.aggregateUmeAlarms, getClient, t, - )), - ctx.tools.register(tool( - 'netx__runUmeDiagnostics', - 'UME alarm diagnostics: severity, top_event_types, top_alarm_codes, top_ne, protocol buckets, freshness meta.', + ), + tool( + 'netx__runNmsDiagnostics', + 'NMS alarm diagnostics: severity, top_event_types, top_alarm_codes, top_ne, protocol buckets, freshness meta.', {}, H.runUmeDiagnostics, getClient, t, - )), - ctx.tools.register(tool( - 'netx__queryUmeNeInventory', - 'Paged UME NE inventory synced in netx (keyword matches ne_id/ne_name/user_label/ip/host_name).', + ), + tool( + 'netx__queryNmsNeInventory', + 'Paged NMS NE inventory synced in netx (keyword matches ne_id/ne_name/user_label/ip/host_name).', { keyword: str(), page: num(), page_size: num(), }, H.queryUmeNeInventory, getClient, t, - )), - ctx.tools.register(tool( - 'netx__getUmeNe', - 'Get single UME NE detail by ne_id (UUID).', + ), + tool( + 'netx__getNmsNe', + 'Get single NMS NE detail by ne_id (UUID).', { - ne_id: { type: 'string' as const, required: true as const, description: 'UME inventory ne_id (UUID).' }, + ne_id: { type: 'string' as const, required: true as const, description: 'NMS inventory ne_id (UUID).' }, }, H.getUmeNe, getClient, t, - )), - ctx.tools.register(tool( - 'netx__queryUmeAlarmsRaw', - 'Power query UME current alarms with full alarm_* + ne_* fields; optional field_preset or select_fields. Use field_preset=evidence for citations.', + ), + tool( + 'netx__queryNmsAlarmsRaw', + 'Power query NMS current alarms with full alarm_* + ne_* fields; optional field_preset or select_fields. Use field_preset=evidence for citations.', { severity: str(), is_cleared: str(), @@ -156,10 +187,10 @@ export function registerNetxTools(ctx: Context, connection: NetxToolConnection): page_size: num(), }, H.queryUmeAlarmsRaw, getClient, t, - )), - ctx.tools.register(tool( - 'netx__aggregateUmeAlarmsRaw', - 'Dynamic aggregation on UME raw fields (group_by/group_by2); prefer alarm_host_name.', + ), + tool( + 'netx__aggregateNmsAlarmsRaw', + 'Dynamic aggregation on NMS raw fields (group_by/group_by2); prefer alarm_host_name.', { group_by: { type: 'string' as const, required: true as const }, group_by2: str(), @@ -174,24 +205,24 @@ export function registerNetxTools(ctx: Context, connection: NetxToolConnection): limit: num(), }, H.aggregateUmeAlarmsRaw, getClient, t, - )), - ctx.tools.register(tool( - 'netx__listUmeAlarmFields', - 'List available fields for UME raw alarm queries.', + ), + tool( + 'netx__listNmsAlarmFields', + 'List available fields for NMS raw alarm queries.', {}, H.listUmeAlarmFields, getClient, t, - )), - ctx.tools.register(tool( - 'netx__sqlQueryUme', - 'Read-only SELECT on UME tables (ume_alarms_current/ume_inventory_ne); server enforces limits. Requires sql:query scope.', + ), + tool( + 'netx__sqlQueryNms', + 'Read-only SELECT on NMS tables (ume_alarms_current/ume_inventory_ne); server enforces limits. Requires sql:query scope.', { sql: { type: 'string' as const, required: true as const }, limit: num(), statement_timeout_ms: num(), }, H.sqlQueryUme, getClient, t, - )), - ctx.tools.register(tool( + ), + tool( 'netx__listManagedNe', 'List filtered netx managed NEs (keyword/vendor/connect_status required); use before execManagedNe.', { @@ -202,25 +233,27 @@ export function registerNetxTools(ctx: Context, connection: NetxToolConnection): page_size: num(), }, H.listManagedNe, getClient, t, - )), - ctx.tools.register(tool( + ), + tool( 'netx__getManagedNe', - 'Get one managed NE by managed ne_id (from listManagedNe / listCliTargets source=managed). Do NOT pass UME inventory UUID here.', + 'Get one managed NE by managed ne_id (from listManagedNe / listCliTargets source=managed). Do NOT pass NMS inventory UUID here.', { ne_id: str('Managed NE id'), managed_ne_id: str('Alias for ne_id'), id: str('Alias for ne_id'), }, H.getManagedNe, getClient, t, - )), - ctx.tools.register(tool( + ), + tool( 'netx__execManagedNe', 'Run read-only CLI via netx (show/display/ping/traceroute). Single NE: ne_id OR ume_ne_id + commands. Many NEs: ne_ids[]/ume_ne_ids[] + shared commands, or targets[{ume_ne_id|ne_id, commands}]. Do NOT loop one-NE calls for multi-NE work.', { ne_id: str(), - ume_ne_id: str(), + nms_ne_id: str('NMS inventory id; alias ume_ne_id'), + ume_ne_id: str('Legacy alias of nms_ne_id'), ne_ids: strArr('Managed NE ids for concurrent batch (shared commands).'), - ume_ne_ids: strArr('UME inventory ne_ids for concurrent batch (shared commands).'), + nms_ne_ids: strArr('NMS inventory ids; alias ume_ne_ids'), + ume_ne_ids: strArr('Legacy alias of nms_ne_ids'), targets: { type: 'array' as const, description: 'Per-NE command sets: each item is one NE (ne_id OR ume_ne_id) with commands[].', @@ -240,25 +273,27 @@ export function registerNetxTools(ctx: Context, connection: NetxToolConnection): async: bool('oclaw-only async hint; ignored by native REST client.'), }, H.execManagedNe, getClient, Math.max(t, 300_000), - )), - ctx.tools.register(tool( + ), + tool( 'netx__listCliTargets', - 'List CLI-capable targets (managed NE and/or UME inventory). Call once per session with keyword/source, cache ids, then execManagedNe.', + 'List CLI-capable targets (managed NE and/or NMS inventory). Call once per session with keyword/source, cache ids, then execManagedNe.', { - source: str('managed | ume | all'), + source: str('managed | nms | ume | all'), keyword: str(), page: num(), page_size: num(), }, H.listCliTargets, getClient, t, - )), - ctx.tools.register(tool( + ), + tool( 'netx__findTopologyPaths', - 'Find up to max_paths simple paths between two fabric nodes. For each endpoint provide exactly one of ume_ne_id or managed_ne_id.', + 'Find up to max_paths simple paths between two fabric nodes (common group — native netx, not NMS-bound). For each endpoint provide exactly one of ume_ne_id or managed_ne_id.', { - from_ume_ne_id: str(), + from_nms_ne_id: str('NMS inventory id'), + from_ume_ne_id: str('Legacy alias of from_nms_ne_id'), from_managed_ne_id: str(), - to_ume_ne_id: str(), + to_nms_ne_id: str('NMS inventory id'), + to_ume_ne_id: str('Legacy alias of to_nms_ne_id'), to_managed_ne_id: str(), max_paths: num(), max_hops: num(), @@ -266,9 +301,249 @@ export function registerNetxTools(ctx: Context, connection: NetxToolConnection): detail: str('summary | full'), }, H.findTopologyPaths, getClient, t, - )), + ), + // ── topology group (netx-topology canvas / fabric) ───────────────────── + tool( + 'netx__getTopologyTree', + 'Get topology folder tree (nav roots + Root map canvases). Start here before createTopologyFolder.', + { compact: bool(), max_depth: num() }, + T.getTopologyTree, getClient, t, + ), + tool( + 'netx__getTopologyView', + 'Get a topology view by view_id. Default detail=summary with sample_nodes + links[].', + { + view_id: { type: 'string' as const, required: true as const }, + detail: str('summary | full'), + sample: num(), + }, + T.getTopologyView, getClient, t, + ), + tool( + 'netx__createTopologyFolder', + 'Create topology folders / region canvases. Only way to create canvases (ne:write).', + { + name: { type: 'string' as const, required: true as const }, + parent_id: str(), + locale: str('zh | en'), + sort_order: num(), + }, + T.createTopologyFolder, getClient, t, + ), + tool( + 'netx__addTopologyViewNodes', + 'Bulk-place fabric nodes on a view via filters or fabric_node_ids (never managed/UME ids).', + { + view_id: { type: 'string' as const, required: true as const }, + max_nodes: num(), + keyword: str(), + role: str(), + vendor: str(), + link_status: str(), + limit: num(), + offset: num(), + fabric_node_ids: strArr(), + layout: str('grid | keep'), + }, + T.addTopologyViewNodes, getClient, t, + ), + tool( + 'netx__removeTopologyViewNodes', + 'Remove placements from a view (not fabric) by filters or fabric_node_ids.', + { + view_id: { type: 'string' as const, required: true as const }, + keyword: str(), + role: str(), + vendor: str(), + link_status: str(), + fabric_node_ids: strArr(), + }, + T.removeTopologyViewNodes, getClient, t, + ), + tool( + 'netx__copyTopologyViewNodes', + 'Clone fabric placements from source_view_id onto target_view_id (optional clear_target / copy_positions).', + { + source_view_id: { type: 'string' as const, required: true as const }, + target_view_id: { type: 'string' as const, required: true as const }, + copy_positions: bool(), + clear_target: bool(), + offset_x: num(), + offset_y: num(), + limit: num(), + dry_run: bool(), + }, + T.copyTopologyViewNodes, getClient, t, + ), + tool( + 'netx__updateTopologyViewPositions', + 'Move nodes on a view via positions[] or layout=grid|offset|stack + filters.', + { + view_id: { type: 'string' as const, required: true as const }, + positions: { type: 'array' as const, items: { type: 'object' as const } }, + layout: str('grid | offset | stack'), + keyword: str(), + role: str(), + vendor: str(), + link_status: str(), + fabric_node_ids: strArr(), + offset_x: num(), + offset_y: num(), + }, + T.updateTopologyViewPositions, getClient, t, + ), + tool( + 'netx__projectTopologyNeighbors', + 'Project existing fabric neighbors of on-view nodes onto the canvas.', + { + view_id: { type: 'string' as const, required: true as const }, + seed_fabric_node_ids: strArr(), + managed_ne_ids: strArr(), + region_folder_id: str(), + detail: str('summary | full'), + sample: num(), + }, + T.projectTopologyNeighbors, getClient, t, + ), + tool( + 'netx__queryTopologyFabricNodes', + 'Fabric inventory: mode=summary|list|search (keyword/role/level/region/link_status).', + { + mode: str('summary | list | search'), + q: str(), + keyword: str(), + role: str(), + level: str(), + level_major: str(), + region_folder_id: str(), + link_status: str(), + page: num(), + page_size: num(), + limit: num(), + summary: bool(), + }, + T.queryTopologyFabricNodes, getClient, t, + ), + tool( + 'netx__classifyTopologyFabricNodes', + 'Classify fabric nodes: action=match|tag|patch|unmatched|preview_rules|apply_rules|list_rules.', + { + action: { type: 'string' as const, required: true as const }, + pattern: str(), + q: str(), + match_field: str(), + sample_limit: num(), + fabric_node_ids: strArr(), + fabric_node_id: str(), + node_id: str(), + level: str(), + role: str(), + region_folder_id: str(), + clear_region: bool(), + dry_run: bool(), + kind: str(), + page: num(), + page_size: num(), + skip_manual: bool(), + overwrite_manual: bool(), + fill_empty_only: bool(), + }, + T.classifyTopologyFabricNodes, getClient, t, + ), + tool( + 'netx__queryTopologyNeighborhood', + 'Neighborhood around a fabric node (depth 1–3) with compact links[].', + { + node_id: { type: 'string' as const, required: true as const }, + depth: num(), + layer: str(), + }, + T.queryTopologyNeighborhood, getClient, t, + ), + tool( + 'netx__queryTopologyEdges', + 'Fabric adjacency links[] (detail=adjacency) or port rows (detail=ports).', + { + node_id: str(), + keyword: str(), + layer: str(), + status: str(), + source: str('lldp | ume | manual'), + detail: str('adjacency | ports'), + page: num(), + page_size: num(), + }, + T.queryTopologyEdges, getClient, t, + ), + tool( + 'netx__suggestSinkHubs', + 'Rank hub territories on a source view for non-dual move_nodes(park) batches (degree stand-in in DSH).', + { + source_view_id: str(), + view_id: str(), + pick: num(), + exclude_portal_ids: strArr(), + exclude_fabric_node_ids: strArr(), + }, + T.suggestSinkHubs, getClient, t, + ), + tool( + 'netx__analyzeTopologyViewLayout', + 'Basic layout QA for a view (bbox/hubs). Full dual_unit/crossing scores need netx-topology MCP.', + { + view_id: str(), + folder_id: str(), + detail: str('summary | structure | hotspots | blocks | both'), + score_profile: str(), + sight_limit: num(), + max_views: num(), + min_nodes: num(), + max_nodes: num(), + with_meta: bool(), + }, + T.analyzeTopologyViewLayout, getClient, t, + ), + tool( + 'netx__sinkTopologyDualUnits', + 'Drain dual_unit eyes from source to sink (requires netx-topology MCP layout engine in DSH).', + { + source_view_id: str(), + sink_view_id: str(), + max_units: num(), + min_nodes: num(), + max_nodes: num(), + max_batch_nodes: num(), + layout_batch: bool(), + until_empty: bool(), + dry_run: bool(), + }, + T.sinkTopologyDualUnits, getClient, t, + ), + tool( + 'netx__layoutTopologyView', + 'Layout / polish a canvas. DSH supports catalog + move_nodes|sink_nodes over HTTP; dual_unit/orbit/polish recipes need netx-topology MCP.', + { + view_id: str(), + action: str(), + source_view_id: str(), + recipe: str(), + preset: str(), + mode: str('preview | apply'), + tune: bool(), + params: { type: 'object' as const }, + catalog: bool(), + fabric_node_ids: strArr(), + park: bool(), + copy_positions: bool(), + }, + T.layoutTopologyView, getClient, Math.max(t, 180_000), + ), ] + const disposers = catalog + .filter((entry) => allow.has(entry.name)) + .map((entry) => ctx.tools.register(entry)) + return () => { for (const dispose of disposers) dispose() } diff --git a/src/netx/topology-handlers.ts b/src/netx/topology-handlers.ts new file mode 100644 index 0000000..c6cdeab --- /dev/null +++ b/src/netx/topology-handlers.ts @@ -0,0 +1,668 @@ +/** + * Topology canvas / fabric handlers — HTTP port of netx-topology-mcp tools. + * Layout recipes (dual_unit / orbit / polish) stay MCP-local; DSH exposes HTTP + * CRUD + fabric query + move_nodes, and returns a clear hint for recipe actions. + */ + +import { type NetxClient, type NetxJson } from './http.ts' + +function asRecord(value: unknown): NetxJson { + return typeof value === 'object' && value !== null && !Array.isArray(value) + ? value as NetxJson + : {} +} + +function str(args: NetxJson, key: string, fallback = ''): string { + const v = args[key] + if (typeof v === 'string') return v + if (typeof v === 'number' || typeof v === 'boolean') return String(v) + return fallback +} + +function num(args: NetxJson, key: string): number | undefined { + const v = args[key] + return typeof v === 'number' && Number.isFinite(v) ? v : undefined +} + +function bool(args: NetxJson, key: string): boolean | undefined { + const v = args[key] + return typeof v === 'boolean' ? v : undefined +} + +function strList(args: NetxJson, key: string): string[] { + const v = args[key] + if (!Array.isArray(v)) return [] + return v.map((x) => String(x).trim()).filter((x) => x.length > 0) +} + +function clampInt(value: number | undefined, fallback: number, min: number, max: number): number { + const n = value === undefined ? fallback : Math.trunc(value) + return Math.max(min, Math.min(max, n)) +} + +function unwrap(out: NetxJson): NetxJson { + if (out.ok !== true) return out + const data = asRecord(out.data) + if (Object.keys(data).length === 0) return { ok: true, data: out.data } + return { ok: true, ...data, data } +} + +function filterFields(args: NetxJson): NetxJson { + const out: NetxJson = {} + for (const key of ['keyword', 'role', 'vendor', 'link_status'] as const) { + const v = str(args, key).trim() + if (v) out[key] = v + } + return out +} + +function edgeEndpoints(edge: NetxJson): [string, string] { + const a = str(edge, 'a_node_id') || str(edge, 'a') + const b = str(edge, 'b_node_id') || str(edge, 'b') + return [a.trim(), b.trim()] +} + +function collapseEdgesToLinks(edges: unknown[], includeNames = false): NetxJson[] { + const buckets = new Map() + for (const raw of edges) { + if (!raw || typeof raw !== 'object' || Array.isArray(raw)) continue + const e = raw as NetxJson + const [a, b] = edgeEndpoints(e) + if (!a || !b || a === b) continue + const lo = a < b ? a : b + const hi = a < b ? b : a + const key = `${lo}\0${hi}` + const row = buckets.get(key) ?? { + a_node_id: lo, + b_node_id: hi, + link_count: 0, + } + row.link_count = Number(row.link_count || 0) + 1 + if (includeNames && !row.a_name) { + if (a === lo) { + row.a_name = str(e, 'a_name') + row.b_name = str(e, 'b_name') + } else { + row.a_name = str(e, 'b_name') + row.b_name = str(e, 'a_name') + } + } + buckets.set(key, row) + } + return [...buckets.values()] +} + +function summarizeViewGraph(graph: NetxJson, sample = 20): NetxJson { + const nodes = Array.isArray(graph.nodes) ? graph.nodes : [] + const edges = Array.isArray(graph.edges) ? graph.edges : (Array.isArray(graph.links) ? graph.links : []) + const sampleNodes = nodes.slice(0, Math.max(0, sample)).map((n) => { + const row = asRecord(n) + return { + id: str(row, 'id') || str(row, 'fabric_node_id'), + name: str(row, 'name'), + x: row.x, + y: row.y, + level: row.level, + role: row.role, + } + }) + return { + ok: true, + view_id: str(graph, 'id') || str(graph, 'view_id'), + node_count: nodes.length, + edge_count: edges.length, + link_count: collapseEdgesToLinks(edges).length, + links: collapseEdgesToLinks(edges), + sample_nodes: sampleNodes, + detail: 'summary', + } +} + +function layoutRecipeUnavailable(action: string): NetxJson { + return { + ok: false, + error: 'layout_recipe_requires_netx_topology_mcp', + action, + hint: 'Canvas CRUD/query and move_nodes work in dsh-netxops. Dual-unit / orbit / polish recipes still need the netx-topology MCP layout engine (or enable that MCP alongside).', + } +} + +/** GET /v1/topology/tree */ +export async function getTopologyTree(client: NetxClient, args: NetxJson, signal?: AbortSignal): Promise { + const out = unwrap(await client.get('/v1/topology/tree', undefined, signal)) + if (out.ok !== true) return out + const compact = bool(args, 'compact') !== false + const maxDepth = num(args, 'max_depth') + if (!compact && maxDepth === undefined) return out + // Tree is already usable; pass through with flags for the model. + return { ...out, compact, max_depth: maxDepth ?? null } +} + +/** GET /v1/topology/views/{id} */ +export async function getTopologyView(client: NetxClient, args: NetxJson, signal?: AbortSignal): Promise { + const viewId = str(args, 'view_id').trim() + if (!viewId) return { ok: false, error: 'view_id_required' } + const out = unwrap(await client.get(`/v1/topology/views/${encodeURIComponent(viewId)}`, undefined, signal, 120_000)) + if (out.ok !== true) return out + const detail = str(args, 'detail', 'summary').trim().toLowerCase() || 'summary' + if (detail === 'full') return { ...out, detail: 'full' } + return summarizeViewGraph(out, clampInt(num(args, 'sample'), 20, 0, 200)) +} + +/** POST /v1/topology/folders */ +export async function createTopologyFolder(client: NetxClient, args: NetxJson, signal?: AbortSignal): Promise { + const name = str(args, 'name').trim() + if (!name) return { ok: false, error: 'name_required' } + const body: NetxJson = { name } + const parentId = str(args, 'parent_id').trim() + if (parentId) body.parent_id = parentId + const locale = str(args, 'locale').trim() + if (locale) body.locale = locale + if (num(args, 'sort_order') !== undefined) body.sort_order = Math.trunc(num(args, 'sort_order')!) + return unwrap(await client.post('/v1/topology/folders', body, signal)) +} + +/** POST /v1/topology/views/{id}/nodes */ +export async function addTopologyViewNodes(client: NetxClient, args: NetxJson, signal?: AbortSignal): Promise { + const viewId = str(args, 'view_id').trim() + if (!viewId) return { ok: false, error: 'view_id_required' } + if (strList(args, 'managed_ne_ids').length || strList(args, 'ume_ne_ids').length) { + return { + ok: false, + error: 'fabric_nodes_only', + detail: 'Use keyword/role/vendor/link_status or fabric_node_ids; never managed/UME ids.', + } + } + const filters = filterFields(args) + const fabricIds = strList(args, 'fabric_node_ids') + if (Object.keys(filters).length === 0 && fabricIds.length === 0) { + return { + ok: false, + error: 'filter_or_fabric_node_ids_required', + detail: 'Pass keyword/role/vendor/link_status (preferred) or fabric_node_ids.', + } + } + if (num(args, 'max_nodes') !== undefined) { + const got = unwrap(await client.get(`/v1/topology/views/${encodeURIComponent(viewId)}`, undefined, signal)) + if (got.ok === true) { + const data = asRecord(got.data) + const filt = asRecord(got.filter ?? data.filter) + const membership = asRecord(filt.membership ?? {}) + membership.max_nodes = clampInt(num(args, 'max_nodes'), 2000, 1, 2000) + filt.membership = membership + await client.patch(`/v1/topology/views/${encodeURIComponent(viewId)}`, { filter: filt }, signal) + } + } + const layout = str(args, 'layout', 'grid').trim() || 'grid' + const body: NetxJson = { + managed_ne_ids: [], + layout, + ...filters, + } + if (fabricIds.length) body.fabric_node_ids = fabricIds + if (num(args, 'limit') !== undefined) body.limit = clampInt(num(args, 'limit'), 500, 1, 2000) + if (num(args, 'offset') !== undefined) body.offset = clampInt(num(args, 'offset'), 0, 0, Number.MAX_SAFE_INTEGER) + return unwrap(await client.post(`/v1/topology/views/${encodeURIComponent(viewId)}/nodes`, body, signal, 180_000)) +} + +/** POST /v1/topology/views/{id}/nodes/remove */ +export async function removeTopologyViewNodes(client: NetxClient, args: NetxJson, signal?: AbortSignal): Promise { + const viewId = str(args, 'view_id').trim() + if (!viewId) return { ok: false, error: 'view_id_required' } + const body: NetxJson = { ...filterFields(args) } + const fabricIds = strList(args, 'fabric_node_ids') + if (fabricIds.length) body.fabric_node_ids = fabricIds + return unwrap(await client.post( + `/v1/topology/views/${encodeURIComponent(viewId)}/nodes/remove`, + body, + signal, + )) +} + +/** Clone placements from source view onto target view. */ +export async function copyTopologyViewNodes(client: NetxClient, args: NetxJson, signal?: AbortSignal): Promise { + const sourceId = str(args, 'source_view_id').trim() + const targetId = str(args, 'target_view_id').trim() + if (!sourceId || !targetId) { + return { ok: false, error: 'source_view_id_and_target_view_id_required' } + } + const source = unwrap(await client.get(`/v1/topology/views/${encodeURIComponent(sourceId)}`, undefined, signal, 120_000)) + if (source.ok !== true) return source + const nodes = Array.isArray(source.nodes) ? source.nodes : [] + const limit = num(args, 'limit') + const selected = typeof limit === 'number' ? nodes.slice(0, clampInt(limit, nodes.length, 1, 2000)) : nodes + const fabricIds = selected.map((n) => { + const row = asRecord(n) + return str(row, 'id') || str(row, 'fabric_node_id') + }).filter(Boolean) + if (bool(args, 'dry_run') === true) { + return { ok: true, dry_run: true, would_copy: fabricIds.length, source_view_id: sourceId, target_view_id: targetId } + } + if (bool(args, 'clear_target') === true) { + await client.post(`/v1/topology/views/${encodeURIComponent(targetId)}/nodes/remove`, { + fabric_node_ids: 'ALL', + clear_all: true, + }, signal) + } + if (fabricIds.length === 0) { + return { ok: true, added: 0, source_view_id: sourceId, target_view_id: targetId } + } + const add = unwrap(await client.post( + `/v1/topology/views/${encodeURIComponent(targetId)}/nodes`, + { fabric_node_ids: fabricIds, layout: 'keep', managed_ne_ids: [] }, + signal, + 180_000, + )) + if (add.ok !== true) return add + if (bool(args, 'copy_positions') !== false) { + const ox = num(args, 'offset_x') ?? 0 + const oy = num(args, 'offset_y') ?? 0 + const positions = selected.map((n) => { + const row = asRecord(n) + const id = str(row, 'id') || str(row, 'fabric_node_id') + return { + fabric_node_id: id, + x: Number(row.x || 0) + ox, + y: Number(row.y || 0) + oy, + } + }).filter((p) => p.fabric_node_id) + if (positions.length) { + await client.patch( + `/v1/topology/views/${encodeURIComponent(targetId)}/positions`, + { positions }, + signal, + 120_000, + ) + } + } + return { ok: true, added: fabricIds.length, source_view_id: sourceId, target_view_id: targetId, ...asRecord(add) } +} + +/** PATCH /v1/topology/views/{id}/positions */ +export async function updateTopologyViewPositions(client: NetxClient, args: NetxJson, signal?: AbortSignal): Promise { + const viewId = str(args, 'view_id').trim() + if (!viewId) return { ok: false, error: 'view_id_required' } + const body: NetxJson = {} + if (Array.isArray(args.positions)) body.positions = args.positions + const layout = str(args, 'layout').trim() + if (layout) body.layout = layout + Object.assign(body, filterFields(args)) + for (const key of ['offset_x', 'offset_y', 'cols', 'gap_x', 'gap_y'] as const) { + if (num(args, key) !== undefined) body[key] = num(args, key) + } + const fabricIds = strList(args, 'fabric_node_ids') + if (fabricIds.length) body.fabric_node_ids = fabricIds + return unwrap(await client.patch( + `/v1/topology/views/${encodeURIComponent(viewId)}/positions`, + body, + signal, + 120_000, + )) +} + +/** POST .../project-neighbors */ +export async function projectTopologyNeighbors(client: NetxClient, args: NetxJson, signal?: AbortSignal): Promise { + const viewId = str(args, 'view_id').trim() + if (!viewId) return { ok: false, error: 'view_id_required' } + const body: NetxJson = {} + const seeds = strList(args, 'seed_fabric_node_ids').length + ? strList(args, 'seed_fabric_node_ids') + : strList(args, 'fabric_node_ids') + if (seeds.length) body.seed_fabric_node_ids = seeds + const mids = strList(args, 'managed_ne_ids') + if (mids.length) body.managed_ne_ids = mids + const region = str(args, 'region_folder_id').trim() + if (region) body.region_folder_id = region + const out = unwrap(await client.post( + `/v1/topology/views/${encodeURIComponent(viewId)}/project-neighbors`, + body, + signal, + 180_000, + )) + if (out.ok !== true) return out + const detail = str(args, 'detail', 'summary').trim().toLowerCase() || 'summary' + if (detail === 'full' || detail === 'raw' || detail === 'graph') { + return { ...out, detail: 'full', projected: true } + } + return { ...summarizeViewGraph(out, clampInt(num(args, 'sample'), 20, 0, 100)), projected: true, view_id: viewId } +} + +/** Fabric inventory summary|list|search */ +export async function queryTopologyFabricNodes(client: NetxClient, args: NetxJson, signal?: AbortSignal): Promise { + let mode = str(args, 'mode').trim().toLowerCase() + const q = str(args, 'q').trim() + const keyword = str(args, 'keyword').trim() + if (!mode) { + if (bool(args, 'summary') === true) mode = 'summary' + else if (q) mode = 'search' + else mode = 'list' + } + if (mode === 'summary' || mode === 'stats' || mode === 'count') { + const out = unwrap(await client.get('/v1/topology/fabric/summary', undefined, signal)) + return out.ok === true ? { ...out, mode: 'summary' } : out + } + if (mode === 'search' || mode === 'find') { + const needle = q || keyword + if (!needle) return { ok: false, error: 'q_required', hint: 'mode=search needs q (or keyword).' } + return unwrap(await client.get('/v1/topology/fabric/nodes/search', { + q: needle, + page: clampInt(num(args, 'page'), 1, 1, Number.MAX_SAFE_INTEGER), + page_size: clampInt(num(args, 'page_size') ?? num(args, 'limit'), 50, 1, 200), + }, signal)) + } + const params: Record = { + page: clampInt(num(args, 'page'), 1, 1, Number.MAX_SAFE_INTEGER), + page_size: clampInt(num(args, 'page_size') ?? num(args, 'limit'), 50, 1, 500), + } + for (const key of ['keyword', 'role', 'level', 'level_major', 'region_folder_id', 'link_status'] as const) { + const v = str(args, key).trim() || (key === 'keyword' ? q : '') + if (v) params[key] = v + } + const out = unwrap(await client.get('/v1/topology/fabric/nodes', params, signal)) + return out.ok === true ? { ...out, mode: 'list' } : out +} + +/** Classify / tag fabric nodes */ +export async function classifyTopologyFabricNodes(client: NetxClient, args: NetxJson, signal?: AbortSignal): Promise { + const action = str(args, 'action').trim().toLowerCase() + if (!action) return { ok: false, error: 'action_required' } + if (action === 'match') { + const pattern = str(args, 'pattern').trim() || str(args, 'q').trim() + if (!pattern) return { ok: false, error: 'pattern_required' } + return unwrap(await client.post('/v1/topology/fabric/nodes/match', { + pattern, + match_field: str(args, 'match_field', 'name').trim() || 'name', + sample_limit: clampInt(num(args, 'sample_limit'), 50, 1, 200), + }, signal)) + } + if (action === 'tag') { + const body: NetxJson = { + fabric_node_ids: strList(args, 'fabric_node_ids'), + dry_run: bool(args, 'dry_run') === true, + } + const pattern = str(args, 'pattern').trim() || str(args, 'q').trim() + if (pattern) body.pattern = pattern + if (args.level !== undefined) body.level = args.level + const role = str(args, 'role').trim() + if (role) body.role = role + const region = str(args, 'region_folder_id').trim() + if (region) body.region_folder_id = region + if (bool(args, 'clear_region') === true) body.clear_region = true + return unwrap(await client.post('/v1/topology/fabric/nodes/tags/bulk', body, signal)) + } + if (action === 'patch') { + const nodeId = str(args, 'fabric_node_id').trim() || str(args, 'node_id').trim() + if (!nodeId) return { ok: false, error: 'fabric_node_id_required' } + const body: NetxJson = {} + if (args.level !== undefined) body.level = args.level + const role = str(args, 'role').trim() + if (role) body.role = role + const region = str(args, 'region_folder_id').trim() + if (region) body.region_folder_id = region + if (bool(args, 'clear_region') === true) body.clear_region = true + return unwrap(await client.patch( + `/v1/topology/fabric/nodes/${encodeURIComponent(nodeId)}/tags`, + body, + signal, + )) + } + if (action === 'unmatched') { + return unwrap(await client.get('/v1/topology/classify/unmatched', { + kind: str(args, 'kind', 'any').trim() || 'any', + page: clampInt(num(args, 'page'), 1, 1, Number.MAX_SAFE_INTEGER), + page_size: clampInt(num(args, 'page_size') ?? num(args, 'limit'), 50, 1, 500), + }, signal)) + } + if (action === 'preview_rules') { + return unwrap(await client.post('/v1/topology/classify/preview', {}, signal)) + } + if (action === 'apply_rules') { + const params: Record = {} + if (bool(args, 'overwrite_manual') === true) params.skip_manual = false + else if (bool(args, 'skip_manual') !== undefined) params.skip_manual = bool(args, 'skip_manual') !== false + if (bool(args, 'fill_empty_only') === true) params.fill_empty_only = true + return unwrap(await client.post('/v1/topology/classify/apply', {}, signal)) + } + if (action === 'list_rules') { + return unwrap(await client.get('/v1/topology/classify/rules', undefined, signal)) + } + return { ok: false, error: 'unknown_action', action } +} + +/** GET /v1/topology/fabric/neighborhood */ +export async function queryTopologyNeighborhood(client: NetxClient, args: NetxJson, signal?: AbortSignal): Promise { + const nodeId = str(args, 'node_id').trim() + if (!nodeId) return { ok: false, error: 'node_id_required' } + const out = unwrap(await client.get('/v1/topology/fabric/neighborhood', { + node_id: nodeId, + depth: clampInt(num(args, 'depth'), 1, 1, 3), + layer: str(args, 'layer', 'physical').trim() || 'physical', + }, signal)) + if (out.ok !== true) return out + const edges = Array.isArray(out.edges) ? out.edges : [] + return { ...out, links: collapseEdgesToLinks(edges) } +} + +/** GET /v1/topology/fabric/edges */ +export async function queryTopologyEdges(client: NetxClient, args: NetxJson, signal?: AbortSignal): Promise { + const params: Record = { + page: clampInt(num(args, 'page'), 1, 1, Number.MAX_SAFE_INTEGER), + page_size: clampInt(num(args, 'page_size'), 100, 1, 500), + layer: str(args, 'layer', 'physical').trim() || 'physical', + } + for (const key of ['node_id', 'keyword', 'status', 'source'] as const) { + const v = str(args, key).trim() + if (v) params[key] = v + } + const out = unwrap(await client.get('/v1/topology/fabric/edges', params, signal)) + if (out.ok !== true) return out + const detail = str(args, 'detail', 'adjacency').trim().toLowerCase() || 'adjacency' + const items = Array.isArray(out.items) ? out.items : (Array.isArray(out.edges) ? out.edges : []) + if (detail === 'ports') return { ...out, detail: 'ports', items } + const links = collapseEdgesToLinks(items, true) + return { ...out, detail: 'adjacency', links, items: undefined } +} + +/** Simple degree-ranked hub suggestions (HTTP-only stand-in). */ +export async function suggestSinkHubs(client: NetxClient, args: NetxJson, signal?: AbortSignal): Promise { + const viewId = str(args, 'source_view_id').trim() || str(args, 'view_id').trim() + if (!viewId) return { ok: false, error: 'source_view_id_required' } + const out = unwrap(await client.get(`/v1/topology/views/${encodeURIComponent(viewId)}`, undefined, signal, 120_000)) + if (out.ok !== true) return out + const nodes = Array.isArray(out.nodes) ? out.nodes : [] + const edges = Array.isArray(out.edges) ? out.edges : [] + const degree = new Map() + for (const raw of edges) { + const [a, b] = edgeEndpoints(asRecord(raw)) + if (!a || !b) continue + degree.set(a, (degree.get(a) || 0) + 1) + degree.set(b, (degree.get(b) || 0) + 1) + } + const exclude = new Set([ + ...strList(args, 'exclude_portal_ids'), + ...strList(args, 'exclude_fabric_node_ids'), + ]) + const pick = clampInt(num(args, 'pick'), 5, 1, 50) + const hubs = [...degree.entries()] + .filter(([id]) => !exclude.has(id)) + .sort((a, b) => b[1] - a[1]) + .slice(0, pick) + .map(([fabric_node_id, deg]) => { + const node = nodes.find((n) => { + const row = asRecord(n) + return (str(row, 'id') || str(row, 'fabric_node_id')) === fabric_node_id + }) + const row = asRecord(node) + return { + fabric_node_id, + degree: deg, + name: str(row, 'name'), + level: row.level, + role: row.role, + } + }) + return { + ok: true, + source_view_id: viewId, + hubs, + hint: 'Degree ranking only — full dual_unit-aware suggestSinkHubs lives in netx-topology MCP.', + } +} + +/** Basic layout QA from view graph (no Python layout_stats). */ +export async function analyzeTopologyViewLayout(client: NetxClient, args: NetxJson, signal?: AbortSignal): Promise { + const viewId = str(args, 'view_id').trim() + if (!viewId) { + return { + ok: false, + error: 'view_id_required', + hint: 'Pass view_id. folder_id sampling without view_id needs netx-topology MCP structure analysis.', + } + } + const out = unwrap(await client.get(`/v1/topology/views/${encodeURIComponent(viewId)}`, undefined, signal, 120_000)) + if (out.ok !== true) return out + const nodes = Array.isArray(out.nodes) ? out.nodes : [] + const edges = Array.isArray(out.edges) ? out.edges : [] + let minX = Infinity + let minY = Infinity + let maxX = -Infinity + let maxY = -Infinity + let missingxy = 0 + for (const raw of nodes) { + const row = asRecord(raw) + const x = Number(row.x) + const y = Number(row.y) + if (!Number.isFinite(x) || !Number.isFinite(y)) { + missingxy += 1 + continue + } + minX = Math.min(minX, x) + minY = Math.min(minY, y) + maxX = Math.max(maxX, x) + maxY = Math.max(maxY, y) + } + const degree = new Map() + for (const raw of edges) { + const [a, b] = edgeEndpoints(asRecord(raw)) + if (!a || !b) continue + degree.set(a, (degree.get(a) || 0) + 1) + degree.set(b, (degree.get(b) || 0) + 1) + } + const hubs = [...degree.entries()].sort((a, b) => b[1] - a[1]).slice(0, 10) + .map(([id, deg]) => ({ fabric_node_id: id, degree: deg })) + return { + ok: true, + view_id: viewId, + detail: str(args, 'detail', 'summary') || 'summary', + node_count: nodes.length, + edge_count: edges.length, + link_count: collapseEdgesToLinks(edges).length, + missing_xy: missingxy, + bbox: Number.isFinite(minX) ? { minX, minY, maxX, maxY, width: maxX - minX, height: maxY - minY } : null, + hubs, + hint: 'Basic graph stats only. Crossing/dual_unit/score.total analysis needs netx-topology MCP.', + } +} + +/** Dual-unit sink requires MCP layout engine. */ +export async function sinkTopologyDualUnits(_client: NetxClient, args: NetxJson): Promise { + return { + ok: false, + error: 'sink_dual_units_requires_netx_topology_mcp', + source_view_id: str(args, 'source_view_id'), + sink_view_id: str(args, 'sink_view_id'), + hint: 'sinkTopologyDualUnits needs the dual_unit layout engine from netx-topology MCP. Use layoutTopologyView(action=move_nodes, park=true) for HTTP-only parking, or enable the topology MCP.', + } +} + +/** Layout tool: catalog + move_nodes over HTTP; recipes deferred to MCP. */ +export async function layoutTopologyView(client: NetxClient, args: NetxJson, signal?: AbortSignal): Promise { + if (bool(args, 'catalog') === true) { + return { + ok: true, + catalog: true, + actions: [ + 'layout', 'layout_dual_unit', 'move_nodes', 'sink_nodes', 'orbit_sweep', 'level_bands', + 'polish_crossings', 'clear_edge_hits', 'compact_bbox', 'pull_far_chains', 'align_reference', + 'fix_overlaps', 'resolve_overlaps', 'untangle', 'straighten_channels', 'job_status', 'job_cancel', + ], + dsh_supported: ['move_nodes', 'sink_nodes', 'catalog'], + recipes: ['rings', 'corridor', 'compact', 'unstick'], + hint: 'Only move_nodes/sink_nodes/catalog run in dsh-netxops; other actions need netx-topology MCP.', + } + } + const action = str(args, 'action', 'layout').trim().toLowerCase() || 'layout' + if (action === 'job_status' || action === 'job_cancel') { + return { + ok: false, + error: 'layout_jobs_not_in_dsh_host', + hint: 'Background layout jobs are owned by netx-topology MCP.', + } + } + if (action === 'move_nodes' || action === 'sink_nodes') { + const toId = str(args, 'view_id').trim() + const fromId = str(args, 'source_view_id').trim() + const ids = strList(args, 'fabric_node_ids') + const params = asRecord(args.params) + const fabricIds = ids.length ? ids : strList(params, 'fabric_node_ids') + if (!toId || !fromId) { + return { ok: false, error: 'view_id_and_source_view_id_required' } + } + if (fabricIds.length === 0) { + return { ok: false, error: 'fabric_node_ids_required' } + } + const copyPositions = bool(params, 'copy_positions') !== false + && bool(args, 'copy_positions') !== false + const removeFromSource = bool(params, 'remove_from_source') !== false + const source = unwrap(await client.get(`/v1/topology/views/${encodeURIComponent(fromId)}`, undefined, signal, 120_000)) + if (source.ok !== true) return source + const nodes = Array.isArray(source.nodes) ? source.nodes : [] + const wanted = new Set(fabricIds) + const selected = nodes.filter((n) => { + const row = asRecord(n) + const id = str(row, 'id') || str(row, 'fabric_node_id') + return wanted.has(id) + }) + const add = unwrap(await client.post( + `/v1/topology/views/${encodeURIComponent(toId)}/nodes`, + { fabric_node_ids: fabricIds, layout: copyPositions ? 'keep' : 'grid', managed_ne_ids: [] }, + signal, + 180_000, + )) + if (add.ok !== true) return add + if (copyPositions && selected.length) { + const ox = Number(params.offset_x || 0) + const oy = Number(params.offset_y || 0) + const positions = selected.map((n) => { + const row = asRecord(n) + return { + fabric_node_id: str(row, 'id') || str(row, 'fabric_node_id'), + x: Number(row.x || 0) + ox, + y: Number(row.y || 0) + oy, + } + }).filter((p) => p.fabric_node_id) + await client.patch( + `/v1/topology/views/${encodeURIComponent(toId)}/positions`, + { positions }, + signal, + 120_000, + ) + } + if (removeFromSource) { + await client.post( + `/v1/topology/views/${encodeURIComponent(fromId)}/nodes/remove`, + { fabric_node_ids: fabricIds }, + signal, + ) + } + return { + ok: true, + action, + moved: fabricIds.length, + source_view_id: fromId, + view_id: toId, + park: bool(params, 'park') === true || bool(args, 'park') === true, + } + } + return layoutRecipeUnavailable(action) +} diff --git a/test/alarm-push.test.mjs b/test/alarm-push.test.mjs index a934b48..2f7ae8e 100644 --- a/test/alarm-push.test.mjs +++ b/test/alarm-push.test.mjs @@ -31,6 +31,7 @@ test('formatAlarmPrompt keeps human fields without raw sockets', () => { ne: { host_name: 'PE1', ip_address: '10.0.0.1' }, }, 'zh') assert.match(text, /告警产生/) + assert.match(text, /\[NMS /) assert.match(text, /PE1 \(10\.0\.0\.1\)/) assert.match(text, /请分析这条关键告警/) }) diff --git a/test/capability-groups.test.mjs b/test/capability-groups.test.mjs new file mode 100644 index 0000000..c0f09ab --- /dev/null +++ b/test/capability-groups.test.mjs @@ -0,0 +1,70 @@ +/** + * Capability-group defaults and plane filters. + */ + +import assert from 'node:assert/strict' +import test from 'node:test' + +import { + capabilityGroupsFromSettings, + DEFAULT_CAPABILITY_GROUPS, + groupsForPlane, + groupsForced, + SKILL_DIR_BY_GROUP, + toolNamesForGroups, + TOOLS_BY_GROUP, +} from '../src/netx/capability-groups.ts' + +test('defaults: nms + common in preset; topology and public off', () => { + assert.deepEqual(DEFAULT_CAPABILITY_GROUPS, { + nms: { inPreset: true, public: false }, + common: { inPreset: true, public: false }, + topology: { inPreset: false, public: false }, + }) + assert.deepEqual(groupsForPlane(undefined, 'preset'), ['nms', 'common']) + assert.deepEqual(groupsForPlane(undefined, 'public'), []) +}) + +test('one group one skill dir; topology owns canvas + layout tools', () => { + assert.deepEqual(SKILL_DIR_BY_GROUP, { + nms: 'nms', + common: 'common', + topology: 'topology', + }) + assert.ok(TOOLS_BY_GROUP.common.includes('netx__findTopologyPaths')) + assert.ok(TOOLS_BY_GROUP.topology.includes('netx__getTopologyTree')) + assert.ok(TOOLS_BY_GROUP.topology.includes('netx__layoutTopologyView')) + assert.ok(TOOLS_BY_GROUP.topology.includes('netx__suggestSinkHubs')) + assert.ok(TOOLS_BY_GROUP.topology.includes('netx__sinkTopologyDualUnits')) + assert.ok(TOOLS_BY_GROUP.nms.includes('netx__queryNmsAlarms')) +}) + +test('capabilityGroupsFromSettings honors explicit false for inPreset defaults', () => { + const groups = capabilityGroupsFromSettings({ + groupNmsInPreset: false, + groupCommonInPreset: false, + groupTopologyInPreset: true, + groupTopologyPublic: true, + }) + assert.deepEqual(groupsForPlane(groups, 'preset'), ['topology']) + assert.deepEqual(groupsForPlane(groups, 'public'), ['topology']) + assert.ok(toolNamesForGroups(['topology']).has('netx__layoutTopologyView')) + assert.ok(toolNamesForGroups(['topology']).has('netx__sinkTopologyDualUnits')) + assert.equal(toolNamesForGroups(['topology']).has('netx__queryNmsAlarms'), false) +}) + +test('legacy groupManagedNe* maps to common; legacy layout flags OR into topology', () => { + const groups = capabilityGroupsFromSettings({ + groupManagedNeInPreset: false, + groupManagedNePublic: true, + groupTopologyLayoutInPreset: true, + }) + assert.equal(groups.common.inPreset, false) + assert.equal(groups.common.public, true) + assert.equal(groups.topology.inPreset, true) +}) + +test('groupsForced ignores settings for per-export mounts', () => { + assert.deepEqual(groupsForced(['common']), ['common']) + assert.deepEqual(groupsForced(['nms', 'topology']), ['nms', 'topology']) +}) diff --git a/test/group-skills.test.mjs b/test/group-skills.test.mjs new file mode 100644 index 0000000..2e49578 --- /dev/null +++ b/test/group-skills.test.mjs @@ -0,0 +1,32 @@ +/** + * Group skill loader: one capability group ↔ one skill dir. + */ + +import assert from 'node:assert/strict' +import test from 'node:test' +import { readdirSync, readFileSync, existsSync } from 'node:fs' +import { join } from 'node:path' + +import { opsSkillsRoot } from '../src/netx/group-skills.ts' +import { SKILL_DIR_BY_GROUP } from '../src/netx/capability-groups.ts' + +test('opsSkillsRoot points at nms / common / topology playbook dirs', () => { + const root = opsSkillsRoot() + assert.ok(existsSync(root), root) + for (const dir of Object.values(SKILL_DIR_BY_GROUP)) { + assert.ok(existsSync(join(root, dir)), join(root, dir)) + } +}) + +test('each group dir has a playbook with name + description', () => { + const root = opsSkillsRoot() + for (const group of Object.keys(SKILL_DIR_BY_GROUP)) { + const groupDir = join(root, SKILL_DIR_BY_GROUP[group]) + const bundles = readdirSync(groupDir).filter((name) => existsSync(join(groupDir, name, 'SKILL.md'))) + assert.ok(bundles.length >= 1, group) + const raw = readFileSync(join(groupDir, bundles[0], 'SKILL.md'), 'utf8') + assert.match(raw, /^---\r?\n/) + assert.match(raw, /\nname:\s+\S+/) + assert.match(raw, /\ndescription:\s/) + } +})