From 63f88bdf6cdc5fa7270a031f7627f349ca40620c Mon Sep 17 00:00:00 2001 From: oliver Date: Sun, 6 Sep 2026 02:11:02 +0800 Subject: [PATCH] Merge nms+common into ops so login questions load execManagedNe. One capability group now ships netx-ops (alarms + managed CLI + paths); keep tools-nms/common as aliases. Co-authored-by: Cursor --- README.md | 6 +- cordis.patch.yml | 10 +- docs/INSTALL.md | 10 +- docs/PORTING.md | 3 +- docs/TOOL_MAP.md | 14 +- lib/agent-tools-common.js | 20 +- lib/agent-tools-nms.js | 20 +- lib/agent-tools-ops.js | 1797 +++++++++++++++++ lib/agent-tools-topology.js | 18 +- lib/agent-tools.js | 18 +- lib/client.js | 50 +- lib/index.js | 46 +- package.json | 6 +- presets/netxops/PERSONA.md | 3 +- presets/netxops/agent.cordis.yml | 3 +- presets/netxops/skills/README.md | 2 +- .../skills/common/netx-common/SKILL.md | 91 - presets/netxops/skills/nms/netx-nms/SKILL.md | 70 - .../netxops/skills/nms/netx-nms/reference.md | 20 - presets/netxops/skills/ops/netx-ops/SKILL.md | 94 + .../netxops/skills/ops/netx-ops/reference.md | 22 + scripts/build-host.mjs | 1 + scripts/sync-skills-from-netx.ps1 | 12 +- src/agent-tools-common.ts | 5 +- src/agent-tools-nms.ts | 6 +- src/agent-tools-ops.ts | 15 + src/client/NetxopsCard.tsx | 24 +- src/client/controller.ts | 24 +- src/client/locales.ts | 17 +- src/index.ts | 18 +- src/netx/capability-groups.ts | 89 +- test/capability-groups.test.mjs | 43 +- test/group-skills.test.mjs | 2 +- 33 files changed, 2133 insertions(+), 446 deletions(-) create mode 100644 lib/agent-tools-ops.js delete mode 100644 presets/netxops/skills/common/netx-common/SKILL.md delete mode 100644 presets/netxops/skills/nms/netx-nms/SKILL.md delete mode 100644 presets/netxops/skills/nms/netx-nms/reference.md create mode 100644 presets/netxops/skills/ops/netx-ops/SKILL.md create mode 100644 presets/netxops/skills/ops/netx-ops/reference.md create mode 100644 src/agent-tools-ops.ts diff --git a/README.md b/README.md index 4e79a38..795b283 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -# netxops — Netx Ops for DeepSeek Harness +# netxops — Netx Ops for DeepSeek Harness Public **DeepSeek Harness** package: native `netx__*` REST tools + Plugins card + **agent preset (skills included)**. @@ -28,9 +28,9 @@ Also need: a reachable **netx API** (no local `pip install netx_mcp`). Details: | In the plugin | Outside (data / runtime) | |---------------|---------------------------| -| `netx__*` REST tools (groups: nms / common / topology; one group ↔ one skill; optional host publish + `tools-*`) + settings + credentials | netx HTTP API (URL + token) | +| `netx__*` REST tools (groups: ops / topology; one group ↔ one skill; optional host publish + `tools-ops|topology`) + settings + credentials | netx HTTP API (URL + token) | | Optional key-alarm push (WSS client → sticky DSH session) | netx `/v1/integrations/dsh-alarm/ws` hub | -| Persona + skills (`netx-nms` / `netx-common` / `netx-topology`) | | +| Persona + skills (`netx-ops` / `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 dbfe78d..b114d06 100644 --- a/cordis.patch.yml +++ b/cordis.patch.yml @@ -1,7 +1,7 @@ # 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). +# Capability groups: ops in preset; topology / public off. +# Rule: one group ↔ one skill (netx-ops / netx-topology). # # 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. @@ -21,9 +21,7 @@ alarmDeliverIm: false imBotId: '' imTargetId: '' - groupNmsInPreset: true - groupNmsPublic: false - groupCommonInPreset: true - groupCommonPublic: false + groupOpsInPreset: true + groupOpsPublic: false groupTopologyInPreset: false groupTopologyPublic: false diff --git a/docs/INSTALL.md b/docs/INSTALL.md index badbda3..5d008e9 100644 --- a/docs/INSTALL.md +++ b/docs/INSTALL.md @@ -1,4 +1,4 @@ -# Install Netx Ops on DeepSeek Harness +# Install Netx Ops on DeepSeek Harness ## What `dsh plugin add` gives you @@ -6,7 +6,7 @@ One install wires **all of**: | Piece | How you use it | |-------|----------------| -| 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-*` | +| Host tools | Capability groups **ops / topology** (one group ↔ one skill). Default ops in Ops preset; topology and all public off. Other agents may mount `dsh-netxops/tools-ops|topology` | | 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 | @@ -31,9 +31,9 @@ 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. - Capability groups: leave defaults for nms+common, or enable **topology** / **对其他预设公开** (new sessions after save). + Capability groups: leave default for **ops**, 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. +3. **New session → preset Netx Ops** → ask e.g. Critical Top / single-host alarms / 「能否登录」. ## Key-alarm push @@ -47,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__queryNmsAlarms` (**nms**). `netx__findTopologyPaths` is **common**. Canvas / dual_unit need **topology** (`netx-topology` skill). +4. Tools include `netx__queryNmsAlarms` / `netx__execManagedNe` / `netx__findTopologyPaths` (**ops** → skill `netx-ops`). Canvas / dual_unit need **topology** (`netx-topology`). See [TOOL_MAP.md](TOOL_MAP.md). diff --git a/docs/PORTING.md b/docs/PORTING.md index 5312b64..55f1836 100644 --- a/docs/PORTING.md +++ b/docs/PORTING.md @@ -5,8 +5,7 @@ | 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/` | **Canonical:** `netx/skills/nms/netx-nms/` | -| `skills/_workspace/ops/ops-netx-managed-ne-playbook/` | **Canonical:** `netx/skills/common/netx-common/` | +| `skills/_workspace/ops/ops-netx-ume-playbook/` + `ops-netx-managed-ne-playbook/` | **Canonical:** `netx/skills/ops/netx-ops/` (merged ops group) | | `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 | diff --git a/docs/TOOL_MAP.md b/docs/TOOL_MAP.md index 3a50dac..eff9b61 100644 --- a/docs/TOOL_MAP.md +++ b/docs/TOOL_MAP.md @@ -4,26 +4,20 @@ Tools register by capability group. **One group ↔ one skill.** | Group | Skill | Default in Ops preset | |-------|-------|------------------------| -| **nms** | `netx-nms` | on | -| **common** | `netx-common` | on | +| **ops** | `netx-ops` | 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`. +Forced mounts: `dsh-netxops/tools-ops` | `tools-topology` (legacy aliases: `tools-nms` / `tools-common` → ops). Model names: `netx__`. NMS tools use `Nms`; adapter `nmsProvider=zte-ume` still hits `/v1/ume/*`. -## nms → `netx-nms` +## ops → `netx-ops` | Tool | Role | |------|------| | `queryNmsAlarms` … `sqlQueryNms` | Alarm + inventory + SQL | - -## common → `netx-common` - -| Tool | Role | -|------|------| -| `listManagedNe` / `getManagedNe` / `execManagedNe` / `listCliTargets` | Managed CLI | +| `listManagedNe` / `getManagedNe` / `execManagedNe` / `listCliTargets` | Managed CLI (login / show) | | `findTopologyPaths` | Fabric path lookup | ## topology → `netx-topology` diff --git a/lib/agent-tools-common.js b/lib/agent-tools-common.js index 57b202a..fdf6b33 100644 --- a/lib/agent-tools-common.js +++ b/lib/agent-tools-common.js @@ -1,11 +1,10 @@ // 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 }), + ops: Object.freeze({ inPreset: true, public: false }), topology: Object.freeze({ inPreset: false, public: false }) }); var TOOLS_BY_GROUP = Object.freeze({ - nms: Object.freeze([ + ops: Object.freeze([ "netx__queryNmsAlarms", "netx__aggregateNmsAlarms", "netx__runNmsDiagnostics", @@ -14,9 +13,7 @@ var TOOLS_BY_GROUP = Object.freeze({ "netx__queryNmsAlarmsRaw", "netx__aggregateNmsAlarmsRaw", "netx__listNmsAlarmFields", - "netx__sqlQueryNms" - ]), - common: Object.freeze([ + "netx__sqlQueryNms", "netx__listManagedNe", "netx__getManagedNe", "netx__execManagedNe", @@ -43,13 +40,11 @@ var TOOLS_BY_GROUP = Object.freeze({ ]) }); var SKILL_DIR_BY_GROUP = Object.freeze({ - nms: "nms", - common: "common", + ops: "ops", topology: "topology" }); var CAPABILITY_GROUP_IDS = Object.freeze([ - "nms", - "common", + "ops", "topology" ]); function groupsForPlane(groups, plane, only) { @@ -61,7 +56,8 @@ function groupsForPlane(groups, plane, only) { return enabled.filter((id) => allow.has(id)); } function groupsForced(only) { - return CAPABILITY_GROUP_IDS.filter((id) => only.includes(id)); + const normalized = only.map((id) => id === "nms" || id === "common" ? "ops" : id); + return CAPABILITY_GROUP_IDS.filter((id) => normalized.includes(id)); } function toolNamesForGroups(groupIds) { const names = new Set; @@ -1792,7 +1788,7 @@ function applyGroupToolsPlugin(ctx, options) { var name = "netxops-tools-common"; var inject = ["tools"]; function apply(ctx) { - applyGroupToolsPlugin(ctx, { name, mode: "forced", only: ["common"] }); + applyGroupToolsPlugin(ctx, { name, mode: "forced", only: ["ops"] }); } export { name, diff --git a/lib/agent-tools-nms.js b/lib/agent-tools-nms.js index 08fc284..8c120ff 100644 --- a/lib/agent-tools-nms.js +++ b/lib/agent-tools-nms.js @@ -1,11 +1,10 @@ // 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 }), + ops: Object.freeze({ inPreset: true, public: false }), topology: Object.freeze({ inPreset: false, public: false }) }); var TOOLS_BY_GROUP = Object.freeze({ - nms: Object.freeze([ + ops: Object.freeze([ "netx__queryNmsAlarms", "netx__aggregateNmsAlarms", "netx__runNmsDiagnostics", @@ -14,9 +13,7 @@ var TOOLS_BY_GROUP = Object.freeze({ "netx__queryNmsAlarmsRaw", "netx__aggregateNmsAlarmsRaw", "netx__listNmsAlarmFields", - "netx__sqlQueryNms" - ]), - common: Object.freeze([ + "netx__sqlQueryNms", "netx__listManagedNe", "netx__getManagedNe", "netx__execManagedNe", @@ -43,13 +40,11 @@ var TOOLS_BY_GROUP = Object.freeze({ ]) }); var SKILL_DIR_BY_GROUP = Object.freeze({ - nms: "nms", - common: "common", + ops: "ops", topology: "topology" }); var CAPABILITY_GROUP_IDS = Object.freeze([ - "nms", - "common", + "ops", "topology" ]); function groupsForPlane(groups, plane, only) { @@ -61,7 +56,8 @@ function groupsForPlane(groups, plane, only) { return enabled.filter((id) => allow.has(id)); } function groupsForced(only) { - return CAPABILITY_GROUP_IDS.filter((id) => only.includes(id)); + const normalized = only.map((id) => id === "nms" || id === "common" ? "ops" : id); + return CAPABILITY_GROUP_IDS.filter((id) => normalized.includes(id)); } function toolNamesForGroups(groupIds) { const names = new Set; @@ -1792,7 +1788,7 @@ function applyGroupToolsPlugin(ctx, options) { var name = "netxops-tools-nms"; var inject = ["tools"]; function apply(ctx) { - applyGroupToolsPlugin(ctx, { name, mode: "forced", only: ["nms"] }); + applyGroupToolsPlugin(ctx, { name, mode: "forced", only: ["ops"] }); } export { name, diff --git a/lib/agent-tools-ops.js b/lib/agent-tools-ops.js new file mode 100644 index 0000000..5a727c0 --- /dev/null +++ b/lib/agent-tools-ops.js @@ -0,0 +1,1797 @@ +// src/netx/capability-groups.ts +var DEFAULT_CAPABILITY_GROUPS = Object.freeze({ + ops: Object.freeze({ inPreset: true, public: false }), + topology: Object.freeze({ inPreset: false, public: false }) +}); +var TOOLS_BY_GROUP = Object.freeze({ + ops: Object.freeze([ + "netx__queryNmsAlarms", + "netx__aggregateNmsAlarms", + "netx__runNmsDiagnostics", + "netx__queryNmsNeInventory", + "netx__getNmsNe", + "netx__queryNmsAlarmsRaw", + "netx__aggregateNmsAlarmsRaw", + "netx__listNmsAlarmFields", + "netx__sqlQueryNms", + "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({ + ops: "ops", + topology: "topology" +}); +var CAPABILITY_GROUP_IDS = Object.freeze([ + "ops", + "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) { + const normalized = only.map((id) => id === "nms" || id === "common" ? "ops" : id); + return CAPABILITY_GROUP_IDS.filter((id) => normalized.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 } : {} +}); +var anyObj = (description) => ({ + type: "object", + additionalProperties: true, + ...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(), + nms_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", + description: "Manual placements: [{ fabric_node_id, x, y }, …]", + items: { + type: "object", + additionalProperties: false, + properties: { + fabric_node_id: str3(), + x: num3(), + y: num3() + } + } + }, + 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: anyObj("Action-specific options (move_nodes / recipes / polish)."), + 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-ops.ts +var name = "netxops-tools-ops"; +var inject = ["tools"]; +function apply(ctx) { + applyGroupToolsPlugin(ctx, { name, mode: "forced", only: ["ops"] }); +} +export { + name, + inject, + apply +}; diff --git a/lib/agent-tools-topology.js b/lib/agent-tools-topology.js index b76ca5f..755d6c6 100644 --- a/lib/agent-tools-topology.js +++ b/lib/agent-tools-topology.js @@ -1,11 +1,10 @@ // 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 }), + ops: Object.freeze({ inPreset: true, public: false }), topology: Object.freeze({ inPreset: false, public: false }) }); var TOOLS_BY_GROUP = Object.freeze({ - nms: Object.freeze([ + ops: Object.freeze([ "netx__queryNmsAlarms", "netx__aggregateNmsAlarms", "netx__runNmsDiagnostics", @@ -14,9 +13,7 @@ var TOOLS_BY_GROUP = Object.freeze({ "netx__queryNmsAlarmsRaw", "netx__aggregateNmsAlarmsRaw", "netx__listNmsAlarmFields", - "netx__sqlQueryNms" - ]), - common: Object.freeze([ + "netx__sqlQueryNms", "netx__listManagedNe", "netx__getManagedNe", "netx__execManagedNe", @@ -43,13 +40,11 @@ var TOOLS_BY_GROUP = Object.freeze({ ]) }); var SKILL_DIR_BY_GROUP = Object.freeze({ - nms: "nms", - common: "common", + ops: "ops", topology: "topology" }); var CAPABILITY_GROUP_IDS = Object.freeze([ - "nms", - "common", + "ops", "topology" ]); function groupsForPlane(groups, plane, only) { @@ -61,7 +56,8 @@ function groupsForPlane(groups, plane, only) { return enabled.filter((id) => allow.has(id)); } function groupsForced(only) { - return CAPABILITY_GROUP_IDS.filter((id) => only.includes(id)); + const normalized = only.map((id) => id === "nms" || id === "common" ? "ops" : id); + return CAPABILITY_GROUP_IDS.filter((id) => normalized.includes(id)); } function toolNamesForGroups(groupIds) { const names = new Set; diff --git a/lib/agent-tools.js b/lib/agent-tools.js index 2ab8afa..64422db 100644 --- a/lib/agent-tools.js +++ b/lib/agent-tools.js @@ -1,11 +1,10 @@ // 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 }), + ops: Object.freeze({ inPreset: true, public: false }), topology: Object.freeze({ inPreset: false, public: false }) }); var TOOLS_BY_GROUP = Object.freeze({ - nms: Object.freeze([ + ops: Object.freeze([ "netx__queryNmsAlarms", "netx__aggregateNmsAlarms", "netx__runNmsDiagnostics", @@ -14,9 +13,7 @@ var TOOLS_BY_GROUP = Object.freeze({ "netx__queryNmsAlarmsRaw", "netx__aggregateNmsAlarmsRaw", "netx__listNmsAlarmFields", - "netx__sqlQueryNms" - ]), - common: Object.freeze([ + "netx__sqlQueryNms", "netx__listManagedNe", "netx__getManagedNe", "netx__execManagedNe", @@ -43,13 +40,11 @@ var TOOLS_BY_GROUP = Object.freeze({ ]) }); var SKILL_DIR_BY_GROUP = Object.freeze({ - nms: "nms", - common: "common", + ops: "ops", topology: "topology" }); var CAPABILITY_GROUP_IDS = Object.freeze([ - "nms", - "common", + "ops", "topology" ]); function groupsForPlane(groups, plane, only) { @@ -61,7 +56,8 @@ function groupsForPlane(groups, plane, only) { return enabled.filter((id) => allow.has(id)); } function groupsForced(only) { - return CAPABILITY_GROUP_IDS.filter((id) => only.includes(id)); + const normalized = only.map((id) => id === "nms" || id === "common" ? "ops" : id); + return CAPABILITY_GROUP_IDS.filter((id) => normalized.includes(id)); } function toolNamesForGroups(groupIds) { const names = new Set; diff --git a/lib/client.js b/lib/client.js index 82b0f59..024ec25 100644 --- a/lib/client.js +++ b/lib/client.js @@ -630,31 +630,17 @@ function NetxopsCard(props) { children: t("capabilityGroupsHint") }), /* @__PURE__ */ jsx_runtime.jsx(CapabilityGroupBlock, { - title: t("groupNms"), + title: t("groupOps"), inPresetLabel: t("groupInPreset"), publicLabel: t("groupPublic"), - inPreset: state.groupNmsInPreset, - published: state.groupNmsPublic, + inPreset: state.groupOpsInPreset, + published: state.groupOpsPublic, disabled, onEditInPreset: (checked) => { - props.edit("groupNmsInPreset", checked ? "true" : "false"); + props.edit("groupOpsInPreset", 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"); + props.edit("groupOpsPublic", checked ? "true" : "false"); } }), /* @__PURE__ */ jsx_runtime.jsx(CapabilityGroupBlock, { @@ -1162,10 +1148,8 @@ class NetxopsCardController { textField("apiUrl"), textField("lang"), textField("nmsProvider"), - booleanFieldPersistFalse("groupNmsInPreset"), - booleanField("groupNmsPublic"), - booleanFieldPersistFalse("groupCommonInPreset"), - booleanField("groupCommonPublic"), + booleanFieldPersistFalse("groupOpsInPreset"), + booleanField("groupOpsPublic"), booleanField("groupTopologyInPreset"), booleanField("groupTopologyPublic"), booleanField("alarmPushEnabled"), @@ -1269,10 +1253,8 @@ class NetxopsCardController { 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"), + groupOpsInPreset: this.form.field("groupOpsInPreset"), + groupOpsPublic: this.form.field("groupOpsPublic"), groupTopologyInPreset: this.form.field("groupTopologyInPreset"), groupTopologyPublic: this.form.field("groupTopologyPublic"), alarmPushEnabled: this.form.field("alarmPushEnabled"), @@ -1350,17 +1332,16 @@ function refOf(snapshot) { // src/client/locales.ts var en = { title: "Netx Ops", - description: "netx API, bearer token, and capability groups (nms / common / topology).", + description: "netx API, bearer token, and capability groups (ops / 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.", + capabilityGroupsHint: "One group ↔ one skill. ops = netx-ops (alarms + CLI login + paths); topology = netx-topology. Defaults: ops on; topology and public off. New sessions after save. Other agents: dsh-netxops/tools-ops|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)", + groupOps: "ops — skill netx-ops (NMS alarms/inventory + managed CLI login + paths)", groupTopology: "topology — skill netx-topology (canvas / fabric / dual_unit)", groupInPreset: "In Netx Ops preset", groupPublic: "Publish to other presets", @@ -1406,17 +1387,16 @@ var en = { }; var zh = { title: "Netx Ops", - description: "netx API、Bearer Token,以及能力组(nms / common / topology)。", + description: "netx API、Bearer Token,以及能力组(ops / 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。", + capabilityGroupsHint: "一组一个 skill。ops = netx-ops(告警 + 纳管登录 + 路径);topology = netx-topology。默认 ops 开;topology / 公开关。保存后新会话生效。其他 Agent:dsh-netxops/tools-ops|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)", + groupOps: "ops — skill netx-ops(NMS 告警/库存 + 纳管 CLI 登录 + 路径)", groupTopology: "topology — skill netx-topology(画布 / Fabric / dual_unit)", groupInPreset: "在 Netx Ops 预设中启用", groupPublic: "对其他预设公开", diff --git a/lib/index.js b/lib/index.js index 64de77c..c1bea4b 100644 --- a/lib/index.js +++ b/lib/index.js @@ -395,12 +395,11 @@ function resetAlarmSession() { // 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 }), + ops: Object.freeze({ inPreset: true, public: false }), topology: Object.freeze({ inPreset: false, public: false }) }); var TOOLS_BY_GROUP = Object.freeze({ - nms: Object.freeze([ + ops: Object.freeze([ "netx__queryNmsAlarms", "netx__aggregateNmsAlarms", "netx__runNmsDiagnostics", @@ -409,9 +408,7 @@ var TOOLS_BY_GROUP = Object.freeze({ "netx__queryNmsAlarmsRaw", "netx__aggregateNmsAlarmsRaw", "netx__listNmsAlarmFields", - "netx__sqlQueryNms" - ]), - common: Object.freeze([ + "netx__sqlQueryNms", "netx__listManagedNe", "netx__getManagedNe", "netx__execManagedNe", @@ -438,27 +435,32 @@ var TOOLS_BY_GROUP = Object.freeze({ ]) }); var SKILL_DIR_BY_GROUP = Object.freeze({ - nms: "nms", - common: "common", + ops: "ops", topology: "topology" }); var CAPABILITY_GROUP_IDS = Object.freeze([ - "nms", - "common", + "ops", "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; + let opsInPreset = true; + if (src.groupOpsInPreset !== undefined) { + opsInPreset = src.groupOpsInPreset !== false; + } else { + const legacy = [ + src.groupNmsInPreset, + src.groupCommonInPreset, + src.groupManagedNeInPreset + ].filter((v) => v !== undefined); + if (legacy.length > 0) { + opsInPreset = legacy.some((v) => v === true); + } + } return { - nms: { - inPreset: src.groupNmsInPreset !== false, - public: src.groupNmsPublic === true - }, - common: { - inPreset: commonInPreset, - public: commonPublic + ops: { + inPreset: opsInPreset, + public: src.groupOpsPublic === true || src.groupNmsPublic === true || src.groupCommonPublic === true || src.groupManagedNePublic === true }, topology: { inPreset: src.groupTopologyInPreset === true || src.groupTopologyLayoutInPreset === true, @@ -2152,10 +2154,8 @@ var Config = z.object({ 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), + groupOpsInPreset: z.boolean().default(true), + groupOpsPublic: z.boolean().default(false), groupTopologyInPreset: z.boolean().default(false), groupTopologyPublic: z.boolean().default(false) }); diff --git a/package.json b/package.json index d23b58c..78d982d 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "dsh-netxops", - "version": "0.1.21", - "description": "DeepSeek Harness Netx Ops: capability groups nms/common/topology (one group ↔ one skill)", + "version": "0.1.22", + "description": "DeepSeek Harness Netx Ops: capability groups ops/topology (one group ↔ one skill)", "license": "MIT", "type": "module", "private": false, @@ -9,6 +9,7 @@ "exports": { ".": "./lib/index.js", "./tools": "./lib/agent-tools.js", + "./tools-ops": "./lib/agent-tools-ops.js", "./tools-nms": "./lib/agent-tools-nms.js", "./tools-common": "./lib/agent-tools-common.js", "./tools-topology": "./lib/agent-tools-topology.js", @@ -19,6 +20,7 @@ "src/", "lib/index.js", "lib/agent-tools.js", + "lib/agent-tools-ops.js", "lib/agent-tools-nms.js", "lib/agent-tools-common.js", "lib/agent-tools-topology.js", diff --git a/presets/netxops/PERSONA.md b/presets/netxops/PERSONA.md index ecab381..366f081 100644 --- a/presets/netxops/PERSONA.md +++ b/presets/netxops/PERSONA.md @@ -26,8 +26,7 @@ You are **Netx Ops**, a network operations specialist for NMS / netx. - Display NEs by **host_name** only — never bare UUID to the user. ## Skills (by capability group) -- `netx-nms` — **nms** -- `netx-common` — **common** +- `netx-ops` — **ops** (NMS alarms/inventory + managed CLI login + paths) - `netx-topology` — **topology** (canvas / dual_unit / layout) ## Tools diff --git a/presets/netxops/agent.cordis.yml b/presets/netxops/agent.cordis.yml index eabf3cc..edaddf0 100644 --- a/presets/netxops/agent.cordis.yml +++ b/presets/netxops/agent.cordis.yml @@ -34,8 +34,7 @@ - No evidence → say evidence insufficient; do not invent root cause. ## Skills (load before acting; only when the capability group is enabled) - - nms alarms / inventory → `netx-nms` - - common managed CLI + paths → `netx-common` + - ops (alarms / inventory / 「能否登录」CLI / paths) → `netx-ops` - topology canvas / dual_unit / layout → `netx-topology` ## Tools diff --git a/presets/netxops/skills/README.md b/presets/netxops/skills/README.md index bb51024..1e18e80 100644 --- a/presets/netxops/skills/README.md +++ b/presets/netxops/skills/README.md @@ -7,4 +7,4 @@ 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. +Groups: **ops** (`netx-ops`) + **topology** (`netx-topology`). One group 鈫?one skill. diff --git a/presets/netxops/skills/common/netx-common/SKILL.md b/presets/netxops/skills/common/netx-common/SKILL.md deleted file mode 100644 index 18b5d16..0000000 --- a/presets/netxops/skills/common/netx-common/SKILL.md +++ /dev/null @@ -1,91 +0,0 @@ ---- -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 deleted file mode 100644 index 5839103..0000000 --- a/presets/netxops/skills/nms/netx-nms/SKILL.md +++ /dev/null @@ -1,70 +0,0 @@ ---- -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 deleted file mode 100644 index 7da47eb..0000000 --- a/presets/netxops/skills/nms/netx-nms/reference.md +++ /dev/null @@ -1,20 +0,0 @@ -# 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-ops/SKILL.md b/presets/netxops/skills/ops/netx-ops/SKILL.md new file mode 100644 index 0000000..22655e4 --- /dev/null +++ b/presets/netxops/skills/ops/netx-ops/SKILL.md @@ -0,0 +1,94 @@ +--- +name: netx-ops +description: >- + Netx Ops playbook (MCP + DSH): NMS alarms/inventory/SQL plus managed-NE CLI + login (execManagedNe batch-first) and findTopologyPaths. Trigger: alarms, + host_name, Critical Top, LOS, 能否登录, show/display, optical, capacity A<>B, + path between sites, netx ops. +--- + +# netx-ops(告警 + 纳管登录) + +**一组一个 skill。** 问「能否登录 / CLI / show」必须走本 skill 的 managed 工具,不要只查 NMS inventory。 + +## Hosts + +| Host | Tools | +|------|--------| +| **MCP** `netx-mcp` | 裸名 `queryNmsAlarms` / `execManagedNe` / … | +| **DSH** `dsh-netxops` | `netx__` + 同 stem;能力组 **ops**(默认开) | + +REST NMS 仍 `/v1/ume/*`(`nmsProvider=zte-ume`)。优先 `nms_ne_id`;`ume_*` 别名可用。 +画布 / dual_unit → **netx-topology**(能力组 topology)。 + +## Tools + +### NMS + +| Purpose | Tool | +|---------|------| +| Alarm list / aggregate / diagnostics | `queryNmsAlarms` / `aggregateNmsAlarms` / `runNmsDiagnostics` | +| Inventory / detail | `queryNmsNeInventory` / `getNmsNe` | +| Raw / fields / SQL | `queryNmsAlarmsRaw` / `listNmsAlarmFields` / `aggregateNmsAlarmsRaw` / `sqlQueryNms` | + +### Managed CLI + paths + +| Purpose | Tool | +|---------|------| +| List CLI targets | `listManagedNe` / `listCliTargets` | +| Managed detail | `getManagedNe`(**仅**纳管 id) | +| Login / show | `execManagedNe`(batch-first) | +| Fabric paths | `findTopologyPaths` | + +## 「能否登录」决策树(强制) + +1. `listCliTargets(keyword=host_or_ip)` 或 `listManagedNe(keyword=…, connect_status=pass)` + - 命中 → `execManagedNe(ne_id|nms_ne_id, commands=["show version"])` **验证真正能登** + - 未命中 → `queryNmsNeInventory(keyword=…)` 说明 NMS 有无 / `connection_status`,并明确:**未纳管 netx CLI 则不能用本通道登录** +2. 禁止只查 inventory 就下「不能登录」或「能登录」结论而不尝试 `execManagedNe`(已纳管时)。 +3. NMS UUID 不要塞进 `getManagedNe`;用 `execManagedNe(nms_ne_id=…)` 或先 list 拿 managed `ne_id`。 + +## NMS 工具顺序 + +1. Freshness: `runNmsDiagnostics` / `aggregateNmsAlarms` → `meta.last_seen_*` +2. Overview + one-page `queryNmsAlarms` +3. Evidence: `queryNmsAlarmsRaw` (`field_preset=evidence`) +4. Paths / CLI as needed (same skill) + +## CLI order + +1. `listManagedNe` / `listCliTargets`(每会话最多一次,缓存 id) +2. 多台 → **一次** `execManagedNe`(`ne_ids` / `nms_ne_ids` / `targets`) +3. 路径 → `findTopologyPaths` + +### Batch 示例 + +```json +{ "nms_ne_ids": ["uuid-a", "uuid-b"], "commands": ["show version"], "concurrency": 4 } +``` + +```json +{ + "targets": [ + {"nms_ne_id": "uuid-zte", "commands": ["show opticalinfo brief"]}, + {"nms_ne_id": "uuid-hw", "commands": ["display optical-module brief"]} + ] +} +``` + +## Short recipes + +| User says | Recipe | +|-----------|--------| +| 能否登录 / login / SSH | 上表「能否登录」决策树 | +| fiber / LOS | Raw `keyword=LOS` / `Fiber Break` | +| Critical Top | `aggregateNmsAlarms(severity=critical, top_ne=20)` | +| capacity A<>B | paths / LLDP → 两端 optic CLI(一批) | + +## Guardrails + +- 展示 **host_name**;勿对用户甩裸 UUID +- CLI 白名单:`show` / `display` / `ping` / `traceroute` … +- 同轮禁止 N× 单台 `execManagedNe` + +See [reference.md](reference.md). diff --git a/presets/netxops/skills/ops/netx-ops/reference.md b/presets/netxops/skills/ops/netx-ops/reference.md new file mode 100644 index 0000000..e785467 --- /dev/null +++ b/presets/netxops/skills/ops/netx-ops/reference.md @@ -0,0 +1,22 @@ +# netx-ops quick reference + +## Can it log in? + +1. `listCliTargets` / `listManagedNe` → if found, `execManagedNe(…, commands=["show version"])` +2. Else `queryNmsNeInventory` → report NMS presence; say CLI not managed if absent from managed list + +## NMS freshness + +- `runNmsDiagnostics` / `aggregateNmsAlarms` → `meta.last_seen_min` / `max` + +## Shortcuts + +| Intent | Call | +|--------|------| +| Critical Top | `aggregateNmsAlarms(severity=critical, top_ne=20)` | +| Fiber / LOS | Raw `keyword=LOS` / `Fiber Break` | +| Inventory | `queryNmsNeInventory(keyword=…)` / `getNmsNe` | +| CLI batch | `execManagedNe(nms_ne_ids=[…], commands=[…])` | +| Paths | `findTopologyPaths(from_nms_ne_id, to_nms_ne_id)` | + +DSH: prefix tools with `netx__`. diff --git a/scripts/build-host.mjs b/scripts/build-host.mjs index d17d07c..c4ab112 100644 --- a/scripts/build-host.mjs +++ b/scripts/build-host.mjs @@ -30,6 +30,7 @@ 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-ops.ts'), out: join(outDir, 'agent-tools-ops.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') }, diff --git a/scripts/sync-skills-from-netx.ps1 b/scripts/sync-skills-from-netx.ps1 index b12f221..db8e87c 100644 --- a/scripts/sync-skills-from-netx.ps1 +++ b/scripts/sync-skills-from-netx.ps1 @@ -17,7 +17,7 @@ $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")) { +foreach ($group in @("ops", "topology")) { $from = Join-Path $src $group $to = Join-Path $dst $group if (-not (Test-Path $from)) { throw "missing group $from" } @@ -26,9 +26,11 @@ foreach ($group in @("nms", "common", "topology")) { 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 } +# Drop legacy groups if present +foreach ($legacy in @("nms", "common", "topology-layout")) { + $legacyPath = Join-Path $dst $legacy + if (Test-Path $legacyPath) { Remove-Item $legacyPath -Recurse -Force } +} @" # Mirrored from netx/skills — do not long-edit here @@ -40,7 +42,7 @@ 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. +Groups: **ops** (``netx-ops``) + **topology** (``netx-topology``). One group ↔ one skill. "@ | Set-Content -Encoding utf8 (Join-Path $dst "README.md") Write-Host "Synced $src -> $dst" diff --git a/src/agent-tools-common.ts b/src/agent-tools-common.ts index d156d00..9cf5bec 100644 --- a/src/agent-tools-common.ts +++ b/src/agent-tools-common.ts @@ -1,6 +1,5 @@ /** - * Forced common-group tools + skill (native managed CLI + findTopologyPaths). - * + * @deprecated Use `dsh-netxops/tools-ops`. Alias that still forces the ops group. * @module dsh-netxops/tools-common */ @@ -11,5 +10,5 @@ export const name = 'netxops-tools-common' export const inject = ['tools'] export function apply(ctx: Context): void { - applyGroupToolsPlugin(ctx, { name, mode: 'forced', only: ['common'] }) + applyGroupToolsPlugin(ctx, { name, mode: 'forced', only: ['ops'] }) } diff --git a/src/agent-tools-nms.ts b/src/agent-tools-nms.ts index 4f65540..bd8d9e9 100644 --- a/src/agent-tools-nms.ts +++ b/src/agent-tools-nms.ts @@ -1,7 +1,5 @@ /** - * Forced nms-group tools + skill (vendor NMS adapter; current provider zte-ume). - * Mount this export in any agent preset that needs NMS alarms/inventory. - * + * @deprecated Use `dsh-netxops/tools-ops`. Alias that still forces the ops group. * @module dsh-netxops/tools-nms */ @@ -12,5 +10,5 @@ export const name = 'netxops-tools-nms' export const inject = ['tools'] export function apply(ctx: Context): void { - applyGroupToolsPlugin(ctx, { name, mode: 'forced', only: ['nms'] }) + applyGroupToolsPlugin(ctx, { name, mode: 'forced', only: ['ops'] }) } diff --git a/src/agent-tools-ops.ts b/src/agent-tools-ops.ts new file mode 100644 index 0000000..ddb18b3 --- /dev/null +++ b/src/agent-tools-ops.ts @@ -0,0 +1,15 @@ +/** + * Forced ops-group tools + skill (NMS + managed CLI + paths). + * + * @module dsh-netxops/tools-ops + */ + +import type { Context } from '@deepseek-ai/cordis' +import { applyGroupToolsPlugin } from './netx/group-tools-plugin.ts' + +export const name = 'netxops-tools-ops' +export const inject = ['tools'] + +export function apply(ctx: Context): void { + applyGroupToolsPlugin(ctx, { name, mode: 'forced', only: ['ops'] }) +} diff --git a/src/client/NetxopsCard.tsx b/src/client/NetxopsCard.tsx index bacaf3a..842d2aa 100644 --- a/src/client/NetxopsCard.tsx +++ b/src/client/NetxopsCard.tsx @@ -364,31 +364,17 @@ export function NetxopsCard(props: NetxopsCardProps) {

