mirror of
https://github.com/hansjone/netxops.git
synced 2026-10-09 02:40:45 +08:00
Ship capability groups (nms/common/topology) with shared netx skills.
One group maps to one skill and tools-*; rename NMS tools to *Nms*; sync playbooks from netx/skills. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
83c3796fbb
commit
3c9a383071
47 changed files with 11790 additions and 680 deletions
3
.gitignore
vendored
3
.gitignore
vendored
|
|
@ -4,6 +4,9 @@ dist/
|
||||||
lib/*
|
lib/*
|
||||||
!lib/index.js
|
!lib/index.js
|
||||||
!lib/agent-tools.js
|
!lib/agent-tools.js
|
||||||
|
!lib/agent-tools-nms.js
|
||||||
|
!lib/agent-tools-common.js
|
||||||
|
!lib/agent-tools-topology.js
|
||||||
!lib/client.js
|
!lib/client.js
|
||||||
*.log
|
*.log
|
||||||
.DS_Store
|
.DS_Store
|
||||||
|
|
|
||||||
|
|
@ -15,20 +15,22 @@ dsh web
|
||||||
|
|
||||||
Then:
|
Then:
|
||||||
|
|
||||||
1. Settings → **Plugins** → **Netx Ops** → API URL / token
|
1. Settings → **Plugins** → **Netx Ops** → API URL / token / capability groups
|
||||||
2. Optional: enable **关键告警推送** — DSH dials out to netx; choose **Deliver to DSH session** and/or **Deliver to WhatsApp / IM** (paste `botId`+`targetId` from IM 投递设置). The card shows live WSS status.
|
2. Optional: enable **关键告警推送** — DSH dials out to netx; choose **Deliver to DSH session** and/or **Deliver to WhatsApp / IM** (paste `botId`+`targetId` from IM 投递设置). The card shows live WSS status.
|
||||||
3. Settings → **Agent presets** → Custom → **Netx Ops** (installed automatically on first host activate)
|
3. Settings → **Agent presets** → Custom → **Netx Ops** (installed automatically on first host activate)
|
||||||
4. New session → choose **Netx Ops**
|
4. New session → choose **Netx Ops**
|
||||||
|
|
||||||
Also need: a reachable **netx API** (no local `pip install netx_mcp`). Details: [docs/INSTALL.md](docs/INSTALL.md).
|
Also need: a reachable **netx API** (no local `pip install netx_mcp`). Details: [docs/INSTALL.md](docs/INSTALL.md).
|
||||||
|
|
||||||
|
**Skills:** edit only in the sibling **netx** repo `skills/` tree, then `powershell -File .\scripts\sync-skills-from-netx.ps1` before publish. Linked local installs prefer `../netx/skills` or `NETX_SKILLS_ROOT` at runtime.
|
||||||
|
|
||||||
## What is coupled
|
## What is coupled
|
||||||
|
|
||||||
| In the plugin | Outside (data / runtime) |
|
| In the plugin | Outside (data / runtime) |
|
||||||
|---------------|---------------------------|
|
|---------------|---------------------------|
|
||||||
| `netx__*` REST tools (Ops preset only) + settings + credentials | netx HTTP API (URL + token) |
|
| `netx__*` REST tools (groups: nms / common / topology; one group ↔ one skill; optional host publish + `tools-*`) + settings + credentials | netx HTTP API (URL + token) |
|
||||||
| Optional key-alarm push (WSS client → sticky DSH session) | netx `/v1/integrations/dsh-alarm/ws` hub |
|
| Optional key-alarm push (WSS client → sticky DSH session) | netx `/v1/integrations/dsh-alarm/ws` hub |
|
||||||
| Persona + UME / managed-NE skills | |
|
| Persona + skills (`netx-nms` / `netx-common` / `netx-topology`) | |
|
||||||
| Agent preset auto-install to `~/.dsh/.agent-presets` | |
|
| Agent preset auto-install to `~/.dsh/.agent-presets` | |
|
||||||
|
|
||||||
MCP (`python -m netx_mcp`) remains available for OpenClaw / other hosts — not required for DSH.
|
MCP (`python -m netx_mcp`) remains available for OpenClaw / other hosts — not required for DSH.
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,9 @@
|
||||||
# Host-plane Netx Ops bridge: settings + credentials → connection publish.
|
# Host-plane Netx Ops bridge: settings + credentials → connection publish.
|
||||||
# Ops-scoped `netx__*` tools mount from the agent preset (`dsh-netxops/tools`).
|
# Ops-scoped `netx__*` tools mount from the agent preset (`dsh-netxops/tools`).
|
||||||
|
# Capability groups: nms + common in preset; topology / public off.
|
||||||
|
# Rule: one group ↔ one skill (netx-nms / netx-common / netx-topology).
|
||||||
#
|
#
|
||||||
# After `dsh plugin add` / link, open Settings → Plugins to edit apiUrl / lang
|
# After `dsh plugin add` / link, open Settings → Plugins to edit apiUrl / lang / groups
|
||||||
# (settings card client half; see docs/INSTALL.md). Token: credential NETX_API_TOKEN.
|
# (settings card client half; see docs/INSTALL.md). Token: credential NETX_API_TOKEN.
|
||||||
|
|
||||||
- insert:
|
- insert:
|
||||||
|
|
@ -10,6 +12,7 @@
|
||||||
config:
|
config:
|
||||||
apiUrl: http://127.0.0.1:8890
|
apiUrl: http://127.0.0.1:8890
|
||||||
lang: zh
|
lang: zh
|
||||||
|
nmsProvider: zte-ume
|
||||||
tokenCredentialRef: NETX_API_TOKEN
|
tokenCredentialRef: NETX_API_TOKEN
|
||||||
toolCallTimeoutMs: 120000
|
toolCallTimeoutMs: 120000
|
||||||
installAgentPreset: true
|
installAgentPreset: true
|
||||||
|
|
@ -18,3 +21,9 @@
|
||||||
alarmDeliverIm: false
|
alarmDeliverIm: false
|
||||||
imBotId: ''
|
imBotId: ''
|
||||||
imTargetId: ''
|
imTargetId: ''
|
||||||
|
groupNmsInPreset: true
|
||||||
|
groupNmsPublic: false
|
||||||
|
groupCommonInPreset: true
|
||||||
|
groupCommonPublic: false
|
||||||
|
groupTopologyInPreset: false
|
||||||
|
groupTopologyPublic: false
|
||||||
|
|
|
||||||
|
|
@ -6,9 +6,9 @@ One install wires **all of**:
|
||||||
|
|
||||||
| Piece | How you use it |
|
| Piece | How you use it |
|
||||||
|-------|----------------|
|
|-------|----------------|
|
||||||
| Host tools | Ops-preset-scoped `netx__*` → netx REST (Bearer token); other presets do not see them |
|
| Host tools | Capability groups **nms / common / topology** (one group ↔ one skill). Default nms+common in Ops preset; topology and all public off. Other agents may mount `dsh-netxops/tools-*` |
|
||||||
| Plugins card | Settings → Plugins → **Netx Ops** (URL / lang / token) |
|
| 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) |
|
| Agent preset + skills | Settings → Agent presets → **Custom → Netx Ops** (copied into `~/.dsh/.agent-presets` on first boot); playbooks follow the same group toggles |
|
||||||
|
|
||||||
You do **not** run `link-preset.ps1` for normal use. That script is only a manual fallback.
|
You do **not** run `link-preset.ps1` for normal use. That script is only a manual fallback.
|
||||||
|
|
||||||
|
|
@ -30,7 +30,8 @@ dsh web # or: pnpm dsh web
|
||||||
|
|
||||||
1. **Settings → Plugins → Netx Ops** → API URL (+ token if the field is enabled).
|
1. **Settings → Plugins → Netx Ops** → API URL (+ token if the field is enabled).
|
||||||
Token fallback: `scripts/set-netx-token.ps1` / `.sh`.
|
Token fallback: `scripts/set-netx-token.ps1` / `.sh`.
|
||||||
Optional: enable **关键告警推送** so this DSH dials `ws(s)://<apiUrl>/v1/integrations/dsh-alarm/ws`. Choose **投递到 DSH 会话** and/or **投递到 WhatsApp / IM**. For IM, install `dsh-im-ops`, create a delivery target, then paste `imBotId` / `imTargetId`. The card header shows live WSS status.
|
Optional: enable **关键告警推送** so this DSH dials `ws(s)://<apiUrl>/v1/integrations/dsh-alarm/ws`. Choose **投递到 DSH 会话** and/or **投递到 WhatsApp / IM**. For IM, install `dsh-im-ops`, create a delivery target, then paste `imBotId` / `imTargetId`. The card header shows live WSS status.
|
||||||
|
Capability groups: leave defaults for nms+common, or enable **topology** / **对其他预设公开** (new sessions after save).
|
||||||
2. Restart or open Settings → **Agent presets** → Custom → **Netx Ops** should appear after the host plugin has activated once.
|
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.
|
||||||
|
|
||||||
|
|
@ -46,7 +47,7 @@ dsh web # or: pnpm dsh web
|
||||||
1. Plugins card **Netx Ops** visible.
|
1. Plugins card **Netx Ops** visible.
|
||||||
2. With key-alarm push on: card badge shows **Connected** (or reconnecting / auth failed with detail).
|
2. With key-alarm push on: card badge shows **Connected** (or reconnecting / auth failed with detail).
|
||||||
3. Agent presets → Custom → **Netx Ops**.
|
3. Agent presets → Custom → **Netx Ops**.
|
||||||
4. Tools include `netx__queryUmeAlarms`.
|
4. Tools include `netx__queryNmsAlarms` (**nms**). `netx__findTopologyPaths` is **common**. Canvas / dual_unit need **topology** (`netx-topology` skill).
|
||||||
|
|
||||||
See [TOOL_MAP.md](TOOL_MAP.md).
|
See [TOOL_MAP.md](TOOL_MAP.md).
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -5,21 +5,22 @@
|
||||||
| Source (oclaw) | Destination |
|
| Source (oclaw) | Destination |
|
||||||
|----------------|-------------|
|
|----------------|-------------|
|
||||||
| `runtime/workspaces/ops/ROLE_SYSTEM.md` | `presets/netxops/PERSONA.md` + persona in `agent.cordis.yml` (brand **Netx Ops**) |
|
| `runtime/workspaces/ops/ROLE_SYSTEM.md` | `presets/netxops/PERSONA.md` + persona in `agent.cordis.yml` (brand **Netx Ops**) |
|
||||||
| `skills/_workspace/ops/ops-netx-ume-playbook/` | `presets/netxops/skills/ops-netx-ume-playbook/` |
|
| `skills/_workspace/ops/ops-netx-ume-playbook/` | **Canonical:** `netx/skills/nms/netx-nms/` |
|
||||||
| `skills/_workspace/ops/ops-netx-managed-ne-playbook/` | `presets/netxops/skills/ops-netx-managed-ne-playbook/` |
|
| `skills/_workspace/ops/ops-netx-managed-ne-playbook/` | **Canonical:** `netx/skills/common/netx-common/` |
|
||||||
|
| `skills/.../netx-topology` (MCP) | **Canonical:** `netx/skills/topology/netx-topology/` |
|
||||||
| netx MCP HTTP surface | Host plugin `src/netx/*` + `src/index.ts`: settings namespace `netxops` + credential `NETX_API_TOKEN` → native `netx__*` tools |
|
| netx MCP HTTP surface | Host plugin `src/netx/*` + `src/index.ts`: settings namespace `netxops` + credential `NETX_API_TOKEN` → native `netx__*` tools |
|
||||||
|
|
||||||
Adaptations: removed oclaw Admin / WhatsApp / `ume_alarm_xlsx_report` / wiki capture / skill_auto_install; fiber/offline recipes use Raw/aggregate tools only. DSH no longer spawns `python -m netx_mcp`.
|
Adaptations: removed oclaw Admin / WhatsApp / `ume_alarm_xlsx_report` / wiki capture / skill_auto_install; fiber/offline recipes use Raw/aggregate tools only. DSH no longer spawns `python -m netx_mcp`. Model-facing NMS tools renamed to `*Nms*`; ZTE UME remains the `nmsProvider=zte-ume` adapter.
|
||||||
|
|
||||||
**Config UX:** Settings → Plugins → **Netx Ops** card (`src/client/` → `lib/client.js`). Token via credentials `NETX_API_TOKEN`; `apiUrl` / `lang` via settings namespace `netxops`.
|
**Config UX:** Settings → Plugins → **Netx Ops** card (`src/client/` → `lib/client.js`). Token via credentials `NETX_API_TOKEN`; `apiUrl` / `lang` / capability groups / `nmsProvider` via settings namespace `netxops`.
|
||||||
|
|
||||||
## Stay in oclaw
|
## Stay in oclaw (until retired)
|
||||||
|
|
||||||
Gateway, WhatsApp/Weixin, Admin MCP UI, specialist router, memory-wiki, ops-ai HTTP, schedulers.
|
Gateway, WhatsApp/Weixin, Admin MCP UI, specialist router, memory-wiki, ops-ai HTTP, schedulers.
|
||||||
|
|
||||||
## Phase 2 candidates
|
## Phase 2 candidates
|
||||||
|
|
||||||
- `ume_alarm_xlsx_report` + thin HTTP client as a DSH tool plugin
|
- `ume_alarm_xlsx_report` + thin HTTP client as a DSH tool plugin
|
||||||
- UME sync context inject
|
- NMS sync context inject
|
||||||
- `ops-ip-knowledge-playbook` + KB content
|
- `ops-ip-knowledge-playbook` + KB content
|
||||||
- `netx-topology` MCP + topology skill
|
- Full dual_unit layout engine in-process (today: MCP or experimental stubs)
|
||||||
|
|
|
||||||
|
|
@ -1,28 +1,40 @@
|
||||||
# Netx Ops tool map
|
# Netx Ops tool map
|
||||||
|
|
||||||
Tools are registered by the Ops agent preset (`dsh-netxops/tools`) into that preset's tool scope — not the host global layer — so other agent presets do not see them.
|
Tools register by capability group. **One group ↔ one skill.**
|
||||||
|
|
||||||
Model-facing name: `netx__<tool>`.
|
| Group | Skill | Default in Ops preset |
|
||||||
|
|-------|-------|------------------------|
|
||||||
|
| **nms** | `netx-nms` | on |
|
||||||
|
| **common** | `netx-common` | on |
|
||||||
|
| **topology** | `netx-topology` | off |
|
||||||
|
|
||||||
|
Public flags mount the same group on the host for other presets.
|
||||||
|
Forced mounts: `dsh-netxops/tools-nms` | `tools-common` | `tools-topology`.
|
||||||
|
|
||||||
|
Model names: `netx__<stem>`. NMS tools use `Nms`; adapter `nmsProvider=zte-ume` still hits `/v1/ume/*`.
|
||||||
|
|
||||||
|
## nms → `netx-nms`
|
||||||
|
|
||||||
| Tool | Role |
|
| Tool | Role |
|
||||||
|------|------|
|
|------|------|
|
||||||
| `queryUmeAlarms` | Paged current alarms |
|
| `queryNmsAlarms` … `sqlQueryNms` | Alarm + inventory + SQL |
|
||||||
| `aggregateUmeAlarms` | Severity / Top NE aggregate |
|
|
||||||
| `runUmeDiagnostics` | Diagnostics + freshness meta |
|
|
||||||
| `queryUmeNeInventory` | UME NE list |
|
|
||||||
| `getUmeNe` | Single UME NE detail |
|
|
||||||
| `queryUmeAlarmsRaw` | Raw / evidence fields |
|
|
||||||
| `aggregateUmeAlarmsRaw` | Dynamic group_by |
|
|
||||||
| `listUmeAlarmFields` | Field catalog |
|
|
||||||
| `sqlQueryUme` | Read-only SELECT |
|
|
||||||
| `findTopologyPaths` | Shortest path between UME NEs (path query only; no canvas layout in v1) |
|
|
||||||
| `listManagedNe` | Managed device list |
|
|
||||||
| `getManagedNe` | Managed device detail |
|
|
||||||
| `execManagedNe` | Read-only CLI (batch-first) |
|
|
||||||
| `listCliTargets` | CLI target index (managed + ume) |
|
|
||||||
|
|
||||||
Execution: browser/`fetch` from the DSH host → HTTP `apiUrl` + Bearer `NETX_API_TOKEN`.
|
## common → `netx-common`
|
||||||
|
|
||||||
Handler/paths mirror [netx `packages/netx-mcp`](https://github.com/hansjone/netx/tree/main/packages/netx-mcp) (`http_tools.py` / `http_client.py`).
|
| Tool | Role |
|
||||||
|
|------|------|
|
||||||
|
| `listManagedNe` / `getManagedNe` / `execManagedNe` / `listCliTargets` | Managed CLI |
|
||||||
|
| `findTopologyPaths` | Fabric path lookup |
|
||||||
|
|
||||||
**Out of v1:** `netx-topology` MCP / topology canvas skills.
|
## topology → `netx-topology`
|
||||||
|
|
||||||
|
| Tool | Role |
|
||||||
|
|------|------|
|
||||||
|
| Tree / view / folders / membership / neighbors | Canvas CRUD |
|
||||||
|
| Fabric query / classify / edges | Inventory + adjacency |
|
||||||
|
| `layoutTopologyView` | move_nodes / catalog / … |
|
||||||
|
| `suggestSinkHubs` / `analyzeTopologyViewLayout` / `sinkTopologyDualUnits` | Dual-unit / QA (may stub → MCP layout engine) |
|
||||||
|
|
||||||
|
Canonical skill bodies: sibling **`netx/skills/`**. DSH loads them at runtime (`NETX_SKILLS_ROOT` or `../netx/skills`) or from `presets/netxops/skills` after sync.
|
||||||
|
|
||||||
|
Execution: HTTP `apiUrl` + Bearer `NETX_API_TOKEN`.
|
||||||
|
|
|
||||||
1783
lib/agent-tools-common.js
Normal file
1783
lib/agent-tools-common.js
Normal file
File diff suppressed because it is too large
Load diff
1783
lib/agent-tools-nms.js
Normal file
1783
lib/agent-tools-nms.js
Normal file
File diff suppressed because it is too large
Load diff
1783
lib/agent-tools-topology.js
Normal file
1783
lib/agent-tools-topology.js
Normal file
File diff suppressed because it is too large
Load diff
1377
lib/agent-tools.js
1377
lib/agent-tools.js
File diff suppressed because it is too large
Load diff
430
lib/client.js
430
lib/client.js
|
|
@ -112,6 +112,68 @@ function alarmPushTone(phase) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// src/client/im-delivery-catalog.ts
|
||||||
|
var IM_DELIVERY_CATALOG_ENDPOINT = "im-delivery.catalog";
|
||||||
|
var EMPTY_IM_DELIVERY_CATALOG = {
|
||||||
|
available: true,
|
||||||
|
options: [],
|
||||||
|
hint: "",
|
||||||
|
loading: false
|
||||||
|
};
|
||||||
|
function asImDeliveryCatalog(value) {
|
||||||
|
if (value === null || typeof value !== "object" || Array.isArray(value)) {
|
||||||
|
return { ...EMPTY_IM_DELIVERY_CATALOG, available: false };
|
||||||
|
}
|
||||||
|
const row = value;
|
||||||
|
const raw = Array.isArray(row.options) ? row.options : [];
|
||||||
|
const options = [];
|
||||||
|
for (const entry of raw) {
|
||||||
|
if (entry === null || typeof entry !== "object" || Array.isArray(entry))
|
||||||
|
continue;
|
||||||
|
const item = entry;
|
||||||
|
const botId = typeof item.botId === "string" ? item.botId.trim() : "";
|
||||||
|
const targetId = typeof item.targetId === "string" ? item.targetId.trim() : "";
|
||||||
|
if (!botId || !targetId)
|
||||||
|
continue;
|
||||||
|
options.push({
|
||||||
|
botId,
|
||||||
|
targetId,
|
||||||
|
name: typeof item.name === "string" && item.name.trim() ? item.name.trim() : targetId,
|
||||||
|
kind: typeof item.kind === "string" ? item.kind : "",
|
||||||
|
channel: typeof item.channel === "string" ? item.channel : "im"
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
available: row.available !== false,
|
||||||
|
options,
|
||||||
|
hint: typeof row.hint === "string" ? row.hint : "",
|
||||||
|
loading: false
|
||||||
|
};
|
||||||
|
}
|
||||||
|
async function fetchImDeliveryCatalog(call, signal) {
|
||||||
|
const result = await call(NETXOPS_RPC_CHANNEL, IM_DELIVERY_CATALOG_ENDPOINT, {}, signal);
|
||||||
|
if (result !== null && typeof result === "object" && result.ok === true) {
|
||||||
|
return asImDeliveryCatalog(result.value);
|
||||||
|
}
|
||||||
|
if (result !== null && typeof result === "object" && result.ok === false) {
|
||||||
|
return {
|
||||||
|
...EMPTY_IM_DELIVERY_CATALOG,
|
||||||
|
available: false,
|
||||||
|
hint: String(result.error?.message ?? "rpc failed")
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return asImDeliveryCatalog(result);
|
||||||
|
}
|
||||||
|
function imCatalogOptionKey(botId, targetId) {
|
||||||
|
return `${botId}::${targetId}`;
|
||||||
|
}
|
||||||
|
function parseImCatalogOptionKey(value) {
|
||||||
|
const at = value.indexOf("::");
|
||||||
|
if (at <= 0)
|
||||||
|
return { botId: "", targetId: "" };
|
||||||
|
return { botId: value.slice(0, at), targetId: value.slice(at + 2) };
|
||||||
|
}
|
||||||
|
|
||||||
// src/client/styles.ts
|
// src/client/styles.ts
|
||||||
var STYLE_ID = "dsh-netxops-card-css";
|
var STYLE_ID = "dsh-netxops-card-css";
|
||||||
var CSS = `
|
var CSS = `
|
||||||
|
|
@ -142,11 +204,15 @@ var CSS = `
|
||||||
.dsh-nx-reset{border:none;background:none;padding:0;font:inherit;font-size:12px;line-height:1.5;color:var(--dsw-alias-label-secondary);cursor:pointer}
|
.dsh-nx-reset{border:none;background:none;padding:0;font:inherit;font-size:12px;line-height:1.5;color:var(--dsw-alias-label-secondary);cursor:pointer}
|
||||||
.dsh-nx-reset:hover:not(:disabled){color:var(--dsw-alias-label-primary)}
|
.dsh-nx-reset:hover:not(:disabled){color:var(--dsw-alias-label-primary)}
|
||||||
.dsh-nx-input{height:34px;padding:0 12px;border:.5px solid var(--dsw-alias-border-l4);border-radius:8px;background:var(--dsw-alias-bg-layer-3);font:inherit;font-size:13px;line-height:1.5;color:var(--dsw-alias-label-primary)}
|
.dsh-nx-input{height:34px;padding:0 12px;border:.5px solid var(--dsw-alias-border-l4);border-radius:8px;background:var(--dsw-alias-bg-layer-3);font:inherit;font-size:13px;line-height:1.5;color:var(--dsw-alias-label-primary)}
|
||||||
|
.dsh-nx-select{appearance:auto;cursor:pointer}
|
||||||
.dsh-nx-input:focus-visible{outline:none;border-color:var(--dsw-alias-brand-primary)}
|
.dsh-nx-input:focus-visible{outline:none;border-color:var(--dsw-alias-brand-primary)}
|
||||||
.dsh-nx-inputInvalid{border-color:var(--dsw-alias-label-error)}
|
.dsh-nx-inputInvalid{border-color:var(--dsw-alias-label-error)}
|
||||||
.dsh-nx-hint{margin:0;font-size:12px;line-height:1.5;color:var(--dsw-alias-label-tertiary)}
|
.dsh-nx-hint{margin:0;font-size:12px;line-height:1.5;color:var(--dsw-alias-label-tertiary)}
|
||||||
.dsh-nx-checkRow{display:flex;align-items:flex-start;gap:10px;font-size:12px;line-height:1.5;color:var(--dsw-alias-label-secondary);cursor:pointer}
|
.dsh-nx-checkRow{display:flex;align-items:flex-start;gap:10px;font-size:12px;line-height:1.5;color:var(--dsw-alias-label-secondary);cursor:pointer}
|
||||||
.dsh-nx-checkRow input{margin-top:2px;flex:none}
|
.dsh-nx-checkRow input{margin-top:2px;flex:none}
|
||||||
|
.dsh-nx-groupBlock{display:flex;flex-direction:column;gap:8px;padding:8px 0 4px}
|
||||||
|
.dsh-nx-groupTitle{font-size:12px;font-weight:600;line-height:1.5;color:var(--dsw-alias-label-primary)}
|
||||||
|
.dsh-nx-groupChecks{display:flex;flex-direction:column;gap:6px;padding-left:2px}
|
||||||
.dsh-nx-invalid{margin:0;font-size:12px;line-height:1.5;color:var(--dsw-alias-label-error)}
|
.dsh-nx-invalid{margin:0;font-size:12px;line-height:1.5;color:var(--dsw-alias-label-error)}
|
||||||
.dsh-nx-footer{display:flex;align-items:center;justify-content:flex-end;gap:8px;padding:12px 0 4px;border-top:.5px solid var(--dsw-alias-border-l2)}
|
.dsh-nx-footer{display:flex;align-items:center;justify-content:flex-end;gap:8px;padding:12px 0 4px;border-top:.5px solid var(--dsw-alias-border-l2)}
|
||||||
.dsh-nx-failed{flex:1;min-width:0;margin:0;font-size:12px;line-height:1.5;color:var(--dsw-alias-label-error)}
|
.dsh-nx-failed{flex:1;min-width:0;margin:0;font-size:12px;line-height:1.5;color:var(--dsw-alias-label-error)}
|
||||||
|
|
@ -247,6 +313,151 @@ function ValueField(props) {
|
||||||
]
|
]
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
function ImDeliveryPicker(props) {
|
||||||
|
const options = props.catalog.options;
|
||||||
|
const selectedKey = props.botId && props.targetId ? imCatalogOptionKey(props.botId, props.targetId) : "";
|
||||||
|
const matched = options.some((row) => imCatalogOptionKey(row.botId, row.targetId) === selectedKey);
|
||||||
|
const [manual, setManual] = import_react.useState(() => !!(selectedKey && !matched));
|
||||||
|
import_react.useEffect(() => {
|
||||||
|
if (selectedKey && matched)
|
||||||
|
setManual(false);
|
||||||
|
else if (selectedKey && !matched)
|
||||||
|
setManual(true);
|
||||||
|
}, [selectedKey, matched]);
|
||||||
|
const selectValue = manual ? "__manual__" : matched ? selectedKey : "";
|
||||||
|
const showManual = manual || selectedKey && !matched;
|
||||||
|
return /* @__PURE__ */ jsx_runtime.jsxs(jsx_runtime.Fragment, {
|
||||||
|
children: [
|
||||||
|
/* @__PURE__ */ jsx_runtime.jsxs("div", {
|
||||||
|
className: "dsh-nx-field",
|
||||||
|
children: [
|
||||||
|
/* @__PURE__ */ jsx_runtime.jsx("div", {
|
||||||
|
className: "dsh-nx-fieldHead",
|
||||||
|
children: /* @__PURE__ */ jsx_runtime.jsx("label", {
|
||||||
|
className: "dsh-nx-label",
|
||||||
|
htmlFor: "netxops-im-target",
|
||||||
|
children: props.labels.target
|
||||||
|
})
|
||||||
|
}),
|
||||||
|
/* @__PURE__ */ jsx_runtime.jsxs("select", {
|
||||||
|
id: "netxops-im-target",
|
||||||
|
className: "dsh-nx-input dsh-nx-select",
|
||||||
|
value: selectValue,
|
||||||
|
disabled: props.disabled,
|
||||||
|
onChange: (event) => {
|
||||||
|
const value = event.target.value;
|
||||||
|
if (value === "__manual__") {
|
||||||
|
setManual(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setManual(false);
|
||||||
|
if (!value) {
|
||||||
|
props.onPick("", "");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const next = parseImCatalogOptionKey(value);
|
||||||
|
props.onPick(next.botId, next.targetId);
|
||||||
|
},
|
||||||
|
children: [
|
||||||
|
/* @__PURE__ */ jsx_runtime.jsx("option", {
|
||||||
|
value: "",
|
||||||
|
children: options.length === 0 ? props.labels.none : `— ${props.labels.target} —`
|
||||||
|
}),
|
||||||
|
options.map((row) => /* @__PURE__ */ jsx_runtime.jsx("option", {
|
||||||
|
value: imCatalogOptionKey(row.botId, row.targetId),
|
||||||
|
children: `${row.name} · ${row.channel || "im"} · ${row.targetId}`
|
||||||
|
}, imCatalogOptionKey(row.botId, row.targetId))),
|
||||||
|
/* @__PURE__ */ jsx_runtime.jsx("option", {
|
||||||
|
value: "__manual__",
|
||||||
|
children: props.labels.manual
|
||||||
|
})
|
||||||
|
]
|
||||||
|
}),
|
||||||
|
!props.catalog.available || options.length === 0 ? /* @__PURE__ */ jsx_runtime.jsx("p", {
|
||||||
|
className: "dsh-nx-hint",
|
||||||
|
children: props.catalog.hint || props.labels.unavailable
|
||||||
|
}) : null
|
||||||
|
]
|
||||||
|
}),
|
||||||
|
showManual ? /* @__PURE__ */ jsx_runtime.jsxs(jsx_runtime.Fragment, {
|
||||||
|
children: [
|
||||||
|
/* @__PURE__ */ jsx_runtime.jsx(ValueField, {
|
||||||
|
id: "netxops-im-bot-id",
|
||||||
|
label: props.labels.botId,
|
||||||
|
hint: props.labels.botHint,
|
||||||
|
field: props.botField,
|
||||||
|
overriddenLabel: props.labels.overridden,
|
||||||
|
resetLabel: props.labels.reset,
|
||||||
|
invalidLabel: props.labels.invalid,
|
||||||
|
disabled: props.disabled,
|
||||||
|
onEdit: props.onEditBot,
|
||||||
|
onReset: props.onResetBot
|
||||||
|
}),
|
||||||
|
/* @__PURE__ */ jsx_runtime.jsx(ValueField, {
|
||||||
|
id: "netxops-im-target-id",
|
||||||
|
label: props.labels.targetId,
|
||||||
|
hint: props.labels.targetHint,
|
||||||
|
field: props.targetField,
|
||||||
|
overriddenLabel: props.labels.overridden,
|
||||||
|
resetLabel: props.labels.reset,
|
||||||
|
invalidLabel: props.labels.invalid,
|
||||||
|
disabled: props.disabled,
|
||||||
|
onEdit: props.onEditTarget,
|
||||||
|
onReset: props.onResetTarget
|
||||||
|
})
|
||||||
|
]
|
||||||
|
}) : null
|
||||||
|
]
|
||||||
|
});
|
||||||
|
}
|
||||||
|
function CapabilityGroupBlock(props) {
|
||||||
|
return /* @__PURE__ */ jsx_runtime.jsxs("div", {
|
||||||
|
className: "dsh-nx-groupBlock",
|
||||||
|
children: [
|
||||||
|
/* @__PURE__ */ jsx_runtime.jsx("div", {
|
||||||
|
className: "dsh-nx-groupTitle",
|
||||||
|
children: props.title
|
||||||
|
}),
|
||||||
|
/* @__PURE__ */ jsx_runtime.jsxs("div", {
|
||||||
|
className: "dsh-nx-groupChecks",
|
||||||
|
children: [
|
||||||
|
/* @__PURE__ */ jsx_runtime.jsxs("label", {
|
||||||
|
className: "dsh-nx-checkRow",
|
||||||
|
children: [
|
||||||
|
/* @__PURE__ */ jsx_runtime.jsx("input", {
|
||||||
|
type: "checkbox",
|
||||||
|
checked: props.inPreset.text === "true",
|
||||||
|
disabled: props.disabled,
|
||||||
|
onChange: (event) => {
|
||||||
|
props.onEditInPreset(event.target.checked);
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
/* @__PURE__ */ jsx_runtime.jsx("span", {
|
||||||
|
children: props.inPresetLabel
|
||||||
|
})
|
||||||
|
]
|
||||||
|
}),
|
||||||
|
/* @__PURE__ */ jsx_runtime.jsxs("label", {
|
||||||
|
className: "dsh-nx-checkRow",
|
||||||
|
children: [
|
||||||
|
/* @__PURE__ */ jsx_runtime.jsx("input", {
|
||||||
|
type: "checkbox",
|
||||||
|
checked: props.published.text === "true",
|
||||||
|
disabled: props.disabled,
|
||||||
|
onChange: (event) => {
|
||||||
|
props.onEditPublic(event.target.checked);
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
/* @__PURE__ */ jsx_runtime.jsx("span", {
|
||||||
|
children: props.publicLabel
|
||||||
|
})
|
||||||
|
]
|
||||||
|
})
|
||||||
|
]
|
||||||
|
})
|
||||||
|
]
|
||||||
|
});
|
||||||
|
}
|
||||||
function NetxopsCard(props) {
|
function NetxopsCard(props) {
|
||||||
ensureStyles();
|
ensureStyles();
|
||||||
const { t } = props;
|
const { t } = props;
|
||||||
|
|
@ -388,6 +599,80 @@ function NetxopsCard(props) {
|
||||||
props.resetField("lang");
|
props.resetField("lang");
|
||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
|
/* @__PURE__ */ jsx_runtime.jsx(ValueField, {
|
||||||
|
id: "netxops-nms-provider",
|
||||||
|
label: t("nmsProvider"),
|
||||||
|
hint: t("nmsProviderHint"),
|
||||||
|
field: state.nmsProvider,
|
||||||
|
overriddenLabel: t("overridden"),
|
||||||
|
resetLabel: t("reset"),
|
||||||
|
invalidLabel: t("invalid"),
|
||||||
|
disabled,
|
||||||
|
onEdit: (text) => {
|
||||||
|
props.edit("nmsProvider", text);
|
||||||
|
},
|
||||||
|
onReset: () => {
|
||||||
|
props.resetField("nmsProvider");
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
/* @__PURE__ */ jsx_runtime.jsxs("div", {
|
||||||
|
className: "dsh-nx-field",
|
||||||
|
children: [
|
||||||
|
/* @__PURE__ */ jsx_runtime.jsx("div", {
|
||||||
|
className: "dsh-nx-fieldHead",
|
||||||
|
children: /* @__PURE__ */ jsx_runtime.jsx("span", {
|
||||||
|
className: "dsh-nx-label",
|
||||||
|
children: t("capabilityGroups")
|
||||||
|
})
|
||||||
|
}),
|
||||||
|
/* @__PURE__ */ jsx_runtime.jsx("p", {
|
||||||
|
className: "dsh-nx-hint",
|
||||||
|
children: t("capabilityGroupsHint")
|
||||||
|
}),
|
||||||
|
/* @__PURE__ */ jsx_runtime.jsx(CapabilityGroupBlock, {
|
||||||
|
title: t("groupNms"),
|
||||||
|
inPresetLabel: t("groupInPreset"),
|
||||||
|
publicLabel: t("groupPublic"),
|
||||||
|
inPreset: state.groupNmsInPreset,
|
||||||
|
published: state.groupNmsPublic,
|
||||||
|
disabled,
|
||||||
|
onEditInPreset: (checked) => {
|
||||||
|
props.edit("groupNmsInPreset", checked ? "true" : "false");
|
||||||
|
},
|
||||||
|
onEditPublic: (checked) => {
|
||||||
|
props.edit("groupNmsPublic", checked ? "true" : "false");
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
/* @__PURE__ */ jsx_runtime.jsx(CapabilityGroupBlock, {
|
||||||
|
title: t("groupCommon"),
|
||||||
|
inPresetLabel: t("groupInPreset"),
|
||||||
|
publicLabel: t("groupPublic"),
|
||||||
|
inPreset: state.groupCommonInPreset,
|
||||||
|
published: state.groupCommonPublic,
|
||||||
|
disabled,
|
||||||
|
onEditInPreset: (checked) => {
|
||||||
|
props.edit("groupCommonInPreset", checked ? "true" : "false");
|
||||||
|
},
|
||||||
|
onEditPublic: (checked) => {
|
||||||
|
props.edit("groupCommonPublic", checked ? "true" : "false");
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
/* @__PURE__ */ jsx_runtime.jsx(CapabilityGroupBlock, {
|
||||||
|
title: t("groupTopology"),
|
||||||
|
inPresetLabel: t("groupInPreset"),
|
||||||
|
publicLabel: t("groupPublic"),
|
||||||
|
inPreset: state.groupTopologyInPreset,
|
||||||
|
published: state.groupTopologyPublic,
|
||||||
|
disabled,
|
||||||
|
onEditInPreset: (checked) => {
|
||||||
|
props.edit("groupTopologyInPreset", checked ? "true" : "false");
|
||||||
|
},
|
||||||
|
onEditPublic: (checked) => {
|
||||||
|
props.edit("groupTopologyPublic", checked ? "true" : "false");
|
||||||
|
}
|
||||||
|
})
|
||||||
|
]
|
||||||
|
}),
|
||||||
/* @__PURE__ */ jsx_runtime.jsxs("div", {
|
/* @__PURE__ */ jsx_runtime.jsxs("div", {
|
||||||
className: "dsh-nx-field",
|
className: "dsh-nx-field",
|
||||||
children: [
|
children: [
|
||||||
|
|
@ -558,35 +843,40 @@ function NetxopsCard(props) {
|
||||||
})
|
})
|
||||||
]
|
]
|
||||||
}),
|
}),
|
||||||
/* @__PURE__ */ jsx_runtime.jsx(ValueField, {
|
/* @__PURE__ */ jsx_runtime.jsx(ImDeliveryPicker, {
|
||||||
id: "netxops-im-bot-id",
|
catalog: state.imDeliveryCatalog,
|
||||||
label: t("imBotId"),
|
botId: state.imBotId.text,
|
||||||
hint: t("imBotIdHint"),
|
targetId: state.imTargetId.text,
|
||||||
field: state.imBotId,
|
|
||||||
overriddenLabel: t("overridden"),
|
|
||||||
resetLabel: t("reset"),
|
|
||||||
invalidLabel: t("invalid"),
|
|
||||||
disabled,
|
disabled,
|
||||||
onEdit: (text) => {
|
labels: {
|
||||||
|
target: t("imTarget"),
|
||||||
|
none: t("imTargetNone"),
|
||||||
|
manual: t("imTargetManual"),
|
||||||
|
unavailable: t("imCatalogUnavailable"),
|
||||||
|
botId: t("imBotId"),
|
||||||
|
botHint: t("imBotIdHint"),
|
||||||
|
targetId: t("imTargetId"),
|
||||||
|
targetHint: t("imTargetIdHint"),
|
||||||
|
overridden: t("overridden"),
|
||||||
|
reset: t("reset"),
|
||||||
|
invalid: t("invalid")
|
||||||
|
},
|
||||||
|
botField: state.imBotId,
|
||||||
|
targetField: state.imTargetId,
|
||||||
|
onPick: (botId, targetId) => {
|
||||||
|
props.edit("imBotId", botId);
|
||||||
|
props.edit("imTargetId", targetId);
|
||||||
|
},
|
||||||
|
onEditBot: (text) => {
|
||||||
props.edit("imBotId", text);
|
props.edit("imBotId", text);
|
||||||
},
|
},
|
||||||
onReset: () => {
|
onEditTarget: (text) => {
|
||||||
props.resetField("imBotId");
|
|
||||||
}
|
|
||||||
}),
|
|
||||||
/* @__PURE__ */ jsx_runtime.jsx(ValueField, {
|
|
||||||
id: "netxops-im-target-id",
|
|
||||||
label: t("imTargetId"),
|
|
||||||
hint: t("imTargetIdHint"),
|
|
||||||
field: state.imTargetId,
|
|
||||||
overriddenLabel: t("overridden"),
|
|
||||||
resetLabel: t("reset"),
|
|
||||||
invalidLabel: t("invalid"),
|
|
||||||
disabled,
|
|
||||||
onEdit: (text) => {
|
|
||||||
props.edit("imTargetId", text);
|
props.edit("imTargetId", text);
|
||||||
},
|
},
|
||||||
onReset: () => {
|
onResetBot: () => {
|
||||||
|
props.resetField("imBotId");
|
||||||
|
},
|
||||||
|
onResetTarget: () => {
|
||||||
props.resetField("imTargetId");
|
props.resetField("imTargetId");
|
||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
|
|
@ -861,14 +1151,23 @@ class NetxopsCardController {
|
||||||
};
|
};
|
||||||
rpcCall;
|
rpcCall;
|
||||||
alarmPushStatus = null;
|
alarmPushStatus = null;
|
||||||
|
imDeliveryCatalog = { ...EMPTY_IM_DELIVERY_CATALOG };
|
||||||
pollTimer;
|
pollTimer;
|
||||||
pollInFlight = false;
|
pollInFlight = false;
|
||||||
|
catalogInFlight = false;
|
||||||
constructor(scope, ctx) {
|
constructor(scope, ctx) {
|
||||||
this.scope = scope;
|
this.scope = scope;
|
||||||
this.ctx = ctx;
|
this.ctx = ctx;
|
||||||
this.form = new CardForm(scope, [
|
this.form = new CardForm(scope, [
|
||||||
textField("apiUrl"),
|
textField("apiUrl"),
|
||||||
textField("lang"),
|
textField("lang"),
|
||||||
|
textField("nmsProvider"),
|
||||||
|
booleanFieldPersistFalse("groupNmsInPreset"),
|
||||||
|
booleanField("groupNmsPublic"),
|
||||||
|
booleanFieldPersistFalse("groupCommonInPreset"),
|
||||||
|
booleanField("groupCommonPublic"),
|
||||||
|
booleanField("groupTopologyInPreset"),
|
||||||
|
booleanField("groupTopologyPublic"),
|
||||||
booleanField("alarmPushEnabled"),
|
booleanField("alarmPushEnabled"),
|
||||||
booleanFieldPersistFalse("alarmDeliverDsh"),
|
booleanFieldPersistFalse("alarmDeliverDsh"),
|
||||||
booleanField("alarmDeliverIm"),
|
booleanField("alarmDeliverIm"),
|
||||||
|
|
@ -897,20 +1196,29 @@ class NetxopsCardController {
|
||||||
this.rpcCall = call;
|
this.rpcCall = call;
|
||||||
if (call === undefined) {
|
if (call === undefined) {
|
||||||
this.stopStatusPoll();
|
this.stopStatusPoll();
|
||||||
|
let changed = false;
|
||||||
if (this.alarmPushStatus !== null) {
|
if (this.alarmPushStatus !== null) {
|
||||||
this.alarmPushStatus = null;
|
this.alarmPushStatus = null;
|
||||||
this.store.set(this.projection());
|
changed = true;
|
||||||
}
|
}
|
||||||
|
if (this.imDeliveryCatalog.options.length > 0 || this.imDeliveryCatalog.available !== true) {
|
||||||
|
this.imDeliveryCatalog = { ...EMPTY_IM_DELIVERY_CATALOG };
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
|
if (changed)
|
||||||
|
this.store.set(this.projection());
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
this.startStatusPoll();
|
this.startStatusPoll();
|
||||||
this.refreshAlarmPushStatus();
|
this.refreshAlarmPushStatus();
|
||||||
|
this.refreshImDeliveryCatalog();
|
||||||
}
|
}
|
||||||
startStatusPoll() {
|
startStatusPoll() {
|
||||||
if (this.pollTimer !== undefined)
|
if (this.pollTimer !== undefined)
|
||||||
return;
|
return;
|
||||||
this.pollTimer = setInterval(() => {
|
this.pollTimer = setInterval(() => {
|
||||||
this.refreshAlarmPushStatus();
|
this.refreshAlarmPushStatus();
|
||||||
|
this.refreshImDeliveryCatalog();
|
||||||
}, STATUS_POLL_MS);
|
}, STATUS_POLL_MS);
|
||||||
}
|
}
|
||||||
stopStatusPoll() {
|
stopStatusPoll() {
|
||||||
|
|
@ -935,11 +1243,38 @@ class NetxopsCardController {
|
||||||
this.pollInFlight = false;
|
this.pollInFlight = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
async refreshImDeliveryCatalog() {
|
||||||
|
const call = this.rpcCall;
|
||||||
|
if (call === undefined || this.catalogInFlight)
|
||||||
|
return;
|
||||||
|
this.catalogInFlight = true;
|
||||||
|
try {
|
||||||
|
const next = await fetchImDeliveryCatalog(call);
|
||||||
|
const prev = this.imDeliveryCatalog;
|
||||||
|
const sameOptions = prev.options.length === next.options.length && prev.options.every((row, index) => {
|
||||||
|
const other = next.options[index];
|
||||||
|
return other && row.botId === other.botId && row.targetId === other.targetId && row.name === other.name && row.channel === other.channel;
|
||||||
|
});
|
||||||
|
if (prev.available === next.available && prev.hint === next.hint && sameOptions)
|
||||||
|
return;
|
||||||
|
this.imDeliveryCatalog = next;
|
||||||
|
this.store.set(this.projection());
|
||||||
|
} catch {} finally {
|
||||||
|
this.catalogInFlight = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
projection() {
|
projection() {
|
||||||
return {
|
return {
|
||||||
...this.form.shell(),
|
...this.form.shell(),
|
||||||
apiUrl: this.form.field("apiUrl"),
|
apiUrl: this.form.field("apiUrl"),
|
||||||
lang: this.form.field("lang"),
|
lang: this.form.field("lang"),
|
||||||
|
nmsProvider: this.form.field("nmsProvider"),
|
||||||
|
groupNmsInPreset: this.form.field("groupNmsInPreset"),
|
||||||
|
groupNmsPublic: this.form.field("groupNmsPublic"),
|
||||||
|
groupCommonInPreset: this.form.field("groupCommonInPreset"),
|
||||||
|
groupCommonPublic: this.form.field("groupCommonPublic"),
|
||||||
|
groupTopologyInPreset: this.form.field("groupTopologyInPreset"),
|
||||||
|
groupTopologyPublic: this.form.field("groupTopologyPublic"),
|
||||||
alarmPushEnabled: this.form.field("alarmPushEnabled"),
|
alarmPushEnabled: this.form.field("alarmPushEnabled"),
|
||||||
alarmDeliverDsh: this.form.field("alarmDeliverDsh"),
|
alarmDeliverDsh: this.form.field("alarmDeliverDsh"),
|
||||||
alarmDeliverIm: this.form.field("alarmDeliverIm"),
|
alarmDeliverIm: this.form.field("alarmDeliverIm"),
|
||||||
|
|
@ -949,7 +1284,8 @@ class NetxopsCardController {
|
||||||
apiTokenConfigured: this.credential.configured,
|
apiTokenConfigured: this.credential.configured,
|
||||||
apiTokenWritable: this.credential.remoteReady && this.credential.writable,
|
apiTokenWritable: this.credential.remoteReady && this.credential.writable,
|
||||||
apiTokenRemoteReady: this.credential.remoteReady,
|
apiTokenRemoteReady: this.credential.remoteReady,
|
||||||
alarmPushStatus: this.alarmPushStatus
|
alarmPushStatus: this.alarmPushStatus,
|
||||||
|
imDeliveryCatalog: this.imDeliveryCatalog
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
credentials() {
|
credentials() {
|
||||||
|
|
@ -1014,11 +1350,20 @@ function refOf(snapshot) {
|
||||||
// src/client/locales.ts
|
// src/client/locales.ts
|
||||||
var en = {
|
var en = {
|
||||||
title: "Netx Ops",
|
title: "Netx Ops",
|
||||||
description: "UME API endpoint and bearer token for netx__* tools.",
|
description: "netx API, bearer token, and capability groups (nms / common / topology).",
|
||||||
apiUrl: "API URL",
|
apiUrl: "API URL",
|
||||||
apiUrlHint: "netx REST root, e.g. http://127.0.0.1:8890 (also used for alarm subscribe)",
|
apiUrlHint: "netx REST root, e.g. http://127.0.0.1:8890 (also used for alarm subscribe)",
|
||||||
lang: "Language",
|
lang: "Language",
|
||||||
langHint: "Response language hint (zh / en).",
|
langHint: "Response language hint (zh / en).",
|
||||||
|
capabilityGroups: "Capability groups",
|
||||||
|
capabilityGroupsHint: "One group ↔ one skill (netx-nms / netx-common / netx-topology). Defaults: nms + common in preset; topology and all public off. Save, then open a new session. Other agents can mount dsh-netxops/tools-nms|common|topology.",
|
||||||
|
nmsProvider: "nms provider",
|
||||||
|
nmsProviderHint: "Vendor adapter id. Supported today: zte-ume (REST still /v1/ume/*; model tools are netx__*Nms*).",
|
||||||
|
groupNms: "nms — skill netx-nms (alarms / inventory / SQL)",
|
||||||
|
groupCommon: "common — skill netx-common (managed CLI + findTopologyPaths)",
|
||||||
|
groupTopology: "topology — skill netx-topology (canvas / fabric / dual_unit)",
|
||||||
|
groupInPreset: "In Netx Ops preset",
|
||||||
|
groupPublic: "Publish to other presets",
|
||||||
alarmPushEnabled: "Key-alarm push",
|
alarmPushEnabled: "Key-alarm push",
|
||||||
alarmPushEnabledHint: "Dial out to netx when a matched key alarm arrives. Choose DSH and/or IM sinks below.",
|
alarmPushEnabledHint: "Dial out to netx when a matched key alarm arrives. Choose DSH and/or IM sinks below.",
|
||||||
alarmPushStatus: "Push link",
|
alarmPushStatus: "Push link",
|
||||||
|
|
@ -1033,11 +1378,15 @@ var en = {
|
||||||
alarmDeliverDsh: "Deliver to DSH session",
|
alarmDeliverDsh: "Deliver to DSH session",
|
||||||
alarmDeliverDshHint: "Open/follow the sticky「Netx 关键告警」session on this Host.",
|
alarmDeliverDshHint: "Open/follow the sticky「Netx 关键告警」session on this Host.",
|
||||||
alarmDeliverIm: "Deliver to WhatsApp / IM",
|
alarmDeliverIm: "Deliver to WhatsApp / IM",
|
||||||
alarmDeliverImHint: "Requires dsh-im-ops. Paste botId + targetId from IM → 投递设置 → 复制调用参数.",
|
alarmDeliverImHint: "Requires dsh-im-ops ≥ops.24. Pick a saved delivery target below (create targets under IM → Delivery settings).",
|
||||||
imBotId: "IM Bot ID",
|
imBotId: "IM Bot ID",
|
||||||
imBotIdHint: "Opaque botId from IM delivery settings (not a phone number).",
|
imBotIdHint: "Filled from the picker, or enter manually.",
|
||||||
imTargetId: "IM Target ID",
|
imTargetId: "IM Target ID",
|
||||||
imTargetIdHint: "Opaque targetId alias (e.g. release-alerts). Create the target in IM first.",
|
imTargetIdHint: "Filled from the picker, or enter manually.",
|
||||||
|
imTarget: "Delivery target",
|
||||||
|
imTargetNone: "(Create a target in IM delivery settings first)",
|
||||||
|
imTargetManual: "Enter manually…",
|
||||||
|
imCatalogUnavailable: "Cannot load targets — install/update dsh-im-ops (≥ops.24) and save at least one delivery target.",
|
||||||
apiToken: "API token",
|
apiToken: "API token",
|
||||||
apiTokenHint: "Stored as credential NETX_API_TOKEN (never written into settings). Leave blank to keep the current token.",
|
apiTokenHint: "Stored as credential NETX_API_TOKEN (never written into settings). Leave blank to keep the current token.",
|
||||||
apiTokenUnavailable: "This DSH build does not expose remote.credentials. Set the token with scripts/set-netx-token.ps1 (or .sh), then restart is not required if credentials are watched.",
|
apiTokenUnavailable: "This DSH build does not expose remote.credentials. Set the token with scripts/set-netx-token.ps1 (or .sh), then restart is not required if credentials are watched.",
|
||||||
|
|
@ -1057,11 +1406,20 @@ var en = {
|
||||||
};
|
};
|
||||||
var zh = {
|
var zh = {
|
||||||
title: "Netx Ops",
|
title: "Netx Ops",
|
||||||
description: "UME API 地址与 Bearer Token,供 netx__* 工具使用。",
|
description: "netx API、Bearer Token,以及能力组(nms / common / topology)。",
|
||||||
apiUrl: "API 地址",
|
apiUrl: "API 地址",
|
||||||
apiUrlHint: "netx REST 根地址,例如 http://127.0.0.1:8890(告警订阅复用同一地址)",
|
apiUrlHint: "netx REST 根地址,例如 http://127.0.0.1:8890(告警订阅复用同一地址)",
|
||||||
lang: "语言",
|
lang: "语言",
|
||||||
langHint: "响应语言提示(zh / en)。",
|
langHint: "响应语言提示(zh / en)。",
|
||||||
|
capabilityGroups: "能力组",
|
||||||
|
capabilityGroupsHint: "一组一个 skill(netx-nms / netx-common / netx-topology)。默认:nms + common 进入预设;topology 与全部公开关闭。保存后请新开会话。其他 Agent 可挂 dsh-netxops/tools-nms|common|topology。",
|
||||||
|
nmsProvider: "nms provider",
|
||||||
|
nmsProviderHint: "厂商适配器 id。当前支持:zte-ume(REST 仍为 /v1/ume/*;模型工具名为 netx__*Nms*)。",
|
||||||
|
groupNms: "nms — skill netx-nms(告警 / 库存 / SQL)",
|
||||||
|
groupCommon: "common — skill netx-common(纳管 CLI + findTopologyPaths)",
|
||||||
|
groupTopology: "topology — skill netx-topology(画布 / Fabric / dual_unit)",
|
||||||
|
groupInPreset: "在 Netx Ops 预设中启用",
|
||||||
|
groupPublic: "对其他预设公开",
|
||||||
alarmPushEnabled: "关键告警推送",
|
alarmPushEnabled: "关键告警推送",
|
||||||
alarmPushEnabledHint: "主动连接 netx;匹配到关键告警后按下述开关投递。可只开 DSH、只开 IM,或两者都开。",
|
alarmPushEnabledHint: "主动连接 netx;匹配到关键告警后按下述开关投递。可只开 DSH、只开 IM,或两者都开。",
|
||||||
alarmPushStatus: "推送链路",
|
alarmPushStatus: "推送链路",
|
||||||
|
|
@ -1076,11 +1434,15 @@ var zh = {
|
||||||
alarmDeliverDsh: "投递到 DSH 会话",
|
alarmDeliverDsh: "投递到 DSH 会话",
|
||||||
alarmDeliverDshHint: "在本机打开/续写 sticky「Netx 关键告警」会话。",
|
alarmDeliverDshHint: "在本机打开/续写 sticky「Netx 关键告警」会话。",
|
||||||
alarmDeliverIm: "投递到 WhatsApp / IM",
|
alarmDeliverIm: "投递到 WhatsApp / IM",
|
||||||
alarmDeliverImHint: "需要已安装 dsh-im-ops。请先在 IM「投递设置」新建目标,再粘贴「复制调用参数」里的 botId / targetId。",
|
alarmDeliverImHint: "需要 dsh-im-ops ≥ops.24。请在下方下拉选择已保存的投递目标(先在 IM「投递设置」新建)。",
|
||||||
imBotId: "IM Bot ID",
|
imBotId: "IM Bot ID",
|
||||||
imBotIdHint: "来自 IM 投递设置的不透明 botId(不是手机号)。",
|
imBotIdHint: "由下拉自动填入,也可手动改。",
|
||||||
imTargetId: "IM Target ID",
|
imTargetId: "IM Target ID",
|
||||||
imTargetIdHint: "投递目标别名(如 release-alerts)。请先在 IM 侧创建目标。",
|
imTargetIdHint: "由下拉自动填入,也可手动改。",
|
||||||
|
imTarget: "投递目标",
|
||||||
|
imTargetNone: "(请先在 IM「投递设置」新建目标)",
|
||||||
|
imTargetManual: "手动填写…",
|
||||||
|
imCatalogUnavailable: "无法加载投递目标 — 请安装/更新 dsh-im-ops(≥ops.24)并至少保存一个投递目标。",
|
||||||
apiToken: "API Token",
|
apiToken: "API Token",
|
||||||
apiTokenHint: "写入凭据 NETX_API_TOKEN(不会进 settings)。留空表示保留已有 token。",
|
apiTokenHint: "写入凭据 NETX_API_TOKEN(不会进 settings)。留空表示保留已有 token。",
|
||||||
apiTokenUnavailable: "当前 DSH 未提供 remote.credentials。请用 scripts/set-netx-token.ps1(或 .sh)写入 token;若 harness 在监视凭据文件则无需重启。",
|
apiTokenUnavailable: "当前 DSH 未提供 remote.credentials。请用 scripts/set-netx-token.ps1(或 .sh)写入 token;若 harness 在监视凭据文件则无需重启。",
|
||||||
|
|
|
||||||
1842
lib/index.js
1842
lib/index.js
File diff suppressed because it is too large
Load diff
12
package.json
12
package.json
|
|
@ -1,7 +1,7 @@
|
||||||
{
|
{
|
||||||
"name": "dsh-netxops",
|
"name": "dsh-netxops",
|
||||||
"version": "0.1.17",
|
"version": "0.1.21",
|
||||||
"description": "DeepSeek Harness Netx Ops: Ops-scoped netx__* REST tools + key-alarm push to DSH sessions + Plugins settings card + agent preset",
|
"description": "DeepSeek Harness Netx Ops: capability groups nms/common/topology (one group ↔ one skill)",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"private": false,
|
"private": false,
|
||||||
|
|
@ -9,6 +9,9 @@
|
||||||
"exports": {
|
"exports": {
|
||||||
".": "./lib/index.js",
|
".": "./lib/index.js",
|
||||||
"./tools": "./lib/agent-tools.js",
|
"./tools": "./lib/agent-tools.js",
|
||||||
|
"./tools-nms": "./lib/agent-tools-nms.js",
|
||||||
|
"./tools-common": "./lib/agent-tools-common.js",
|
||||||
|
"./tools-topology": "./lib/agent-tools-topology.js",
|
||||||
"./client": "./lib/client.js",
|
"./client": "./lib/client.js",
|
||||||
"./package.json": "./package.json"
|
"./package.json": "./package.json"
|
||||||
},
|
},
|
||||||
|
|
@ -16,6 +19,9 @@
|
||||||
"src/",
|
"src/",
|
||||||
"lib/index.js",
|
"lib/index.js",
|
||||||
"lib/agent-tools.js",
|
"lib/agent-tools.js",
|
||||||
|
"lib/agent-tools-nms.js",
|
||||||
|
"lib/agent-tools-common.js",
|
||||||
|
"lib/agent-tools-topology.js",
|
||||||
"lib/client.js",
|
"lib/client.js",
|
||||||
"presets/",
|
"presets/",
|
||||||
"cordis.patch.yml",
|
"cordis.patch.yml",
|
||||||
|
|
@ -30,7 +36,7 @@
|
||||||
"deepseek-harness",
|
"deepseek-harness",
|
||||||
"netx",
|
"netx",
|
||||||
"ops",
|
"ops",
|
||||||
"ume",
|
"nms",
|
||||||
"agent-preset"
|
"agent-preset"
|
||||||
],
|
],
|
||||||
"repository": {
|
"repository": {
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
# Netx Ops persona (source for `agent.cordis.yml` → `@deepseek-ai/dsh-persona`)
|
# Netx Ops persona (source for `agent.cordis.yml` → `@deepseek-ai/dsh-persona`)
|
||||||
|
|
||||||
You are **Netx Ops**, a network operations specialist for ZTE UME / netx.
|
You are **Netx Ops**, a network operations specialist for NMS / netx.
|
||||||
|
|
||||||
## Identity
|
## Identity
|
||||||
- If asked who you are or which model you use: answer only that you are **Netx Ops**.
|
- If asked who you are or which model you use: answer only that you are **Netx Ops**.
|
||||||
|
|
@ -25,9 +25,10 @@ You are **Netx Ops**, a network operations specialist for ZTE UME / netx.
|
||||||
- Severity: Critical / Major / Minor / Warning.
|
- Severity: Critical / Major / Minor / Warning.
|
||||||
- Display NEs by **host_name** only — never bare UUID to the user.
|
- Display NEs by **host_name** only — never bare UUID to the user.
|
||||||
|
|
||||||
## Skills
|
## Skills (by capability group)
|
||||||
- `ops-netx-ume-playbook` — UME alarms / inventory / SQL / path query
|
- `netx-nms` — **nms**
|
||||||
- `ops-netx-managed-ne-playbook` — managed / UME CLI batch
|
- `netx-common` — **common**
|
||||||
|
- `netx-topology` — **topology** (canvas / dual_unit / layout)
|
||||||
|
|
||||||
## Tools
|
## Tools
|
||||||
`netx__*` only. Multi-NE CLI = one `execManagedNe` batch.
|
`netx__*` only. Multi-NE CLI = one `execManagedNe` batch.
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
# Netx Ops agent preset — UME alarms / NE inventory / managed CLI.
|
# Netx Ops agent preset — NMS alarms / NE inventory / managed CLI.
|
||||||
# Host keeps registries, sandbox, model route, settings; this file owns
|
# Host keeps registries, sandbox, model route, settings; this file owns
|
||||||
# persona, skills, and Ops-scoped `netx__*` tools.
|
# persona and Ops-scoped `netx__*` tools. Playbook skills are registered by
|
||||||
|
# `dsh-netxops/tools` according to Settings → capability groups (not customSkillDirs).
|
||||||
|
|
||||||
# ── identity ────────────────────────────────────────────────────────────────
|
# ── identity ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
@ -8,7 +9,7 @@
|
||||||
name: '@deepseek-ai/dsh-persona'
|
name: '@deepseek-ai/dsh-persona'
|
||||||
config:
|
config:
|
||||||
text: |-
|
text: |-
|
||||||
You are **Netx Ops**, a network operations specialist for ZTE UME / netx.
|
You are **Netx Ops**, a network operations specialist for NMS / netx.
|
||||||
|
|
||||||
## Identity
|
## Identity
|
||||||
- If asked who you are or which model you use: answer only that you are **Netx Ops**.
|
- If asked who you are or which model you use: answer only that you are **Netx Ops**.
|
||||||
|
|
@ -32,13 +33,14 @@
|
||||||
- Display NEs by **host_name** only — never bare UUID `ne_id` to the user.
|
- Display NEs by **host_name** only — never bare UUID `ne_id` to the user.
|
||||||
- No evidence → say evidence insufficient; do not invent root cause.
|
- No evidence → say evidence insufficient; do not invent root cause.
|
||||||
|
|
||||||
## Skills (load before acting)
|
## Skills (load before acting; only when the capability group is enabled)
|
||||||
- UME alarms / inventory → `ops-netx-ume-playbook`
|
- nms alarms / inventory → `netx-nms`
|
||||||
- Managed SSH/Telnet CLI → `ops-netx-managed-ne-playbook`
|
- common managed CLI + paths → `netx-common`
|
||||||
|
- topology canvas / dual_unit / layout → `netx-topology`
|
||||||
|
|
||||||
## Tools
|
## Tools
|
||||||
Call netx as `netx__*` (camelCase tool names). See skill bodies for decision trees.
|
Call netx as `netx__*` (camelCase tool names). See skill bodies for decision trees.
|
||||||
Batch multi-NE CLI in **one** `execManagedNe` call (`ne_ids` / `ume_ne_ids` / `targets`).
|
Batch multi-NE CLI in **one** `execManagedNe` call (`ne_ids` / `nms_ne_ids` / `targets`).
|
||||||
|
|
||||||
- id: agent-instructions
|
- id: agent-instructions
|
||||||
name: '@deepseek-ai/dsh-agent-instructions'
|
name: '@deepseek-ai/dsh-agent-instructions'
|
||||||
|
|
@ -58,13 +60,14 @@
|
||||||
- id: skill-filesystem
|
- id: skill-filesystem
|
||||||
name: '@deepseek-ai/dsh-skill-filesystem'
|
name: '@deepseek-ai/dsh-skill-filesystem'
|
||||||
config:
|
config:
|
||||||
customSkillDirs:
|
# Ops playbooks are registered by netxops-tools from capability groups.
|
||||||
- !!js "process.getBuiltinModule('node:url').fileURLToPath(new URL('skills/', baseUrl))"
|
includeDefaultRoots: true
|
||||||
|
customSkillDirs: []
|
||||||
|
|
||||||
- id: tool-skill
|
- id: tool-skill
|
||||||
name: '@deepseek-ai/dsh-tool-skill'
|
name: '@deepseek-ai/dsh-tool-skill'
|
||||||
|
|
||||||
# Ops-only tools: register into this preset's tool scope (not the host global layer).
|
# Ops-only tools (+ gated skills): register into this preset's scope.
|
||||||
- id: netxops-tools
|
- id: netxops-tools
|
||||||
name: dsh-netxops/tools
|
name: dsh-netxops/tools
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,3 @@
|
||||||
name: Netx Ops
|
name: Netx Ops
|
||||||
description: UME 告警 / 网元清单 / 纳管 CLI 运维 Agent。证据优先,短回复,host_name 主键。
|
description: NMS 告警 / 网元清单 / 纳管 CLI 运维 Agent。证据优先,短回复,host_name 主键。
|
||||||
order: 50
|
order: 50
|
||||||
|
|
|
||||||
10
presets/netxops/skills/README.md
Normal file
10
presets/netxops/skills/README.md
Normal file
|
|
@ -0,0 +1,10 @@
|
||||||
|
# Mirrored from netx/skills 鈥?do not long-edit here
|
||||||
|
|
||||||
|
Canonical: `D:\project\chatgpt\netx\skills`
|
||||||
|
|
||||||
|
`powershell
|
||||||
|
powershell -File .\scripts\sync-skills-from-netx.ps1
|
||||||
|
`
|
||||||
|
|
||||||
|
Runtime prefers `NETX_SKILLS_ROOT` or sibling `../netx/skills`.
|
||||||
|
One topology skill covers canvas + dual_unit / layout recipes; DSH may still gate **tools** via topology vs topology-layout groups.
|
||||||
91
presets/netxops/skills/common/netx-common/SKILL.md
Normal file
91
presets/netxops/skills/common/netx-common/SKILL.md
Normal file
|
|
@ -0,0 +1,91 @@
|
||||||
|
---
|
||||||
|
name: netx-common
|
||||||
|
description: >-
|
||||||
|
netx common playbook (MCP + DSH): managed-NE CLI (SSH/Telnet batch-first) plus
|
||||||
|
native findTopologyPaths. Not NMS REST alarms/inventory. Trigger: execManagedNe,
|
||||||
|
show/display, optical, capacity A<>B, path between sites.
|
||||||
|
---
|
||||||
|
|
||||||
|
# netx-common (managed CLI + paths)
|
||||||
|
|
||||||
|
## Naming (MCP + DSH)
|
||||||
|
|
||||||
|
Canonical: **`netx/skills/common/netx-common/`**. Mirrored into MCP / dsh-netxops — edit once.
|
||||||
|
|
||||||
|
| Host | How tools appear |
|
||||||
|
|------|------------------|
|
||||||
|
| **MCP** (`netx-mcp`) | Bare names: `execManagedNe`, `findTopologyPaths`, … |
|
||||||
|
| **DSH** (`dsh-netxops`) | Prefixed: `netx__execManagedNe`, … |
|
||||||
|
|
||||||
|
NMS alarms/inventory → **netx-nms**. Canvas / dual_unit → **netx-topology**.
|
||||||
|
|
||||||
|
## Tools
|
||||||
|
|
||||||
|
| Purpose | Tool |
|
||||||
|
|---------|------|
|
||||||
|
| List managed NEs | `listManagedNe` |
|
||||||
|
| Managed detail | `getManagedNe` |
|
||||||
|
| Read-only CLI (batch-first) | `execManagedNe` |
|
||||||
|
| CLI target index | `listCliTargets` |
|
||||||
|
| Fabric paths | `findTopologyPaths` |
|
||||||
|
|
||||||
|
Prefer `nms_ne_id` / `nms_ne_ids`; legacy `ume_*` accepted. `listCliTargets(source=nms)` preferred (`ume` alias).
|
||||||
|
|
||||||
|
## CLI order
|
||||||
|
|
||||||
|
1. `listManagedNe` (`connect_status=pass`) or `listCliTargets` (**once** per session, cache ids)
|
||||||
|
2. Multi-NE → **one** `execManagedNe` with `ne_ids` / `nms_ne_ids` / `targets`
|
||||||
|
3. Paths → `findTopologyPaths` (`nms_ne_id` **or** `managed_ne_id` per end)
|
||||||
|
|
||||||
|
### Batch examples
|
||||||
|
|
||||||
|
**Same commands, many NEs**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"nms_ne_ids": ["uuid-a", "uuid-b", "uuid-c"],
|
||||||
|
"commands": ["show version"],
|
||||||
|
"read_timeout_sec": 60,
|
||||||
|
"concurrency": 4
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Different commands per NE (still one call)**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"targets": [
|
||||||
|
{"nms_ne_id": "uuid-zte", "commands": ["show opticalinfo brief"]},
|
||||||
|
{"nms_ne_id": "uuid-hw", "commands": ["display optical-module brief"]},
|
||||||
|
{"nms_ne_id": "uuid-cisco", "commands": ["show interface transceiver"]}
|
||||||
|
],
|
||||||
|
"read_timeout_sec": 90
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Wrong:** N× single-NE `execManagedNe` in one turn (stdio serial).
|
||||||
|
|
||||||
|
## Field recipes
|
||||||
|
|
||||||
|
### Capacity / optical A<>B
|
||||||
|
|
||||||
|
1. Resolve nicknames → `host_name`
|
||||||
|
2. `findTopologyPaths` and/or LLDP → both ports
|
||||||
|
3. Optics CLI on **both** ends; summarize RX/TX / thresholds
|
||||||
|
4. Do not answer with only NMS bandwidth/optical-power-threshold alarm tallies unless asked
|
||||||
|
|
||||||
|
### ZTE optical CLI
|
||||||
|
|
||||||
|
Try in order; one failure → switch spelling (do not blind-retry):
|
||||||
|
|
||||||
|
| Prefer | Fallback |
|
||||||
|
|--------|----------|
|
||||||
|
| `show opticalinfo brief` | Field-confirmed on many ZXR10 |
|
||||||
|
| `show optical brief` | Some EN platforms |
|
||||||
|
| `show opticalinfo brief \| begin <if>` | 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
|
||||||
70
presets/netxops/skills/nms/netx-nms/SKILL.md
Normal file
70
presets/netxops/skills/nms/netx-nms/SKILL.md
Normal file
|
|
@ -0,0 +1,70 @@
|
||||||
|
---
|
||||||
|
name: netx-nms
|
||||||
|
description: >-
|
||||||
|
netx NMS playbook (MCP + DSH): vendor NMS adapter (zte-ume) for alarm
|
||||||
|
query/aggregate/diagnostics, NE inventory, raw fields, and read-only SQL.
|
||||||
|
Trigger: NMS alarms, host_name, Critical Top, LOS, BN EMS, netx ops.
|
||||||
|
---
|
||||||
|
|
||||||
|
# netx-nms (vendor NMS adapter)
|
||||||
|
|
||||||
|
## Naming (MCP + DSH)
|
||||||
|
|
||||||
|
Canonical playbooks live in **`netx/skills/`** (this file is mirrored into MCP packages / dsh-netxops).
|
||||||
|
Edit the netx repo copy; run sync scripts — do not maintain divergent forks.
|
||||||
|
|
||||||
|
| Host | How tools appear |
|
||||||
|
|------|------------------|
|
||||||
|
| **MCP** (`netx-mcp`) | Bare names: `queryNmsAlarms`, … |
|
||||||
|
| **DSH** (`dsh-netxops`) | Prefixed: `netx__queryNmsAlarms`, … (same camelCase stem) |
|
||||||
|
|
||||||
|
REST still `/v1/ume/*` for provider `zte-ume`.
|
||||||
|
Prefer `nms_ne_id` / `nms_ne_ids`; legacy `ume_*` aliases still work.
|
||||||
|
|
||||||
|
Path lookup → **common** skill `netx-common` (`findTopologyPaths`).
|
||||||
|
Canvas → **netx-topology**(含 dual_unit / 布图;DSH 可把部分布局工具放在实验 tool group)。
|
||||||
|
|
||||||
|
## Tool names
|
||||||
|
|
||||||
|
| Purpose | Tool |
|
||||||
|
|---------|------|
|
||||||
|
| Alarm list | `queryNmsAlarms` |
|
||||||
|
| Alarm aggregate | `aggregateNmsAlarms` |
|
||||||
|
| Diagnostics | `runNmsDiagnostics` |
|
||||||
|
| NE inventory | `queryNmsNeInventory` |
|
||||||
|
| NE detail | `getNmsNe` |
|
||||||
|
| Field list | `listNmsAlarmFields` |
|
||||||
|
| Raw rows | `queryNmsAlarmsRaw` |
|
||||||
|
| Dynamic aggregate | `aggregateNmsAlarmsRaw` |
|
||||||
|
| SQL | `sqlQueryNms` |
|
||||||
|
| Managed CLI (other skill) | `listManagedNe` / `getManagedNe` / `execManagedNe` / `listCliTargets` |
|
||||||
|
|
||||||
|
## Tool order
|
||||||
|
|
||||||
|
1. **Freshness first**: `runNmsDiagnostics` or `aggregateNmsAlarms` → `meta.last_seen_min` / `last_seen_max`.
|
||||||
|
2. Overview: `aggregateNmsAlarms` + `runNmsDiagnostics`; samples via `queryNmsAlarms` (one page).
|
||||||
|
3. Evidence: `listNmsAlarmFields` → `queryNmsAlarmsRaw` (`field_preset=evidence`).
|
||||||
|
4. Custom aggregate: `aggregateNmsAlarmsRaw`.
|
||||||
|
5. SQL: `sqlQueryNms` (SELECT only; `statement_timeout_ms`).
|
||||||
|
6. Paths: `findTopologyPaths` via **netx-common**.
|
||||||
|
7. Device CLI: **netx-common** — multi-NE = **one** `execManagedNe` batch.
|
||||||
|
|
||||||
|
## Short-intent recipes
|
||||||
|
|
||||||
|
| User says | Recipe |
|
||||||
|
|-----------|--------|
|
||||||
|
| fiber cut / LOS / 断纤 | `queryNmsAlarmsRaw(keyword=LOS)` and/or `Fiber Break` → **host_name** list |
|
||||||
|
| offline / BN EMS | keyword=`BN EMS` |
|
||||||
|
| Critical Top | `aggregateNmsAlarms(severity=critical, top_ne=20)` |
|
||||||
|
| CRC in area | Raw `keyword=CRC` + `AREA-` hostname prefix |
|
||||||
|
| optical power **threshold** | keyword=`optical power` + area — **not** fiber-cut |
|
||||||
|
| one hostname | host-scoped `queryNmsAlarms` / Raw only |
|
||||||
|
|
||||||
|
## Guardrails
|
||||||
|
|
||||||
|
- Prefer non-SQL; filter severity → keyword/host → time → ne_id.
|
||||||
|
- Lists ≤2 pages; `page_size` default 50.
|
||||||
|
- Display **host_name** only; never bare UUID to users.
|
||||||
|
- `getManagedNe` needs managed id; NMS UUID → `getNmsNe` / `execManagedNe(nms_ne_id=...)`.
|
||||||
|
|
||||||
|
See [reference.md](reference.md).
|
||||||
20
presets/netxops/skills/nms/netx-nms/reference.md
Normal file
20
presets/netxops/skills/nms/netx-nms/reference.md
Normal file
|
|
@ -0,0 +1,20 @@
|
||||||
|
# netx-nms quick reference
|
||||||
|
|
||||||
|
## Freshness
|
||||||
|
|
||||||
|
- `runNmsDiagnostics` / `aggregateNmsAlarms` → `meta.last_seen_min` / `last_seen_max`
|
||||||
|
- Snapshot: windows inside min~max — do not default to `now()-30m`
|
||||||
|
|
||||||
|
## Tools
|
||||||
|
|
||||||
|
| Intent | Call |
|
||||||
|
|--------|------|
|
||||||
|
| Critical Top | `aggregateNmsAlarms(severity=critical, top_ne=20)` |
|
||||||
|
| Fiber / LOS | Raw `keyword=LOS` / `Fiber Break` |
|
||||||
|
| Offline / BN EMS | Raw keyword=`BN EMS` |
|
||||||
|
| Single host | `queryNmsAlarms(host_name=…)` |
|
||||||
|
| Inventory | `queryNmsNeInventory(keyword=…)` / `getNmsNe` |
|
||||||
|
| Paths | `findTopologyPaths(from_nms_ne_id, to_nms_ne_id)` — **common** skill |
|
||||||
|
| SQL | `sqlQueryNms` SELECT; `statement_timeout_ms=8000` |
|
||||||
|
|
||||||
|
DSH: prefix every tool with `netx__`.
|
||||||
|
|
@ -1,107 +0,0 @@
|
||||||
---
|
|
||||||
name: ops-netx-managed-ne-playbook
|
|
||||||
description: >-
|
|
||||||
Netx Ops managed-NE playbook: device list, connect status, read-only CLI
|
|
||||||
via SSH/Telnet (batch-first). Trigger: execManagedNe, show/display, optical, capacity A<>B.
|
|
||||||
---
|
|
||||||
|
|
||||||
# Ops Netx managed NE playbook
|
|
||||||
|
|
||||||
## Scope
|
|
||||||
|
|
||||||
Load this skill whenever you must **log into** devices registered under netx **managed NE** (or UME CLI profiles) to run show/display/ping.
|
|
||||||
|
|
||||||
Differs from UME inventory (`ops-netx-ume-playbook`): this is **SSH/Telnet** (ZTE/Huawei/Cisco hops, Linux tunnels, bastion), not UME REST sync alone.
|
|
||||||
|
|
||||||
## Tool order
|
|
||||||
|
|
||||||
Use `netx__*`.
|
|
||||||
|
|
||||||
1. **Locate**
|
|
||||||
- `listManagedNe`: `keyword`, `connect_status=pass` (preferred)
|
|
||||||
- `getManagedNe`: only for `connect_detail`; **managed `ne_id` only**
|
|
||||||
- Do **not** pass UME alarm UUIDs as managed `ne_id`; on failure follow returned `hint`
|
|
||||||
- UME without per-device managed rows: `listCliTargets(source=ume)` or inventory → `execManagedNe(ume_ne_id=…)` (requires netx **UME → CLI** profile)
|
|
||||||
2. **Execute**
|
|
||||||
- `execManagedNe`: `ne_id` **or** `ume_ne_id` + `commands` (default max 5; `NETX_NE_EXEC_MAX_COMMANDS`, hard cap 50)
|
|
||||||
- **Multi-NE = batch-first (one tool call; server concurrency default 4, max 20)**:
|
|
||||||
- Same commands: `ne_ids` / `ume_ne_ids` + shared `commands`
|
|
||||||
- Different commands per NE: `targets=[{ume_ne_id|ne_id, commands:[…]}, …]`
|
|
||||||
- Many single-NE calls in one turn are **serial** — forbidden for multi-NE work
|
|
||||||
- Per session: call `listCliTargets` at most once; merge shows into each target's `commands[]`
|
|
||||||
- Timeouts: raise `read_timeout_sec` (default 60; slow 90–120) or fewer commands — no blind retry
|
|
||||||
|
|
||||||
## Field link recipes
|
|
||||||
|
|
||||||
### Capacity / optical between two names
|
|
||||||
|
|
||||||
User says **capacity**, **bandwidth between A and B**, **optical power A <> B**, or site pairs:
|
|
||||||
|
|
||||||
1. Resolve nicknames → real `host_name` via inventory.
|
|
||||||
2. Interconnect: `findTopologyPaths` and/or LLDP — both ports.
|
|
||||||
3. Optics on **both** ends with correct vendor command. Summarize interface, RX/TX, thresholds, link up.
|
|
||||||
4. Do not answer with only UME bandwidth-usage or optical-threshold **alarm** tallies unless asked.
|
|
||||||
5. Prefer one batch (`targets` if vendors differ).
|
|
||||||
|
|
||||||
### Area optical-power **alarm** list (UME only)
|
|
||||||
|
|
||||||
Use UME keyword=`optical power` + hostname prefix — **not** this CLI recipe.
|
|
||||||
|
|
||||||
### ZTE optical CLI
|
|
||||||
|
|
||||||
Try in order; one failure → switch command (do not retry same spelling):
|
|
||||||
|
|
||||||
| Prefer | Notes |
|
|
||||||
|--------|-------|
|
|
||||||
| `show opticalinfo brief` | Field-confirmed on many ZXR10 |
|
|
||||||
| `show optical brief` | Some EN platforms |
|
|
||||||
| `show opticalinfo brief \| begin <if>` | After port known |
|
|
||||||
|
|
||||||
Cisco/Huawei: allowlisted `show interface transceiver` / `display optical-module` style.
|
|
||||||
|
|
||||||
### Multi-NE examples
|
|
||||||
|
|
||||||
**Same command, many NEs (one call):**
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"ume_ne_ids": ["uuid-a", "uuid-b", "uuid-c"],
|
|
||||||
"commands": ["show version"],
|
|
||||||
"read_timeout_sec": 60,
|
|
||||||
"concurrency": 4
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**Different commands (still one call):**
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"targets": [
|
|
||||||
{"ume_ne_id": "uuid-zte", "commands": ["show opticalinfo brief"]},
|
|
||||||
{"ume_ne_id": "uuid-hw", "commands": ["display optical-module brief"]},
|
|
||||||
{"ume_ne_id": "uuid-cisco", "commands": ["show interface transceiver"]}
|
|
||||||
],
|
|
||||||
"read_timeout_sec": 90
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**Wrong:** N× single-NE `execManagedNe` in one turn (serial + budget burn).
|
|
||||||
|
|
||||||
## CLI constraints (server-enforced)
|
|
||||||
|
|
||||||
- Allowed prefixes: `show `, `display `, `ping `, `ping6 `, `traceroute `, `tracert `, `trace `, `trace6 `
|
|
||||||
- Pipes: whitelist filters only (`include`/`exclude`/`begin`/…); no `redirect`/`tee`
|
|
||||||
- Forbidden: `;`, newlines, config/write/reload/delete
|
|
||||||
- Examples: `show version`, `display current-configuration | include sysname`, `ping 192.168.0.1`
|
|
||||||
|
|
||||||
## Troubleshooting
|
|
||||||
|
|
||||||
1. `connect_status=fail`: read `getManagedNe` `connect_detail` — do not blind-exec.
|
|
||||||
2. Hop / bastion: verify `hop_enabled`, `hop_vendor`, templates.
|
|
||||||
3. Timeout: raise `read_timeout_sec` (max 120) or fewer commands.
|
|
||||||
|
|
||||||
## Output
|
|
||||||
|
|
||||||
- Conclusion + **tool output excerpts** (never invent CLI).
|
|
||||||
- Prefer name/IP for users; keep `ne_id` as correlation key only.
|
|
||||||
- English sessions: no Chinese in user-visible prose (device output may be quoted as device text).
|
|
||||||
|
|
@ -1,116 +0,0 @@
|
||||||
---
|
|
||||||
name: ops-netx-ume-playbook
|
|
||||||
description: >-
|
|
||||||
Netx Ops UME playbook: alarm query/aggregate/diagnostics, NE inventory,
|
|
||||||
raw fields, read-only SQL, and alarm-related topology path lookup.
|
|
||||||
Trigger: UME alarms, host_name, Critical Top, LOS, BN EMS, netx ops.
|
|
||||||
---
|
|
||||||
|
|
||||||
# Ops Netx UME playbook
|
|
||||||
|
|
||||||
## Scope
|
|
||||||
|
|
||||||
For any Netx Ops request about UME **alarms** or **NE inventory**, load this skill first.
|
|
||||||
|
|
||||||
## Tool names
|
|
||||||
|
|
||||||
Host tool names are `netx__<camelCase>`:
|
|
||||||
|
|
||||||
| Purpose | Tool |
|
|
||||||
|---------|------|
|
|
||||||
| Alarm list | `queryUmeAlarms` |
|
|
||||||
| Alarm aggregate | `aggregateUmeAlarms` |
|
|
||||||
| Diagnostics | `runUmeDiagnostics` |
|
|
||||||
| NE inventory | `queryUmeNeInventory` |
|
|
||||||
| NE detail | `getUmeNe` |
|
|
||||||
| Field list | `listUmeAlarmFields` |
|
|
||||||
| Raw rows | `queryUmeAlarmsRaw` |
|
|
||||||
| Dynamic aggregate | `aggregateUmeAlarmsRaw` |
|
|
||||||
| SQL | `sqlQueryUme` |
|
|
||||||
| Topology paths | `findTopologyPaths` |
|
|
||||||
| Managed CLI (other skill) | `listManagedNe` / `getManagedNe` / `execManagedNe` / `listCliTargets` |
|
|
||||||
|
|
||||||
Do not use removed inline `netx_*` names.
|
|
||||||
|
|
||||||
## Tool order
|
|
||||||
|
|
||||||
1. **Freshness first**: `runUmeDiagnostics` or `aggregateUmeAlarms` → `meta.last_seen_min` / `last_seen_max`.
|
|
||||||
- If max is far from now, treat as **snapshot**: time windows must fall inside that range — never blind `now()-30m`.
|
|
||||||
2. Overview: `aggregateUmeAlarms` + `runUmeDiagnostics`; samples via `queryUmeAlarms` (one page).
|
|
||||||
3. Evidence: `listUmeAlarmFields` → `queryUmeAlarmsRaw` (`field_preset=evidence` / `select_fields`).
|
|
||||||
4. Custom aggregate: `aggregateUmeAlarmsRaw` (`group_by=alarm_host_name`, …).
|
|
||||||
5. SQL: `sqlQueryUme` (SELECT only; set `statement_timeout_ms`).
|
|
||||||
6. Related path: take `ne_id` from alarms → `findTopologyPaths` (shortest first).
|
|
||||||
7. Device CLI: `ops-netx-managed-ne-playbook` — multi-NE must be **one** `execManagedNe` batch.
|
|
||||||
|
|
||||||
## Decision tree
|
|
||||||
|
|
||||||
- **Fleet / Top risk**: `runUmeDiagnostics` + `aggregateUmeAlarms` (missing host excluded by default; check `by_ne_missing`).
|
|
||||||
- Critical Top-N: `aggregateUmeAlarms(severity=critical, top_ne=10)`.
|
|
||||||
- Group by host: `aggregateUmeAlarms(group_by=alarm_host_name, …)` or `aggregateUmeAlarmsRaw`.
|
|
||||||
- **Time window**: `time_from` / `time_to` = `last_seen_at`; check freshness first.
|
|
||||||
- **Citeable rows**: `queryUmeAlarmsRaw` + `field_preset=evidence`.
|
|
||||||
- **Complex filters**: `sqlQueryUme`.
|
|
||||||
- **Critical port / fiber**: sample 1–2 `ne_id` → `findTopologyPaths` → then CLI if needed.
|
|
||||||
- **NE identity**: `queryUmeNeInventory(keyword=host_name)`; full raw via `getUmeNe`.
|
|
||||||
|
|
||||||
## Short-intent recipes (≤3 tool calls)
|
|
||||||
|
|
||||||
| User says | Recipe |
|
|
||||||
|-----------|--------|
|
|
||||||
| fiber cut / LOS / 断纤 / sitelist | `queryUmeAlarmsRaw(keyword=LOS)` and/or `keyword=Fiber Break`; reply **host_name** list + counts (not optical-power threshold) |
|
|
||||||
| offline / unmanaged / 离线 | keyword=`BN EMS` / NE communication failure; clarify unmanaged vs unreachable |
|
|
||||||
| Critical Top / tally | `aggregateUmeAlarms(severity=critical, top_ne=20)` |
|
|
||||||
| how many alarms / 现网告警数量 | `runUmeDiagnostics` or `aggregateUmeAlarms` → by_severity + freshness |
|
|
||||||
| CRC in area PAD / ACH / … | `queryUmeAlarmsRaw(keyword=CRC)` then keep `AREA-` hostname prefix |
|
|
||||||
| bandwidth / congestion (+ area) | keyword=`bandwidth`; filter hostname prefix; CLI confirm = top 3–5 NEs **one** batch |
|
|
||||||
| optical power **threshold** in area | keyword=`optical power`; keep `AREA-` hosts — **not** fiber-cut |
|
|
||||||
| dying gasp / BN EMS | See correlation below — do not stop at one NE |
|
|
||||||
| power / temperature / fan / undervoltage | matching keyword; scope host or area prefix |
|
|
||||||
| license | keyword=`License` |
|
|
||||||
| BGP / OSPF / ISIS / LDP / PW / Tunnel on host | host-scoped `queryUmeAlarmsRaw` |
|
|
||||||
| Port down / which segment | keyword=`Port down` or `LOS`; `object_name` + `findTopologyPaths` / LLDP |
|
|
||||||
| alarm on **one hostname** | `queryUmeAlarms` / Raw with that host only — never hijack unrelated playbooks |
|
|
||||||
| history / time range (`17.50-18.15`) | Resolve **WIB (UTC+7)** → `time_from`/`time_to`; check freshness first |
|
|
||||||
|
|
||||||
Confirm replies (`YES` / `confirm` / `继续`): continue the prior task; do not restart the query.
|
|
||||||
|
|
||||||
### Field vocabulary
|
|
||||||
|
|
||||||
- **Area** = hostname prefix before first `-` (`MDN-`, `ACH-`, …), case-insensitive starts-with.
|
|
||||||
- **Capacity A<>B / optical between sites** = interconnect SFP/optics CLI (managed-ne skill), not bandwidth-usage alarms alone.
|
|
||||||
- **Optical power threshold** ≠ fiber cut / LOS sitelist.
|
|
||||||
- **Local clock phrases**: Asia/Jakarta (WIB, UTC+7) unless user says otherwise.
|
|
||||||
|
|
||||||
### Dying gasp / BN EMS
|
|
||||||
|
|
||||||
1. Named NE: Raw keyword=`dying gasp` — note `object_name` / times.
|
|
||||||
2. Peer via `findTopologyPaths` and/or LLDP.
|
|
||||||
3. Peer: BN EMS / communication failure near that timestamp.
|
|
||||||
4. Reply both sides + times.
|
|
||||||
|
|
||||||
### Anti-patterns
|
|
||||||
|
|
||||||
1. Wrong playbook hijack (single-host ask → do not run license/daily scripts).
|
|
||||||
2. Narration-only final replies.
|
|
||||||
3. Dense Markdown pipe tables in chat-style channels — prefer `*bold*` + `-` lists.
|
|
||||||
4. Blind CLI retries with the same failed command.
|
|
||||||
5. Unfiltered dump of huge uncleared sets — always severity/keyword/host/area/time.
|
|
||||||
|
|
||||||
## Guardrails
|
|
||||||
|
|
||||||
- Prefer non-SQL; use SQL only when parameters cannot express the filter.
|
|
||||||
- Filter order: `severity` → `keyword`/`host_name` → time → `event_type`/`ne_id`.
|
|
||||||
- Lists ≤2 pages by default; `page_size` default 50; aggregate `top_ne` default 50; dynamic aggregate `limit≤200`.
|
|
||||||
- Top NEs: ignore `(host_name missing)` in rankings; report missing count separately.
|
|
||||||
- SQL: `statement_timeout_ms=8000`; no `WITH RECURSIVE`.
|
|
||||||
- `getManagedNe` needs **managed** `ne_id` only; UME UUID → `getUmeNe` / `execManagedNe(ume_ne_id=...)`.
|
|
||||||
|
|
||||||
## Display
|
|
||||||
|
|
||||||
- Primary NE key for users = **`host_name`** (`alarm_host_name` in raw).
|
|
||||||
- Never show bare `ne_id` UUID; use it only for filters / `findTopologyPaths`.
|
|
||||||
|
|
||||||
## Templates
|
|
||||||
|
|
||||||
See [reference.md](reference.md).
|
|
||||||
|
|
@ -1,58 +0,0 @@
|
||||||
# Ops Netx UME quick reference
|
|
||||||
|
|
||||||
## 0) Freshness (required)
|
|
||||||
|
|
||||||
- `runUmeDiagnostics` / `aggregateUmeAlarms` → `meta.last_seen_min` / `last_seen_max`
|
|
||||||
- Snapshot data: windows inside min~max — **do not** default to `now()-30 minutes`
|
|
||||||
|
|
||||||
## 1) Current alarms (light)
|
|
||||||
|
|
||||||
- Tool: `queryUmeAlarms`
|
|
||||||
- Params: `severity`, `host_name`, `ne_id` (filter only), `keyword`, `time_from`, `time_to`, `page`, `page_size`
|
|
||||||
- Suggest: `page_size=50`; at most 2 pages by default
|
|
||||||
|
|
||||||
## 2) Raw evidence
|
|
||||||
|
|
||||||
- Tool: `queryUmeAlarmsRaw`
|
|
||||||
- Optional: `listUmeAlarmFields`
|
|
||||||
- `field_preset`: `brief` / `evidence` / `ne_debug`
|
|
||||||
- Display key: `alarm_host_name`
|
|
||||||
|
|
||||||
## 3) Aggregate
|
|
||||||
|
|
||||||
- `aggregateUmeAlarms`: `severity`, `top_ne`, `exclude_missing_host`, time window
|
|
||||||
- Critical Top: `severity=critical`
|
|
||||||
- `group_by=alarm_host_name` → dynamic aggregate path
|
|
||||||
- `aggregateUmeAlarmsRaw`: custom `group_by`
|
|
||||||
|
|
||||||
## 3b) Short paths
|
|
||||||
|
|
||||||
| Intent | Call |
|
|
||||||
|--------|------|
|
|
||||||
| Critical Top | `aggregateUmeAlarms(severity=critical, top_ne=20)` |
|
|
||||||
| Fiber / LOS sitelist | Raw `keyword=LOS` and/or `Fiber Break` → host list |
|
|
||||||
| Offline / BN EMS | Raw keyword=`BN EMS` |
|
|
||||||
| Area optical threshold | Raw `keyword=optical power` + `AREA-` prefix — **not** fiber cut |
|
|
||||||
| Single host alarms | `queryUmeAlarms(host_name=…)` |
|
|
||||||
| dying gasp | local dying gasp → peer BN EMS near time |
|
|
||||||
| Capacity A<>B | resolve hosts → `findTopologyPaths` / LLDP → optic CLI (managed-ne) |
|
|
||||||
| History (WIB) | freshness → `time_from`/`time_to` |
|
|
||||||
|
|
||||||
## 4) Diagnostics
|
|
||||||
|
|
||||||
- `runUmeDiagnostics`
|
|
||||||
- `top_event_types`, `top_alarm_codes`, `top_ne`, `meta.last_seen_*`
|
|
||||||
|
|
||||||
## 4b) Inventory
|
|
||||||
|
|
||||||
- `queryUmeNeInventory(keyword=…)`
|
|
||||||
- `getUmeNe` for full `raw_json`
|
|
||||||
|
|
||||||
## 5) Paths
|
|
||||||
|
|
||||||
- `findTopologyPaths(from_ume_ne_id, to_ume_ne_id)` — default `detail=summary`
|
|
||||||
|
|
||||||
## 6) SQL
|
|
||||||
|
|
||||||
- `sqlQueryUme` SELECT only; `statement_timeout_ms=8000`
|
|
||||||
- Prefer aggregate/raw when SQL scope is insufficient
|
|
||||||
378
presets/netxops/skills/topology/netx-topology/SKILL.md
Normal file
378
presets/netxops/skills/topology/netx-topology/SKILL.md
Normal file
|
|
@ -0,0 +1,378 @@
|
||||||
|
---
|
||||||
|
name: netx-topology
|
||||||
|
description: >-
|
||||||
|
用 netx-topology MCP / DSH topology 组查邻接、分类打标;先 dual_unit 一次抽最大核心眼,
|
||||||
|
再换其它算法下沉/布图(不污染 Fabric)。触发:画拓扑、布图、拖图、分类、LLDP、Fabric、netx-topology。
|
||||||
|
---
|
||||||
|
|
||||||
|
# netx 拓扑(通用)
|
||||||
|
|
||||||
|
## Hosts(MCP + DSH)
|
||||||
|
|
||||||
|
| Host | Tools |
|
||||||
|
|------|--------|
|
||||||
|
| **MCP** `netx-topology-mcp` | 裸名(`sinkTopologyDualUnits` …);安装见 [`docs/MCP_TOPOLOGY.md`](../../../docs/MCP_TOPOLOGY.md) |
|
||||||
|
| **DSH** `dsh-netxops` | `netx__` + 同 stem。整组工具 + skill **`netx-topology`**(能力组 **topology**,默认关)。缺布局引擎能力时用 MCP。 |
|
||||||
|
|
||||||
|
Canonical: `netx/skills/topology/netx-topology/`(唯一正文)。
|
||||||
|
|
||||||
|
**原则**:
|
||||||
|
1. **第一步用 dual_units**:从**核心**出发,选**覆盖网元最多**的那一只眼(`prefer_top_eye` + max cover)。
|
||||||
|
2. **`sinkTopologyDualUnits` / `layout_dual_unit` 整图流程只用一次**——只沉这一只最大眼;**禁止**再连抽下一批 dual_unit。
|
||||||
|
3. **后续下沉换算法**:`suggestSinkHubs` → `move_nodes(park)` / `projectTopologyNeighbors`;**不要**再 `sinkTopologyDualUnits`。
|
||||||
|
4. **眼图已定型 → 门控精修**:对该 sink 只用下表「允许」动作;禁止全局拆眼工具。
|
||||||
|
5. 布眼目标:少交叉、眼心空旷、少重叠(椭圆弧带;长链在眼外)。对照人工金标时还要看:**紧凑度、正交边、贴边清开**(见「算法天花板」)。
|
||||||
|
|
||||||
|
**禁止**写临时 py 穷举坐标或直接调 HTTP;验证与压交叉**只调 MCP**(或 DSH 已实现的同名工具)。不造 Fabric 边。
|
||||||
|
**脱敏(硬)**:勿把客户网元名、站点/区域名、具体交叉数、具体 view_id 写进本 skill。角色只用通用词:门户 / 枢纽 / 汇聚 / 接入 / 末梢。**禁止**在 skill 正文写站点缩写或设备角色缩写当专名举例。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 完整处理思路(一次大区接入眼)
|
||||||
|
|
||||||
|
按阶段执行;每阶段只调 MCP,读返回再决定下一步。
|
||||||
|
|
||||||
|
```
|
||||||
|
① 认眼
|
||||||
|
analyze(structure) → dual_units 里取覆盖最大的核心门户对
|
||||||
|
② 一次 sink
|
||||||
|
sinkTopologyDualUnits(max_units=1, prefer_top_eye, layout_batch)
|
||||||
|
→ 同一 sink 贯穿全程;眼形不对只 layout_dual_unit 重布同一对门户
|
||||||
|
③ 换算法下沉(禁 dual)
|
||||||
|
循环:suggestSinkHubs(pick:1) → move_nodes(park) → until_limit(crossing)
|
||||||
|
空批次后:orphan_batch / 对根−sink−门户 分批 park;仍禁 dual
|
||||||
|
④ 眼定型精修(门控序列,见下)
|
||||||
|
until_limit(crossing) → until_limit(total)
|
||||||
|
→ clear_edge_hits(portal_ids) → pull_far_chains(portal_ids)
|
||||||
|
→ compact_bbox(portal_ids, outlier_only) → 可选再 clear / total
|
||||||
|
⑤ 停手(算法天花板,**默认无范本**)
|
||||||
|
overlaps=0;until_limit stall;pull/compact/clear 均 moved≈0
|
||||||
|
→ 交付:眼可读 + ov=0 + 交叉可接受;util/正交/贴边缺口 → 手拖或改初布
|
||||||
|
→ 勿再发明无门控全局工具硬挤
|
||||||
|
```
|
||||||
|
|
||||||
|
**范本现实(硬)**:
|
||||||
|
- **日常默认没有范本**;有人工图也几乎不是同一张网 → **禁止**把 `align_reference` / 抄坐标当主路径。
|
||||||
|
- `align_reference` **仅**用于:同网同成员画布的 A/B 对照、调试、回放(共享 `fabric_node_id`)。跨网 ID 对不上,抄了也无意义。
|
||||||
|
- 生产要缩短与人工观感差距 → **改初布配方**(正交/紧凑从 dual 一次就偏地铁风),不是现场找金标对齐。
|
||||||
|
|
||||||
|
**从零重布(无范本算力路径)**:
|
||||||
|
- 眼已在 sink、只需甩掉坏坐标 / 金标残留:对本 sink **`layout_dual_unit`(同一门户对)→ 立刻门控精修**。这**不算**再 sink 一批 dual。
|
||||||
|
- 初布后交叉会先飙高、贴边变差属正常;**不要**因此改走 `polish_crossings` / `fix_overlaps`。
|
||||||
|
- 纯算力压交叉后 util/bbox 常崩(枢纽被 `until_limit` 甩远)→ **必须**接 `pull_far_chains` → `compact_bbox`;缺这两个 action 就先重启 MCP,勿半截交付。
|
||||||
|
- 无范本时:交叉可压到可读,但仍常高于「抄同网坐标」路径;util/正交缺口靠 pull + 初布,**不是**再堆 until_limit。
|
||||||
|
|
||||||
|
**开局核对 catalog(硬)**:
|
||||||
|
- 精修前先 `layoutTopologyView(catalog=true)`:须见 `pull_far_chains`、`compact_bbox`,且 `version`/`rev` 为当前包。
|
||||||
|
- 缺 action 或 `rev` 过旧 → **重启** netx-topology MCP 再干;禁止用旧进程硬跑、禁止改走 HTTP/临时 py。
|
||||||
|
|
||||||
|
**精修硬门控(已写进算法,skill 须遵守)**:
|
||||||
|
- 任何落笔:**全局交叉不得升高**;升高 → 弃该候选。
|
||||||
|
- **最小化展开**(bundle):从小压缩步长试探;步长须清开 footprint(成员互叠也算失败)。
|
||||||
|
- **侵入** = 展开后踏入外人框 / 留下任意 footprint 重叠 → 换 tip,勿硬塞。
|
||||||
|
- `until_limit` 只接受 `overlaps=0`;apply 遇 `overlaps_remain` → 修算法,**勿**对眼 sink 跑 `fix_overlaps`。
|
||||||
|
- `portal_ids` **只钉眼门户**;`freeze_layers=["core"]` 会冻住 sink 上所有核心(含旁路枢纽)——慎用。
|
||||||
|
- 旁路枢纽也要动时:`protect_rigid=off`(仍受 `portal_ids` 约束)。
|
||||||
|
- `clear_edge_hits`:**禁止**放宽交叉(含 `preserve_axis`);交叉升高一律拒。
|
||||||
|
- `pull_far_chains`:相对**门户中点**缩放远走廊/孤立点/远叶(不是朝外圈枢纽);枢纽本身在外围时朝枢纽缩**收不了 bbox**。
|
||||||
|
- `compact_bbox`:默认 **farthest-K**(`outlier_only`);全图/分位均匀缩易叠点 → 拒。
|
||||||
|
- `until_limit`:`stop_reason=max_moves` **≠** stall → **再调**直到 `stall`(可提高 `max_moves`,但仍以 stall 为准)。
|
||||||
|
- 大眼 `max_jump` 用 4k–8k;勿收到 <2k。大图会 background → `job_status`(可数分钟);勿中途改走拆眼工具。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 算法天花板(经验固化)
|
||||||
|
|
||||||
|
大区接入眼跑完门控序列后,常见结果:
|
||||||
|
|
||||||
|
| 维度 | 算法能做到 | 再挤会怎样 | 交付策略 |
|
||||||
|
|------|------------|------------|----------|
|
||||||
|
| 交叉 | 从零 dual 后 `until_limit` 可大幅压下;无范本时仍常高于抄坐标路径 | 再挪极值尖端易抬交叉;`max_moves` 停了要续跑到 stall | **停**于 stall;残余当弦交叉可接受 |
|
||||||
|
| 重叠 | 硬零 | — | 必须保持 |
|
||||||
|
| 紧凑 util / bbox | 压交叉常先**炸开** bbox;`pull_far_chains` 多轮收远走廊 | 缺 pull 就半截交付;强缩常 `Δg>0` 拒 | **必跑** pull→compact 到 `moved=0`;其余手拖 |
|
||||||
|
| 贴边 clearance | `clear` + `objective=total` 能降一批 | 与交叉/紧凑拉扯,清到个位数很难 | stall 后手拖擦边点 |
|
||||||
|
| 正交 axis | `total` 略抬;远低于人工地铁风 | `preserve_axis` 清边若放宽交叉会毁掉前面成果(已禁 slack) | 手拖走廊;勿为轴牺牲交叉 |
|
||||||
|
| 直链 / 环穿 | bundle 后仍有折角与穿环 | 全局 straighten 拆眼 | 勿清零;结构优先 |
|
||||||
|
|
||||||
|
**判定「算法到头」**(门控精修,无范本):
|
||||||
|
1. `overlaps=0`
|
||||||
|
2. `until_limit`(crossing 与 total)均 stall
|
||||||
|
3. pull/compact/clear `moved≈0`
|
||||||
|
4. 目视:眼心空、双门户可辨
|
||||||
|
|
||||||
|
**可选对照**(仅评测):对**同网**人工画布与算法 sink 各 `analyze`,只比 `summary`;**勿**因此去 `align_reference` 当交付手段。
|
||||||
|
|
||||||
|
**明确无效 / 禁止再试**(眼 sink):
|
||||||
|
- 再次 `sinkTopologyDualUnits` / `until_empty` / 新块画布抽 dual
|
||||||
|
- `polish_crossings` / `straighten_channels` / `untangle` / `fix_overlaps` / `orbit_sweep(round=true)`
|
||||||
|
- 临时 py 穷举坐标、无门控的整图缩放/平移极值点
|
||||||
|
- 为追 util 关掉交叉门控或给 `clear` 开交叉 slack
|
||||||
|
- **把跨网/他网人工图当范本抄坐标**(含滥用 `align_reference`)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 主路径(必循)
|
||||||
|
|
||||||
|
```
|
||||||
|
analyze(structure) → 读 dual_units(认核心最大眼)
|
||||||
|
→ 有 level:先打标;巨图勿先全量 level_bands
|
||||||
|
→ 【仅一次】sinkTopologyDualUnits(
|
||||||
|
max_units=1, prefer_top_eye=true, prefer_pure=false,
|
||||||
|
sink_layers=[access], keep_layers=[core,agg], layout_batch=true,
|
||||||
|
max_nodes/max_batch_nodes 放行大眼
|
||||||
|
)
|
||||||
|
→ 眼形不对:可对本 sink 重跑 layout_dual_unit(仍算同一只眼;勿再 sink 下一批 dual)
|
||||||
|
→ 【换算法】suggestSinkHubs → move_nodes(park) 分批迁入同一 sink;每批后 until_limit;禁止再 dual
|
||||||
|
→ 【眼定型】门控精修序列(下节);禁止全局拆眼
|
||||||
|
→ 根图可 level_bands(只动根上残留,不动已定型的眼 sink)
|
||||||
|
```
|
||||||
|
|
||||||
|
停手:最大眼可读、眼心空、`overlaps≈0`、门户清晰;其余节点用非 dual 手段靠上去即可。交叉允许来自跨走廊弦边。
|
||||||
|
|
||||||
|
**dual 一次用尽**:`until_empty`、再次 `sinkTopologyDualUnits`、为下一批 dual 新建块画布 —— **一律禁止**(日常)。
|
||||||
|
|
||||||
|
**布眼几何**(仅那一次):嵌套半椭圆;中轴只有双门户;长链/尾巴放眼外禁穿环。眼坏了重跑 `layout_dual_unit`,勿全局拆眼。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 眼图定型后的精修(门控序列)
|
||||||
|
|
||||||
|
眼 sink 一经验收,**禁止**再对该画布跑会整图拆眼的工具。
|
||||||
|
|
||||||
|
| 允许 | 禁止(眼 sink 上) |
|
||||||
|
|------|-------------------|
|
||||||
|
| `analyze(detail=hotspots\|both)` 只读 | `polish_crossings` / `straighten_channels` |
|
||||||
|
| `orbit_sweep`:**`until_limit=true`** 或 `node_id` preview→pick | `orbit_sweep(round=true)` 全图轮扫 |
|
||||||
|
| `clear_edge_hits`(**须** `portal_ids`;交叉不升) | `fix_overlaps` / `untangle` 批处理整图 |
|
||||||
|
| `pull_far_chains`(朝**门户中点**收远走廊/孤立点;同上门控) | `layout` / 再次 `sinkTopologyDualUnits` |
|
||||||
|
| `compact_bbox`(farthest-K;交叉不升、ov=0) | 临时 py 穷举坐标 |
|
||||||
|
| `updateTopologyViewPositions` 手拖 1~少数点 | `align_reference` 当无同网范本时的交付手段 |
|
||||||
|
| 眼形整体崩了才 `layout_dual_unit` 重布同一门户对 | |
|
||||||
|
|
||||||
|
**推荐节奏**(一次序列,读返回再决定是否重复某一环):
|
||||||
|
|
||||||
|
```
|
||||||
|
0) catalog=true 核对 pull_far_chains / compact_bbox(缺则重启 MCP)
|
||||||
|
0b) 从零重布时:layout_dual_unit(同一门户对, apply) // 交叉飙高正常
|
||||||
|
1) until_limit + objective=crossing + portal_ids + bundle
|
||||||
|
→ stop_reason=max_moves 则续跑,直到 stall
|
||||||
|
2) until_limit + objective=total // 交叉不升;抬贴边/正交;亦可 max_moves→续跑
|
||||||
|
3) clear_edge_hits(portal_ids, top_n/max_moves 可加大;勿 preserve_axis 除非确认不抬交叉)
|
||||||
|
4) pull_far_chains(portal_ids) // 可 2~3 轮直到 moved=0;压交叉后必做
|
||||||
|
5) compact_bbox(portal_ids, outlier_only) // 常 accepted=false,正常
|
||||||
|
6) 可选:再 clear 或 total;再 stall → 停手
|
||||||
|
```
|
||||||
|
|
||||||
|
`until_limit` 调用模板:
|
||||||
|
|
||||||
|
```
|
||||||
|
layoutTopologyView({
|
||||||
|
action: "orbit_sweep", mode: "apply",
|
||||||
|
params: {
|
||||||
|
until_limit: true,
|
||||||
|
objective: "crossing", // 再一轮改 total
|
||||||
|
portal_ids: [<门户A>, <门户B>],
|
||||||
|
protect_rigid: "off",
|
||||||
|
max_degree: 14,
|
||||||
|
max_jump: 8000, // 大眼用 4k–8k;勿 <2k
|
||||||
|
max_stretch: 32,
|
||||||
|
max_moves: 40, // 触顶只说明配额满,不是算法到头
|
||||||
|
bundle: true
|
||||||
|
}
|
||||||
|
})
|
||||||
|
→ 读 local.op:start/end_crossings、moves[]、stop_reason
|
||||||
|
→ max_moves → 再调;stall → 进入下一步
|
||||||
|
```
|
||||||
|
|
||||||
|
`round=true` ≠ `until_limit`:前者一批 top_n 死拿 pick#1;后者循环到 stall。
|
||||||
|
勿为清零交叉拆弧带;以 `verdict.total` + 目视眼形 +「算法到头」四条为准。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 阶段 0 — 画布与成员
|
||||||
|
|
||||||
|
1. `getTopologyTree` → **`view_id`**(文件夹 physical 画布)。
|
||||||
|
2. 建根/子区域只用 `createTopologyFolder`。
|
||||||
|
3. 缺层级/区域时:`classifyTopologyFabricNodes`(`match` → `tag(level, dry_run)` → `tag`;或 `unmatched` / `apply_rules`)。**level** 越小越靠外(0 外部 / 1 核心 / 2 汇聚 / 3 接入;可用 1.1、2.1 子层)。**勿**切片建图。
|
||||||
|
4. `analyzeTopologyViewLayout({ view_id, detail: "structure" })` 读 `dual_units` / `shape` / `layers` —— **先认核心最大眼**(`unit_count` / 各 unit `node_count`)。
|
||||||
|
5. `addTopologyViewNodes`(可按 `role` 大档或后续 level)/ `projectTopologyNeighbors`(区域画布务必 `region_folder_id`)。
|
||||||
|
6. 多余点用 `removeTopologyViewNodes` **移出画布**(不删 Fabric)。`region:…` 幽灵点勿当网元拖。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 形状与 dual_units(只服务「第一只眼」)
|
||||||
|
|
||||||
|
```
|
||||||
|
analyzeTopologyViewLayout({ view_id, detail: "structure" })
|
||||||
|
```
|
||||||
|
|
||||||
|
| 字段 | 用途 |
|
||||||
|
|------|------|
|
||||||
|
| `shape.primary` | `chains` / `star` / `mesh` / `mixed_blocks` |
|
||||||
|
| `dual_units` | **从核心出发**:core–core → core–agg → …,再取 **node_count 最大**的一只做唯一 sink |
|
||||||
|
| `advice.block_plan` | 其余块怎么靠(非 dual 连抽) |
|
||||||
|
| `gravity.type` | 链图勿当 hub 花瓣 |
|
||||||
|
|
||||||
|
- **第一步必选最大核心眼**:`prefer_top_eye=true`,`prefer_pure=false`,`max_units=1`;目标是**一块吃进尽量多网元**,不要求交叉=0。
|
||||||
|
- **禁止**把 dual_units 当成「抽干源图」的循环泵;剩余网元见下方「后续下沉」。
|
||||||
|
- **禁止**再用互斥 soft_block 把通路切开。
|
||||||
|
- **链图 / 小图**:可不走 dual;preview `corridor`/`compact`。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 根图 → 子区域:第一次(dual,仅一次)
|
||||||
|
|
||||||
|
```
|
||||||
|
sinkTopologyDualUnits({
|
||||||
|
source_view_id: <根图>,
|
||||||
|
sink_view_id: <子区域>, // 同一张接入画布贯穿全程
|
||||||
|
max_units: 1,
|
||||||
|
max_nodes: 300,
|
||||||
|
max_batch_nodes: 400,
|
||||||
|
layout_batch: true,
|
||||||
|
sink_layers: ["access"],
|
||||||
|
keep_layers: ["core", "agg"],
|
||||||
|
prefer_top_eye: true,
|
||||||
|
prefer_pure: false,
|
||||||
|
// 勿 until_empty;勿随后再调本工具
|
||||||
|
})
|
||||||
|
→ 眼形验收 / 手拖;必要时 layout_dual_unit 重布同一眼
|
||||||
|
```
|
||||||
|
|
||||||
|
- **同一 sink**:后续非 dual 迁入也进**同一个** `sink_view_id`;禁止块1/块2/块3。
|
||||||
|
- **眼定型后**:对该 sink **只门控精修**(见上节)。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 后续下沉(换算法,禁用 dual)
|
||||||
|
|
||||||
|
源图仍有接入 / 旁路网元时,**不要**再 `sinkTopologyDualUnits`。
|
||||||
|
|
||||||
|
0. **先问算法要批次**(推荐)
|
||||||
|
```
|
||||||
|
suggestSinkHubs({
|
||||||
|
source_view_id: <根图>,
|
||||||
|
sink_view_id: <眼 sink>,
|
||||||
|
pick: 1,
|
||||||
|
})
|
||||||
|
→ batch.fabric_node_ids / move_nodes 可直接套用
|
||||||
|
```
|
||||||
|
排序:剩余领地大优先、agg 优于 core;眼门户不当 batch 头。
|
||||||
|
|
||||||
|
**每批迁完立刻** `until_limit(crossing, portal_ids)`,再取下一批;勿攒多批再一起 polish。
|
||||||
|
|
||||||
|
`suggestSinkHubs` 空了但根上还有孤立末梢/旁路:清掉已在 sink 的重复点,再对 `root−sink−眼门户` 分批 `move_nodes(park)`(每批后 `until_limit`)。也会直接吐 `orphan_batch_*`。勿再 dual。
|
||||||
|
|
||||||
|
1. **指定迁移 + 扫角停靠**
|
||||||
|
```
|
||||||
|
layoutTopologyView({
|
||||||
|
action: "move_nodes",
|
||||||
|
source_view_id: <FROM>, view_id: <TO>,
|
||||||
|
mode: "apply",
|
||||||
|
params: {
|
||||||
|
fabric_node_ids: [...],
|
||||||
|
park: true,
|
||||||
|
remove_from_source: true,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **投影邻居**:`projectTopologyNeighbors`(区域画布带 `region_folder_id`),再手拖或 `orbit_sweep`。
|
||||||
|
|
||||||
|
3. **根图收尾**:`level_bands` 只钉**根图**上残留的 core/agg;**勿**对已定型眼 sink 跑全局带。
|
||||||
|
|
||||||
|
4. **小连通块**:源上剩余短链/星,可对**源图**或迁入后**单点**处理;勿对眼 sink `layout(recipe=…)`。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 分层布局(特殊场景)
|
||||||
|
|
||||||
|
当网络需呈现层级结构(外部→接入→核心→汇聚→客户等)时:
|
||||||
|
|
||||||
|
### 分层通用规则
|
||||||
|
1. **顶层:外部/对接网络**
|
||||||
|
2. **次顶层:终端客户接入层**
|
||||||
|
3. **中间层:核心层**
|
||||||
|
4. **核心下层:汇聚层**
|
||||||
|
5. **底层:接入层/孤立层**
|
||||||
|
|
||||||
|
### 优化流程
|
||||||
|
1. **先手动后算法**:用户手动拖拽保证业务结构,再用算法优化
|
||||||
|
2. **分层约束**:`updateTopologyViewPositions` 按层钉 y;`orbit_sweep` 可传 `y_min/y_max`
|
||||||
|
3. **分步(非眼画布 / 根图)**:可贴边、分层钉 y;**已定型眼 sink 除外**——只允许门控精修 / 手拖
|
||||||
|
|
||||||
|
### 冲突处理
|
||||||
|
| 冲突场景 | 处理方式 |
|
||||||
|
|---------|---------|
|
||||||
|
| 交叉 vs 结构 | **结构优先**(最大眼 / 分层) |
|
||||||
|
| 算法 vs 手动 | **手动优先** |
|
||||||
|
| polish 拆眼 | **禁止**对 dual 眼走 straighten 主路径 |
|
||||||
|
| 算法 vs 金标观感 | 交叉可赢;紧凑/正交/贴边交给手拖 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## layoutTopologyView(精简)
|
||||||
|
|
||||||
|
| action | 用途 |
|
||||||
|
|--------|------|
|
||||||
|
| `layout` | 小图 / 剩余块:`compact` / `corridor` / `rings` / `unstick` |
|
||||||
|
| `layout_dual_unit` | **仅**重布已沉的那一只眼;椭圆弧带 + 空心眼 |
|
||||||
|
| `move_nodes` / `sink_nodes` | **后续下沉主路径**;`park` 块扫 |
|
||||||
|
| `orbit_sweep` | **眼后精修主工具**:`until_limit` 或 `node_id`;`objective=crossing\|total` |
|
||||||
|
| `clear_edge_hits` | 眼 sink **可**(须 `portal_ids`;交叉不升) |
|
||||||
|
| `pull_far_chains` | 眼 sink **可**:朝门户中点收远走廊/孤立点 |
|
||||||
|
| `align_reference` | **非主路径**:仅同网同成员 A/B 调试;跨网禁用 |
|
||||||
|
| `compact_bbox` | 眼 sink **可**:farthest-K 收 bbox |
|
||||||
|
| `level_bands` | 仅根图/未定型画布;巨图在 dual 一次之后钉根 |
|
||||||
|
| `polish_crossings` | **眼 sink 禁用** |
|
||||||
|
| `fix_overlaps` / `untangle` / `straighten_channels` | **眼 sink 禁用** |
|
||||||
|
| `job_status` / `job_cancel` | 后台 job |
|
||||||
|
|
||||||
|
眼 sink 精修 = 门控序列 + 手拖补观感;不要排全局流水线,也不要在 stall 后继续发明新全局动作。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 验收
|
||||||
|
|
||||||
|
| 块 | 看什么 |
|
||||||
|
|----|--------|
|
||||||
|
| `overlap` | 硬零(权重 0.24) |
|
||||||
|
| `crossing` | crossings/cpl;`top_nodes` / `top_edges` |
|
||||||
|
| `sparsity` / `edge_axis` / `edge_clearance` | 对照人工:紧凑、正交、贴边(算法常弱于此) |
|
||||||
|
| `rings` / `chains` | 环被穿、走廊折角 |
|
||||||
|
| `verdict.total` | ≈70 可交付(ov=0);结构可读优先于刷分 |
|
||||||
|
| `score_profile` | `auto`(默认)/ `default` / `eye`;大稀疏斜边画布自动走 eye |
|
||||||
|
|
||||||
|
**评分修订(相对旧权)**:util 甜区下限 0.08;贴边分=节点命中∪hits/link;rings 降权;eye 再降 axis、抬 util/clearance;`compactness` 仅诊断不加权。
|
||||||
|
|
||||||
|
图标 25px;推荐中心距 Δx≥200、Δy≥170。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 工具速查
|
||||||
|
|
||||||
|
| 工具 | 作用 |
|
||||||
|
|------|------|
|
||||||
|
| `getTopologyTree` / `getTopologyView` | 树与画布 |
|
||||||
|
| `createTopologyFolder` | 新建根/区域 |
|
||||||
|
| `add` / `remove` / `updateTopologyViewPositions` | 成员与手拖 |
|
||||||
|
| `sinkTopologyDualUnits` | **仅一次**:最大核心眼 → sink |
|
||||||
|
| `suggestSinkHubs` | **后续下沉批次**(含 orphan_batch) |
|
||||||
|
| `move_nodes`(经 layoutTopologyView) | **后续下沉** |
|
||||||
|
| `projectTopologyNeighbors` | 投影邻居 |
|
||||||
|
| `copyTopologyViewNodes` | 克隆沙箱 |
|
||||||
|
| `queryTopologyFabricNodes` | 库存 |
|
||||||
|
| neighborhood / edges | 邻接 |
|
||||||
|
| `layoutTopologyView` | 上表 action |
|
||||||
|
| `analyzeTopologyViewLayout` | structure + 验收 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 代码热更
|
||||||
|
|
||||||
|
1. 本仓 MCP 用 `PYTHONPATH=…/src`(见用户 `~/.cursor/mcp.json`),改源码后不必为加载而 pip install。
|
||||||
|
2. **必须重启** stdio 进程(杀旧 `python -m netx_topology_mcp` 后由 Cursor 拉起;或 MCP 面板禁用/启用)。
|
||||||
|
3. `layoutTopologyView(catalog=true)` 核对:`version` / `rev`(`NETX_MCP_REV`)+ action 含 `pull_far_chains` / `compact_bbox`。
|
||||||
|
4. 机器上常残留多份旧 MCP 进程 → 精修前 catalog 若仍缺 action,**杀光再启**;勿用旧进程半截交付,勿改走 HTTP/临时 py。
|
||||||
|
|
||||||
|
拓扑页开「实时同步」可看落笔。勿用告警/CLI MCP 写拓扑。
|
||||||
|
|
@ -30,6 +30,9 @@ const external = [
|
||||||
const entries = [
|
const entries = [
|
||||||
{ entry: join(root, 'src/index.ts'), out: join(outDir, 'index.js') },
|
{ 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.ts'), out: join(outDir, 'agent-tools.js') },
|
||||||
|
{ entry: join(root, 'src/agent-tools-nms.ts'), out: join(outDir, 'agent-tools-nms.js') },
|
||||||
|
{ entry: join(root, 'src/agent-tools-common.ts'), out: join(outDir, 'agent-tools-common.js') },
|
||||||
|
{ entry: join(root, 'src/agent-tools-topology.ts'), out: join(outDir, 'agent-tools-topology.js') },
|
||||||
]
|
]
|
||||||
|
|
||||||
mkdirSync(outDir, { recursive: true })
|
mkdirSync(outDir, { recursive: true })
|
||||||
|
|
|
||||||
47
scripts/sync-skills-from-netx.ps1
Normal file
47
scripts/sync-skills-from-netx.ps1
Normal file
|
|
@ -0,0 +1,47 @@
|
||||||
|
# Sync canonical netx/skills → dsh-netxops presets (npm embed only).
|
||||||
|
# MCP packages do NOT carry a skills/ mirror — point Cursor/hosts at netx/skills/.
|
||||||
|
param(
|
||||||
|
[string]$NetxRoot = ""
|
||||||
|
)
|
||||||
|
$ErrorActionPreference = "Stop"
|
||||||
|
$opsRoot = Resolve-Path (Join-Path $PSScriptRoot "..")
|
||||||
|
if (-not $NetxRoot) {
|
||||||
|
$sibling = Join-Path $opsRoot "..\netx"
|
||||||
|
if (Test-Path (Join-Path $sibling "skills")) {
|
||||||
|
$NetxRoot = (Resolve-Path $sibling).Path
|
||||||
|
} else {
|
||||||
|
throw "Pass -NetxRoot or place netx checkout next to netxops (../netx/skills)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
$src = Join-Path $NetxRoot "skills"
|
||||||
|
$dst = Join-Path $opsRoot "presets\netxops\skills"
|
||||||
|
if (-not (Test-Path $src)) { throw "missing $src" }
|
||||||
|
|
||||||
|
foreach ($group in @("nms", "common", "topology")) {
|
||||||
|
$from = Join-Path $src $group
|
||||||
|
$to = Join-Path $dst $group
|
||||||
|
if (-not (Test-Path $from)) { throw "missing group $from" }
|
||||||
|
if (Test-Path $to) { Remove-Item $to -Recurse -Force }
|
||||||
|
New-Item -ItemType Directory -Force -Path (Split-Path $to) | Out-Null
|
||||||
|
Copy-Item $from $to -Recurse -Force
|
||||||
|
}
|
||||||
|
|
||||||
|
# Drop legacy separate layout skill if present
|
||||||
|
$legacyLayout = Join-Path $dst "topology-layout"
|
||||||
|
if (Test-Path $legacyLayout) { Remove-Item $legacyLayout -Recurse -Force }
|
||||||
|
|
||||||
|
@"
|
||||||
|
# Mirrored from netx/skills — do not long-edit here
|
||||||
|
|
||||||
|
Canonical: ``$NetxRoot\skills``
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
powershell -File .\scripts\sync-skills-from-netx.ps1
|
||||||
|
```
|
||||||
|
|
||||||
|
Runtime prefers ``NETX_SKILLS_ROOT`` or sibling ``../netx/skills``.
|
||||||
|
One topology skill covers canvas + dual_unit / layout recipes; DSH may still gate **tools** via topology vs topology-layout groups.
|
||||||
|
"@ | Set-Content -Encoding utf8 (Join-Path $dst "README.md")
|
||||||
|
|
||||||
|
Write-Host "Synced $src -> $dst"
|
||||||
|
Get-ChildItem -Recurse $dst -Filter SKILL.md | ForEach-Object { $_.FullName.Substring($opsRoot.Path.Length) }
|
||||||
15
src/agent-tools-common.ts
Normal file
15
src/agent-tools-common.ts
Normal file
|
|
@ -0,0 +1,15 @@
|
||||||
|
/**
|
||||||
|
* Forced common-group tools + skill (native managed CLI + findTopologyPaths).
|
||||||
|
*
|
||||||
|
* @module dsh-netxops/tools-common
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { Context } from '@deepseek-ai/cordis'
|
||||||
|
import { applyGroupToolsPlugin } from './netx/group-tools-plugin.ts'
|
||||||
|
|
||||||
|
export const name = 'netxops-tools-common'
|
||||||
|
export const inject = ['tools']
|
||||||
|
|
||||||
|
export function apply(ctx: Context): void {
|
||||||
|
applyGroupToolsPlugin(ctx, { name, mode: 'forced', only: ['common'] })
|
||||||
|
}
|
||||||
16
src/agent-tools-nms.ts
Normal file
16
src/agent-tools-nms.ts
Normal file
|
|
@ -0,0 +1,16 @@
|
||||||
|
/**
|
||||||
|
* Forced nms-group tools + skill (vendor NMS adapter; current provider zte-ume).
|
||||||
|
* Mount this export in any agent preset that needs NMS alarms/inventory.
|
||||||
|
*
|
||||||
|
* @module dsh-netxops/tools-nms
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { Context } from '@deepseek-ai/cordis'
|
||||||
|
import { applyGroupToolsPlugin } from './netx/group-tools-plugin.ts'
|
||||||
|
|
||||||
|
export const name = 'netxops-tools-nms'
|
||||||
|
export const inject = ['tools']
|
||||||
|
|
||||||
|
export function apply(ctx: Context): void {
|
||||||
|
applyGroupToolsPlugin(ctx, { name, mode: 'forced', only: ['nms'] })
|
||||||
|
}
|
||||||
15
src/agent-tools-topology.ts
Normal file
15
src/agent-tools-topology.ts
Normal file
|
|
@ -0,0 +1,15 @@
|
||||||
|
/**
|
||||||
|
* Forced topology-group tools + skill (canvas / fabric / dual_unit / layout).
|
||||||
|
*
|
||||||
|
* @module dsh-netxops/tools-topology
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { Context } from '@deepseek-ai/cordis'
|
||||||
|
import { applyGroupToolsPlugin } from './netx/group-tools-plugin.ts'
|
||||||
|
|
||||||
|
export const name = 'netxops-tools-topology'
|
||||||
|
export const inject = ['tools']
|
||||||
|
|
||||||
|
export function apply(ctx: Context): void {
|
||||||
|
applyGroupToolsPlugin(ctx, { name, mode: 'forced', only: ['topology'] })
|
||||||
|
}
|
||||||
|
|
@ -1,14 +1,11 @@
|
||||||
/**
|
/**
|
||||||
* Agent-plane Netx Ops tools: register `netx__*` into the calling context's
|
* Agent-plane aggregate mount: all capability groups gated by Settings inPreset.
|
||||||
* tool scope (the Netx Ops preset standing mount), so other presets do not see them.
|
|
||||||
*
|
*
|
||||||
* @module dsh-netxops/tools
|
* @module dsh-netxops/tools
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import type { Context } from '@deepseek-ai/cordis'
|
import type { Context } from '@deepseek-ai/cordis'
|
||||||
import type {} from '@deepseek-ai/dsh-tools'
|
import { applyGroupToolsPlugin } from './netx/group-tools-plugin.ts'
|
||||||
import { getNetxConnection, watchNetxConnection } from './netx/runtime.ts'
|
|
||||||
import { registerNetxTools } from './netx/tools.ts'
|
|
||||||
|
|
||||||
/** Cordis plugin name. */
|
/** Cordis plugin name. */
|
||||||
export const name = 'netxops-tools'
|
export const name = 'netxops-tools'
|
||||||
|
|
@ -16,35 +13,7 @@ export const name = 'netxops-tools'
|
||||||
/** Tool registry must exist in this (preset-scoped) context. */
|
/** Tool registry must exist in this (preset-scoped) context. */
|
||||||
export const inject = ['tools']
|
export const inject = ['tools']
|
||||||
|
|
||||||
/**
|
/** Mount enabled groups for the Netx Ops preset. */
|
||||||
* Mount netx REST tools for the Netx Ops agent preset only.
|
|
||||||
*/
|
|
||||||
export function apply(ctx: Context): void {
|
export function apply(ctx: Context): void {
|
||||||
let unregister: (() => void) | undefined
|
applyGroupToolsPlugin(ctx, { name, mode: 'settings' })
|
||||||
|
|
||||||
const remount = (): void => {
|
|
||||||
unregister?.()
|
|
||||||
unregister = undefined
|
|
||||||
const connection = getNetxConnection()
|
|
||||||
if (connection === undefined) {
|
|
||||||
ctx.logger.warn('netxops-tools: no connection yet — waiting for host settings bridge')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
unregister = registerNetxTools(ctx, connection)
|
|
||||||
const tokenConfigured = connection.token.trim().length > 0
|
|
||||||
ctx.logger.info(
|
|
||||||
'netxops-tools: registered netx__* for Ops preset → %s tokenConfigured=%s',
|
|
||||||
connection.apiUrl,
|
|
||||||
tokenConfigured,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
remount()
|
|
||||||
const stopWatch = watchNetxConnection(() => { remount() })
|
|
||||||
|
|
||||||
ctx.effect(() => () => {
|
|
||||||
stopWatch()
|
|
||||||
unregister?.()
|
|
||||||
unregister = undefined
|
|
||||||
}, 'netxops-tools: dispose')
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,11 @@ import type { InjectFace, PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-cli
|
||||||
import type { NetxopsCardFace, NetxopsCardState } from './controller.ts'
|
import type { NetxopsCardFace, NetxopsCardState } from './controller.ts'
|
||||||
import type { NetxopsLocaleKey } from './locales.ts'
|
import type { NetxopsLocaleKey } from './locales.ts'
|
||||||
import type { CardFieldState } from './card-form.ts'
|
import type { CardFieldState } from './card-form.ts'
|
||||||
|
import {
|
||||||
|
imCatalogOptionKey,
|
||||||
|
parseImCatalogOptionKey,
|
||||||
|
type ImDeliveryCatalog,
|
||||||
|
} from './im-delivery-catalog.ts'
|
||||||
import { alarmPushTone, type AlarmPushPhase } from './alarm-push-status-view.ts'
|
import { alarmPushTone, type AlarmPushPhase } from './alarm-push-status-view.ts'
|
||||||
import { ensureStyles } from './styles.ts'
|
import { ensureStyles } from './styles.ts'
|
||||||
import type {} from '@deepseek-ai/dsh-client-ui-settings-plugins/client'
|
import type {} from '@deepseek-ai/dsh-client-ui-settings-plugins/client'
|
||||||
|
|
@ -88,6 +93,159 @@ function ValueField(props: {
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function ImDeliveryPicker(props: {
|
||||||
|
catalog: ImDeliveryCatalog
|
||||||
|
botId: string
|
||||||
|
targetId: string
|
||||||
|
disabled: boolean
|
||||||
|
labels: {
|
||||||
|
target: string
|
||||||
|
none: string
|
||||||
|
manual: string
|
||||||
|
unavailable: string
|
||||||
|
botId: string
|
||||||
|
botHint: string
|
||||||
|
targetId: string
|
||||||
|
targetHint: string
|
||||||
|
overridden: string
|
||||||
|
reset: string
|
||||||
|
invalid: string
|
||||||
|
}
|
||||||
|
botField: CardFieldState
|
||||||
|
targetField: CardFieldState
|
||||||
|
onPick: (botId: string, targetId: string) => void
|
||||||
|
onEditBot: (text: string) => void
|
||||||
|
onEditTarget: (text: string) => void
|
||||||
|
onResetBot: () => void
|
||||||
|
onResetTarget: () => void
|
||||||
|
}) {
|
||||||
|
const options = props.catalog.options
|
||||||
|
const selectedKey = props.botId && props.targetId
|
||||||
|
? imCatalogOptionKey(props.botId, props.targetId)
|
||||||
|
: ''
|
||||||
|
const matched = options.some((row) => imCatalogOptionKey(row.botId, row.targetId) === selectedKey)
|
||||||
|
const [manual, setManual] = useState(() => !!(selectedKey && !matched))
|
||||||
|
useEffect(() => {
|
||||||
|
if (selectedKey && matched) setManual(false)
|
||||||
|
else if (selectedKey && !matched) setManual(true)
|
||||||
|
}, [selectedKey, matched])
|
||||||
|
const selectValue = manual ? '__manual__' : (matched ? selectedKey : '')
|
||||||
|
const showManual = manual || (selectedKey && !matched)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="dsh-nx-field">
|
||||||
|
<div className="dsh-nx-fieldHead">
|
||||||
|
<label className="dsh-nx-label" htmlFor="netxops-im-target">{props.labels.target}</label>
|
||||||
|
</div>
|
||||||
|
<select
|
||||||
|
id="netxops-im-target"
|
||||||
|
className="dsh-nx-input dsh-nx-select"
|
||||||
|
value={selectValue}
|
||||||
|
disabled={props.disabled}
|
||||||
|
onChange={(event) => {
|
||||||
|
const value = event.target.value
|
||||||
|
if (value === '__manual__') {
|
||||||
|
setManual(true)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setManual(false)
|
||||||
|
if (!value) {
|
||||||
|
props.onPick('', '')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const next = parseImCatalogOptionKey(value)
|
||||||
|
props.onPick(next.botId, next.targetId)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<option value="">
|
||||||
|
{options.length === 0 ? props.labels.none : `— ${props.labels.target} —`}
|
||||||
|
</option>
|
||||||
|
{options.map((row) => (
|
||||||
|
<option
|
||||||
|
key={imCatalogOptionKey(row.botId, row.targetId)}
|
||||||
|
value={imCatalogOptionKey(row.botId, row.targetId)}
|
||||||
|
>
|
||||||
|
{`${row.name} · ${row.channel || 'im'} · ${row.targetId}`}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
<option value="__manual__">{props.labels.manual}</option>
|
||||||
|
</select>
|
||||||
|
{!props.catalog.available || options.length === 0
|
||||||
|
? <p className="dsh-nx-hint">{props.catalog.hint || props.labels.unavailable}</p>
|
||||||
|
: null}
|
||||||
|
</div>
|
||||||
|
{showManual
|
||||||
|
? (
|
||||||
|
<>
|
||||||
|
<ValueField
|
||||||
|
id="netxops-im-bot-id"
|
||||||
|
label={props.labels.botId}
|
||||||
|
hint={props.labels.botHint}
|
||||||
|
field={props.botField}
|
||||||
|
overriddenLabel={props.labels.overridden}
|
||||||
|
resetLabel={props.labels.reset}
|
||||||
|
invalidLabel={props.labels.invalid}
|
||||||
|
disabled={props.disabled}
|
||||||
|
onEdit={props.onEditBot}
|
||||||
|
onReset={props.onResetBot}
|
||||||
|
/>
|
||||||
|
<ValueField
|
||||||
|
id="netxops-im-target-id"
|
||||||
|
label={props.labels.targetId}
|
||||||
|
hint={props.labels.targetHint}
|
||||||
|
field={props.targetField}
|
||||||
|
overriddenLabel={props.labels.overridden}
|
||||||
|
resetLabel={props.labels.reset}
|
||||||
|
invalidLabel={props.labels.invalid}
|
||||||
|
disabled={props.disabled}
|
||||||
|
onEdit={props.onEditTarget}
|
||||||
|
onReset={props.onResetTarget}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
: null}
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function CapabilityGroupBlock(props: {
|
||||||
|
title: string
|
||||||
|
inPresetLabel: string
|
||||||
|
publicLabel: string
|
||||||
|
inPreset: CardFieldState
|
||||||
|
published: CardFieldState
|
||||||
|
disabled: boolean
|
||||||
|
onEditInPreset: (checked: boolean) => void
|
||||||
|
onEditPublic: (checked: boolean) => void
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="dsh-nx-groupBlock">
|
||||||
|
<div className="dsh-nx-groupTitle">{props.title}</div>
|
||||||
|
<div className="dsh-nx-groupChecks">
|
||||||
|
<label className="dsh-nx-checkRow">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={props.inPreset.text === 'true'}
|
||||||
|
disabled={props.disabled}
|
||||||
|
onChange={(event) => { props.onEditInPreset(event.target.checked) }}
|
||||||
|
/>
|
||||||
|
<span>{props.inPresetLabel}</span>
|
||||||
|
</label>
|
||||||
|
<label className="dsh-nx-checkRow">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={props.published.text === 'true'}
|
||||||
|
disabled={props.disabled}
|
||||||
|
onChange={(event) => { props.onEditPublic(event.target.checked) }}
|
||||||
|
/>
|
||||||
|
<span>{props.publicLabel}</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
export function NetxopsCard(props: NetxopsCardProps) {
|
export function NetxopsCard(props: NetxopsCardProps) {
|
||||||
ensureStyles()
|
ensureStyles()
|
||||||
const { t } = props
|
const { t } = props
|
||||||
|
|
@ -188,6 +346,66 @@ export function NetxopsCard(props: NetxopsCardProps) {
|
||||||
onEdit={(text) => { props.edit('lang', text) }}
|
onEdit={(text) => { props.edit('lang', text) }}
|
||||||
onReset={() => { props.resetField('lang') }}
|
onReset={() => { props.resetField('lang') }}
|
||||||
/>
|
/>
|
||||||
|
<ValueField
|
||||||
|
id="netxops-nms-provider"
|
||||||
|
label={t('nmsProvider')}
|
||||||
|
hint={t('nmsProviderHint')}
|
||||||
|
field={state.nmsProvider}
|
||||||
|
overriddenLabel={t('overridden')}
|
||||||
|
resetLabel={t('reset')}
|
||||||
|
invalidLabel={t('invalid')}
|
||||||
|
disabled={disabled}
|
||||||
|
onEdit={(text) => { props.edit('nmsProvider', text) }}
|
||||||
|
onReset={() => { props.resetField('nmsProvider') }}
|
||||||
|
/>
|
||||||
|
<div className="dsh-nx-field">
|
||||||
|
<div className="dsh-nx-fieldHead">
|
||||||
|
<span className="dsh-nx-label">{t('capabilityGroups')}</span>
|
||||||
|
</div>
|
||||||
|
<p className="dsh-nx-hint">{t('capabilityGroupsHint')}</p>
|
||||||
|
<CapabilityGroupBlock
|
||||||
|
title={t('groupNms')}
|
||||||
|
inPresetLabel={t('groupInPreset')}
|
||||||
|
publicLabel={t('groupPublic')}
|
||||||
|
inPreset={state.groupNmsInPreset}
|
||||||
|
published={state.groupNmsPublic}
|
||||||
|
disabled={disabled}
|
||||||
|
onEditInPreset={(checked) => {
|
||||||
|
props.edit('groupNmsInPreset', checked ? 'true' : 'false')
|
||||||
|
}}
|
||||||
|
onEditPublic={(checked) => {
|
||||||
|
props.edit('groupNmsPublic', checked ? 'true' : 'false')
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<CapabilityGroupBlock
|
||||||
|
title={t('groupCommon')}
|
||||||
|
inPresetLabel={t('groupInPreset')}
|
||||||
|
publicLabel={t('groupPublic')}
|
||||||
|
inPreset={state.groupCommonInPreset}
|
||||||
|
published={state.groupCommonPublic}
|
||||||
|
disabled={disabled}
|
||||||
|
onEditInPreset={(checked) => {
|
||||||
|
props.edit('groupCommonInPreset', checked ? 'true' : 'false')
|
||||||
|
}}
|
||||||
|
onEditPublic={(checked) => {
|
||||||
|
props.edit('groupCommonPublic', checked ? 'true' : 'false')
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<CapabilityGroupBlock
|
||||||
|
title={t('groupTopology')}
|
||||||
|
inPresetLabel={t('groupInPreset')}
|
||||||
|
publicLabel={t('groupPublic')}
|
||||||
|
inPreset={state.groupTopologyInPreset}
|
||||||
|
published={state.groupTopologyPublic}
|
||||||
|
disabled={disabled}
|
||||||
|
onEditInPreset={(checked) => {
|
||||||
|
props.edit('groupTopologyInPreset', checked ? 'true' : 'false')
|
||||||
|
}}
|
||||||
|
onEditPublic={(checked) => {
|
||||||
|
props.edit('groupTopologyPublic', checked ? 'true' : 'false')
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
<div className="dsh-nx-field">
|
<div className="dsh-nx-field">
|
||||||
<div className="dsh-nx-fieldHead">
|
<div className="dsh-nx-fieldHead">
|
||||||
<label className="dsh-nx-label" htmlFor="netxops-alarm-push">{t('alarmPushEnabled')}</label>
|
<label className="dsh-nx-label" htmlFor="netxops-alarm-push">{t('alarmPushEnabled')}</label>
|
||||||
|
|
@ -290,29 +508,34 @@ export function NetxopsCard(props: NetxopsCardProps) {
|
||||||
<span>{t('alarmDeliverImHint')}</span>
|
<span>{t('alarmDeliverImHint')}</span>
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
<ValueField
|
<ImDeliveryPicker
|
||||||
id="netxops-im-bot-id"
|
catalog={state.imDeliveryCatalog}
|
||||||
label={t('imBotId')}
|
botId={state.imBotId.text}
|
||||||
hint={t('imBotIdHint')}
|
targetId={state.imTargetId.text}
|
||||||
field={state.imBotId}
|
|
||||||
overriddenLabel={t('overridden')}
|
|
||||||
resetLabel={t('reset')}
|
|
||||||
invalidLabel={t('invalid')}
|
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
onEdit={(text) => { props.edit('imBotId', text) }}
|
labels={{
|
||||||
onReset={() => { props.resetField('imBotId') }}
|
target: t('imTarget'),
|
||||||
/>
|
none: t('imTargetNone'),
|
||||||
<ValueField
|
manual: t('imTargetManual'),
|
||||||
id="netxops-im-target-id"
|
unavailable: t('imCatalogUnavailable'),
|
||||||
label={t('imTargetId')}
|
botId: t('imBotId'),
|
||||||
hint={t('imTargetIdHint')}
|
botHint: t('imBotIdHint'),
|
||||||
field={state.imTargetId}
|
targetId: t('imTargetId'),
|
||||||
overriddenLabel={t('overridden')}
|
targetHint: t('imTargetIdHint'),
|
||||||
resetLabel={t('reset')}
|
overridden: t('overridden'),
|
||||||
invalidLabel={t('invalid')}
|
reset: t('reset'),
|
||||||
disabled={disabled}
|
invalid: t('invalid'),
|
||||||
onEdit={(text) => { props.edit('imTargetId', text) }}
|
}}
|
||||||
onReset={() => { props.resetField('imTargetId') }}
|
botField={state.imBotId}
|
||||||
|
targetField={state.imTargetId}
|
||||||
|
onPick={(botId, targetId) => {
|
||||||
|
props.edit('imBotId', botId)
|
||||||
|
props.edit('imTargetId', targetId)
|
||||||
|
}}
|
||||||
|
onEditBot={(text) => { props.edit('imBotId', text) }}
|
||||||
|
onEditTarget={(text) => { props.edit('imTargetId', text) }}
|
||||||
|
onResetBot={() => { props.resetField('imBotId') }}
|
||||||
|
onResetTarget={() => { props.resetField('imTargetId') }}
|
||||||
/>
|
/>
|
||||||
<div className="dsh-nx-footer">
|
<div className="dsh-nx-footer">
|
||||||
{state.failed ? <p className="dsh-nx-failed" role="status">{t('saveFailed')}</p> : null}
|
{state.failed ? <p className="dsh-nx-failed" role="status">{t('saveFailed')}</p> : null}
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,11 @@ import {
|
||||||
type AlarmPushRpcCall,
|
type AlarmPushRpcCall,
|
||||||
type AlarmPushStatus,
|
type AlarmPushStatus,
|
||||||
} from './alarm-push-status-view.ts'
|
} from './alarm-push-status-view.ts'
|
||||||
|
import {
|
||||||
|
EMPTY_IM_DELIVERY_CATALOG,
|
||||||
|
fetchImDeliveryCatalog,
|
||||||
|
type ImDeliveryCatalog,
|
||||||
|
} from './im-delivery-catalog.ts'
|
||||||
|
|
||||||
export const NETXOPS_NS = 'netxops'
|
export const NETXOPS_NS = 'netxops'
|
||||||
const DEFAULT_TOKEN_REF = 'NETX_API_TOKEN'
|
const DEFAULT_TOKEN_REF = 'NETX_API_TOKEN'
|
||||||
|
|
@ -30,6 +35,13 @@ export interface NetxopsSettings {
|
||||||
alarmDeliverIm?: boolean
|
alarmDeliverIm?: boolean
|
||||||
imBotId?: string
|
imBotId?: string
|
||||||
imTargetId?: string
|
imTargetId?: string
|
||||||
|
groupNmsInPreset?: boolean
|
||||||
|
groupNmsPublic?: boolean
|
||||||
|
groupCommonInPreset?: boolean
|
||||||
|
groupCommonPublic?: boolean
|
||||||
|
groupTopologyInPreset?: boolean
|
||||||
|
groupTopologyPublic?: boolean
|
||||||
|
nmsProvider?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
interface CredentialState {
|
interface CredentialState {
|
||||||
|
|
@ -42,6 +54,13 @@ interface CredentialState {
|
||||||
export interface NetxopsCardState extends CardShell {
|
export interface NetxopsCardState extends CardShell {
|
||||||
apiUrl: CardFieldState
|
apiUrl: CardFieldState
|
||||||
lang: CardFieldState
|
lang: CardFieldState
|
||||||
|
groupNmsInPreset: CardFieldState
|
||||||
|
groupNmsPublic: CardFieldState
|
||||||
|
groupCommonInPreset: CardFieldState
|
||||||
|
groupCommonPublic: CardFieldState
|
||||||
|
groupTopologyInPreset: CardFieldState
|
||||||
|
groupTopologyPublic: CardFieldState
|
||||||
|
nmsProvider: CardFieldState
|
||||||
alarmPushEnabled: CardFieldState
|
alarmPushEnabled: CardFieldState
|
||||||
alarmDeliverDsh: CardFieldState
|
alarmDeliverDsh: CardFieldState
|
||||||
alarmDeliverIm: CardFieldState
|
alarmDeliverIm: CardFieldState
|
||||||
|
|
@ -53,6 +72,8 @@ export interface NetxopsCardState extends CardShell {
|
||||||
apiTokenRemoteReady: boolean
|
apiTokenRemoteReady: boolean
|
||||||
/** Host WSS status when Connection RPC is available; otherwise null. */
|
/** Host WSS status when Connection RPC is available; otherwise null. */
|
||||||
alarmPushStatus: AlarmPushStatus | null
|
alarmPushStatus: AlarmPushStatus | null
|
||||||
|
/** Saved IM delivery targets for the picker (soft-depends on dsh-im-ops). */
|
||||||
|
imDeliveryCatalog: ImDeliveryCatalog
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface NetxopsCardFace extends CardActions {
|
export interface NetxopsCardFace extends CardActions {
|
||||||
|
|
@ -77,8 +98,10 @@ export class NetxopsCardController {
|
||||||
}
|
}
|
||||||
private rpcCall: AlarmPushRpcCall | undefined
|
private rpcCall: AlarmPushRpcCall | undefined
|
||||||
private alarmPushStatus: AlarmPushStatus | null = null
|
private alarmPushStatus: AlarmPushStatus | null = null
|
||||||
|
private imDeliveryCatalog: ImDeliveryCatalog = { ...EMPTY_IM_DELIVERY_CATALOG }
|
||||||
private pollTimer: ReturnType<typeof setInterval> | undefined
|
private pollTimer: ReturnType<typeof setInterval> | undefined
|
||||||
private pollInFlight = false
|
private pollInFlight = false
|
||||||
|
private catalogInFlight = false
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private readonly scope: SettingsScope<NetxopsSettings>,
|
private readonly scope: SettingsScope<NetxopsSettings>,
|
||||||
|
|
@ -89,6 +112,13 @@ export class NetxopsCardController {
|
||||||
[
|
[
|
||||||
textField('apiUrl'),
|
textField('apiUrl'),
|
||||||
textField('lang'),
|
textField('lang'),
|
||||||
|
textField('nmsProvider'),
|
||||||
|
booleanFieldPersistFalse('groupNmsInPreset'),
|
||||||
|
booleanField('groupNmsPublic'),
|
||||||
|
booleanFieldPersistFalse('groupCommonInPreset'),
|
||||||
|
booleanField('groupCommonPublic'),
|
||||||
|
booleanField('groupTopologyInPreset'),
|
||||||
|
booleanField('groupTopologyPublic'),
|
||||||
booleanField('alarmPushEnabled'),
|
booleanField('alarmPushEnabled'),
|
||||||
booleanFieldPersistFalse('alarmDeliverDsh'),
|
booleanFieldPersistFalse('alarmDeliverDsh'),
|
||||||
booleanField('alarmDeliverIm'),
|
booleanField('alarmDeliverIm'),
|
||||||
|
|
@ -122,19 +152,29 @@ export class NetxopsCardController {
|
||||||
this.rpcCall = call
|
this.rpcCall = call
|
||||||
if (call === undefined) {
|
if (call === undefined) {
|
||||||
this.stopStatusPoll()
|
this.stopStatusPoll()
|
||||||
|
let changed = false
|
||||||
if (this.alarmPushStatus !== null) {
|
if (this.alarmPushStatus !== null) {
|
||||||
this.alarmPushStatus = null
|
this.alarmPushStatus = null
|
||||||
this.store.set(this.projection())
|
changed = true
|
||||||
}
|
}
|
||||||
|
if (this.imDeliveryCatalog.options.length > 0 || this.imDeliveryCatalog.available !== true) {
|
||||||
|
this.imDeliveryCatalog = { ...EMPTY_IM_DELIVERY_CATALOG }
|
||||||
|
changed = true
|
||||||
|
}
|
||||||
|
if (changed) this.store.set(this.projection())
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
this.startStatusPoll()
|
this.startStatusPoll()
|
||||||
void this.refreshAlarmPushStatus()
|
void this.refreshAlarmPushStatus()
|
||||||
|
void this.refreshImDeliveryCatalog()
|
||||||
}
|
}
|
||||||
|
|
||||||
private startStatusPoll(): void {
|
private startStatusPoll(): void {
|
||||||
if (this.pollTimer !== undefined) return
|
if (this.pollTimer !== undefined) return
|
||||||
this.pollTimer = setInterval(() => { void this.refreshAlarmPushStatus() }, STATUS_POLL_MS)
|
this.pollTimer = setInterval(() => {
|
||||||
|
void this.refreshAlarmPushStatus()
|
||||||
|
void this.refreshImDeliveryCatalog()
|
||||||
|
}, STATUS_POLL_MS)
|
||||||
}
|
}
|
||||||
|
|
||||||
private stopStatusPoll(): void {
|
private stopStatusPoll(): void {
|
||||||
|
|
@ -168,11 +208,48 @@ export class NetxopsCardController {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async refreshImDeliveryCatalog(): Promise<void> {
|
||||||
|
const call = this.rpcCall
|
||||||
|
if (call === undefined || this.catalogInFlight) return
|
||||||
|
this.catalogInFlight = true
|
||||||
|
try {
|
||||||
|
const next = await fetchImDeliveryCatalog(call)
|
||||||
|
const prev = this.imDeliveryCatalog
|
||||||
|
const sameOptions = prev.options.length === next.options.length
|
||||||
|
&& prev.options.every((row, index) => {
|
||||||
|
const other = next.options[index]
|
||||||
|
return other
|
||||||
|
&& row.botId === other.botId
|
||||||
|
&& row.targetId === other.targetId
|
||||||
|
&& row.name === other.name
|
||||||
|
&& row.channel === other.channel
|
||||||
|
})
|
||||||
|
if (
|
||||||
|
prev.available === next.available
|
||||||
|
&& prev.hint === next.hint
|
||||||
|
&& sameOptions
|
||||||
|
) return
|
||||||
|
this.imDeliveryCatalog = next
|
||||||
|
this.store.set(this.projection())
|
||||||
|
} catch {
|
||||||
|
// Keep last good snapshot; next poll retries.
|
||||||
|
} finally {
|
||||||
|
this.catalogInFlight = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private projection(): NetxopsCardState {
|
private projection(): NetxopsCardState {
|
||||||
return {
|
return {
|
||||||
...this.form.shell(),
|
...this.form.shell(),
|
||||||
apiUrl: this.form.field('apiUrl'),
|
apiUrl: this.form.field('apiUrl'),
|
||||||
lang: this.form.field('lang'),
|
lang: this.form.field('lang'),
|
||||||
|
nmsProvider: this.form.field('nmsProvider'),
|
||||||
|
groupNmsInPreset: this.form.field('groupNmsInPreset'),
|
||||||
|
groupNmsPublic: this.form.field('groupNmsPublic'),
|
||||||
|
groupCommonInPreset: this.form.field('groupCommonInPreset'),
|
||||||
|
groupCommonPublic: this.form.field('groupCommonPublic'),
|
||||||
|
groupTopologyInPreset: this.form.field('groupTopologyInPreset'),
|
||||||
|
groupTopologyPublic: this.form.field('groupTopologyPublic'),
|
||||||
alarmPushEnabled: this.form.field('alarmPushEnabled'),
|
alarmPushEnabled: this.form.field('alarmPushEnabled'),
|
||||||
alarmDeliverDsh: this.form.field('alarmDeliverDsh'),
|
alarmDeliverDsh: this.form.field('alarmDeliverDsh'),
|
||||||
alarmDeliverIm: this.form.field('alarmDeliverIm'),
|
alarmDeliverIm: this.form.field('alarmDeliverIm'),
|
||||||
|
|
@ -183,6 +260,7 @@ export class NetxopsCardController {
|
||||||
apiTokenWritable: this.credential.remoteReady && this.credential.writable,
|
apiTokenWritable: this.credential.remoteReady && this.credential.writable,
|
||||||
apiTokenRemoteReady: this.credential.remoteReady,
|
apiTokenRemoteReady: this.credential.remoteReady,
|
||||||
alarmPushStatus: this.alarmPushStatus,
|
alarmPushStatus: this.alarmPushStatus,
|
||||||
|
imDeliveryCatalog: this.imDeliveryCatalog,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
98
src/client/im-delivery-catalog.ts
Normal file
98
src/client/im-delivery-catalog.ts
Normal file
|
|
@ -0,0 +1,98 @@
|
||||||
|
/**
|
||||||
|
* Browser helper: load saved IM delivery targets via Netx Ops Connection RPC.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import {
|
||||||
|
NETXOPS_RPC_CHANNEL,
|
||||||
|
type AlarmPushRpcCall,
|
||||||
|
} from './alarm-push-status-view.ts'
|
||||||
|
|
||||||
|
export const IM_DELIVERY_CATALOG_ENDPOINT = 'im-delivery.catalog'
|
||||||
|
|
||||||
|
export interface ImDeliveryCatalogOption {
|
||||||
|
botId: string
|
||||||
|
targetId: string
|
||||||
|
name: string
|
||||||
|
kind: string
|
||||||
|
channel: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ImDeliveryCatalog {
|
||||||
|
available: boolean
|
||||||
|
options: ImDeliveryCatalogOption[]
|
||||||
|
hint: string
|
||||||
|
loading: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export const EMPTY_IM_DELIVERY_CATALOG: ImDeliveryCatalog = {
|
||||||
|
available: true,
|
||||||
|
options: [],
|
||||||
|
hint: '',
|
||||||
|
loading: false,
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param value - RPC catalog payload.
|
||||||
|
*/
|
||||||
|
export function asImDeliveryCatalog(value: unknown): ImDeliveryCatalog {
|
||||||
|
if (value === null || typeof value !== 'object' || Array.isArray(value)) {
|
||||||
|
return { ...EMPTY_IM_DELIVERY_CATALOG, available: false }
|
||||||
|
}
|
||||||
|
const row = value as Record<string, unknown>
|
||||||
|
const raw = Array.isArray(row.options) ? row.options : []
|
||||||
|
const options: ImDeliveryCatalogOption[] = []
|
||||||
|
for (const entry of raw) {
|
||||||
|
if (entry === null || typeof entry !== 'object' || Array.isArray(entry)) continue
|
||||||
|
const item = entry as Record<string, unknown>
|
||||||
|
const botId = typeof item.botId === 'string' ? item.botId.trim() : ''
|
||||||
|
const targetId = typeof item.targetId === 'string' ? item.targetId.trim() : ''
|
||||||
|
if (!botId || !targetId) continue
|
||||||
|
options.push({
|
||||||
|
botId,
|
||||||
|
targetId,
|
||||||
|
name: typeof item.name === 'string' && item.name.trim() ? item.name.trim() : targetId,
|
||||||
|
kind: typeof item.kind === 'string' ? item.kind : '',
|
||||||
|
channel: typeof item.channel === 'string' ? item.channel : 'im',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
available: row.available !== false,
|
||||||
|
options,
|
||||||
|
hint: typeof row.hint === 'string' ? row.hint : '',
|
||||||
|
loading: false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param call - `ctx.connection.rpc.call`.
|
||||||
|
* @param signal - optional abort.
|
||||||
|
*/
|
||||||
|
export async function fetchImDeliveryCatalog(
|
||||||
|
call: AlarmPushRpcCall,
|
||||||
|
signal?: AbortSignal,
|
||||||
|
): Promise<ImDeliveryCatalog> {
|
||||||
|
const result = await call(NETXOPS_RPC_CHANNEL, IM_DELIVERY_CATALOG_ENDPOINT, {}, signal)
|
||||||
|
if (result !== null && typeof result === 'object' && (result as { ok?: boolean }).ok === true) {
|
||||||
|
return asImDeliveryCatalog((result as { value?: unknown }).value)
|
||||||
|
}
|
||||||
|
if (result !== null && typeof result === 'object' && (result as { ok?: boolean }).ok === false) {
|
||||||
|
return {
|
||||||
|
...EMPTY_IM_DELIVERY_CATALOG,
|
||||||
|
available: false,
|
||||||
|
hint: String((result as { error?: { message?: string } }).error?.message ?? 'rpc failed'),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return asImDeliveryCatalog(result)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Stable select value for one catalog row. */
|
||||||
|
export function imCatalogOptionKey(botId: string, targetId: string): string {
|
||||||
|
return `${botId}::${targetId}`
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Parse {@link imCatalogOptionKey}. */
|
||||||
|
export function parseImCatalogOptionKey(value: string): { botId: string, targetId: string } {
|
||||||
|
const at = value.indexOf('::')
|
||||||
|
if (at <= 0) return { botId: '', targetId: '' }
|
||||||
|
return { botId: value.slice(0, at), targetId: value.slice(at + 2) }
|
||||||
|
}
|
||||||
|
|
@ -7,6 +7,15 @@ export type NetxopsLocaleKey =
|
||||||
| 'apiUrlHint'
|
| 'apiUrlHint'
|
||||||
| 'lang'
|
| 'lang'
|
||||||
| 'langHint'
|
| 'langHint'
|
||||||
|
| 'capabilityGroups'
|
||||||
|
| 'capabilityGroupsHint'
|
||||||
|
| 'nmsProvider'
|
||||||
|
| 'nmsProviderHint'
|
||||||
|
| 'groupNms'
|
||||||
|
| 'groupCommon'
|
||||||
|
| 'groupTopology'
|
||||||
|
| 'groupInPreset'
|
||||||
|
| 'groupPublic'
|
||||||
| 'alarmPushEnabled'
|
| 'alarmPushEnabled'
|
||||||
| 'alarmPushEnabledHint'
|
| 'alarmPushEnabledHint'
|
||||||
| 'alarmPushStatus'
|
| 'alarmPushStatus'
|
||||||
|
|
@ -26,6 +35,10 @@ export type NetxopsLocaleKey =
|
||||||
| 'imBotIdHint'
|
| 'imBotIdHint'
|
||||||
| 'imTargetId'
|
| 'imTargetId'
|
||||||
| 'imTargetIdHint'
|
| 'imTargetIdHint'
|
||||||
|
| 'imTarget'
|
||||||
|
| 'imTargetNone'
|
||||||
|
| 'imTargetManual'
|
||||||
|
| 'imCatalogUnavailable'
|
||||||
| 'apiToken'
|
| 'apiToken'
|
||||||
| 'apiTokenHint'
|
| 'apiTokenHint'
|
||||||
| 'apiTokenUnavailable'
|
| 'apiTokenUnavailable'
|
||||||
|
|
@ -45,11 +58,20 @@ export type NetxopsLocaleKey =
|
||||||
|
|
||||||
export const en: Record<NetxopsLocaleKey, string> = {
|
export const en: Record<NetxopsLocaleKey, string> = {
|
||||||
title: 'Netx Ops',
|
title: 'Netx Ops',
|
||||||
description: 'UME API endpoint and bearer token for netx__* tools.',
|
description: 'netx API, bearer token, and capability groups (nms / common / topology).',
|
||||||
apiUrl: 'API URL',
|
apiUrl: 'API URL',
|
||||||
apiUrlHint: 'netx REST root, e.g. http://127.0.0.1:8890 (also used for alarm subscribe)',
|
apiUrlHint: 'netx REST root, e.g. http://127.0.0.1:8890 (also used for alarm subscribe)',
|
||||||
lang: 'Language',
|
lang: 'Language',
|
||||||
langHint: 'Response language hint (zh / en).',
|
langHint: 'Response language hint (zh / en).',
|
||||||
|
capabilityGroups: 'Capability groups',
|
||||||
|
capabilityGroupsHint: 'One group ↔ one skill (netx-nms / netx-common / netx-topology). Defaults: nms + common in preset; topology and all public off. Save, then open a new session. Other agents can mount dsh-netxops/tools-nms|common|topology.',
|
||||||
|
nmsProvider: 'nms provider',
|
||||||
|
nmsProviderHint: 'Vendor adapter id. Supported today: zte-ume (REST still /v1/ume/*; model tools are netx__*Nms*).',
|
||||||
|
groupNms: 'nms — skill netx-nms (alarms / inventory / SQL)',
|
||||||
|
groupCommon: 'common — skill netx-common (managed CLI + findTopologyPaths)',
|
||||||
|
groupTopology: 'topology — skill netx-topology (canvas / fabric / dual_unit)',
|
||||||
|
groupInPreset: 'In Netx Ops preset',
|
||||||
|
groupPublic: 'Publish to other presets',
|
||||||
alarmPushEnabled: 'Key-alarm push',
|
alarmPushEnabled: 'Key-alarm push',
|
||||||
alarmPushEnabledHint: 'Dial out to netx when a matched key alarm arrives. Choose DSH and/or IM sinks below.',
|
alarmPushEnabledHint: 'Dial out to netx when a matched key alarm arrives. Choose DSH and/or IM sinks below.',
|
||||||
alarmPushStatus: 'Push link',
|
alarmPushStatus: 'Push link',
|
||||||
|
|
@ -64,11 +86,15 @@ export const en: Record<NetxopsLocaleKey, string> = {
|
||||||
alarmDeliverDsh: 'Deliver to DSH session',
|
alarmDeliverDsh: 'Deliver to DSH session',
|
||||||
alarmDeliverDshHint: 'Open/follow the sticky「Netx 关键告警」session on this Host.',
|
alarmDeliverDshHint: 'Open/follow the sticky「Netx 关键告警」session on this Host.',
|
||||||
alarmDeliverIm: 'Deliver to WhatsApp / IM',
|
alarmDeliverIm: 'Deliver to WhatsApp / IM',
|
||||||
alarmDeliverImHint: 'Requires dsh-im-ops. Paste botId + targetId from IM → 投递设置 → 复制调用参数.',
|
alarmDeliverImHint: 'Requires dsh-im-ops ≥ops.24. Pick a saved delivery target below (create targets under IM → Delivery settings).',
|
||||||
imBotId: 'IM Bot ID',
|
imBotId: 'IM Bot ID',
|
||||||
imBotIdHint: 'Opaque botId from IM delivery settings (not a phone number).',
|
imBotIdHint: 'Filled from the picker, or enter manually.',
|
||||||
imTargetId: 'IM Target ID',
|
imTargetId: 'IM Target ID',
|
||||||
imTargetIdHint: 'Opaque targetId alias (e.g. release-alerts). Create the target in IM first.',
|
imTargetIdHint: 'Filled from the picker, or enter manually.',
|
||||||
|
imTarget: 'Delivery target',
|
||||||
|
imTargetNone: '(Create a target in IM delivery settings first)',
|
||||||
|
imTargetManual: 'Enter manually…',
|
||||||
|
imCatalogUnavailable: 'Cannot load targets — install/update dsh-im-ops (≥ops.24) and save at least one delivery target.',
|
||||||
apiToken: 'API token',
|
apiToken: 'API token',
|
||||||
apiTokenHint: 'Stored as credential NETX_API_TOKEN (never written into settings). Leave blank to keep the current token.',
|
apiTokenHint: 'Stored as credential NETX_API_TOKEN (never written into settings). Leave blank to keep the current token.',
|
||||||
apiTokenUnavailable: 'This DSH build does not expose remote.credentials. Set the token with scripts/set-netx-token.ps1 (or .sh), then restart is not required if credentials are watched.',
|
apiTokenUnavailable: 'This DSH build does not expose remote.credentials. Set the token with scripts/set-netx-token.ps1 (or .sh), then restart is not required if credentials are watched.',
|
||||||
|
|
@ -89,11 +115,20 @@ export const en: Record<NetxopsLocaleKey, string> = {
|
||||||
|
|
||||||
export const zh: Record<NetxopsLocaleKey, string> = {
|
export const zh: Record<NetxopsLocaleKey, string> = {
|
||||||
title: 'Netx Ops',
|
title: 'Netx Ops',
|
||||||
description: 'UME API 地址与 Bearer Token,供 netx__* 工具使用。',
|
description: 'netx API、Bearer Token,以及能力组(nms / common / topology)。',
|
||||||
apiUrl: 'API 地址',
|
apiUrl: 'API 地址',
|
||||||
apiUrlHint: 'netx REST 根地址,例如 http://127.0.0.1:8890(告警订阅复用同一地址)',
|
apiUrlHint: 'netx REST 根地址,例如 http://127.0.0.1:8890(告警订阅复用同一地址)',
|
||||||
lang: '语言',
|
lang: '语言',
|
||||||
langHint: '响应语言提示(zh / en)。',
|
langHint: '响应语言提示(zh / en)。',
|
||||||
|
capabilityGroups: '能力组',
|
||||||
|
capabilityGroupsHint: '一组一个 skill(netx-nms / netx-common / netx-topology)。默认:nms + common 进入预设;topology 与全部公开关闭。保存后请新开会话。其他 Agent 可挂 dsh-netxops/tools-nms|common|topology。',
|
||||||
|
nmsProvider: 'nms provider',
|
||||||
|
nmsProviderHint: '厂商适配器 id。当前支持:zte-ume(REST 仍为 /v1/ume/*;模型工具名为 netx__*Nms*)。',
|
||||||
|
groupNms: 'nms — skill netx-nms(告警 / 库存 / SQL)',
|
||||||
|
groupCommon: 'common — skill netx-common(纳管 CLI + findTopologyPaths)',
|
||||||
|
groupTopology: 'topology — skill netx-topology(画布 / Fabric / dual_unit)',
|
||||||
|
groupInPreset: '在 Netx Ops 预设中启用',
|
||||||
|
groupPublic: '对其他预设公开',
|
||||||
alarmPushEnabled: '关键告警推送',
|
alarmPushEnabled: '关键告警推送',
|
||||||
alarmPushEnabledHint: '主动连接 netx;匹配到关键告警后按下述开关投递。可只开 DSH、只开 IM,或两者都开。',
|
alarmPushEnabledHint: '主动连接 netx;匹配到关键告警后按下述开关投递。可只开 DSH、只开 IM,或两者都开。',
|
||||||
alarmPushStatus: '推送链路',
|
alarmPushStatus: '推送链路',
|
||||||
|
|
@ -108,11 +143,15 @@ export const zh: Record<NetxopsLocaleKey, string> = {
|
||||||
alarmDeliverDsh: '投递到 DSH 会话',
|
alarmDeliverDsh: '投递到 DSH 会话',
|
||||||
alarmDeliverDshHint: '在本机打开/续写 sticky「Netx 关键告警」会话。',
|
alarmDeliverDshHint: '在本机打开/续写 sticky「Netx 关键告警」会话。',
|
||||||
alarmDeliverIm: '投递到 WhatsApp / IM',
|
alarmDeliverIm: '投递到 WhatsApp / IM',
|
||||||
alarmDeliverImHint: '需要已安装 dsh-im-ops。请先在 IM「投递设置」新建目标,再粘贴「复制调用参数」里的 botId / targetId。',
|
alarmDeliverImHint: '需要 dsh-im-ops ≥ops.24。请在下方下拉选择已保存的投递目标(先在 IM「投递设置」新建)。',
|
||||||
imBotId: 'IM Bot ID',
|
imBotId: 'IM Bot ID',
|
||||||
imBotIdHint: '来自 IM 投递设置的不透明 botId(不是手机号)。',
|
imBotIdHint: '由下拉自动填入,也可手动改。',
|
||||||
imTargetId: 'IM Target ID',
|
imTargetId: 'IM Target ID',
|
||||||
imTargetIdHint: '投递目标别名(如 release-alerts)。请先在 IM 侧创建目标。',
|
imTargetIdHint: '由下拉自动填入,也可手动改。',
|
||||||
|
imTarget: '投递目标',
|
||||||
|
imTargetNone: '(请先在 IM「投递设置」新建目标)',
|
||||||
|
imTargetManual: '手动填写…',
|
||||||
|
imCatalogUnavailable: '无法加载投递目标 — 请安装/更新 dsh-im-ops(≥ops.24)并至少保存一个投递目标。',
|
||||||
apiToken: 'API Token',
|
apiToken: 'API Token',
|
||||||
apiTokenHint: '写入凭据 NETX_API_TOKEN(不会进 settings)。留空表示保留已有 token。',
|
apiTokenHint: '写入凭据 NETX_API_TOKEN(不会进 settings)。留空表示保留已有 token。',
|
||||||
apiTokenUnavailable: '当前 DSH 未提供 remote.credentials。请用 scripts/set-netx-token.ps1(或 .sh)写入 token;若 harness 在监视凭据文件则无需重启。',
|
apiTokenUnavailable: '当前 DSH 未提供 remote.credentials。请用 scripts/set-netx-token.ps1(或 .sh)写入 token;若 harness 在监视凭据文件则无需重启。',
|
||||||
|
|
|
||||||
|
|
@ -30,11 +30,15 @@ const CSS = `
|
||||||
.dsh-nx-reset{border:none;background:none;padding:0;font:inherit;font-size:12px;line-height:1.5;color:var(--dsw-alias-label-secondary);cursor:pointer}
|
.dsh-nx-reset{border:none;background:none;padding:0;font:inherit;font-size:12px;line-height:1.5;color:var(--dsw-alias-label-secondary);cursor:pointer}
|
||||||
.dsh-nx-reset:hover:not(:disabled){color:var(--dsw-alias-label-primary)}
|
.dsh-nx-reset:hover:not(:disabled){color:var(--dsw-alias-label-primary)}
|
||||||
.dsh-nx-input{height:34px;padding:0 12px;border:.5px solid var(--dsw-alias-border-l4);border-radius:8px;background:var(--dsw-alias-bg-layer-3);font:inherit;font-size:13px;line-height:1.5;color:var(--dsw-alias-label-primary)}
|
.dsh-nx-input{height:34px;padding:0 12px;border:.5px solid var(--dsw-alias-border-l4);border-radius:8px;background:var(--dsw-alias-bg-layer-3);font:inherit;font-size:13px;line-height:1.5;color:var(--dsw-alias-label-primary)}
|
||||||
|
.dsh-nx-select{appearance:auto;cursor:pointer}
|
||||||
.dsh-nx-input:focus-visible{outline:none;border-color:var(--dsw-alias-brand-primary)}
|
.dsh-nx-input:focus-visible{outline:none;border-color:var(--dsw-alias-brand-primary)}
|
||||||
.dsh-nx-inputInvalid{border-color:var(--dsw-alias-label-error)}
|
.dsh-nx-inputInvalid{border-color:var(--dsw-alias-label-error)}
|
||||||
.dsh-nx-hint{margin:0;font-size:12px;line-height:1.5;color:var(--dsw-alias-label-tertiary)}
|
.dsh-nx-hint{margin:0;font-size:12px;line-height:1.5;color:var(--dsw-alias-label-tertiary)}
|
||||||
.dsh-nx-checkRow{display:flex;align-items:flex-start;gap:10px;font-size:12px;line-height:1.5;color:var(--dsw-alias-label-secondary);cursor:pointer}
|
.dsh-nx-checkRow{display:flex;align-items:flex-start;gap:10px;font-size:12px;line-height:1.5;color:var(--dsw-alias-label-secondary);cursor:pointer}
|
||||||
.dsh-nx-checkRow input{margin-top:2px;flex:none}
|
.dsh-nx-checkRow input{margin-top:2px;flex:none}
|
||||||
|
.dsh-nx-groupBlock{display:flex;flex-direction:column;gap:8px;padding:8px 0 4px}
|
||||||
|
.dsh-nx-groupTitle{font-size:12px;font-weight:600;line-height:1.5;color:var(--dsw-alias-label-primary)}
|
||||||
|
.dsh-nx-groupChecks{display:flex;flex-direction:column;gap:6px;padding-left:2px}
|
||||||
.dsh-nx-invalid{margin:0;font-size:12px;line-height:1.5;color:var(--dsw-alias-label-error)}
|
.dsh-nx-invalid{margin:0;font-size:12px;line-height:1.5;color:var(--dsw-alias-label-error)}
|
||||||
.dsh-nx-footer{display:flex;align-items:center;justify-content:flex-end;gap:8px;padding:12px 0 4px;border-top:.5px solid var(--dsw-alias-border-l2)}
|
.dsh-nx-footer{display:flex;align-items:center;justify-content:flex-end;gap:8px;padding:12px 0 4px;border-top:.5px solid var(--dsw-alias-border-l2)}
|
||||||
.dsh-nx-failed{flex:1;min-width:0;margin:0;font-size:12px;line-height:1.5;color:var(--dsw-alias-label-error)}
|
.dsh-nx-failed{flex:1;min-width:0;margin:0;font-size:12px;line-height:1.5;color:var(--dsw-alias-label-error)}
|
||||||
|
|
|
||||||
151
src/index.ts
151
src/index.ts
|
|
@ -1,7 +1,8 @@
|
||||||
/**
|
/**
|
||||||
* Host-plane Netx Ops: settings (apiUrl / lang / alarm push) + credentials
|
* Host-plane Netx Ops: settings (apiUrl / lang / alarm push / capability groups)
|
||||||
* (NETX_API_TOKEN) publish a connection snapshot; the Ops preset mounts
|
* + credentials (NETX_API_TOKEN) publish a connection snapshot. The Ops preset
|
||||||
* `netx__*` into its own tool scope (`dsh-netxops/tools`).
|
* mounts selected `netx__*` groups via `dsh-netxops/tools`; groups marked public
|
||||||
|
* also register on the host tool/skill layer for other presets.
|
||||||
*
|
*
|
||||||
* When「关键告警推送」is on, this host dials out to netx's fixed-IP alarm hub and
|
* When「关键告警推送」is on, this host dials out to netx's fixed-IP alarm hub and
|
||||||
* opens/follows a sticky DSH session (im / WhatsApp is optional and separate).
|
* opens/follows a sticky DSH session (im / WhatsApp is optional and separate).
|
||||||
|
|
@ -27,7 +28,14 @@ import {
|
||||||
} from './netx/alarm-push-status.ts'
|
} from './netx/alarm-push-status.ts'
|
||||||
import { deliverAlarmToIm } from './netx/alarm-im.ts'
|
import { deliverAlarmToIm } from './netx/alarm-im.ts'
|
||||||
import { deliverAlarmToSession, resetAlarmSession } from './netx/alarm-session.ts'
|
import { deliverAlarmToSession, resetAlarmSession } from './netx/alarm-session.ts'
|
||||||
import { publishNetxConnection } from './netx/runtime.ts'
|
import {
|
||||||
|
capabilityGroupsFromSettings,
|
||||||
|
groupsForPlane,
|
||||||
|
type NetxCapabilityGroupSettingsFields,
|
||||||
|
} from './netx/capability-groups.ts'
|
||||||
|
import { registerGroupSkills } from './netx/group-skills.ts'
|
||||||
|
import { publishNetxConnection, getNetxConnection, watchNetxConnection } from './netx/runtime.ts'
|
||||||
|
import { registerNetxTools } from './netx/tools.ts'
|
||||||
|
|
||||||
/** Cordis plugin name. */
|
/** Cordis plugin name. */
|
||||||
export const name = 'netxops'
|
export const name = 'netxops'
|
||||||
|
|
@ -78,6 +86,23 @@ export interface Config {
|
||||||
imBotId: string
|
imBotId: string
|
||||||
/** Opaque target id from the same copy payload. */
|
/** Opaque target id from the same copy payload. */
|
||||||
imTargetId: string
|
imTargetId: string
|
||||||
|
/**
|
||||||
|
* NMS provider adapter id. Supported today: `zte-ume`
|
||||||
|
* (REST `/v1/ume/*`; model tools are generic `netx__*Nms*`).
|
||||||
|
*/
|
||||||
|
nmsProvider: string
|
||||||
|
/** nms tools/skills in the Netx Ops preset (default on). */
|
||||||
|
groupNmsInPreset: boolean
|
||||||
|
/** Publish nms tools/skills to other presets via the host layer (default off). */
|
||||||
|
groupNmsPublic: boolean
|
||||||
|
/** common tools/skills in the Netx Ops preset (default on). */
|
||||||
|
groupCommonInPreset: boolean
|
||||||
|
/** Publish common tools/skills to other presets (default off). */
|
||||||
|
groupCommonPublic: boolean
|
||||||
|
/** topology canvas / layout tools in the Netx Ops preset (default off). */
|
||||||
|
groupTopologyInPreset: boolean
|
||||||
|
/** Publish topology tools/skills to other presets (default off). */
|
||||||
|
groupTopologyPublic: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export const Config: z<Config> = z.object({
|
export const Config: z<Config> = z.object({
|
||||||
|
|
@ -91,6 +116,13 @@ export const Config: z<Config> = z.object({
|
||||||
alarmDeliverIm: z.boolean().default(false),
|
alarmDeliverIm: z.boolean().default(false),
|
||||||
imBotId: z.string().default(''),
|
imBotId: z.string().default(''),
|
||||||
imTargetId: z.string().default(''),
|
imTargetId: z.string().default(''),
|
||||||
|
nmsProvider: z.string().default('zte-ume'),
|
||||||
|
groupNmsInPreset: z.boolean().default(true),
|
||||||
|
groupNmsPublic: z.boolean().default(false),
|
||||||
|
groupCommonInPreset: z.boolean().default(true),
|
||||||
|
groupCommonPublic: z.boolean().default(false),
|
||||||
|
groupTopologyInPreset: z.boolean().default(false),
|
||||||
|
groupTopologyPublic: z.boolean().default(false),
|
||||||
})
|
})
|
||||||
|
|
||||||
/** Package root (parent of `lib/` or `src/` depending on launch). */
|
/** Package root (parent of `lib/` or `src/` depending on launch). */
|
||||||
|
|
@ -238,11 +270,19 @@ export function apply(ctx: Context, config: Config = Config({})): void {
|
||||||
|
|
||||||
const apiUrl = current.apiUrl.replace(/\/$/, '')
|
const apiUrl = current.apiUrl.replace(/\/$/, '')
|
||||||
const tokenConfigured = token.trim().length > 0
|
const tokenConfigured = token.trim().length > 0
|
||||||
|
const groups = capabilityGroupsFromSettings(current as NetxCapabilityGroupSettingsFields)
|
||||||
|
if (current.nmsProvider && current.nmsProvider !== 'zte-ume') {
|
||||||
|
ctx.logger.warn(
|
||||||
|
'netxops: nmsProvider=%s is not implemented yet; using zte-ume REST adapter',
|
||||||
|
current.nmsProvider,
|
||||||
|
)
|
||||||
|
}
|
||||||
publishNetxConnection({
|
publishNetxConnection({
|
||||||
apiUrl,
|
apiUrl,
|
||||||
token,
|
token,
|
||||||
lang: current.lang,
|
lang: current.lang,
|
||||||
toolCallTimeoutMs: current.toolCallTimeoutMs,
|
toolCallTimeoutMs: current.toolCallTimeoutMs,
|
||||||
|
groups,
|
||||||
})
|
})
|
||||||
restartAlarmPush(current, apiUrl, token)
|
restartAlarmPush(current, apiUrl, token)
|
||||||
if (!tokenConfigured) {
|
if (!tokenConfigured) {
|
||||||
|
|
@ -253,11 +293,12 @@ export function apply(ctx: Context, config: Config = Config({})): void {
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
ctx.logger.info(
|
ctx.logger.info(
|
||||||
'netxops: published connection → %s tokenConfigured=true alarmPush=%s dsh=%s im=%s',
|
'netxops: published connection → %s tokenConfigured=true alarmPush=%s dsh=%s im=%s public=[%s]',
|
||||||
apiUrl,
|
apiUrl,
|
||||||
current.alarmPushEnabled === true,
|
current.alarmPushEnabled === true,
|
||||||
current.alarmDeliverDsh !== false,
|
current.alarmDeliverDsh !== false,
|
||||||
current.alarmDeliverIm === true,
|
current.alarmDeliverIm === true,
|
||||||
|
groupsForPlane(groups, 'public').join(',') || '(none)',
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}).catch((error) => {
|
}).catch((error) => {
|
||||||
|
|
@ -280,7 +321,63 @@ export function apply(ctx: Context, config: Config = Config({})): void {
|
||||||
if (String(ref) === source().tokenCredentialRef) publish()
|
if (String(ref) === source().tokenCredentialRef) publish()
|
||||||
})
|
})
|
||||||
|
|
||||||
// Browser card polls alarm-push WSS status through Connection RPC.
|
// Optional host-layer publish: groups with `public=true` become visible to other presets.
|
||||||
|
ctx.inject(['tools'], (toolsCtx) => {
|
||||||
|
let unregisterTools: (() => void) | undefined
|
||||||
|
const remountPublicTools = (): void => {
|
||||||
|
unregisterTools?.()
|
||||||
|
unregisterTools = undefined
|
||||||
|
const connection = getNetxConnection()
|
||||||
|
if (!connection) return
|
||||||
|
const enabled = groupsForPlane(connection.groups, 'public')
|
||||||
|
unregisterTools = registerNetxTools(toolsCtx, connection, { plane: 'public' })
|
||||||
|
toolsCtx.logger.info(
|
||||||
|
'netxops: host public tools groups=[%s]',
|
||||||
|
enabled.join(',') || '(none)',
|
||||||
|
)
|
||||||
|
}
|
||||||
|
remountPublicTools()
|
||||||
|
const stopWatch = watchNetxConnection(() => { remountPublicTools() })
|
||||||
|
toolsCtx.effect(() => () => {
|
||||||
|
stopWatch()
|
||||||
|
unregisterTools?.()
|
||||||
|
}, 'netxops: dispose public tools')
|
||||||
|
})
|
||||||
|
|
||||||
|
ctx.inject(['skills'], (skillsCtx) => {
|
||||||
|
let unregisterSkills: (() => void) | undefined
|
||||||
|
let generation = 0
|
||||||
|
const remountPublicSkills = (): void => {
|
||||||
|
const gen = ++generation
|
||||||
|
unregisterSkills?.()
|
||||||
|
unregisterSkills = undefined
|
||||||
|
const connection = getNetxConnection()
|
||||||
|
if (!connection) return
|
||||||
|
const enabled = groupsForPlane(connection.groups, 'public')
|
||||||
|
void registerGroupSkills(skillsCtx, enabled, 'netxops-public').then((dispose) => {
|
||||||
|
if (gen !== generation) {
|
||||||
|
dispose()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
unregisterSkills = dispose
|
||||||
|
skillsCtx.logger.info(
|
||||||
|
'netxops: host public skills groups=[%s]',
|
||||||
|
enabled.join(',') || '(none)',
|
||||||
|
)
|
||||||
|
}).catch((error) => {
|
||||||
|
skillsCtx.logger.warn('netxops: public skill register failed: %s', error)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
remountPublicSkills()
|
||||||
|
const stopWatch = watchNetxConnection(() => { remountPublicSkills() })
|
||||||
|
skillsCtx.effect(() => () => {
|
||||||
|
generation += 1
|
||||||
|
stopWatch()
|
||||||
|
unregisterSkills?.()
|
||||||
|
}, 'netxops: dispose public skills')
|
||||||
|
})
|
||||||
|
|
||||||
|
// Browser card polls alarm-push WSS status + IM delivery catalog through Connection RPC.
|
||||||
ctx.inject(['connection'], (connCtx) => {
|
ctx.inject(['connection'], (connCtx) => {
|
||||||
const rpc = connCtx.connection?.rpc
|
const rpc = connCtx.connection?.rpc
|
||||||
if (!rpc || typeof rpc.handle !== 'function') {
|
if (!rpc || typeof rpc.handle !== 'function') {
|
||||||
|
|
@ -291,10 +388,46 @@ export function apply(ctx: Context, config: Config = Config({})): void {
|
||||||
const dispose = rpc.handle(
|
const dispose = rpc.handle(
|
||||||
NETXOPS_RPC_CHANNEL,
|
NETXOPS_RPC_CHANNEL,
|
||||||
async (endpoint: string) => {
|
async (endpoint: string) => {
|
||||||
if (endpoint !== 'alarm-push.status') {
|
if (endpoint === 'alarm-push.status') {
|
||||||
return { ok: false, error: { code: 'bad-request', message: 'Unknown endpoint.' } }
|
return { ok: true, value: getAlarmPushStatus() }
|
||||||
}
|
}
|
||||||
return { ok: true, value: getAlarmPushStatus() }
|
if (endpoint === 'im-delivery.catalog') {
|
||||||
|
type DshImCatalog = { listDeliveryCatalog?: () => Promise<unknown> }
|
||||||
|
const fromGet = typeof (ctx as { get?: (name: string) => unknown }).get === 'function'
|
||||||
|
? (ctx as { get: (name: string) => unknown }).get('dshIm') as DshImCatalog | undefined
|
||||||
|
: undefined
|
||||||
|
const im = fromGet ?? (ctx as { dshIm?: DshImCatalog }).dshIm
|
||||||
|
if (!im || typeof im.listDeliveryCatalog !== 'function') {
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
value: {
|
||||||
|
available: false,
|
||||||
|
options: [],
|
||||||
|
hint: 'dsh-im-ops missing or outdated — install ≥ops.24 for delivery picker',
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const options = await im.listDeliveryCatalog()
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
value: {
|
||||||
|
available: true,
|
||||||
|
options: Array.isArray(options) ? options : [],
|
||||||
|
},
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
value: {
|
||||||
|
available: false,
|
||||||
|
options: [],
|
||||||
|
hint: error instanceof Error ? error.message : String(error),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { ok: false, error: { code: 'bad-request', message: 'Unknown endpoint.' } }
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
return () => { void dispose() }
|
return () => { void dispose() }
|
||||||
|
|
|
||||||
|
|
@ -62,7 +62,7 @@ export function formatAlarmPrompt(payload: KeyAlarmPayload, lang = 'zh'): string
|
||||||
const label = String(payload.rule_label ?? payload.native_probable_cause ?? '关键告警').trim()
|
const label = String(payload.rule_label ?? payload.native_probable_cause ?? '关键告警').trim()
|
||||||
if (lang.startsWith('en')) {
|
if (lang.startsWith('en')) {
|
||||||
return [
|
return [
|
||||||
`[UME ${actionEn[action] ?? (action || 'Alarm')}] ${label}`,
|
`[NMS ${actionEn[action] ?? (action || 'Alarm')}] ${label}`,
|
||||||
`Device: ${device}`,
|
`Device: ${device}`,
|
||||||
`Object: ${String(payload.object_name ?? '-').trim()}`,
|
`Object: ${String(payload.object_name ?? '-').trim()}`,
|
||||||
`Severity: ${String(payload.perceived_severity ?? '-').trim()}`,
|
`Severity: ${String(payload.perceived_severity ?? '-').trim()}`,
|
||||||
|
|
@ -75,7 +75,7 @@ export function formatAlarmPrompt(payload: KeyAlarmPayload, lang = 'zh'): string
|
||||||
].join('\n')
|
].join('\n')
|
||||||
}
|
}
|
||||||
return [
|
return [
|
||||||
`[UME ${actionZh[action] ?? (action || '告警')}] ${label}`,
|
`[NMS ${actionZh[action] ?? (action || '告警')}] ${label}`,
|
||||||
`设备: ${device}`,
|
`设备: ${device}`,
|
||||||
`对象: ${String(payload.object_name ?? '-').trim()}`,
|
`对象: ${String(payload.object_name ?? '-').trim()}`,
|
||||||
`级别: ${String(payload.perceived_severity ?? '-').trim()}`,
|
`级别: ${String(payload.perceived_severity ?? '-').trim()}`,
|
||||||
|
|
|
||||||
176
src/netx/capability-groups.ts
Normal file
176
src/netx/capability-groups.ts
Normal file
|
|
@ -0,0 +1,176 @@
|
||||||
|
/**
|
||||||
|
* Netx Ops capability groups: selective tool/skill exposure.
|
||||||
|
*
|
||||||
|
* Rule: **one group ↔ one skill** (dirs under `netx/skills/<group>/`).
|
||||||
|
*
|
||||||
|
* - `nms` → skill `netx-nms` — vendor NMS adapter (zte-ume behind generic names)
|
||||||
|
* - `common` → skill `netx-common` — managed CLI + findTopologyPaths
|
||||||
|
* - `topology` → skill `netx-topology` — canvas / fabric / dual_unit / layout recipes
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** Stable group ids used in settings, skill dirs, and registration filters. */
|
||||||
|
export type NetxCapabilityGroupId = 'nms' | 'common' | 'topology'
|
||||||
|
|
||||||
|
/** Per-group exposure knobs. */
|
||||||
|
export interface NetxGroupExposure {
|
||||||
|
/** Mount into the Netx Ops agent preset scope. */
|
||||||
|
inPreset: boolean
|
||||||
|
/** Mount on the host tool/skill layer so other presets can see the group. */
|
||||||
|
public: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Full capability-group policy published with the connection snapshot. */
|
||||||
|
export type NetxCapabilityGroups = Record<NetxCapabilityGroupId, NetxGroupExposure>
|
||||||
|
|
||||||
|
/** Flat settings fields (Plugins card + schemastery Config). */
|
||||||
|
export interface NetxCapabilityGroupSettingsFields {
|
||||||
|
groupNmsInPreset: boolean
|
||||||
|
groupNmsPublic: boolean
|
||||||
|
groupCommonInPreset: boolean
|
||||||
|
groupCommonPublic: boolean
|
||||||
|
groupTopologyInPreset: boolean
|
||||||
|
groupTopologyPublic: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Default: nms + common in Ops preset; topology and all public off.
|
||||||
|
*/
|
||||||
|
export const DEFAULT_CAPABILITY_GROUPS: NetxCapabilityGroups = Object.freeze({
|
||||||
|
nms: Object.freeze({ inPreset: true, public: false }),
|
||||||
|
common: Object.freeze({ inPreset: true, public: false }),
|
||||||
|
topology: Object.freeze({ inPreset: false, public: false }),
|
||||||
|
})
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Model-facing tool names (`netx__*`) owned by each group.
|
||||||
|
* nms tools use generic `Nms` names; HTTP still hits the configured NMS provider adapter.
|
||||||
|
*/
|
||||||
|
export const TOOLS_BY_GROUP: Readonly<Record<NetxCapabilityGroupId, readonly string[]>> = Object.freeze({
|
||||||
|
nms: Object.freeze([
|
||||||
|
'netx__queryNmsAlarms',
|
||||||
|
'netx__aggregateNmsAlarms',
|
||||||
|
'netx__runNmsDiagnostics',
|
||||||
|
'netx__queryNmsNeInventory',
|
||||||
|
'netx__getNmsNe',
|
||||||
|
'netx__queryNmsAlarmsRaw',
|
||||||
|
'netx__aggregateNmsAlarmsRaw',
|
||||||
|
'netx__listNmsAlarmFields',
|
||||||
|
'netx__sqlQueryNms',
|
||||||
|
]),
|
||||||
|
common: Object.freeze([
|
||||||
|
'netx__listManagedNe',
|
||||||
|
'netx__getManagedNe',
|
||||||
|
'netx__execManagedNe',
|
||||||
|
'netx__listCliTargets',
|
||||||
|
'netx__findTopologyPaths',
|
||||||
|
]),
|
||||||
|
topology: Object.freeze([
|
||||||
|
'netx__getTopologyTree',
|
||||||
|
'netx__getTopologyView',
|
||||||
|
'netx__createTopologyFolder',
|
||||||
|
'netx__addTopologyViewNodes',
|
||||||
|
'netx__removeTopologyViewNodes',
|
||||||
|
'netx__copyTopologyViewNodes',
|
||||||
|
'netx__updateTopologyViewPositions',
|
||||||
|
'netx__projectTopologyNeighbors',
|
||||||
|
'netx__queryTopologyFabricNodes',
|
||||||
|
'netx__classifyTopologyFabricNodes',
|
||||||
|
'netx__queryTopologyNeighborhood',
|
||||||
|
'netx__queryTopologyEdges',
|
||||||
|
'netx__layoutTopologyView',
|
||||||
|
'netx__suggestSinkHubs',
|
||||||
|
'netx__analyzeTopologyViewLayout',
|
||||||
|
'netx__sinkTopologyDualUnits',
|
||||||
|
]),
|
||||||
|
})
|
||||||
|
|
||||||
|
/** Skill directory name under skills root / `presets/netxops/skills/<group>/`. */
|
||||||
|
export const SKILL_DIR_BY_GROUP: Readonly<Record<NetxCapabilityGroupId, string>> = Object.freeze({
|
||||||
|
nms: 'nms',
|
||||||
|
common: 'common',
|
||||||
|
topology: 'topology',
|
||||||
|
})
|
||||||
|
|
||||||
|
export const CAPABILITY_GROUP_IDS: readonly NetxCapabilityGroupId[] = Object.freeze([
|
||||||
|
'nms',
|
||||||
|
'common',
|
||||||
|
'topology',
|
||||||
|
])
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build group policy from flat settings / Config fields.
|
||||||
|
* Accepts legacy `groupManagedNe*` as aliases of `groupCommon*`.
|
||||||
|
* Legacy `groupTopologyLayout*` ORs into `topology` (layout tools now live in topology).
|
||||||
|
*/
|
||||||
|
export function capabilityGroupsFromSettings(
|
||||||
|
fields: Partial<NetxCapabilityGroupSettingsFields & {
|
||||||
|
groupManagedNeInPreset?: boolean
|
||||||
|
groupManagedNePublic?: boolean
|
||||||
|
groupTopologyLayoutInPreset?: boolean
|
||||||
|
groupTopologyLayoutPublic?: boolean
|
||||||
|
}> | null | undefined,
|
||||||
|
): NetxCapabilityGroups {
|
||||||
|
const src = fields ?? {}
|
||||||
|
const commonInPreset = src.groupCommonInPreset !== undefined
|
||||||
|
? src.groupCommonInPreset !== false
|
||||||
|
: src.groupManagedNeInPreset !== false
|
||||||
|
const commonPublic = src.groupCommonPublic === true
|
||||||
|
|| src.groupManagedNePublic === true
|
||||||
|
return {
|
||||||
|
nms: {
|
||||||
|
inPreset: src.groupNmsInPreset !== false,
|
||||||
|
public: src.groupNmsPublic === true,
|
||||||
|
},
|
||||||
|
common: {
|
||||||
|
inPreset: commonInPreset,
|
||||||
|
public: commonPublic,
|
||||||
|
},
|
||||||
|
topology: {
|
||||||
|
inPreset: src.groupTopologyInPreset === true
|
||||||
|
|| src.groupTopologyLayoutInPreset === true,
|
||||||
|
public: src.groupTopologyPublic === true
|
||||||
|
|| src.groupTopologyLayoutPublic === true,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Groups enabled for one registration plane.
|
||||||
|
* @param groups - published policy.
|
||||||
|
* @param plane - preset scope vs host public layer.
|
||||||
|
* @param only - when set, intersect with this allow-list (per-export tools packages).
|
||||||
|
*/
|
||||||
|
export function groupsForPlane(
|
||||||
|
groups: NetxCapabilityGroups | undefined,
|
||||||
|
plane: 'preset' | 'public',
|
||||||
|
only?: readonly NetxCapabilityGroupId[],
|
||||||
|
): NetxCapabilityGroupId[] {
|
||||||
|
const policy = groups ?? DEFAULT_CAPABILITY_GROUPS
|
||||||
|
const enabled = CAPABILITY_GROUP_IDS.filter((id) => (
|
||||||
|
plane === 'preset' ? policy[id].inPreset : policy[id].public
|
||||||
|
))
|
||||||
|
if (!only || only.length === 0) return enabled
|
||||||
|
const allow = new Set(only)
|
||||||
|
return enabled.filter((id) => allow.has(id))
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Force-enable the listed groups (for `dsh-netxops/tools-<group>` mounts that
|
||||||
|
* intentionally ignore Ops-preset inPreset flags).
|
||||||
|
*/
|
||||||
|
export function groupsForced(
|
||||||
|
only: readonly NetxCapabilityGroupId[],
|
||||||
|
): NetxCapabilityGroupId[] {
|
||||||
|
return CAPABILITY_GROUP_IDS.filter((id) => only.includes(id))
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tool name allow-list for the given groups.
|
||||||
|
*/
|
||||||
|
export function toolNamesForGroups(groupIds: readonly NetxCapabilityGroupId[]): Set<string> {
|
||||||
|
const names = new Set<string>()
|
||||||
|
for (const id of groupIds) {
|
||||||
|
for (const tool of TOOLS_BY_GROUP[id]) names.add(tool)
|
||||||
|
}
|
||||||
|
return names
|
||||||
|
}
|
||||||
196
src/netx/group-skills.ts
Normal file
196
src/netx/group-skills.ts
Normal file
|
|
@ -0,0 +1,196 @@
|
||||||
|
/**
|
||||||
|
* Register Ops playbook skills for enabled capability groups from disk.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { existsSync } from 'node:fs'
|
||||||
|
import { readdir, readFile, stat } from 'node:fs/promises'
|
||||||
|
import { dirname, join } from 'node:path'
|
||||||
|
import { fileURLToPath } from 'node:url'
|
||||||
|
import type { Context } from '@deepseek-ai/cordis'
|
||||||
|
import {
|
||||||
|
CAPABILITY_GROUP_IDS,
|
||||||
|
SKILL_DIR_BY_GROUP,
|
||||||
|
type NetxCapabilityGroupId,
|
||||||
|
} from './capability-groups.ts'
|
||||||
|
|
||||||
|
interface ParsedSkill {
|
||||||
|
name: string
|
||||||
|
description: string
|
||||||
|
content: string
|
||||||
|
path: string
|
||||||
|
directory: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Package `presets/netxops/skills` root, or a shared netx skills checkout.
|
||||||
|
*
|
||||||
|
* Resolution order:
|
||||||
|
* 1. `NETX_SKILLS_ROOT` (canonical `netx/skills`)
|
||||||
|
* 2. Sibling `../netx/skills` when developing next to the netx repo
|
||||||
|
* 3. Bundled `presets/netxops/skills` inside this package
|
||||||
|
*/
|
||||||
|
export function opsSkillsRoot(): string {
|
||||||
|
const envRoot = process.env.NETX_SKILLS_ROOT?.trim()
|
||||||
|
if (envRoot && existsSync(envRoot)) return envRoot
|
||||||
|
|
||||||
|
const here = dirname(fileURLToPath(import.meta.url))
|
||||||
|
const siblingCandidates = [
|
||||||
|
// src/netx → ../../../netx/skills ; lib → ../../netx/skills
|
||||||
|
join(here, '..', '..', '..', 'netx', 'skills'),
|
||||||
|
join(here, '..', '..', 'netx', 'skills'),
|
||||||
|
]
|
||||||
|
for (const candidate of siblingCandidates) {
|
||||||
|
if (existsSync(candidate)) return candidate
|
||||||
|
}
|
||||||
|
|
||||||
|
const packaged = [
|
||||||
|
join(here, '..', 'presets', 'netxops', 'skills'),
|
||||||
|
join(here, '..', '..', 'presets', 'netxops', 'skills'),
|
||||||
|
]
|
||||||
|
for (const candidate of packaged) {
|
||||||
|
if (existsSync(candidate)) return candidate
|
||||||
|
}
|
||||||
|
return packaged[0]
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseFrontmatter(raw: string): { data: Record<string, unknown>; body: string } | undefined {
|
||||||
|
if (!raw.startsWith('---')) return undefined
|
||||||
|
const end = raw.indexOf('\n---', 3)
|
||||||
|
if (end < 0) return undefined
|
||||||
|
const yaml = raw.slice(3, end).replace(/^\r?\n/, '')
|
||||||
|
const body = raw.slice(end + 4).replace(/^\r?\n/, '')
|
||||||
|
const data: Record<string, unknown> = {}
|
||||||
|
const lines = yaml.split(/\r?\n/)
|
||||||
|
for (let i = 0; i < lines.length; i += 1) {
|
||||||
|
const line = lines[i]
|
||||||
|
const nameMatch = /^name\s*:\s*(.+)\s*$/.exec(line)
|
||||||
|
if (nameMatch) {
|
||||||
|
data.name = stripQuotes(nameMatch[1].trim())
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
const descMatch = /^description\s*:\s*(.*)$/.exec(line)
|
||||||
|
if (!descMatch) continue
|
||||||
|
const head = descMatch[1].trim()
|
||||||
|
if (head === '>' || head === '>-' || head === '|' || head === '|-') {
|
||||||
|
const parts: string[] = []
|
||||||
|
while (i + 1 < lines.length && /^[ \t]+/.test(lines[i + 1])) {
|
||||||
|
i += 1
|
||||||
|
parts.push(lines[i].trim())
|
||||||
|
}
|
||||||
|
data.description = parts.filter(Boolean).join(' ')
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
data.description = stripQuotes(head)
|
||||||
|
}
|
||||||
|
return { data, body }
|
||||||
|
}
|
||||||
|
|
||||||
|
function stripQuotes(value: string): string {
|
||||||
|
if (
|
||||||
|
(value.startsWith('"') && value.endsWith('"'))
|
||||||
|
|| (value.startsWith("'") && value.endsWith("'"))
|
||||||
|
) {
|
||||||
|
return value.slice(1, -1)
|
||||||
|
}
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadSkillBundle(dir: string): Promise<ParsedSkill | null> {
|
||||||
|
const skillPath = join(dir, 'SKILL.md')
|
||||||
|
let raw: string
|
||||||
|
try {
|
||||||
|
raw = await readFile(skillPath, 'utf8')
|
||||||
|
} catch {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
const parsed = parseFrontmatter(raw)
|
||||||
|
if (!parsed) return null
|
||||||
|
const name = typeof parsed.data.name === 'string' ? parsed.data.name.trim() : ''
|
||||||
|
const description = typeof parsed.data.description === 'string'
|
||||||
|
? parsed.data.description.trim()
|
||||||
|
: ''
|
||||||
|
if (!name || !description) return null
|
||||||
|
return {
|
||||||
|
name,
|
||||||
|
description,
|
||||||
|
content: parsed.body.trimStart(),
|
||||||
|
path: skillPath,
|
||||||
|
directory: dir,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadGroupSkills(
|
||||||
|
skillsRoot: string,
|
||||||
|
groupId: NetxCapabilityGroupId,
|
||||||
|
): Promise<ParsedSkill[]> {
|
||||||
|
const groupDir = join(skillsRoot, SKILL_DIR_BY_GROUP[groupId])
|
||||||
|
let entries: string[]
|
||||||
|
try {
|
||||||
|
entries = await readdir(groupDir)
|
||||||
|
} catch {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
const skills: ParsedSkill[] = []
|
||||||
|
for (const entry of entries) {
|
||||||
|
const full = join(groupDir, entry)
|
||||||
|
let isDir = false
|
||||||
|
try {
|
||||||
|
isDir = (await stat(full)).isDirectory()
|
||||||
|
} catch {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if (!isDir) continue
|
||||||
|
const skill = await loadSkillBundle(full)
|
||||||
|
if (skill) skills.push(skill)
|
||||||
|
}
|
||||||
|
return skills
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Register playbook skills for the given capability groups into `ctx.skills`.
|
||||||
|
* @param ctx - context with a skills registry (preset or host layer).
|
||||||
|
* @param groupIds - enabled groups for this plane.
|
||||||
|
* @param providerLabel - `provider` stamp on each registration.
|
||||||
|
* @returns disposer that unregisters every skill.
|
||||||
|
*/
|
||||||
|
export async function registerGroupSkills(
|
||||||
|
ctx: Context,
|
||||||
|
groupIds: readonly NetxCapabilityGroupId[],
|
||||||
|
providerLabel: string,
|
||||||
|
): Promise<() => void> {
|
||||||
|
const skillsApi = (ctx as { skills?: { register: (skill: {
|
||||||
|
name: string
|
||||||
|
description: string
|
||||||
|
content: string
|
||||||
|
path?: string
|
||||||
|
resourceBase?: { kind: 'directory'; path: string }
|
||||||
|
provider?: string
|
||||||
|
source: string
|
||||||
|
}) => () => void } }).skills
|
||||||
|
if (!skillsApi || typeof skillsApi.register !== 'function') {
|
||||||
|
return () => {}
|
||||||
|
}
|
||||||
|
|
||||||
|
const root = opsSkillsRoot()
|
||||||
|
const disposers: Array<() => void> = []
|
||||||
|
const enabled = new Set(groupIds)
|
||||||
|
for (const groupId of CAPABILITY_GROUP_IDS) {
|
||||||
|
if (!enabled.has(groupId)) continue
|
||||||
|
const skills = await loadGroupSkills(root, groupId)
|
||||||
|
for (const skill of skills) {
|
||||||
|
disposers.push(skillsApi.register({
|
||||||
|
name: skill.name,
|
||||||
|
description: skill.description,
|
||||||
|
content: skill.content,
|
||||||
|
path: skill.path,
|
||||||
|
resourceBase: { kind: 'directory', path: skill.directory },
|
||||||
|
provider: providerLabel,
|
||||||
|
source: 'custom',
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
for (const dispose of disposers) dispose()
|
||||||
|
}
|
||||||
|
}
|
||||||
107
src/netx/group-tools-plugin.ts
Normal file
107
src/netx/group-tools-plugin.ts
Normal file
|
|
@ -0,0 +1,107 @@
|
||||||
|
/**
|
||||||
|
* Shared agent-plane mount: tools + skills for one or more capability groups.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { Context } from '@deepseek-ai/cordis'
|
||||||
|
import type {} from '@deepseek-ai/dsh-tools'
|
||||||
|
import {
|
||||||
|
groupsForced,
|
||||||
|
groupsForPlane,
|
||||||
|
type NetxCapabilityGroupId,
|
||||||
|
} from './capability-groups.ts'
|
||||||
|
import { registerGroupSkills } from './group-skills.ts'
|
||||||
|
import { getNetxConnection, watchNetxConnection } from './runtime.ts'
|
||||||
|
import { registerNetxTools } from './tools.ts'
|
||||||
|
|
||||||
|
export interface GroupToolsPluginOptions {
|
||||||
|
/** Cordis plugin name. */
|
||||||
|
name: string
|
||||||
|
/**
|
||||||
|
* `settings` — honor inPreset/public from connection (Ops aggregate mount).
|
||||||
|
* `forced` — always register `only` groups when connection exists (per-group export).
|
||||||
|
*/
|
||||||
|
mode: 'settings' | 'forced'
|
||||||
|
/** Group allow-list; omit in settings mode to use all groups. */
|
||||||
|
only?: readonly NetxCapabilityGroupId[]
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Apply a group-scoped tools/skills plugin on the calling (usually preset) context.
|
||||||
|
*/
|
||||||
|
export function applyGroupToolsPlugin(ctx: Context, options: GroupToolsPluginOptions): void {
|
||||||
|
let unregisterTools: (() => void) | undefined
|
||||||
|
let unregisterSkills: (() => void) | undefined
|
||||||
|
let skillGeneration = 0
|
||||||
|
|
||||||
|
const resolveGroups = (): NetxCapabilityGroupId[] => {
|
||||||
|
const connection = getNetxConnection()
|
||||||
|
if (options.mode === 'forced') {
|
||||||
|
return groupsForced(options.only ?? [])
|
||||||
|
}
|
||||||
|
return groupsForPlane(connection?.groups, 'preset', options.only)
|
||||||
|
}
|
||||||
|
|
||||||
|
const remountTools = (): void => {
|
||||||
|
unregisterTools?.()
|
||||||
|
unregisterTools = undefined
|
||||||
|
const connection = getNetxConnection()
|
||||||
|
if (connection === undefined) {
|
||||||
|
ctx.logger.warn('%s: no connection yet — waiting for host settings bridge', options.name)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const enabled = resolveGroups()
|
||||||
|
if (enabled.length === 0) {
|
||||||
|
ctx.logger.info('%s: no groups enabled', options.name)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
unregisterTools = registerNetxTools(ctx, connection, {
|
||||||
|
plane: 'preset',
|
||||||
|
only: options.mode === 'forced' ? enabled : options.only,
|
||||||
|
forceGroups: options.mode === 'forced' ? enabled : undefined,
|
||||||
|
})
|
||||||
|
ctx.logger.info(
|
||||||
|
'%s: groups=[%s] → %s tokenConfigured=%s',
|
||||||
|
options.name,
|
||||||
|
enabled.join(',') || '(none)',
|
||||||
|
connection.apiUrl,
|
||||||
|
connection.token.trim().length > 0,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
remountTools()
|
||||||
|
const stopToolWatch = watchNetxConnection(() => { remountTools() })
|
||||||
|
|
||||||
|
ctx.inject(['skills'], (skillsCtx) => {
|
||||||
|
const remountSkills = (): void => {
|
||||||
|
const gen = ++skillGeneration
|
||||||
|
unregisterSkills?.()
|
||||||
|
unregisterSkills = undefined
|
||||||
|
if (getNetxConnection() === undefined) return
|
||||||
|
const enabled = resolveGroups()
|
||||||
|
void registerGroupSkills(skillsCtx, enabled, options.name).then((dispose) => {
|
||||||
|
if (gen !== skillGeneration) {
|
||||||
|
dispose()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
unregisterSkills = dispose
|
||||||
|
}).catch((error) => {
|
||||||
|
skillsCtx.logger.warn('%s: skill register failed: %s', options.name, error)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
remountSkills()
|
||||||
|
const stopSkillWatch = watchNetxConnection(() => { remountSkills() })
|
||||||
|
skillsCtx.effect(() => () => {
|
||||||
|
skillGeneration += 1
|
||||||
|
stopSkillWatch()
|
||||||
|
unregisterSkills?.()
|
||||||
|
unregisterSkills = undefined
|
||||||
|
}, `${options.name}: dispose skills`)
|
||||||
|
})
|
||||||
|
|
||||||
|
ctx.effect(() => () => {
|
||||||
|
stopToolWatch()
|
||||||
|
unregisterTools?.()
|
||||||
|
unregisterTools = undefined
|
||||||
|
}, `${options.name}: dispose tools`)
|
||||||
|
}
|
||||||
|
|
@ -38,6 +38,16 @@ function str(args: NetxJson, key: string, fallback = ''): string {
|
||||||
return fallback
|
return fallback
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Prefer generic `nms_*` aliases; fall back to historical `ume_*` wire names. */
|
||||||
|
function nmsOrUme(args: NetxJson, nmsKey: string, umeKey: string): string {
|
||||||
|
return str(args, nmsKey).trim() || str(args, umeKey).trim()
|
||||||
|
}
|
||||||
|
|
||||||
|
function nmsOrUmeList(args: NetxJson, nmsKey: string, umeKey: string): string[] {
|
||||||
|
const primary = strList(args, nmsKey)
|
||||||
|
return primary.length > 0 ? primary : strList(args, umeKey)
|
||||||
|
}
|
||||||
|
|
||||||
function num(args: NetxJson, key: string): number | undefined {
|
function num(args: NetxJson, key: string): number | undefined {
|
||||||
const v = args[key]
|
const v = args[key]
|
||||||
return typeof v === 'number' && Number.isFinite(v) ? v : undefined
|
return typeof v === 'number' && Number.isFinite(v) ? v : undefined
|
||||||
|
|
@ -189,7 +199,7 @@ export async function getManagedNe(client: NetxClient, args: NetxJson, signal?:
|
||||||
ok: false,
|
ok: false,
|
||||||
error: 'ne_id_required',
|
error: 'ne_id_required',
|
||||||
error_code: 'ne_id_required',
|
error_code: 'ne_id_required',
|
||||||
hint: 'Pass managed NE id from listManagedNe/listCliTargets (source=managed). For UME inventory UUIDs use execManagedNe(ume_ne_id=...) or getUmeNe, not getManagedNe.',
|
hint: 'Pass managed NE id from listManagedNe/listCliTargets (source=managed). For NMS inventory UUIDs use execManagedNe(nms_ne_id=...) or getNmsNe, not getManagedNe.',
|
||||||
example: { ne_id: '<managed-ne-uuid-from-listManagedNe>' },
|
example: { ne_id: '<managed-ne-uuid-from-listManagedNe>' },
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -199,7 +209,7 @@ export async function getManagedNe(client: NetxClient, args: NetxJson, signal?:
|
||||||
if (detail.includes('404') || detail.includes('not_found') || detail.includes('not found') || out.error === 'netx_http_404') {
|
if (detail.includes('404') || detail.includes('not_found') || detail.includes('not found') || out.error === 'netx_http_404') {
|
||||||
return {
|
return {
|
||||||
...out,
|
...out,
|
||||||
hint: 'Managed NE not found for this ne_id. Call listManagedNe(keyword=...) or listCliTargets(source=managed) first. If this is a UME ne_id, use execManagedNe(ume_ne_id=...) / getUmeNe instead of getManagedNe.',
|
hint: 'Managed NE not found for this ne_id. Call listManagedNe(keyword=...) or listCliTargets(source=managed) first. If this is an NMS inventory id, use execManagedNe(nms_ne_id=...) / getNmsNe instead of getManagedNe.',
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -209,7 +219,7 @@ export async function getManagedNe(client: NetxClient, args: NetxJson, signal?:
|
||||||
export async function execManagedNe(client: NetxClient, args: NetxJson, signal?: AbortSignal): Promise<NetxJson> {
|
export async function execManagedNe(client: NetxClient, args: NetxJson, signal?: AbortSignal): Promise<NetxJson> {
|
||||||
const targetsRaw = args.targets
|
const targetsRaw = args.targets
|
||||||
const neIds = strList(args, 'ne_ids')
|
const neIds = strList(args, 'ne_ids')
|
||||||
const umeNeIds = strList(args, 'ume_ne_ids')
|
const umeNeIds = nmsOrUmeList(args, 'nms_ne_ids', 'ume_ne_ids')
|
||||||
const sharedCommands = strList(args, 'commands')
|
const sharedCommands = strList(args, 'commands')
|
||||||
const multi = (Array.isArray(targetsRaw) && targetsRaw.length > 0)
|
const multi = (Array.isArray(targetsRaw) && targetsRaw.length > 0)
|
||||||
|| neIds.length > 0
|
|| neIds.length > 0
|
||||||
|
|
@ -224,7 +234,7 @@ export async function execManagedNe(client: NetxClient, args: NetxJson, signal?:
|
||||||
const row = t as NetxJson
|
const row = t as NetxJson
|
||||||
const item: NetxJson = {}
|
const item: NetxJson = {}
|
||||||
const neId = str(row, 'ne_id').trim()
|
const neId = str(row, 'ne_id').trim()
|
||||||
const umeNeId = str(row, 'ume_ne_id').trim()
|
const umeNeId = nmsOrUme(row, 'nms_ne_id', 'ume_ne_id')
|
||||||
if (neId) item.ne_id = neId
|
if (neId) item.ne_id = neId
|
||||||
if (umeNeId) item.ume_ne_id = umeNeId
|
if (umeNeId) item.ume_ne_id = umeNeId
|
||||||
const cmds = Array.isArray(row.commands)
|
const cmds = Array.isArray(row.commands)
|
||||||
|
|
@ -256,13 +266,13 @@ export async function execManagedNe(client: NetxClient, args: NetxJson, signal?:
|
||||||
}
|
}
|
||||||
|
|
||||||
const neId = str(args, 'ne_id').trim()
|
const neId = str(args, 'ne_id').trim()
|
||||||
const umeNeId = str(args, 'ume_ne_id').trim()
|
const umeNeId = nmsOrUme(args, 'nms_ne_id', 'ume_ne_id')
|
||||||
if (Boolean(neId) === Boolean(umeNeId)) {
|
if (Boolean(neId) === Boolean(umeNeId)) {
|
||||||
return {
|
return {
|
||||||
ok: false,
|
ok: false,
|
||||||
error: 'exactly_one_of_ne_id_or_ume_ne_id_required',
|
error: 'exactly_one_of_ne_id_or_nms_ne_id_required',
|
||||||
error_code: 'exactly_one_of_ne_id_or_ume_ne_id_required',
|
error_code: 'exactly_one_of_ne_id_or_nms_ne_id_required',
|
||||||
hint: 'For one NE pass ne_id OR ume_ne_id. For many NEs pass ne_ids / ume_ne_ids with shared commands, or targets[] with per-NE commands — one call, concurrent on server.',
|
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) {
|
if (sharedCommands.length === 0) {
|
||||||
|
|
@ -296,15 +306,15 @@ export async function listCliTargets(client: NetxClient, args: NetxJson, signal?
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function findTopologyPaths(client: NetxClient, args: NetxJson, signal?: AbortSignal): Promise<NetxJson> {
|
export async function findTopologyPaths(client: NetxClient, args: NetxJson, signal?: AbortSignal): Promise<NetxJson> {
|
||||||
const fromUid = str(args, 'from_ume_ne_id').trim()
|
const fromUid = nmsOrUme(args, 'from_nms_ne_id', 'from_ume_ne_id')
|
||||||
const fromMid = str(args, 'from_managed_ne_id').trim()
|
const fromMid = str(args, 'from_managed_ne_id').trim()
|
||||||
const toUid = str(args, 'to_ume_ne_id').trim()
|
const toUid = nmsOrUme(args, 'to_nms_ne_id', 'to_ume_ne_id')
|
||||||
const toMid = str(args, 'to_managed_ne_id').trim()
|
const toMid = str(args, 'to_managed_ne_id').trim()
|
||||||
if (Boolean(fromUid) === Boolean(fromMid)) {
|
if (Boolean(fromUid) === Boolean(fromMid)) {
|
||||||
return { ok: false, error: 'exactly_one_of_from_ume_ne_id_or_from_managed_ne_id_required' }
|
return { ok: false, error: 'exactly_one_of_from_nms_ne_id_or_from_managed_ne_id_required' }
|
||||||
}
|
}
|
||||||
if (Boolean(toUid) === Boolean(toMid)) {
|
if (Boolean(toUid) === Boolean(toMid)) {
|
||||||
return { ok: false, error: 'exactly_one_of_to_ume_ne_id_or_to_managed_ne_id_required' }
|
return { ok: false, error: 'exactly_one_of_to_nms_ne_id_or_to_managed_ne_id_required' }
|
||||||
}
|
}
|
||||||
let detail = str(args, 'detail', 'summary').trim().toLowerCase() || 'summary'
|
let detail = str(args, 'detail', 'summary').trim().toLowerCase() || 'summary'
|
||||||
if (detail !== 'summary' && detail !== 'full') detail = 'summary'
|
if (detail !== 'summary' && detail !== 'full') detail = 'summary'
|
||||||
|
|
|
||||||
|
|
@ -121,6 +121,9 @@ export function createNetxClient(connection: NetxConnection) {
|
||||||
post(path: string, body: NetxJson, signal?: AbortSignal, timeoutMs?: number) {
|
post(path: string, body: NetxJson, signal?: AbortSignal, timeoutMs?: number) {
|
||||||
return request('POST', path, { body, signal, timeoutMs })
|
return request('POST', path, { body, signal, timeoutMs })
|
||||||
},
|
},
|
||||||
|
patch(path: string, body: NetxJson, signal?: AbortSignal, timeoutMs?: number) {
|
||||||
|
return request('PATCH', path, { body, signal, timeoutMs })
|
||||||
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -4,15 +4,24 @@
|
||||||
|
|
||||||
import type { Context } from '@deepseek-ai/cordis'
|
import type { Context } from '@deepseek-ai/cordis'
|
||||||
import { defineTool } from '@deepseek-ai/dsh-tools'
|
import { defineTool } from '@deepseek-ai/dsh-tools'
|
||||||
|
import {
|
||||||
|
groupsForPlane,
|
||||||
|
toolNamesForGroups,
|
||||||
|
type NetxCapabilityGroupId,
|
||||||
|
type NetxCapabilityGroups,
|
||||||
|
} from './capability-groups.ts'
|
||||||
import { createNetxClient, type NetxClient, type NetxJson } from './http.ts'
|
import { createNetxClient, type NetxClient, type NetxJson } from './http.ts'
|
||||||
import { getNetxConnection } from './runtime.ts'
|
import { getNetxConnection } from './runtime.ts'
|
||||||
import * as H from './handlers.ts'
|
import * as H from './handlers.ts'
|
||||||
|
import * as T from './topology-handlers.ts'
|
||||||
|
|
||||||
export interface NetxToolConnection {
|
export interface NetxToolConnection {
|
||||||
apiUrl: string
|
apiUrl: string
|
||||||
token: string
|
token: string
|
||||||
lang: string
|
lang: string
|
||||||
toolCallTimeoutMs: number
|
toolCallTimeoutMs: number
|
||||||
|
/** Capability-group exposure; omitted → package defaults. */
|
||||||
|
groups?: NetxCapabilityGroups
|
||||||
}
|
}
|
||||||
|
|
||||||
type Handler = (client: NetxClient, args: NetxJson, signal?: AbortSignal) => Promise<NetxJson>
|
type Handler = (client: NetxClient, args: NetxJson, signal?: AbortSignal) => Promise<NetxJson>
|
||||||
|
|
@ -60,14 +69,36 @@ function tool(
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface RegisterNetxToolsOptions {
|
||||||
|
/**
|
||||||
|
* `preset` — groups with `inPreset` (Netx Ops preset scope).
|
||||||
|
* `public` — groups with `public` (host layer for other presets).
|
||||||
|
*/
|
||||||
|
plane: 'preset' | 'public'
|
||||||
|
/** Optional intersect with settings-enabled groups. */
|
||||||
|
only?: readonly NetxCapabilityGroupId[]
|
||||||
|
/** When set, register exactly these groups (ignore plane flags). */
|
||||||
|
forceGroups?: readonly NetxCapabilityGroupId[]
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Register all Netx Ops tools against the current connection snapshot.
|
* Register Netx Ops tools for one exposure plane against the connection snapshot.
|
||||||
* Bearer is read from the live process store on each request (not frozen here).
|
* Bearer is read from the live process store on each request (not frozen here).
|
||||||
* @param ctx - host context with `tools`.
|
* @param ctx - context with `tools`.
|
||||||
* @param connection - apiUrl / token / lang / timeout (apiUrl/lang/timeout used for client base).
|
* @param connection - apiUrl / token / lang / timeout / groups.
|
||||||
|
* @param options - which plane's group flags to honor.
|
||||||
* @returns disposer that unregisters every tool.
|
* @returns disposer that unregisters every tool.
|
||||||
*/
|
*/
|
||||||
export function registerNetxTools(ctx: Context, connection: NetxToolConnection): () => void {
|
export function registerNetxTools(
|
||||||
|
ctx: Context,
|
||||||
|
connection: NetxToolConnection,
|
||||||
|
options: RegisterNetxToolsOptions,
|
||||||
|
): () => void {
|
||||||
|
const enabledGroups = options.forceGroups
|
||||||
|
?? groupsForPlane(connection.groups, options.plane, options.only)
|
||||||
|
const allow = toolNamesForGroups(enabledGroups)
|
||||||
|
if (allow.size === 0) return () => {}
|
||||||
|
|
||||||
const client = createNetxClient({
|
const client = createNetxClient({
|
||||||
apiUrl: connection.apiUrl,
|
apiUrl: connection.apiUrl,
|
||||||
lang: connection.lang,
|
lang: connection.lang,
|
||||||
|
|
@ -77,10 +108,10 @@ export function registerNetxTools(ctx: Context, connection: NetxToolConnection):
|
||||||
const getClient = () => client
|
const getClient = () => client
|
||||||
const t = connection.toolCallTimeoutMs
|
const t = connection.toolCallTimeoutMs
|
||||||
|
|
||||||
const disposers = [
|
const catalog = [
|
||||||
ctx.tools.register(tool(
|
tool(
|
||||||
'netx__queryUmeAlarms',
|
'netx__queryNmsAlarms',
|
||||||
'Query UME current alarms (each row includes host_name). Supports severity/ne_id/host_name/keyword, last_seen time_from/time_to, pagination. Prefer host_name for display; ne_id is for filters only.',
|
'Query NMS current alarms (each row includes host_name). Supports severity/ne_id/host_name/keyword, last_seen time_from/time_to, pagination. Prefer host_name for display; ne_id is for filters only.',
|
||||||
{
|
{
|
||||||
severity: str(),
|
severity: str(),
|
||||||
ne_id: str('Filter only; do not show UUID to users'),
|
ne_id: str('Filter only; do not show UUID to users'),
|
||||||
|
|
@ -93,10 +124,10 @@ export function registerNetxTools(ctx: Context, connection: NetxToolConnection):
|
||||||
page_size: num(),
|
page_size: num(),
|
||||||
},
|
},
|
||||||
H.queryUmeAlarms, getClient, t,
|
H.queryUmeAlarms, getClient, t,
|
||||||
)),
|
),
|
||||||
ctx.tools.register(tool(
|
tool(
|
||||||
'netx__aggregateUmeAlarms',
|
'netx__aggregateNmsAlarms',
|
||||||
'Aggregate UME current alarms (by_severity + top by_ne). If group_by is set, routes to aggregateUmeAlarmsRaw. Always filter severity/keyword/time before paging.',
|
'Aggregate NMS current alarms (by_severity + top by_ne). If group_by is set, routes to aggregateUmeAlarmsRaw. Always filter severity/keyword/time before paging.',
|
||||||
{
|
{
|
||||||
severity: str('Optional perceived_severity filter (critical/major/minor/warning).'),
|
severity: str('Optional perceived_severity filter (critical/major/minor/warning).'),
|
||||||
top_ne: num('Max NE buckets (default 50). Ignored when group_by is set.'),
|
top_ne: num('Max NE buckets (default 50). Ignored when group_by is set.'),
|
||||||
|
|
@ -112,34 +143,34 @@ export function registerNetxTools(ctx: Context, connection: NetxToolConnection):
|
||||||
limit: num(),
|
limit: num(),
|
||||||
},
|
},
|
||||||
H.aggregateUmeAlarms, getClient, t,
|
H.aggregateUmeAlarms, getClient, t,
|
||||||
)),
|
),
|
||||||
ctx.tools.register(tool(
|
tool(
|
||||||
'netx__runUmeDiagnostics',
|
'netx__runNmsDiagnostics',
|
||||||
'UME alarm diagnostics: severity, top_event_types, top_alarm_codes, top_ne, protocol buckets, freshness meta.',
|
'NMS alarm diagnostics: severity, top_event_types, top_alarm_codes, top_ne, protocol buckets, freshness meta.',
|
||||||
{},
|
{},
|
||||||
H.runUmeDiagnostics, getClient, t,
|
H.runUmeDiagnostics, getClient, t,
|
||||||
)),
|
),
|
||||||
ctx.tools.register(tool(
|
tool(
|
||||||
'netx__queryUmeNeInventory',
|
'netx__queryNmsNeInventory',
|
||||||
'Paged UME NE inventory synced in netx (keyword matches ne_id/ne_name/user_label/ip/host_name).',
|
'Paged NMS NE inventory synced in netx (keyword matches ne_id/ne_name/user_label/ip/host_name).',
|
||||||
{
|
{
|
||||||
keyword: str(),
|
keyword: str(),
|
||||||
page: num(),
|
page: num(),
|
||||||
page_size: num(),
|
page_size: num(),
|
||||||
},
|
},
|
||||||
H.queryUmeNeInventory, getClient, t,
|
H.queryUmeNeInventory, getClient, t,
|
||||||
)),
|
),
|
||||||
ctx.tools.register(tool(
|
tool(
|
||||||
'netx__getUmeNe',
|
'netx__getNmsNe',
|
||||||
'Get single UME NE detail by ne_id (UUID).',
|
'Get single NMS NE detail by ne_id (UUID).',
|
||||||
{
|
{
|
||||||
ne_id: { type: 'string' as const, required: true as const, description: 'UME inventory ne_id (UUID).' },
|
ne_id: { type: 'string' as const, required: true as const, description: 'NMS inventory ne_id (UUID).' },
|
||||||
},
|
},
|
||||||
H.getUmeNe, getClient, t,
|
H.getUmeNe, getClient, t,
|
||||||
)),
|
),
|
||||||
ctx.tools.register(tool(
|
tool(
|
||||||
'netx__queryUmeAlarmsRaw',
|
'netx__queryNmsAlarmsRaw',
|
||||||
'Power query UME current alarms with full alarm_* + ne_* fields; optional field_preset or select_fields. Use field_preset=evidence for citations.',
|
'Power query NMS current alarms with full alarm_* + ne_* fields; optional field_preset or select_fields. Use field_preset=evidence for citations.',
|
||||||
{
|
{
|
||||||
severity: str(),
|
severity: str(),
|
||||||
is_cleared: str(),
|
is_cleared: str(),
|
||||||
|
|
@ -156,10 +187,10 @@ export function registerNetxTools(ctx: Context, connection: NetxToolConnection):
|
||||||
page_size: num(),
|
page_size: num(),
|
||||||
},
|
},
|
||||||
H.queryUmeAlarmsRaw, getClient, t,
|
H.queryUmeAlarmsRaw, getClient, t,
|
||||||
)),
|
),
|
||||||
ctx.tools.register(tool(
|
tool(
|
||||||
'netx__aggregateUmeAlarmsRaw',
|
'netx__aggregateNmsAlarmsRaw',
|
||||||
'Dynamic aggregation on UME raw fields (group_by/group_by2); prefer alarm_host_name.',
|
'Dynamic aggregation on NMS raw fields (group_by/group_by2); prefer alarm_host_name.',
|
||||||
{
|
{
|
||||||
group_by: { type: 'string' as const, required: true as const },
|
group_by: { type: 'string' as const, required: true as const },
|
||||||
group_by2: str(),
|
group_by2: str(),
|
||||||
|
|
@ -174,24 +205,24 @@ export function registerNetxTools(ctx: Context, connection: NetxToolConnection):
|
||||||
limit: num(),
|
limit: num(),
|
||||||
},
|
},
|
||||||
H.aggregateUmeAlarmsRaw, getClient, t,
|
H.aggregateUmeAlarmsRaw, getClient, t,
|
||||||
)),
|
),
|
||||||
ctx.tools.register(tool(
|
tool(
|
||||||
'netx__listUmeAlarmFields',
|
'netx__listNmsAlarmFields',
|
||||||
'List available fields for UME raw alarm queries.',
|
'List available fields for NMS raw alarm queries.',
|
||||||
{},
|
{},
|
||||||
H.listUmeAlarmFields, getClient, t,
|
H.listUmeAlarmFields, getClient, t,
|
||||||
)),
|
),
|
||||||
ctx.tools.register(tool(
|
tool(
|
||||||
'netx__sqlQueryUme',
|
'netx__sqlQueryNms',
|
||||||
'Read-only SELECT on UME tables (ume_alarms_current/ume_inventory_ne); server enforces limits. Requires sql:query scope.',
|
'Read-only SELECT on NMS tables (ume_alarms_current/ume_inventory_ne); server enforces limits. Requires sql:query scope.',
|
||||||
{
|
{
|
||||||
sql: { type: 'string' as const, required: true as const },
|
sql: { type: 'string' as const, required: true as const },
|
||||||
limit: num(),
|
limit: num(),
|
||||||
statement_timeout_ms: num(),
|
statement_timeout_ms: num(),
|
||||||
},
|
},
|
||||||
H.sqlQueryUme, getClient, t,
|
H.sqlQueryUme, getClient, t,
|
||||||
)),
|
),
|
||||||
ctx.tools.register(tool(
|
tool(
|
||||||
'netx__listManagedNe',
|
'netx__listManagedNe',
|
||||||
'List filtered netx managed NEs (keyword/vendor/connect_status required); use before execManagedNe.',
|
'List filtered netx managed NEs (keyword/vendor/connect_status required); use before execManagedNe.',
|
||||||
{
|
{
|
||||||
|
|
@ -202,25 +233,27 @@ export function registerNetxTools(ctx: Context, connection: NetxToolConnection):
|
||||||
page_size: num(),
|
page_size: num(),
|
||||||
},
|
},
|
||||||
H.listManagedNe, getClient, t,
|
H.listManagedNe, getClient, t,
|
||||||
)),
|
),
|
||||||
ctx.tools.register(tool(
|
tool(
|
||||||
'netx__getManagedNe',
|
'netx__getManagedNe',
|
||||||
'Get one managed NE by managed ne_id (from listManagedNe / listCliTargets source=managed). Do NOT pass UME inventory UUID here.',
|
'Get one managed NE by managed ne_id (from listManagedNe / listCliTargets source=managed). Do NOT pass NMS inventory UUID here.',
|
||||||
{
|
{
|
||||||
ne_id: str('Managed NE id'),
|
ne_id: str('Managed NE id'),
|
||||||
managed_ne_id: str('Alias for ne_id'),
|
managed_ne_id: str('Alias for ne_id'),
|
||||||
id: str('Alias for ne_id'),
|
id: str('Alias for ne_id'),
|
||||||
},
|
},
|
||||||
H.getManagedNe, getClient, t,
|
H.getManagedNe, getClient, t,
|
||||||
)),
|
),
|
||||||
ctx.tools.register(tool(
|
tool(
|
||||||
'netx__execManagedNe',
|
'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.',
|
'Run read-only CLI via netx (show/display/ping/traceroute). Single NE: ne_id OR ume_ne_id + commands. Many NEs: ne_ids[]/ume_ne_ids[] + shared commands, or targets[{ume_ne_id|ne_id, commands}]. Do NOT loop one-NE calls for multi-NE work.',
|
||||||
{
|
{
|
||||||
ne_id: str(),
|
ne_id: str(),
|
||||||
ume_ne_id: str(),
|
nms_ne_id: str('NMS inventory id; alias ume_ne_id'),
|
||||||
|
ume_ne_id: str('Legacy alias of nms_ne_id'),
|
||||||
ne_ids: strArr('Managed NE ids for concurrent batch (shared commands).'),
|
ne_ids: strArr('Managed NE ids for concurrent batch (shared commands).'),
|
||||||
ume_ne_ids: strArr('UME inventory ne_ids for concurrent batch (shared commands).'),
|
nms_ne_ids: strArr('NMS inventory ids; alias ume_ne_ids'),
|
||||||
|
ume_ne_ids: strArr('Legacy alias of nms_ne_ids'),
|
||||||
targets: {
|
targets: {
|
||||||
type: 'array' as const,
|
type: 'array' as const,
|
||||||
description: 'Per-NE command sets: each item is one NE (ne_id OR ume_ne_id) with commands[].',
|
description: 'Per-NE command sets: each item is one NE (ne_id OR ume_ne_id) with commands[].',
|
||||||
|
|
@ -240,25 +273,27 @@ export function registerNetxTools(ctx: Context, connection: NetxToolConnection):
|
||||||
async: bool('oclaw-only async hint; ignored by native REST client.'),
|
async: bool('oclaw-only async hint; ignored by native REST client.'),
|
||||||
},
|
},
|
||||||
H.execManagedNe, getClient, Math.max(t, 300_000),
|
H.execManagedNe, getClient, Math.max(t, 300_000),
|
||||||
)),
|
),
|
||||||
ctx.tools.register(tool(
|
tool(
|
||||||
'netx__listCliTargets',
|
'netx__listCliTargets',
|
||||||
'List CLI-capable targets (managed NE and/or UME inventory). Call once per session with keyword/source, cache ids, then execManagedNe.',
|
'List CLI-capable targets (managed NE and/or NMS inventory). Call once per session with keyword/source, cache ids, then execManagedNe.',
|
||||||
{
|
{
|
||||||
source: str('managed | ume | all'),
|
source: str('managed | nms | ume | all'),
|
||||||
keyword: str(),
|
keyword: str(),
|
||||||
page: num(),
|
page: num(),
|
||||||
page_size: num(),
|
page_size: num(),
|
||||||
},
|
},
|
||||||
H.listCliTargets, getClient, t,
|
H.listCliTargets, getClient, t,
|
||||||
)),
|
),
|
||||||
ctx.tools.register(tool(
|
tool(
|
||||||
'netx__findTopologyPaths',
|
'netx__findTopologyPaths',
|
||||||
'Find up to max_paths simple paths between two fabric nodes. For each endpoint provide exactly one of ume_ne_id or managed_ne_id.',
|
'Find up to max_paths simple paths between two fabric nodes (common group — native netx, not NMS-bound). For each endpoint provide exactly one of ume_ne_id or managed_ne_id.',
|
||||||
{
|
{
|
||||||
from_ume_ne_id: str(),
|
from_nms_ne_id: str('NMS inventory id'),
|
||||||
|
from_ume_ne_id: str('Legacy alias of from_nms_ne_id'),
|
||||||
from_managed_ne_id: str(),
|
from_managed_ne_id: str(),
|
||||||
to_ume_ne_id: str(),
|
to_nms_ne_id: str('NMS inventory id'),
|
||||||
|
to_ume_ne_id: str('Legacy alias of to_nms_ne_id'),
|
||||||
to_managed_ne_id: str(),
|
to_managed_ne_id: str(),
|
||||||
max_paths: num(),
|
max_paths: num(),
|
||||||
max_hops: num(),
|
max_hops: num(),
|
||||||
|
|
@ -266,9 +301,249 @@ export function registerNetxTools(ctx: Context, connection: NetxToolConnection):
|
||||||
detail: str('summary | full'),
|
detail: str('summary | full'),
|
||||||
},
|
},
|
||||||
H.findTopologyPaths, getClient, t,
|
H.findTopologyPaths, getClient, t,
|
||||||
)),
|
),
|
||||||
|
// ── topology group (netx-topology canvas / fabric) ─────────────────────
|
||||||
|
tool(
|
||||||
|
'netx__getTopologyTree',
|
||||||
|
'Get topology folder tree (nav roots + Root map canvases). Start here before createTopologyFolder.',
|
||||||
|
{ compact: bool(), max_depth: num() },
|
||||||
|
T.getTopologyTree, getClient, t,
|
||||||
|
),
|
||||||
|
tool(
|
||||||
|
'netx__getTopologyView',
|
||||||
|
'Get a topology view by view_id. Default detail=summary with sample_nodes + links[].',
|
||||||
|
{
|
||||||
|
view_id: { type: 'string' as const, required: true as const },
|
||||||
|
detail: str('summary | full'),
|
||||||
|
sample: num(),
|
||||||
|
},
|
||||||
|
T.getTopologyView, getClient, t,
|
||||||
|
),
|
||||||
|
tool(
|
||||||
|
'netx__createTopologyFolder',
|
||||||
|
'Create topology folders / region canvases. Only way to create canvases (ne:write).',
|
||||||
|
{
|
||||||
|
name: { type: 'string' as const, required: true as const },
|
||||||
|
parent_id: str(),
|
||||||
|
locale: str('zh | en'),
|
||||||
|
sort_order: num(),
|
||||||
|
},
|
||||||
|
T.createTopologyFolder, getClient, t,
|
||||||
|
),
|
||||||
|
tool(
|
||||||
|
'netx__addTopologyViewNodes',
|
||||||
|
'Bulk-place fabric nodes on a view via filters or fabric_node_ids (never managed/UME ids).',
|
||||||
|
{
|
||||||
|
view_id: { type: 'string' as const, required: true as const },
|
||||||
|
max_nodes: num(),
|
||||||
|
keyword: str(),
|
||||||
|
role: str(),
|
||||||
|
vendor: str(),
|
||||||
|
link_status: str(),
|
||||||
|
limit: num(),
|
||||||
|
offset: num(),
|
||||||
|
fabric_node_ids: strArr(),
|
||||||
|
layout: str('grid | keep'),
|
||||||
|
},
|
||||||
|
T.addTopologyViewNodes, getClient, t,
|
||||||
|
),
|
||||||
|
tool(
|
||||||
|
'netx__removeTopologyViewNodes',
|
||||||
|
'Remove placements from a view (not fabric) by filters or fabric_node_ids.',
|
||||||
|
{
|
||||||
|
view_id: { type: 'string' as const, required: true as const },
|
||||||
|
keyword: str(),
|
||||||
|
role: str(),
|
||||||
|
vendor: str(),
|
||||||
|
link_status: str(),
|
||||||
|
fabric_node_ids: strArr(),
|
||||||
|
},
|
||||||
|
T.removeTopologyViewNodes, getClient, t,
|
||||||
|
),
|
||||||
|
tool(
|
||||||
|
'netx__copyTopologyViewNodes',
|
||||||
|
'Clone fabric placements from source_view_id onto target_view_id (optional clear_target / copy_positions).',
|
||||||
|
{
|
||||||
|
source_view_id: { type: 'string' as const, required: true as const },
|
||||||
|
target_view_id: { type: 'string' as const, required: true as const },
|
||||||
|
copy_positions: bool(),
|
||||||
|
clear_target: bool(),
|
||||||
|
offset_x: num(),
|
||||||
|
offset_y: num(),
|
||||||
|
limit: num(),
|
||||||
|
dry_run: bool(),
|
||||||
|
},
|
||||||
|
T.copyTopologyViewNodes, getClient, t,
|
||||||
|
),
|
||||||
|
tool(
|
||||||
|
'netx__updateTopologyViewPositions',
|
||||||
|
'Move nodes on a view via positions[] or layout=grid|offset|stack + filters.',
|
||||||
|
{
|
||||||
|
view_id: { type: 'string' as const, required: true as const },
|
||||||
|
positions: { type: 'array' as const, items: { type: 'object' as const } },
|
||||||
|
layout: str('grid | offset | stack'),
|
||||||
|
keyword: str(),
|
||||||
|
role: str(),
|
||||||
|
vendor: str(),
|
||||||
|
link_status: str(),
|
||||||
|
fabric_node_ids: strArr(),
|
||||||
|
offset_x: num(),
|
||||||
|
offset_y: num(),
|
||||||
|
},
|
||||||
|
T.updateTopologyViewPositions, getClient, t,
|
||||||
|
),
|
||||||
|
tool(
|
||||||
|
'netx__projectTopologyNeighbors',
|
||||||
|
'Project existing fabric neighbors of on-view nodes onto the canvas.',
|
||||||
|
{
|
||||||
|
view_id: { type: 'string' as const, required: true as const },
|
||||||
|
seed_fabric_node_ids: strArr(),
|
||||||
|
managed_ne_ids: strArr(),
|
||||||
|
region_folder_id: str(),
|
||||||
|
detail: str('summary | full'),
|
||||||
|
sample: num(),
|
||||||
|
},
|
||||||
|
T.projectTopologyNeighbors, getClient, t,
|
||||||
|
),
|
||||||
|
tool(
|
||||||
|
'netx__queryTopologyFabricNodes',
|
||||||
|
'Fabric inventory: mode=summary|list|search (keyword/role/level/region/link_status).',
|
||||||
|
{
|
||||||
|
mode: str('summary | list | search'),
|
||||||
|
q: str(),
|
||||||
|
keyword: str(),
|
||||||
|
role: str(),
|
||||||
|
level: str(),
|
||||||
|
level_major: str(),
|
||||||
|
region_folder_id: str(),
|
||||||
|
link_status: str(),
|
||||||
|
page: num(),
|
||||||
|
page_size: num(),
|
||||||
|
limit: num(),
|
||||||
|
summary: bool(),
|
||||||
|
},
|
||||||
|
T.queryTopologyFabricNodes, getClient, t,
|
||||||
|
),
|
||||||
|
tool(
|
||||||
|
'netx__classifyTopologyFabricNodes',
|
||||||
|
'Classify fabric nodes: action=match|tag|patch|unmatched|preview_rules|apply_rules|list_rules.',
|
||||||
|
{
|
||||||
|
action: { type: 'string' as const, required: true as const },
|
||||||
|
pattern: str(),
|
||||||
|
q: str(),
|
||||||
|
match_field: str(),
|
||||||
|
sample_limit: num(),
|
||||||
|
fabric_node_ids: strArr(),
|
||||||
|
fabric_node_id: str(),
|
||||||
|
node_id: str(),
|
||||||
|
level: str(),
|
||||||
|
role: str(),
|
||||||
|
region_folder_id: str(),
|
||||||
|
clear_region: bool(),
|
||||||
|
dry_run: bool(),
|
||||||
|
kind: str(),
|
||||||
|
page: num(),
|
||||||
|
page_size: num(),
|
||||||
|
skip_manual: bool(),
|
||||||
|
overwrite_manual: bool(),
|
||||||
|
fill_empty_only: bool(),
|
||||||
|
},
|
||||||
|
T.classifyTopologyFabricNodes, getClient, t,
|
||||||
|
),
|
||||||
|
tool(
|
||||||
|
'netx__queryTopologyNeighborhood',
|
||||||
|
'Neighborhood around a fabric node (depth 1–3) with compact links[].',
|
||||||
|
{
|
||||||
|
node_id: { type: 'string' as const, required: true as const },
|
||||||
|
depth: num(),
|
||||||
|
layer: str(),
|
||||||
|
},
|
||||||
|
T.queryTopologyNeighborhood, getClient, t,
|
||||||
|
),
|
||||||
|
tool(
|
||||||
|
'netx__queryTopologyEdges',
|
||||||
|
'Fabric adjacency links[] (detail=adjacency) or port rows (detail=ports).',
|
||||||
|
{
|
||||||
|
node_id: str(),
|
||||||
|
keyword: str(),
|
||||||
|
layer: str(),
|
||||||
|
status: str(),
|
||||||
|
source: str('lldp | ume | manual'),
|
||||||
|
detail: str('adjacency | ports'),
|
||||||
|
page: num(),
|
||||||
|
page_size: num(),
|
||||||
|
},
|
||||||
|
T.queryTopologyEdges, getClient, t,
|
||||||
|
),
|
||||||
|
tool(
|
||||||
|
'netx__suggestSinkHubs',
|
||||||
|
'Rank hub territories on a source view for non-dual move_nodes(park) batches (degree stand-in in DSH).',
|
||||||
|
{
|
||||||
|
source_view_id: str(),
|
||||||
|
view_id: str(),
|
||||||
|
pick: num(),
|
||||||
|
exclude_portal_ids: strArr(),
|
||||||
|
exclude_fabric_node_ids: strArr(),
|
||||||
|
},
|
||||||
|
T.suggestSinkHubs, getClient, t,
|
||||||
|
),
|
||||||
|
tool(
|
||||||
|
'netx__analyzeTopologyViewLayout',
|
||||||
|
'Basic layout QA for a view (bbox/hubs). Full dual_unit/crossing scores need netx-topology MCP.',
|
||||||
|
{
|
||||||
|
view_id: str(),
|
||||||
|
folder_id: str(),
|
||||||
|
detail: str('summary | structure | hotspots | blocks | both'),
|
||||||
|
score_profile: str(),
|
||||||
|
sight_limit: num(),
|
||||||
|
max_views: num(),
|
||||||
|
min_nodes: num(),
|
||||||
|
max_nodes: num(),
|
||||||
|
with_meta: bool(),
|
||||||
|
},
|
||||||
|
T.analyzeTopologyViewLayout, getClient, t,
|
||||||
|
),
|
||||||
|
tool(
|
||||||
|
'netx__sinkTopologyDualUnits',
|
||||||
|
'Drain dual_unit eyes from source to sink (requires netx-topology MCP layout engine in DSH).',
|
||||||
|
{
|
||||||
|
source_view_id: str(),
|
||||||
|
sink_view_id: str(),
|
||||||
|
max_units: num(),
|
||||||
|
min_nodes: num(),
|
||||||
|
max_nodes: num(),
|
||||||
|
max_batch_nodes: num(),
|
||||||
|
layout_batch: bool(),
|
||||||
|
until_empty: bool(),
|
||||||
|
dry_run: bool(),
|
||||||
|
},
|
||||||
|
T.sinkTopologyDualUnits, getClient, t,
|
||||||
|
),
|
||||||
|
tool(
|
||||||
|
'netx__layoutTopologyView',
|
||||||
|
'Layout / polish a canvas. DSH supports catalog + move_nodes|sink_nodes over HTTP; dual_unit/orbit/polish recipes need netx-topology MCP.',
|
||||||
|
{
|
||||||
|
view_id: str(),
|
||||||
|
action: str(),
|
||||||
|
source_view_id: str(),
|
||||||
|
recipe: str(),
|
||||||
|
preset: str(),
|
||||||
|
mode: str('preview | apply'),
|
||||||
|
tune: bool(),
|
||||||
|
params: { type: 'object' as const },
|
||||||
|
catalog: bool(),
|
||||||
|
fabric_node_ids: strArr(),
|
||||||
|
park: bool(),
|
||||||
|
copy_positions: bool(),
|
||||||
|
},
|
||||||
|
T.layoutTopologyView, getClient, Math.max(t, 180_000),
|
||||||
|
),
|
||||||
]
|
]
|
||||||
|
|
||||||
|
const disposers = catalog
|
||||||
|
.filter((entry) => allow.has(entry.name))
|
||||||
|
.map((entry) => ctx.tools.register(entry))
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
for (const dispose of disposers) dispose()
|
for (const dispose of disposers) dispose()
|
||||||
}
|
}
|
||||||
|
|
|
||||||
668
src/netx/topology-handlers.ts
Normal file
668
src/netx/topology-handlers.ts
Normal file
|
|
@ -0,0 +1,668 @@
|
||||||
|
/**
|
||||||
|
* Topology canvas / fabric handlers — HTTP port of netx-topology-mcp tools.
|
||||||
|
* Layout recipes (dual_unit / orbit / polish) stay MCP-local; DSH exposes HTTP
|
||||||
|
* CRUD + fabric query + move_nodes, and returns a clear hint for recipe actions.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { type NetxClient, type NetxJson } from './http.ts'
|
||||||
|
|
||||||
|
function asRecord(value: unknown): NetxJson {
|
||||||
|
return typeof value === 'object' && value !== null && !Array.isArray(value)
|
||||||
|
? value as NetxJson
|
||||||
|
: {}
|
||||||
|
}
|
||||||
|
|
||||||
|
function str(args: NetxJson, key: string, fallback = ''): string {
|
||||||
|
const v = args[key]
|
||||||
|
if (typeof v === 'string') return v
|
||||||
|
if (typeof v === 'number' || typeof v === 'boolean') return String(v)
|
||||||
|
return fallback
|
||||||
|
}
|
||||||
|
|
||||||
|
function num(args: NetxJson, key: string): number | undefined {
|
||||||
|
const v = args[key]
|
||||||
|
return typeof v === 'number' && Number.isFinite(v) ? v : undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
function bool(args: NetxJson, key: string): boolean | undefined {
|
||||||
|
const v = args[key]
|
||||||
|
return typeof v === 'boolean' ? v : undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
function strList(args: NetxJson, key: string): string[] {
|
||||||
|
const v = args[key]
|
||||||
|
if (!Array.isArray(v)) return []
|
||||||
|
return v.map((x) => String(x).trim()).filter((x) => x.length > 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
function clampInt(value: number | undefined, fallback: number, min: number, max: number): number {
|
||||||
|
const n = value === undefined ? fallback : Math.trunc(value)
|
||||||
|
return Math.max(min, Math.min(max, n))
|
||||||
|
}
|
||||||
|
|
||||||
|
function unwrap(out: NetxJson): NetxJson {
|
||||||
|
if (out.ok !== true) return out
|
||||||
|
const data = asRecord(out.data)
|
||||||
|
if (Object.keys(data).length === 0) return { ok: true, data: out.data }
|
||||||
|
return { ok: true, ...data, data }
|
||||||
|
}
|
||||||
|
|
||||||
|
function filterFields(args: NetxJson): NetxJson {
|
||||||
|
const out: NetxJson = {}
|
||||||
|
for (const key of ['keyword', 'role', 'vendor', 'link_status'] as const) {
|
||||||
|
const v = str(args, key).trim()
|
||||||
|
if (v) out[key] = v
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
function edgeEndpoints(edge: NetxJson): [string, string] {
|
||||||
|
const a = str(edge, 'a_node_id') || str(edge, 'a')
|
||||||
|
const b = str(edge, 'b_node_id') || str(edge, 'b')
|
||||||
|
return [a.trim(), b.trim()]
|
||||||
|
}
|
||||||
|
|
||||||
|
function collapseEdgesToLinks(edges: unknown[], includeNames = false): NetxJson[] {
|
||||||
|
const buckets = new Map<string, NetxJson>()
|
||||||
|
for (const raw of edges) {
|
||||||
|
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) continue
|
||||||
|
const e = raw as NetxJson
|
||||||
|
const [a, b] = edgeEndpoints(e)
|
||||||
|
if (!a || !b || a === b) continue
|
||||||
|
const lo = a < b ? a : b
|
||||||
|
const hi = a < b ? b : a
|
||||||
|
const key = `${lo}\0${hi}`
|
||||||
|
const row = buckets.get(key) ?? {
|
||||||
|
a_node_id: lo,
|
||||||
|
b_node_id: hi,
|
||||||
|
link_count: 0,
|
||||||
|
}
|
||||||
|
row.link_count = Number(row.link_count || 0) + 1
|
||||||
|
if (includeNames && !row.a_name) {
|
||||||
|
if (a === lo) {
|
||||||
|
row.a_name = str(e, 'a_name')
|
||||||
|
row.b_name = str(e, 'b_name')
|
||||||
|
} else {
|
||||||
|
row.a_name = str(e, 'b_name')
|
||||||
|
row.b_name = str(e, 'a_name')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
buckets.set(key, row)
|
||||||
|
}
|
||||||
|
return [...buckets.values()]
|
||||||
|
}
|
||||||
|
|
||||||
|
function summarizeViewGraph(graph: NetxJson, sample = 20): NetxJson {
|
||||||
|
const nodes = Array.isArray(graph.nodes) ? graph.nodes : []
|
||||||
|
const edges = Array.isArray(graph.edges) ? graph.edges : (Array.isArray(graph.links) ? graph.links : [])
|
||||||
|
const sampleNodes = nodes.slice(0, Math.max(0, sample)).map((n) => {
|
||||||
|
const row = asRecord(n)
|
||||||
|
return {
|
||||||
|
id: str(row, 'id') || str(row, 'fabric_node_id'),
|
||||||
|
name: str(row, 'name'),
|
||||||
|
x: row.x,
|
||||||
|
y: row.y,
|
||||||
|
level: row.level,
|
||||||
|
role: row.role,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
view_id: str(graph, 'id') || str(graph, 'view_id'),
|
||||||
|
node_count: nodes.length,
|
||||||
|
edge_count: edges.length,
|
||||||
|
link_count: collapseEdgesToLinks(edges).length,
|
||||||
|
links: collapseEdgesToLinks(edges),
|
||||||
|
sample_nodes: sampleNodes,
|
||||||
|
detail: 'summary',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function layoutRecipeUnavailable(action: string): NetxJson {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
error: 'layout_recipe_requires_netx_topology_mcp',
|
||||||
|
action,
|
||||||
|
hint: 'Canvas CRUD/query and move_nodes work in dsh-netxops. Dual-unit / orbit / polish recipes still need the netx-topology MCP layout engine (or enable that MCP alongside).',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** GET /v1/topology/tree */
|
||||||
|
export async function getTopologyTree(client: NetxClient, args: NetxJson, signal?: AbortSignal): Promise<NetxJson> {
|
||||||
|
const out = unwrap(await client.get('/v1/topology/tree', undefined, signal))
|
||||||
|
if (out.ok !== true) return out
|
||||||
|
const compact = bool(args, 'compact') !== false
|
||||||
|
const maxDepth = num(args, 'max_depth')
|
||||||
|
if (!compact && maxDepth === undefined) return out
|
||||||
|
// Tree is already usable; pass through with flags for the model.
|
||||||
|
return { ...out, compact, max_depth: maxDepth ?? null }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** GET /v1/topology/views/{id} */
|
||||||
|
export async function getTopologyView(client: NetxClient, args: NetxJson, signal?: AbortSignal): Promise<NetxJson> {
|
||||||
|
const viewId = str(args, 'view_id').trim()
|
||||||
|
if (!viewId) return { ok: false, error: 'view_id_required' }
|
||||||
|
const out = unwrap(await client.get(`/v1/topology/views/${encodeURIComponent(viewId)}`, undefined, signal, 120_000))
|
||||||
|
if (out.ok !== true) return out
|
||||||
|
const detail = str(args, 'detail', 'summary').trim().toLowerCase() || 'summary'
|
||||||
|
if (detail === 'full') return { ...out, detail: 'full' }
|
||||||
|
return summarizeViewGraph(out, clampInt(num(args, 'sample'), 20, 0, 200))
|
||||||
|
}
|
||||||
|
|
||||||
|
/** POST /v1/topology/folders */
|
||||||
|
export async function createTopologyFolder(client: NetxClient, args: NetxJson, signal?: AbortSignal): Promise<NetxJson> {
|
||||||
|
const name = str(args, 'name').trim()
|
||||||
|
if (!name) return { ok: false, error: 'name_required' }
|
||||||
|
const body: NetxJson = { name }
|
||||||
|
const parentId = str(args, 'parent_id').trim()
|
||||||
|
if (parentId) body.parent_id = parentId
|
||||||
|
const locale = str(args, 'locale').trim()
|
||||||
|
if (locale) body.locale = locale
|
||||||
|
if (num(args, 'sort_order') !== undefined) body.sort_order = Math.trunc(num(args, 'sort_order')!)
|
||||||
|
return unwrap(await client.post('/v1/topology/folders', body, signal))
|
||||||
|
}
|
||||||
|
|
||||||
|
/** POST /v1/topology/views/{id}/nodes */
|
||||||
|
export async function addTopologyViewNodes(client: NetxClient, args: NetxJson, signal?: AbortSignal): Promise<NetxJson> {
|
||||||
|
const viewId = str(args, 'view_id').trim()
|
||||||
|
if (!viewId) return { ok: false, error: 'view_id_required' }
|
||||||
|
if (strList(args, 'managed_ne_ids').length || strList(args, 'ume_ne_ids').length) {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
error: 'fabric_nodes_only',
|
||||||
|
detail: 'Use keyword/role/vendor/link_status or fabric_node_ids; never managed/UME ids.',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const filters = filterFields(args)
|
||||||
|
const fabricIds = strList(args, 'fabric_node_ids')
|
||||||
|
if (Object.keys(filters).length === 0 && fabricIds.length === 0) {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
error: 'filter_or_fabric_node_ids_required',
|
||||||
|
detail: 'Pass keyword/role/vendor/link_status (preferred) or fabric_node_ids.',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (num(args, 'max_nodes') !== undefined) {
|
||||||
|
const got = unwrap(await client.get(`/v1/topology/views/${encodeURIComponent(viewId)}`, undefined, signal))
|
||||||
|
if (got.ok === true) {
|
||||||
|
const data = asRecord(got.data)
|
||||||
|
const filt = asRecord(got.filter ?? data.filter)
|
||||||
|
const membership = asRecord(filt.membership ?? {})
|
||||||
|
membership.max_nodes = clampInt(num(args, 'max_nodes'), 2000, 1, 2000)
|
||||||
|
filt.membership = membership
|
||||||
|
await client.patch(`/v1/topology/views/${encodeURIComponent(viewId)}`, { filter: filt }, signal)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const layout = str(args, 'layout', 'grid').trim() || 'grid'
|
||||||
|
const body: NetxJson = {
|
||||||
|
managed_ne_ids: [],
|
||||||
|
layout,
|
||||||
|
...filters,
|
||||||
|
}
|
||||||
|
if (fabricIds.length) body.fabric_node_ids = fabricIds
|
||||||
|
if (num(args, 'limit') !== undefined) body.limit = clampInt(num(args, 'limit'), 500, 1, 2000)
|
||||||
|
if (num(args, 'offset') !== undefined) body.offset = clampInt(num(args, 'offset'), 0, 0, Number.MAX_SAFE_INTEGER)
|
||||||
|
return unwrap(await client.post(`/v1/topology/views/${encodeURIComponent(viewId)}/nodes`, body, signal, 180_000))
|
||||||
|
}
|
||||||
|
|
||||||
|
/** POST /v1/topology/views/{id}/nodes/remove */
|
||||||
|
export async function removeTopologyViewNodes(client: NetxClient, args: NetxJson, signal?: AbortSignal): Promise<NetxJson> {
|
||||||
|
const viewId = str(args, 'view_id').trim()
|
||||||
|
if (!viewId) return { ok: false, error: 'view_id_required' }
|
||||||
|
const body: NetxJson = { ...filterFields(args) }
|
||||||
|
const fabricIds = strList(args, 'fabric_node_ids')
|
||||||
|
if (fabricIds.length) body.fabric_node_ids = fabricIds
|
||||||
|
return unwrap(await client.post(
|
||||||
|
`/v1/topology/views/${encodeURIComponent(viewId)}/nodes/remove`,
|
||||||
|
body,
|
||||||
|
signal,
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Clone placements from source view onto target view. */
|
||||||
|
export async function copyTopologyViewNodes(client: NetxClient, args: NetxJson, signal?: AbortSignal): Promise<NetxJson> {
|
||||||
|
const sourceId = str(args, 'source_view_id').trim()
|
||||||
|
const targetId = str(args, 'target_view_id').trim()
|
||||||
|
if (!sourceId || !targetId) {
|
||||||
|
return { ok: false, error: 'source_view_id_and_target_view_id_required' }
|
||||||
|
}
|
||||||
|
const source = unwrap(await client.get(`/v1/topology/views/${encodeURIComponent(sourceId)}`, undefined, signal, 120_000))
|
||||||
|
if (source.ok !== true) return source
|
||||||
|
const nodes = Array.isArray(source.nodes) ? source.nodes : []
|
||||||
|
const limit = num(args, 'limit')
|
||||||
|
const selected = typeof limit === 'number' ? nodes.slice(0, clampInt(limit, nodes.length, 1, 2000)) : nodes
|
||||||
|
const fabricIds = selected.map((n) => {
|
||||||
|
const row = asRecord(n)
|
||||||
|
return str(row, 'id') || str(row, 'fabric_node_id')
|
||||||
|
}).filter(Boolean)
|
||||||
|
if (bool(args, 'dry_run') === true) {
|
||||||
|
return { ok: true, dry_run: true, would_copy: fabricIds.length, source_view_id: sourceId, target_view_id: targetId }
|
||||||
|
}
|
||||||
|
if (bool(args, 'clear_target') === true) {
|
||||||
|
await client.post(`/v1/topology/views/${encodeURIComponent(targetId)}/nodes/remove`, {
|
||||||
|
fabric_node_ids: 'ALL',
|
||||||
|
clear_all: true,
|
||||||
|
}, signal)
|
||||||
|
}
|
||||||
|
if (fabricIds.length === 0) {
|
||||||
|
return { ok: true, added: 0, source_view_id: sourceId, target_view_id: targetId }
|
||||||
|
}
|
||||||
|
const add = unwrap(await client.post(
|
||||||
|
`/v1/topology/views/${encodeURIComponent(targetId)}/nodes`,
|
||||||
|
{ fabric_node_ids: fabricIds, layout: 'keep', managed_ne_ids: [] },
|
||||||
|
signal,
|
||||||
|
180_000,
|
||||||
|
))
|
||||||
|
if (add.ok !== true) return add
|
||||||
|
if (bool(args, 'copy_positions') !== false) {
|
||||||
|
const ox = num(args, 'offset_x') ?? 0
|
||||||
|
const oy = num(args, 'offset_y') ?? 0
|
||||||
|
const positions = selected.map((n) => {
|
||||||
|
const row = asRecord(n)
|
||||||
|
const id = str(row, 'id') || str(row, 'fabric_node_id')
|
||||||
|
return {
|
||||||
|
fabric_node_id: id,
|
||||||
|
x: Number(row.x || 0) + ox,
|
||||||
|
y: Number(row.y || 0) + oy,
|
||||||
|
}
|
||||||
|
}).filter((p) => p.fabric_node_id)
|
||||||
|
if (positions.length) {
|
||||||
|
await client.patch(
|
||||||
|
`/v1/topology/views/${encodeURIComponent(targetId)}/positions`,
|
||||||
|
{ positions },
|
||||||
|
signal,
|
||||||
|
120_000,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { ok: true, added: fabricIds.length, source_view_id: sourceId, target_view_id: targetId, ...asRecord(add) }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** PATCH /v1/topology/views/{id}/positions */
|
||||||
|
export async function updateTopologyViewPositions(client: NetxClient, args: NetxJson, signal?: AbortSignal): Promise<NetxJson> {
|
||||||
|
const viewId = str(args, 'view_id').trim()
|
||||||
|
if (!viewId) return { ok: false, error: 'view_id_required' }
|
||||||
|
const body: NetxJson = {}
|
||||||
|
if (Array.isArray(args.positions)) body.positions = args.positions
|
||||||
|
const layout = str(args, 'layout').trim()
|
||||||
|
if (layout) body.layout = layout
|
||||||
|
Object.assign(body, filterFields(args))
|
||||||
|
for (const key of ['offset_x', 'offset_y', 'cols', 'gap_x', 'gap_y'] as const) {
|
||||||
|
if (num(args, key) !== undefined) body[key] = num(args, key)
|
||||||
|
}
|
||||||
|
const fabricIds = strList(args, 'fabric_node_ids')
|
||||||
|
if (fabricIds.length) body.fabric_node_ids = fabricIds
|
||||||
|
return unwrap(await client.patch(
|
||||||
|
`/v1/topology/views/${encodeURIComponent(viewId)}/positions`,
|
||||||
|
body,
|
||||||
|
signal,
|
||||||
|
120_000,
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
/** POST .../project-neighbors */
|
||||||
|
export async function projectTopologyNeighbors(client: NetxClient, args: NetxJson, signal?: AbortSignal): Promise<NetxJson> {
|
||||||
|
const viewId = str(args, 'view_id').trim()
|
||||||
|
if (!viewId) return { ok: false, error: 'view_id_required' }
|
||||||
|
const body: NetxJson = {}
|
||||||
|
const seeds = strList(args, 'seed_fabric_node_ids').length
|
||||||
|
? strList(args, 'seed_fabric_node_ids')
|
||||||
|
: strList(args, 'fabric_node_ids')
|
||||||
|
if (seeds.length) body.seed_fabric_node_ids = seeds
|
||||||
|
const mids = strList(args, 'managed_ne_ids')
|
||||||
|
if (mids.length) body.managed_ne_ids = mids
|
||||||
|
const region = str(args, 'region_folder_id').trim()
|
||||||
|
if (region) body.region_folder_id = region
|
||||||
|
const out = unwrap(await client.post(
|
||||||
|
`/v1/topology/views/${encodeURIComponent(viewId)}/project-neighbors`,
|
||||||
|
body,
|
||||||
|
signal,
|
||||||
|
180_000,
|
||||||
|
))
|
||||||
|
if (out.ok !== true) return out
|
||||||
|
const detail = str(args, 'detail', 'summary').trim().toLowerCase() || 'summary'
|
||||||
|
if (detail === 'full' || detail === 'raw' || detail === 'graph') {
|
||||||
|
return { ...out, detail: 'full', projected: true }
|
||||||
|
}
|
||||||
|
return { ...summarizeViewGraph(out, clampInt(num(args, 'sample'), 20, 0, 100)), projected: true, view_id: viewId }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Fabric inventory summary|list|search */
|
||||||
|
export async function queryTopologyFabricNodes(client: NetxClient, args: NetxJson, signal?: AbortSignal): Promise<NetxJson> {
|
||||||
|
let mode = str(args, 'mode').trim().toLowerCase()
|
||||||
|
const q = str(args, 'q').trim()
|
||||||
|
const keyword = str(args, 'keyword').trim()
|
||||||
|
if (!mode) {
|
||||||
|
if (bool(args, 'summary') === true) mode = 'summary'
|
||||||
|
else if (q) mode = 'search'
|
||||||
|
else mode = 'list'
|
||||||
|
}
|
||||||
|
if (mode === 'summary' || mode === 'stats' || mode === 'count') {
|
||||||
|
const out = unwrap(await client.get('/v1/topology/fabric/summary', undefined, signal))
|
||||||
|
return out.ok === true ? { ...out, mode: 'summary' } : out
|
||||||
|
}
|
||||||
|
if (mode === 'search' || mode === 'find') {
|
||||||
|
const needle = q || keyword
|
||||||
|
if (!needle) return { ok: false, error: 'q_required', hint: 'mode=search needs q (or keyword).' }
|
||||||
|
return unwrap(await client.get('/v1/topology/fabric/nodes/search', {
|
||||||
|
q: needle,
|
||||||
|
page: clampInt(num(args, 'page'), 1, 1, Number.MAX_SAFE_INTEGER),
|
||||||
|
page_size: clampInt(num(args, 'page_size') ?? num(args, 'limit'), 50, 1, 200),
|
||||||
|
}, signal))
|
||||||
|
}
|
||||||
|
const params: Record<string, string | number | boolean> = {
|
||||||
|
page: clampInt(num(args, 'page'), 1, 1, Number.MAX_SAFE_INTEGER),
|
||||||
|
page_size: clampInt(num(args, 'page_size') ?? num(args, 'limit'), 50, 1, 500),
|
||||||
|
}
|
||||||
|
for (const key of ['keyword', 'role', 'level', 'level_major', 'region_folder_id', 'link_status'] as const) {
|
||||||
|
const v = str(args, key).trim() || (key === 'keyword' ? q : '')
|
||||||
|
if (v) params[key] = v
|
||||||
|
}
|
||||||
|
const out = unwrap(await client.get('/v1/topology/fabric/nodes', params, signal))
|
||||||
|
return out.ok === true ? { ...out, mode: 'list' } : out
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Classify / tag fabric nodes */
|
||||||
|
export async function classifyTopologyFabricNodes(client: NetxClient, args: NetxJson, signal?: AbortSignal): Promise<NetxJson> {
|
||||||
|
const action = str(args, 'action').trim().toLowerCase()
|
||||||
|
if (!action) return { ok: false, error: 'action_required' }
|
||||||
|
if (action === 'match') {
|
||||||
|
const pattern = str(args, 'pattern').trim() || str(args, 'q').trim()
|
||||||
|
if (!pattern) return { ok: false, error: 'pattern_required' }
|
||||||
|
return unwrap(await client.post('/v1/topology/fabric/nodes/match', {
|
||||||
|
pattern,
|
||||||
|
match_field: str(args, 'match_field', 'name').trim() || 'name',
|
||||||
|
sample_limit: clampInt(num(args, 'sample_limit'), 50, 1, 200),
|
||||||
|
}, signal))
|
||||||
|
}
|
||||||
|
if (action === 'tag') {
|
||||||
|
const body: NetxJson = {
|
||||||
|
fabric_node_ids: strList(args, 'fabric_node_ids'),
|
||||||
|
dry_run: bool(args, 'dry_run') === true,
|
||||||
|
}
|
||||||
|
const pattern = str(args, 'pattern').trim() || str(args, 'q').trim()
|
||||||
|
if (pattern) body.pattern = pattern
|
||||||
|
if (args.level !== undefined) body.level = args.level
|
||||||
|
const role = str(args, 'role').trim()
|
||||||
|
if (role) body.role = role
|
||||||
|
const region = str(args, 'region_folder_id').trim()
|
||||||
|
if (region) body.region_folder_id = region
|
||||||
|
if (bool(args, 'clear_region') === true) body.clear_region = true
|
||||||
|
return unwrap(await client.post('/v1/topology/fabric/nodes/tags/bulk', body, signal))
|
||||||
|
}
|
||||||
|
if (action === 'patch') {
|
||||||
|
const nodeId = str(args, 'fabric_node_id').trim() || str(args, 'node_id').trim()
|
||||||
|
if (!nodeId) return { ok: false, error: 'fabric_node_id_required' }
|
||||||
|
const body: NetxJson = {}
|
||||||
|
if (args.level !== undefined) body.level = args.level
|
||||||
|
const role = str(args, 'role').trim()
|
||||||
|
if (role) body.role = role
|
||||||
|
const region = str(args, 'region_folder_id').trim()
|
||||||
|
if (region) body.region_folder_id = region
|
||||||
|
if (bool(args, 'clear_region') === true) body.clear_region = true
|
||||||
|
return unwrap(await client.patch(
|
||||||
|
`/v1/topology/fabric/nodes/${encodeURIComponent(nodeId)}/tags`,
|
||||||
|
body,
|
||||||
|
signal,
|
||||||
|
))
|
||||||
|
}
|
||||||
|
if (action === 'unmatched') {
|
||||||
|
return unwrap(await client.get('/v1/topology/classify/unmatched', {
|
||||||
|
kind: str(args, 'kind', 'any').trim() || 'any',
|
||||||
|
page: clampInt(num(args, 'page'), 1, 1, Number.MAX_SAFE_INTEGER),
|
||||||
|
page_size: clampInt(num(args, 'page_size') ?? num(args, 'limit'), 50, 1, 500),
|
||||||
|
}, signal))
|
||||||
|
}
|
||||||
|
if (action === 'preview_rules') {
|
||||||
|
return unwrap(await client.post('/v1/topology/classify/preview', {}, signal))
|
||||||
|
}
|
||||||
|
if (action === 'apply_rules') {
|
||||||
|
const params: Record<string, string | number | boolean> = {}
|
||||||
|
if (bool(args, 'overwrite_manual') === true) params.skip_manual = false
|
||||||
|
else if (bool(args, 'skip_manual') !== undefined) params.skip_manual = bool(args, 'skip_manual') !== false
|
||||||
|
if (bool(args, 'fill_empty_only') === true) params.fill_empty_only = true
|
||||||
|
return unwrap(await client.post('/v1/topology/classify/apply', {}, signal))
|
||||||
|
}
|
||||||
|
if (action === 'list_rules') {
|
||||||
|
return unwrap(await client.get('/v1/topology/classify/rules', undefined, signal))
|
||||||
|
}
|
||||||
|
return { ok: false, error: 'unknown_action', action }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** GET /v1/topology/fabric/neighborhood */
|
||||||
|
export async function queryTopologyNeighborhood(client: NetxClient, args: NetxJson, signal?: AbortSignal): Promise<NetxJson> {
|
||||||
|
const nodeId = str(args, 'node_id').trim()
|
||||||
|
if (!nodeId) return { ok: false, error: 'node_id_required' }
|
||||||
|
const out = unwrap(await client.get('/v1/topology/fabric/neighborhood', {
|
||||||
|
node_id: nodeId,
|
||||||
|
depth: clampInt(num(args, 'depth'), 1, 1, 3),
|
||||||
|
layer: str(args, 'layer', 'physical').trim() || 'physical',
|
||||||
|
}, signal))
|
||||||
|
if (out.ok !== true) return out
|
||||||
|
const edges = Array.isArray(out.edges) ? out.edges : []
|
||||||
|
return { ...out, links: collapseEdgesToLinks(edges) }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** GET /v1/topology/fabric/edges */
|
||||||
|
export async function queryTopologyEdges(client: NetxClient, args: NetxJson, signal?: AbortSignal): Promise<NetxJson> {
|
||||||
|
const params: Record<string, string | number | boolean> = {
|
||||||
|
page: clampInt(num(args, 'page'), 1, 1, Number.MAX_SAFE_INTEGER),
|
||||||
|
page_size: clampInt(num(args, 'page_size'), 100, 1, 500),
|
||||||
|
layer: str(args, 'layer', 'physical').trim() || 'physical',
|
||||||
|
}
|
||||||
|
for (const key of ['node_id', 'keyword', 'status', 'source'] as const) {
|
||||||
|
const v = str(args, key).trim()
|
||||||
|
if (v) params[key] = v
|
||||||
|
}
|
||||||
|
const out = unwrap(await client.get('/v1/topology/fabric/edges', params, signal))
|
||||||
|
if (out.ok !== true) return out
|
||||||
|
const detail = str(args, 'detail', 'adjacency').trim().toLowerCase() || 'adjacency'
|
||||||
|
const items = Array.isArray(out.items) ? out.items : (Array.isArray(out.edges) ? out.edges : [])
|
||||||
|
if (detail === 'ports') return { ...out, detail: 'ports', items }
|
||||||
|
const links = collapseEdgesToLinks(items, true)
|
||||||
|
return { ...out, detail: 'adjacency', links, items: undefined }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Simple degree-ranked hub suggestions (HTTP-only stand-in). */
|
||||||
|
export async function suggestSinkHubs(client: NetxClient, args: NetxJson, signal?: AbortSignal): Promise<NetxJson> {
|
||||||
|
const viewId = str(args, 'source_view_id').trim() || str(args, 'view_id').trim()
|
||||||
|
if (!viewId) return { ok: false, error: 'source_view_id_required' }
|
||||||
|
const out = unwrap(await client.get(`/v1/topology/views/${encodeURIComponent(viewId)}`, undefined, signal, 120_000))
|
||||||
|
if (out.ok !== true) return out
|
||||||
|
const nodes = Array.isArray(out.nodes) ? out.nodes : []
|
||||||
|
const edges = Array.isArray(out.edges) ? out.edges : []
|
||||||
|
const degree = new Map<string, number>()
|
||||||
|
for (const raw of edges) {
|
||||||
|
const [a, b] = edgeEndpoints(asRecord(raw))
|
||||||
|
if (!a || !b) continue
|
||||||
|
degree.set(a, (degree.get(a) || 0) + 1)
|
||||||
|
degree.set(b, (degree.get(b) || 0) + 1)
|
||||||
|
}
|
||||||
|
const exclude = new Set([
|
||||||
|
...strList(args, 'exclude_portal_ids'),
|
||||||
|
...strList(args, 'exclude_fabric_node_ids'),
|
||||||
|
])
|
||||||
|
const pick = clampInt(num(args, 'pick'), 5, 1, 50)
|
||||||
|
const hubs = [...degree.entries()]
|
||||||
|
.filter(([id]) => !exclude.has(id))
|
||||||
|
.sort((a, b) => b[1] - a[1])
|
||||||
|
.slice(0, pick)
|
||||||
|
.map(([fabric_node_id, deg]) => {
|
||||||
|
const node = nodes.find((n) => {
|
||||||
|
const row = asRecord(n)
|
||||||
|
return (str(row, 'id') || str(row, 'fabric_node_id')) === fabric_node_id
|
||||||
|
})
|
||||||
|
const row = asRecord(node)
|
||||||
|
return {
|
||||||
|
fabric_node_id,
|
||||||
|
degree: deg,
|
||||||
|
name: str(row, 'name'),
|
||||||
|
level: row.level,
|
||||||
|
role: row.role,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
source_view_id: viewId,
|
||||||
|
hubs,
|
||||||
|
hint: 'Degree ranking only — full dual_unit-aware suggestSinkHubs lives in netx-topology MCP.',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Basic layout QA from view graph (no Python layout_stats). */
|
||||||
|
export async function analyzeTopologyViewLayout(client: NetxClient, args: NetxJson, signal?: AbortSignal): Promise<NetxJson> {
|
||||||
|
const viewId = str(args, 'view_id').trim()
|
||||||
|
if (!viewId) {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
error: 'view_id_required',
|
||||||
|
hint: 'Pass view_id. folder_id sampling without view_id needs netx-topology MCP structure analysis.',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const out = unwrap(await client.get(`/v1/topology/views/${encodeURIComponent(viewId)}`, undefined, signal, 120_000))
|
||||||
|
if (out.ok !== true) return out
|
||||||
|
const nodes = Array.isArray(out.nodes) ? out.nodes : []
|
||||||
|
const edges = Array.isArray(out.edges) ? out.edges : []
|
||||||
|
let minX = Infinity
|
||||||
|
let minY = Infinity
|
||||||
|
let maxX = -Infinity
|
||||||
|
let maxY = -Infinity
|
||||||
|
let missingxy = 0
|
||||||
|
for (const raw of nodes) {
|
||||||
|
const row = asRecord(raw)
|
||||||
|
const x = Number(row.x)
|
||||||
|
const y = Number(row.y)
|
||||||
|
if (!Number.isFinite(x) || !Number.isFinite(y)) {
|
||||||
|
missingxy += 1
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
minX = Math.min(minX, x)
|
||||||
|
minY = Math.min(minY, y)
|
||||||
|
maxX = Math.max(maxX, x)
|
||||||
|
maxY = Math.max(maxY, y)
|
||||||
|
}
|
||||||
|
const degree = new Map<string, number>()
|
||||||
|
for (const raw of edges) {
|
||||||
|
const [a, b] = edgeEndpoints(asRecord(raw))
|
||||||
|
if (!a || !b) continue
|
||||||
|
degree.set(a, (degree.get(a) || 0) + 1)
|
||||||
|
degree.set(b, (degree.get(b) || 0) + 1)
|
||||||
|
}
|
||||||
|
const hubs = [...degree.entries()].sort((a, b) => b[1] - a[1]).slice(0, 10)
|
||||||
|
.map(([id, deg]) => ({ fabric_node_id: id, degree: deg }))
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
view_id: viewId,
|
||||||
|
detail: str(args, 'detail', 'summary') || 'summary',
|
||||||
|
node_count: nodes.length,
|
||||||
|
edge_count: edges.length,
|
||||||
|
link_count: collapseEdgesToLinks(edges).length,
|
||||||
|
missing_xy: missingxy,
|
||||||
|
bbox: Number.isFinite(minX) ? { minX, minY, maxX, maxY, width: maxX - minX, height: maxY - minY } : null,
|
||||||
|
hubs,
|
||||||
|
hint: 'Basic graph stats only. Crossing/dual_unit/score.total analysis needs netx-topology MCP.',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Dual-unit sink requires MCP layout engine. */
|
||||||
|
export async function sinkTopologyDualUnits(_client: NetxClient, args: NetxJson): Promise<NetxJson> {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
error: 'sink_dual_units_requires_netx_topology_mcp',
|
||||||
|
source_view_id: str(args, 'source_view_id'),
|
||||||
|
sink_view_id: str(args, 'sink_view_id'),
|
||||||
|
hint: 'sinkTopologyDualUnits needs the dual_unit layout engine from netx-topology MCP. Use layoutTopologyView(action=move_nodes, park=true) for HTTP-only parking, or enable the topology MCP.',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Layout tool: catalog + move_nodes over HTTP; recipes deferred to MCP. */
|
||||||
|
export async function layoutTopologyView(client: NetxClient, args: NetxJson, signal?: AbortSignal): Promise<NetxJson> {
|
||||||
|
if (bool(args, 'catalog') === true) {
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
catalog: true,
|
||||||
|
actions: [
|
||||||
|
'layout', 'layout_dual_unit', 'move_nodes', 'sink_nodes', 'orbit_sweep', 'level_bands',
|
||||||
|
'polish_crossings', 'clear_edge_hits', 'compact_bbox', 'pull_far_chains', 'align_reference',
|
||||||
|
'fix_overlaps', 'resolve_overlaps', 'untangle', 'straighten_channels', 'job_status', 'job_cancel',
|
||||||
|
],
|
||||||
|
dsh_supported: ['move_nodes', 'sink_nodes', 'catalog'],
|
||||||
|
recipes: ['rings', 'corridor', 'compact', 'unstick'],
|
||||||
|
hint: 'Only move_nodes/sink_nodes/catalog run in dsh-netxops; other actions need netx-topology MCP.',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const action = str(args, 'action', 'layout').trim().toLowerCase() || 'layout'
|
||||||
|
if (action === 'job_status' || action === 'job_cancel') {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
error: 'layout_jobs_not_in_dsh_host',
|
||||||
|
hint: 'Background layout jobs are owned by netx-topology MCP.',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (action === 'move_nodes' || action === 'sink_nodes') {
|
||||||
|
const toId = str(args, 'view_id').trim()
|
||||||
|
const fromId = str(args, 'source_view_id').trim()
|
||||||
|
const ids = strList(args, 'fabric_node_ids')
|
||||||
|
const params = asRecord(args.params)
|
||||||
|
const fabricIds = ids.length ? ids : strList(params, 'fabric_node_ids')
|
||||||
|
if (!toId || !fromId) {
|
||||||
|
return { ok: false, error: 'view_id_and_source_view_id_required' }
|
||||||
|
}
|
||||||
|
if (fabricIds.length === 0) {
|
||||||
|
return { ok: false, error: 'fabric_node_ids_required' }
|
||||||
|
}
|
||||||
|
const copyPositions = bool(params, 'copy_positions') !== false
|
||||||
|
&& bool(args, 'copy_positions') !== false
|
||||||
|
const removeFromSource = bool(params, 'remove_from_source') !== false
|
||||||
|
const source = unwrap(await client.get(`/v1/topology/views/${encodeURIComponent(fromId)}`, undefined, signal, 120_000))
|
||||||
|
if (source.ok !== true) return source
|
||||||
|
const nodes = Array.isArray(source.nodes) ? source.nodes : []
|
||||||
|
const wanted = new Set(fabricIds)
|
||||||
|
const selected = nodes.filter((n) => {
|
||||||
|
const row = asRecord(n)
|
||||||
|
const id = str(row, 'id') || str(row, 'fabric_node_id')
|
||||||
|
return wanted.has(id)
|
||||||
|
})
|
||||||
|
const add = unwrap(await client.post(
|
||||||
|
`/v1/topology/views/${encodeURIComponent(toId)}/nodes`,
|
||||||
|
{ fabric_node_ids: fabricIds, layout: copyPositions ? 'keep' : 'grid', managed_ne_ids: [] },
|
||||||
|
signal,
|
||||||
|
180_000,
|
||||||
|
))
|
||||||
|
if (add.ok !== true) return add
|
||||||
|
if (copyPositions && selected.length) {
|
||||||
|
const ox = Number(params.offset_x || 0)
|
||||||
|
const oy = Number(params.offset_y || 0)
|
||||||
|
const positions = selected.map((n) => {
|
||||||
|
const row = asRecord(n)
|
||||||
|
return {
|
||||||
|
fabric_node_id: str(row, 'id') || str(row, 'fabric_node_id'),
|
||||||
|
x: Number(row.x || 0) + ox,
|
||||||
|
y: Number(row.y || 0) + oy,
|
||||||
|
}
|
||||||
|
}).filter((p) => p.fabric_node_id)
|
||||||
|
await client.patch(
|
||||||
|
`/v1/topology/views/${encodeURIComponent(toId)}/positions`,
|
||||||
|
{ positions },
|
||||||
|
signal,
|
||||||
|
120_000,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (removeFromSource) {
|
||||||
|
await client.post(
|
||||||
|
`/v1/topology/views/${encodeURIComponent(fromId)}/nodes/remove`,
|
||||||
|
{ fabric_node_ids: fabricIds },
|
||||||
|
signal,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
action,
|
||||||
|
moved: fabricIds.length,
|
||||||
|
source_view_id: fromId,
|
||||||
|
view_id: toId,
|
||||||
|
park: bool(params, 'park') === true || bool(args, 'park') === true,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return layoutRecipeUnavailable(action)
|
||||||
|
}
|
||||||
|
|
@ -31,6 +31,7 @@ test('formatAlarmPrompt keeps human fields without raw sockets', () => {
|
||||||
ne: { host_name: 'PE1', ip_address: '10.0.0.1' },
|
ne: { host_name: 'PE1', ip_address: '10.0.0.1' },
|
||||||
}, 'zh')
|
}, 'zh')
|
||||||
assert.match(text, /告警产生/)
|
assert.match(text, /告警产生/)
|
||||||
|
assert.match(text, /\[NMS /)
|
||||||
assert.match(text, /PE1 \(10\.0\.0\.1\)/)
|
assert.match(text, /PE1 \(10\.0\.0\.1\)/)
|
||||||
assert.match(text, /请分析这条关键告警/)
|
assert.match(text, /请分析这条关键告警/)
|
||||||
})
|
})
|
||||||
|
|
|
||||||
70
test/capability-groups.test.mjs
Normal file
70
test/capability-groups.test.mjs
Normal file
|
|
@ -0,0 +1,70 @@
|
||||||
|
/**
|
||||||
|
* Capability-group defaults and plane filters.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import assert from 'node:assert/strict'
|
||||||
|
import test from 'node:test'
|
||||||
|
|
||||||
|
import {
|
||||||
|
capabilityGroupsFromSettings,
|
||||||
|
DEFAULT_CAPABILITY_GROUPS,
|
||||||
|
groupsForPlane,
|
||||||
|
groupsForced,
|
||||||
|
SKILL_DIR_BY_GROUP,
|
||||||
|
toolNamesForGroups,
|
||||||
|
TOOLS_BY_GROUP,
|
||||||
|
} from '../src/netx/capability-groups.ts'
|
||||||
|
|
||||||
|
test('defaults: nms + common in preset; topology and public off', () => {
|
||||||
|
assert.deepEqual(DEFAULT_CAPABILITY_GROUPS, {
|
||||||
|
nms: { inPreset: true, public: false },
|
||||||
|
common: { inPreset: true, public: false },
|
||||||
|
topology: { inPreset: false, public: false },
|
||||||
|
})
|
||||||
|
assert.deepEqual(groupsForPlane(undefined, 'preset'), ['nms', 'common'])
|
||||||
|
assert.deepEqual(groupsForPlane(undefined, 'public'), [])
|
||||||
|
})
|
||||||
|
|
||||||
|
test('one group one skill dir; topology owns canvas + layout tools', () => {
|
||||||
|
assert.deepEqual(SKILL_DIR_BY_GROUP, {
|
||||||
|
nms: 'nms',
|
||||||
|
common: 'common',
|
||||||
|
topology: 'topology',
|
||||||
|
})
|
||||||
|
assert.ok(TOOLS_BY_GROUP.common.includes('netx__findTopologyPaths'))
|
||||||
|
assert.ok(TOOLS_BY_GROUP.topology.includes('netx__getTopologyTree'))
|
||||||
|
assert.ok(TOOLS_BY_GROUP.topology.includes('netx__layoutTopologyView'))
|
||||||
|
assert.ok(TOOLS_BY_GROUP.topology.includes('netx__suggestSinkHubs'))
|
||||||
|
assert.ok(TOOLS_BY_GROUP.topology.includes('netx__sinkTopologyDualUnits'))
|
||||||
|
assert.ok(TOOLS_BY_GROUP.nms.includes('netx__queryNmsAlarms'))
|
||||||
|
})
|
||||||
|
|
||||||
|
test('capabilityGroupsFromSettings honors explicit false for inPreset defaults', () => {
|
||||||
|
const groups = capabilityGroupsFromSettings({
|
||||||
|
groupNmsInPreset: false,
|
||||||
|
groupCommonInPreset: false,
|
||||||
|
groupTopologyInPreset: true,
|
||||||
|
groupTopologyPublic: true,
|
||||||
|
})
|
||||||
|
assert.deepEqual(groupsForPlane(groups, 'preset'), ['topology'])
|
||||||
|
assert.deepEqual(groupsForPlane(groups, 'public'), ['topology'])
|
||||||
|
assert.ok(toolNamesForGroups(['topology']).has('netx__layoutTopologyView'))
|
||||||
|
assert.ok(toolNamesForGroups(['topology']).has('netx__sinkTopologyDualUnits'))
|
||||||
|
assert.equal(toolNamesForGroups(['topology']).has('netx__queryNmsAlarms'), false)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('legacy groupManagedNe* maps to common; legacy layout flags OR into topology', () => {
|
||||||
|
const groups = capabilityGroupsFromSettings({
|
||||||
|
groupManagedNeInPreset: false,
|
||||||
|
groupManagedNePublic: true,
|
||||||
|
groupTopologyLayoutInPreset: true,
|
||||||
|
})
|
||||||
|
assert.equal(groups.common.inPreset, false)
|
||||||
|
assert.equal(groups.common.public, true)
|
||||||
|
assert.equal(groups.topology.inPreset, true)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('groupsForced ignores settings for per-export mounts', () => {
|
||||||
|
assert.deepEqual(groupsForced(['common']), ['common'])
|
||||||
|
assert.deepEqual(groupsForced(['nms', 'topology']), ['nms', 'topology'])
|
||||||
|
})
|
||||||
32
test/group-skills.test.mjs
Normal file
32
test/group-skills.test.mjs
Normal file
|
|
@ -0,0 +1,32 @@
|
||||||
|
/**
|
||||||
|
* Group skill loader: one capability group ↔ one skill dir.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import assert from 'node:assert/strict'
|
||||||
|
import test from 'node:test'
|
||||||
|
import { readdirSync, readFileSync, existsSync } from 'node:fs'
|
||||||
|
import { join } from 'node:path'
|
||||||
|
|
||||||
|
import { opsSkillsRoot } from '../src/netx/group-skills.ts'
|
||||||
|
import { SKILL_DIR_BY_GROUP } from '../src/netx/capability-groups.ts'
|
||||||
|
|
||||||
|
test('opsSkillsRoot points at nms / common / topology playbook dirs', () => {
|
||||||
|
const root = opsSkillsRoot()
|
||||||
|
assert.ok(existsSync(root), root)
|
||||||
|
for (const dir of Object.values(SKILL_DIR_BY_GROUP)) {
|
||||||
|
assert.ok(existsSync(join(root, dir)), join(root, dir))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
test('each group dir has a playbook with name + description', () => {
|
||||||
|
const root = opsSkillsRoot()
|
||||||
|
for (const group of Object.keys(SKILL_DIR_BY_GROUP)) {
|
||||||
|
const groupDir = join(root, SKILL_DIR_BY_GROUP[group])
|
||||||
|
const bundles = readdirSync(groupDir).filter((name) => existsSync(join(groupDir, name, 'SKILL.md')))
|
||||||
|
assert.ok(bundles.length >= 1, group)
|
||||||
|
const raw = readFileSync(join(groupDir, bundles[0], 'SKILL.md'), 'utf8')
|
||||||
|
assert.match(raw, /^---\r?\n/)
|
||||||
|
assert.match(raw, /\nname:\s+\S+/)
|
||||||
|
assert.match(raw, /\ndescription:\s/)
|
||||||
|
}
|
||||||
|
})
|
||||||
Loading…
Add table
Add a link
Reference in a new issue