{t('capabilityGroupsHint')}

{ - props.edit('groupNmsInPreset', checked ? 'true' : 'false') + props.edit('groupOpsInPreset', 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('groupOpsPublic', checked ? 'true' : 'false') }} /> = { title: 'Netx Ops', - description: 'netx API, bearer token, and capability groups (nms / common / topology).', + description: 'netx API, bearer token, and capability groups (ops / 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.', + capabilityGroupsHint: 'One group ↔ one skill. ops = netx-ops (alarms + CLI login + paths); topology = netx-topology. Defaults: ops on; topology and public off. New sessions after save. Other agents: dsh-netxops/tools-ops|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)', + groupOps: 'ops — skill netx-ops (NMS alarms/inventory + managed CLI login + paths)', groupTopology: 'topology — skill netx-topology (canvas / fabric / dual_unit)', groupInPreset: 'In Netx Ops preset', groupPublic: 'Publish to other presets', @@ -115,17 +113,16 @@ export const en: Record = { export const zh: Record = { title: 'Netx Ops', - description: 'netx API、Bearer Token,以及能力组(nms / common / topology)。', + description: 'netx API、Bearer Token,以及能力组(ops / 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。', + capabilityGroupsHint: '一组一个 skill。ops = netx-ops(告警 + 纳管登录 + 路径);topology = netx-topology。默认 ops 开;topology / 公开关。保存后新会话生效。其他 Agent:dsh-netxops/tools-ops|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)', + groupOps: 'ops — skill netx-ops(NMS 告警/库存 + 纳管 CLI 登录 + 路径)', groupTopology: 'topology — skill netx-topology(画布 / Fabric / dual_unit)', groupInPreset: '在 Netx Ops 预设中启用', groupPublic: '对其他预设公开', diff --git a/src/index.ts b/src/index.ts index f2a1a06..fc9fc86 100644 --- a/src/index.ts +++ b/src/index.ts @@ -91,14 +91,10 @@ export interface Config { * (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 + /** ops tools/skills in the Netx Ops preset (default on) — NMS + CLI + paths. */ + groupOpsInPreset: boolean + /** Publish ops tools/skills to other presets (default off). */ + groupOpsPublic: boolean /** topology canvas / layout tools in the Netx Ops preset (default off). */ groupTopologyInPreset: boolean /** Publish topology tools/skills to other presets (default off). */ @@ -117,10 +113,8 @@ export const Config: z = z.object({ 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), + groupOpsInPreset: z.boolean().default(true), + groupOpsPublic: z.boolean().default(false), groupTopologyInPreset: z.boolean().default(false), groupTopologyPublic: z.boolean().default(false), }) diff --git a/src/netx/capability-groups.ts b/src/netx/capability-groups.ts index 299fe3d..2c85ed2 100644 --- a/src/netx/capability-groups.ts +++ b/src/netx/capability-groups.ts @@ -3,13 +3,12 @@ * * 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 + * - `ops` → skill `netx-ops` — NMS alarms/inventory/SQL + managed CLI + findTopologyPaths + * - `topology` → skill `netx-topology` — canvas / fabric / dual_unit / layout */ /** Stable group ids used in settings, skill dirs, and registration filters. */ -export type NetxCapabilityGroupId = 'nms' | 'common' | 'topology' +export type NetxCapabilityGroupId = 'ops' | 'topology' /** Per-group exposure knobs. */ export interface NetxGroupExposure { @@ -24,29 +23,26 @@ export type NetxCapabilityGroups = Record> = Object.freeze({ - nms: Object.freeze([ + ops: Object.freeze([ 'netx__queryNmsAlarms', 'netx__aggregateNmsAlarms', 'netx__runNmsDiagnostics', @@ -56,8 +52,6 @@ export const TOOLS_BY_GROUP: Readonly/`. */ export const SKILL_DIR_BY_GROUP: Readonly> = Object.freeze({ - nms: 'nms', - common: 'common', + ops: 'ops', topology: 'topology', }) export const CAPABILITY_GROUP_IDS: readonly NetxCapabilityGroupId[] = Object.freeze([ - 'nms', - 'common', + 'ops', '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). + * + * Legacy aliases (all map into `ops`): + * - `groupNms*` / `groupCommon*` / `groupManagedNe*` + * + * Legacy `groupTopologyLayout*` ORs into `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 + + let opsInPreset = true + if (src.groupOpsInPreset !== undefined) { + opsInPreset = src.groupOpsInPreset !== false + } else { + const legacy = [ + src.groupNmsInPreset, + src.groupCommonInPreset, + src.groupManagedNeInPreset, + ].filter((v): v is boolean => v !== undefined) + if (legacy.length > 0) { + // Any explicit true → on; all explicit false → off. + opsInPreset = legacy.some((v) => v === true) + } + } + return { - nms: { - inPreset: src.groupNmsInPreset !== false, - public: src.groupNmsPublic === true, - }, - common: { - inPreset: commonInPreset, - public: commonPublic, + ops: { + inPreset: opsInPreset, + public: src.groupOpsPublic === true + || src.groupNmsPublic === true + || src.groupCommonPublic === true + || src.groupManagedNePublic === true, }, topology: { inPreset: src.groupTopologyInPreset === true @@ -136,9 +145,6 @@ export function capabilityGroupsFromSettings( /** * 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, @@ -155,13 +161,16 @@ export function groupsForPlane( } /** - * Force-enable the listed groups (for `dsh-netxops/tools-` mounts that - * intentionally ignore Ops-preset inPreset flags). + * Force-enable the listed groups (for `dsh-netxops/tools-` mounts). + * Legacy `nms` / `common` aliases resolve to `ops`. */ export function groupsForced( - only: readonly NetxCapabilityGroupId[], + only: readonly (NetxCapabilityGroupId | 'nms' | 'common')[], ): NetxCapabilityGroupId[] { - return CAPABILITY_GROUP_IDS.filter((id) => only.includes(id)) + const normalized = only.map((id) => ( + id === 'nms' || id === 'common' ? 'ops' as const : id + )) + return CAPABILITY_GROUP_IDS.filter((id) => normalized.includes(id)) } /** diff --git a/test/capability-groups.test.mjs b/test/capability-groups.test.mjs index c0f09ab..f2954cf 100644 --- a/test/capability-groups.test.mjs +++ b/test/capability-groups.test.mjs @@ -15,34 +15,32 @@ import { TOOLS_BY_GROUP, } from '../src/netx/capability-groups.ts' -test('defaults: nms + common in preset; topology and public off', () => { +test('defaults: ops in preset; topology and public off', () => { assert.deepEqual(DEFAULT_CAPABILITY_GROUPS, { - nms: { inPreset: true, public: false }, - common: { inPreset: true, public: false }, + ops: { inPreset: true, public: false }, topology: { inPreset: false, public: false }, }) - assert.deepEqual(groupsForPlane(undefined, 'preset'), ['nms', 'common']) + assert.deepEqual(groupsForPlane(undefined, 'preset'), ['ops']) assert.deepEqual(groupsForPlane(undefined, 'public'), []) }) -test('one group one skill dir; topology owns canvas + layout tools', () => { +test('one group one skill dir; ops owns NMS + managed CLI; topology owns canvas', () => { assert.deepEqual(SKILL_DIR_BY_GROUP, { - nms: 'nms', - common: 'common', + ops: 'ops', topology: 'topology', }) - assert.ok(TOOLS_BY_GROUP.common.includes('netx__findTopologyPaths')) + assert.ok(TOOLS_BY_GROUP.ops.includes('netx__findTopologyPaths')) + assert.ok(TOOLS_BY_GROUP.ops.includes('netx__execManagedNe')) + assert.ok(TOOLS_BY_GROUP.ops.includes('netx__queryNmsAlarms')) 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, + groupOpsInPreset: false, groupTopologyInPreset: true, groupTopologyPublic: true, }) @@ -53,18 +51,27 @@ test('capabilityGroupsFromSettings honors explicit false for inPreset defaults', assert.equal(toolNamesForGroups(['topology']).has('netx__queryNmsAlarms'), false) }) -test('legacy groupManagedNe* maps to common; legacy layout flags OR into topology', () => { +test('legacy groupNms/common/managedNe map to ops; legacy layout flags OR into topology', () => { const groups = capabilityGroupsFromSettings({ - groupManagedNeInPreset: false, + groupNmsInPreset: false, + groupCommonInPreset: false, groupManagedNePublic: true, groupTopologyLayoutInPreset: true, }) - assert.equal(groups.common.inPreset, false) - assert.equal(groups.common.public, true) + assert.equal(groups.ops.inPreset, false) + assert.equal(groups.ops.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']) +test('legacy any-true turns ops inPreset on', () => { + const groups = capabilityGroupsFromSettings({ + groupNmsInPreset: false, + groupCommonInPreset: true, + }) + assert.equal(groups.ops.inPreset, true) +}) + +test('groupsForced ignores settings for per-export mounts', () => { + assert.deepEqual(groupsForced(['ops']), ['ops']) + assert.deepEqual(groupsForced(['ops', 'topology']), ['ops', 'topology']) }) diff --git a/test/group-skills.test.mjs b/test/group-skills.test.mjs index 2e49578..b85da39 100644 --- a/test/group-skills.test.mjs +++ b/test/group-skills.test.mjs @@ -10,7 +10,7 @@ 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', () => { +test('opsSkillsRoot points at ops / topology playbook dirs', () => { const root = opsSkillsRoot() assert.ok(existsSync(root), root) for (const dir of Object.values(SKILL_DIR_BY_GROUP)) {