mirror of
https://github.com/hansjone/netxops.git
synced 2026-10-09 04:00:46 +08:00
Call netx REST from native netx__* tools.
Drop the local python -m netx_mcp / mcp-client path so terminal users only need API URL + token. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
5179eb74b1
commit
ed219842d3
22 changed files with 1431 additions and 179 deletions
10
README.md
10
README.md
|
|
@ -1,6 +1,6 @@
|
||||||
# netxops — Netx Ops for DeepSeek Harness
|
# netxops — Netx Ops for DeepSeek Harness
|
||||||
|
|
||||||
Public **DeepSeek Harness** package: host bridge + Plugins card + **agent preset (skills included)**.
|
Public **DeepSeek Harness** package: native `netx__*` REST tools + Plugins card + **agent preset (skills included)**.
|
||||||
|
|
||||||
- GitHub: https://github.com/hansjone/netxops
|
- GitHub: https://github.com/hansjone/netxops
|
||||||
- Package: `dsh-netxops`
|
- Package: `dsh-netxops`
|
||||||
|
|
@ -19,16 +19,18 @@ Then:
|
||||||
2. Settings → **Agent presets** → Custom → **Netx Ops** (installed automatically on first host activate)
|
2. Settings → **Agent presets** → Custom → **Netx Ops** (installed automatically on first host activate)
|
||||||
3. New session → choose **Netx Ops**
|
3. New session → choose **Netx Ops**
|
||||||
|
|
||||||
Also need: running **netx API**, and `pip install` of `netx_mcp` (Python execution plane). 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).
|
||||||
|
|
||||||
## What is coupled
|
## What is coupled
|
||||||
|
|
||||||
| In the plugin | Outside (data / runtime) |
|
| In the plugin | Outside (data / runtime) |
|
||||||
|---------------|---------------------------|
|
|---------------|---------------------------|
|
||||||
| MCP spawn + settings + credentials | netx HTTP API |
|
| `netx__*` REST tools + settings + credentials | netx HTTP API (URL + token) |
|
||||||
| Persona + UME / managed-NE skills | `python -m netx_mcp` on PATH |
|
| Persona + UME / managed-NE skills | |
|
||||||
| 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.
|
||||||
|
|
||||||
## License
|
## License
|
||||||
|
|
||||||
MIT
|
MIT
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
# Host-plane Netx Ops bridge: settings + credentials → mcp__netx__* tools.
|
# Host-plane Netx Ops bridge: settings + credentials → netx__* REST tools.
|
||||||
#
|
#
|
||||||
# After `dsh plugin add` / link, open Settings → Plugins to edit apiUrl / lang
|
# After `dsh plugin add` / link, open Settings → Plugins to edit apiUrl / lang
|
||||||
# (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.
|
||||||
|
|
@ -9,7 +9,6 @@
|
||||||
config:
|
config:
|
||||||
apiUrl: http://127.0.0.1:8890
|
apiUrl: http://127.0.0.1:8890
|
||||||
lang: zh
|
lang: zh
|
||||||
pythonCommand: python
|
|
||||||
tokenCredentialRef: NETX_API_TOKEN
|
tokenCredentialRef: NETX_API_TOKEN
|
||||||
toolCallTimeoutMs: 120000
|
toolCallTimeoutMs: 120000
|
||||||
installAgentPreset: true
|
installAgentPreset: true
|
||||||
|
|
|
||||||
|
|
@ -6,8 +6,8 @@ One install wires **all of**:
|
||||||
|
|
||||||
| Piece | How you use it |
|
| Piece | How you use it |
|
||||||
|-------|----------------|
|
|-------|----------------|
|
||||||
| Host bridge | spawns `mcp__netx__*` via `python -m netx_mcp` |
|
| Host tools | native `netx__*` tools → netx REST (Bearer token) |
|
||||||
| Plugins card | Settings → Plugins → **Netx Ops** (URL / lang / python) |
|
| Plugins card | Settings → Plugins → **Netx Ops** (URL / lang / token) |
|
||||||
| Agent preset + skills | Settings → Agent presets → **Custom → Netx Ops** (copied into `~/.dsh/.agent-presets` on first boot) |
|
| Agent preset + skills | Settings → Agent presets → **Custom → Netx Ops** (copied into `~/.dsh/.agent-presets` on first boot) |
|
||||||
|
|
||||||
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.
|
||||||
|
|
@ -15,14 +15,9 @@ You do **not** run `link-preset.ps1` for normal use. That script is only a manua
|
||||||
## Still required outside the npm package
|
## Still required outside the npm package
|
||||||
|
|
||||||
1. **netx API** reachable (default `http://127.0.0.1:8890`) — the data plane.
|
1. **netx API** reachable (default `http://127.0.0.1:8890`) — the data plane.
|
||||||
2. **Python `netx_mcp`** on the same machine as `dsh`:
|
2. **API token** with scopes matching the tools you use (`alarms:read`, `ne:read`, `ne:exec`, `sql:query`, …).
|
||||||
|
|
||||||
```powershell
|
No local Python / `netx_mcp` install is required for DSH. (OpenClaw and other MCP hosts can still use `python -m netx_mcp` separately.)
|
||||||
pip install "git+https://github.com/hansjone/netx.git#subdirectory=packages/netx-mcp"
|
|
||||||
python -c "import netx_mcp; print('ok')"
|
|
||||||
```
|
|
||||||
|
|
||||||
v1 keeps the Python MCP as the execution plane (shared with OpenClaw / `python -m netx_mcp`). The DSH plugin owns orchestration, settings, persona, and skills — not a second MCP install in the agent preset.
|
|
||||||
|
|
||||||
## Install
|
## Install
|
||||||
|
|
||||||
|
|
@ -42,7 +37,7 @@ dsh web # or: pnpm dsh web
|
||||||
|
|
||||||
1. Plugins card **Netx Ops** visible.
|
1. Plugins card **Netx Ops** visible.
|
||||||
2. Agent presets → Custom → **Netx Ops**.
|
2. Agent presets → Custom → **Netx Ops**.
|
||||||
3. Tools include `mcp__netx__queryUmeAlarms`.
|
3. Tools include `netx__queryUmeAlarms`.
|
||||||
|
|
||||||
See [TOOL_MAP.md](TOOL_MAP.md).
|
See [TOOL_MAP.md](TOOL_MAP.md).
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -7,11 +7,11 @@
|
||||||
| `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/` | `presets/netxops/skills/ops-netx-ume-playbook/` |
|
||||||
| `skills/_workspace/ops/ops-netx-managed-ne-playbook/` | `presets/netxops/skills/ops-netx-managed-ne-playbook/` |
|
| `skills/_workspace/ops/ops-netx-managed-ne-playbook/` | `presets/netxops/skills/ops-netx-managed-ne-playbook/` |
|
||||||
| netx MCP + API URL/token | Host plugin `src/index.ts` (`dsh-netxops`): settings namespace `netxops` + credential `NETX_API_TOKEN` → dynamic `dsh-mcp-client` |
|
| 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 MCP only.
|
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`.
|
||||||
|
|
||||||
**Config UX:** Settings → Plugins → **Netx Ops** card (`src/client/` → `lib/client.js`). Token via credentials `NETX_API_TOKEN`; `apiUrl` / `lang` / `pythonCommand` via settings namespace `netxops`.
|
**Config UX:** Settings → Plugins → **Netx Ops** card (`src/client/` → `lib/client.js`). Token via credentials `NETX_API_TOKEN`; `apiUrl` / `lang` via settings namespace `netxops`.
|
||||||
|
|
||||||
## Stay in oclaw
|
## Stay in oclaw
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,8 @@
|
||||||
# Netx MCP tool map (v1)
|
# Netx Ops tool map
|
||||||
|
|
||||||
Tools are registered by `@deepseek-ai/dsh-mcp-client` with `serverName: netx`.
|
Tools are registered by the host plugin (`dsh-netxops`) via `@deepseek-ai/dsh-tools`.
|
||||||
|
|
||||||
Model-facing name: `mcp__netx__<tool>`.
|
Model-facing name: `netx__<tool>`.
|
||||||
|
|
||||||
| Tool | Role |
|
| Tool | Role |
|
||||||
|------|------|
|
|------|------|
|
||||||
|
|
@ -21,8 +21,8 @@ Model-facing name: `mcp__netx__<tool>`.
|
||||||
| `execManagedNe` | Read-only CLI (batch-first) |
|
| `execManagedNe` | Read-only CLI (batch-first) |
|
||||||
| `listCliTargets` | CLI target index (managed + ume) |
|
| `listCliTargets` | CLI target index (managed + ume) |
|
||||||
|
|
||||||
stdio entry: `python -m netx_mcp` → HTTP `NETX_API_URL`.
|
Execution: browser/`fetch` from the DSH host → HTTP `apiUrl` + Bearer `NETX_API_TOKEN`.
|
||||||
|
|
||||||
Upstream package: [netx `packages/netx-mcp`](https://github.com/hansjone/netx/tree/main/packages/netx-mcp).
|
Handler/paths mirror [netx `packages/netx-mcp`](https://github.com/hansjone/netx/tree/main/packages/netx-mcp) (`http_tools.py` / `http_client.py`).
|
||||||
|
|
||||||
**Out of v1:** `netx-topology` MCP / topology canvas skills.
|
**Out of v1:** `netx-topology` MCP / topology canvas skills.
|
||||||
|
|
|
||||||
|
|
@ -33,5 +33,5 @@ Adjust paths if your checkout differs.
|
||||||
|
|
||||||
1. Settings → Plugins → **Netx Ops** card shows URL / token fields (after `bun run bundle` if you edited `src/client/`)
|
1. Settings → Plugins → **Netx Ops** card shows URL / token fields (after `bun run bundle` if you edited `src/client/`)
|
||||||
2. New session → preset **Netx Ops**
|
2. New session → preset **Netx Ops**
|
||||||
3. Tools include `mcp__netx__aggregateUmeAlarms`
|
3. Tools include `netx__aggregateUmeAlarms`
|
||||||
4. Ask Critical Top / single host alarms
|
4. Ask Critical Top / single host alarms
|
||||||
|
|
|
||||||
|
|
@ -22,7 +22,6 @@
|
||||||
config:
|
config:
|
||||||
apiUrl: http://127.0.0.1:8890
|
apiUrl: http://127.0.0.1:8890
|
||||||
lang: zh
|
lang: zh
|
||||||
pythonCommand: python
|
|
||||||
tokenCredentialRef: NETX_API_TOKEN
|
tokenCredentialRef: NETX_API_TOKEN
|
||||||
toolCallTimeoutMs: 120000
|
toolCallTimeoutMs: 120000
|
||||||
failOnStartupError: false
|
installAgentPreset: true
|
||||||
|
|
|
||||||
|
|
@ -287,22 +287,6 @@ function NetxopsCard(props) {
|
||||||
props.resetField("lang");
|
props.resetField("lang");
|
||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
/* @__PURE__ */ jsx_runtime.jsx(ValueField, {
|
|
||||||
id: "netxops-python",
|
|
||||||
label: t("pythonCommand"),
|
|
||||||
hint: t("pythonCommandHint"),
|
|
||||||
field: state.pythonCommand,
|
|
||||||
overriddenLabel: t("overridden"),
|
|
||||||
resetLabel: t("reset"),
|
|
||||||
invalidLabel: t("invalid"),
|
|
||||||
disabled,
|
|
||||||
onEdit: (text) => {
|
|
||||||
props.edit("pythonCommand", text);
|
|
||||||
},
|
|
||||||
onReset: () => {
|
|
||||||
props.resetField("pythonCommand");
|
|
||||||
}
|
|
||||||
}),
|
|
||||||
/* @__PURE__ */ jsx_runtime.jsxs("div", {
|
/* @__PURE__ */ jsx_runtime.jsxs("div", {
|
||||||
className: "dsh-nx-footer",
|
className: "dsh-nx-footer",
|
||||||
children: [
|
children: [
|
||||||
|
|
@ -542,7 +526,7 @@ class NetxopsCardController {
|
||||||
constructor(scope, ctx) {
|
constructor(scope, ctx) {
|
||||||
this.scope = scope;
|
this.scope = scope;
|
||||||
this.ctx = ctx;
|
this.ctx = ctx;
|
||||||
this.form = new CardForm(scope, [textField("apiUrl"), textField("lang"), textField("pythonCommand")], [{ field: API_TOKEN_FIELD, write: (text) => this.writeToken(text) }]);
|
this.form = new CardForm(scope, [textField("apiUrl"), textField("lang")], [{ field: API_TOKEN_FIELD, write: (text) => this.writeToken(text) }]);
|
||||||
this.store = this.form.bind(() => this.projection());
|
this.store = this.form.bind(() => this.projection());
|
||||||
scope.subscribe(() => {
|
scope.subscribe(() => {
|
||||||
this.readCredential();
|
this.readCredential();
|
||||||
|
|
@ -566,7 +550,6 @@ class NetxopsCardController {
|
||||||
...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"),
|
||||||
pythonCommand: this.form.field("pythonCommand"),
|
|
||||||
apiToken: this.form.field(API_TOKEN_FIELD),
|
apiToken: this.form.field(API_TOKEN_FIELD),
|
||||||
apiTokenConfigured: this.credential.configured,
|
apiTokenConfigured: this.credential.configured,
|
||||||
apiTokenWritable: this.credential.remoteReady && this.credential.writable,
|
apiTokenWritable: this.credential.remoteReady && this.credential.writable,
|
||||||
|
|
@ -635,13 +618,11 @@ 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 mcp__netx__* tools.",
|
description: "UME API endpoint and bearer token for netx__* tools.",
|
||||||
apiUrl: "API URL",
|
apiUrl: "API URL",
|
||||||
apiUrlHint: "netx REST root, e.g. http://127.0.0.1:8890",
|
apiUrlHint: "netx REST root, e.g. http://127.0.0.1:8890",
|
||||||
lang: "Language",
|
lang: "Language",
|
||||||
langHint: "Passed as NETX_LANG (zh / en).",
|
langHint: "Response language hint (zh / en).",
|
||||||
pythonCommand: "Python command",
|
|
||||||
pythonCommandHint: "Executable that can run `python -m netx_mcp`.",
|
|
||||||
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.",
|
||||||
|
|
@ -661,13 +642,11 @@ var en = {
|
||||||
};
|
};
|
||||||
var zh = {
|
var zh = {
|
||||||
title: "Netx Ops",
|
title: "Netx Ops",
|
||||||
description: "UME API 地址与 Bearer Token,供 mcp__netx__* 工具使用。",
|
description: "UME API 地址与 Bearer Token,供 netx__* 工具使用。",
|
||||||
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: "传给 MCP 的 NETX_LANG(zh / en)。",
|
langHint: "响应语言提示(zh / en)。",
|
||||||
pythonCommand: "Python 命令",
|
|
||||||
pythonCommandHint: "能执行 `python -m netx_mcp` 的解释器。",
|
|
||||||
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 在监视凭据文件则无需重启。",
|
||||||
|
|
|
||||||
688
lib/index.js
688
lib/index.js
|
|
@ -6,19 +6,646 @@ import { fileURLToPath } from "node:url";
|
||||||
import z from "@deepseek-ai/schemastery";
|
import z from "@deepseek-ai/schemastery";
|
||||||
import { credentialRef } from "@deepseek-ai/dsh-credentials";
|
import { credentialRef } from "@deepseek-ai/dsh-credentials";
|
||||||
import * as DshSettings from "@deepseek-ai/dsh-settings";
|
import * as DshSettings from "@deepseek-ai/dsh-settings";
|
||||||
import * as McpClient from "@deepseek-ai/dsh-mcp-client";
|
|
||||||
|
// src/netx/tools.ts
|
||||||
|
import { defineTool } from "@deepseek-ai/dsh-tools";
|
||||||
|
|
||||||
|
// src/netx/http.ts
|
||||||
|
var PROTOCOL_KEY_ZH_TO_EN = {
|
||||||
|
其他: "Other",
|
||||||
|
时钟: "Clock",
|
||||||
|
"OTN/光": "OTN/Optical",
|
||||||
|
电源: "Power"
|
||||||
|
};
|
||||||
|
function localizePayload(lang, data) {
|
||||||
|
if (!lang.trim().toLowerCase().startsWith("en"))
|
||||||
|
return data;
|
||||||
|
const proto = data.protocol_summary;
|
||||||
|
if (!Array.isArray(proto))
|
||||||
|
return data;
|
||||||
|
for (const row of proto) {
|
||||||
|
if (typeof row !== "object" || row === null || Array.isArray(row))
|
||||||
|
continue;
|
||||||
|
const rec = row;
|
||||||
|
const key = typeof rec.key === "string" ? rec.key : "";
|
||||||
|
const mapped = PROTOCOL_KEY_ZH_TO_EN[key];
|
||||||
|
if (mapped !== undefined)
|
||||||
|
rec.key = mapped;
|
||||||
|
}
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
function encodeQuery(params) {
|
||||||
|
const sp = new URLSearchParams;
|
||||||
|
for (const [key, value] of Object.entries(params)) {
|
||||||
|
sp.set(key, String(value));
|
||||||
|
}
|
||||||
|
const q = sp.toString();
|
||||||
|
return q.length > 0 ? `?${q}` : "";
|
||||||
|
}
|
||||||
|
function createNetxClient(connection) {
|
||||||
|
const base = connection.apiUrl.replace(/\/$/, "");
|
||||||
|
const headers = {
|
||||||
|
accept: "application/json"
|
||||||
|
};
|
||||||
|
if (connection.token.trim().length > 0) {
|
||||||
|
headers.authorization = `Bearer ${connection.token.trim()}`;
|
||||||
|
}
|
||||||
|
const langParams = () => {
|
||||||
|
const lang = connection.lang.trim().toLowerCase();
|
||||||
|
if (lang.startsWith("en"))
|
||||||
|
return { lang: "en" };
|
||||||
|
return {};
|
||||||
|
};
|
||||||
|
async function request(method, path, options = {}) {
|
||||||
|
const merged = { ...langParams(), ...options.params };
|
||||||
|
const url = `${base}${path}${encodeQuery(merged)}`;
|
||||||
|
const timeoutMs = options.timeoutMs ?? connection.timeoutMs;
|
||||||
|
const controller = new AbortController;
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
controller.abort();
|
||||||
|
}, timeoutMs);
|
||||||
|
const onOuterAbort = () => {
|
||||||
|
controller.abort();
|
||||||
|
};
|
||||||
|
options.signal?.addEventListener("abort", onOuterAbort, { once: true });
|
||||||
|
try {
|
||||||
|
const init = {
|
||||||
|
method,
|
||||||
|
headers: options.body === undefined ? headers : { ...headers, "content-type": "application/json" },
|
||||||
|
signal: controller.signal
|
||||||
|
};
|
||||||
|
if (options.body !== undefined)
|
||||||
|
init.body = JSON.stringify(options.body);
|
||||||
|
const resp = await fetch(url, init);
|
||||||
|
const text = await resp.text();
|
||||||
|
if (!resp.ok) {
|
||||||
|
return { ok: false, error: `netx_http_${resp.status}`, detail: text.slice(0, 800) };
|
||||||
|
}
|
||||||
|
const data = text.length > 0 ? JSON.parse(text) : {};
|
||||||
|
if (typeof data === "object" && data !== null && !Array.isArray(data)) {
|
||||||
|
return { ok: true, data: localizePayload(connection.lang, data) };
|
||||||
|
}
|
||||||
|
return { ok: true, data: { raw: data } };
|
||||||
|
} catch (error) {
|
||||||
|
const detail = error instanceof Error ? error.message : String(error);
|
||||||
|
return { ok: false, error: "netx_request_failed", detail: detail.slice(0, 800) };
|
||||||
|
} finally {
|
||||||
|
clearTimeout(timer);
|
||||||
|
options.signal?.removeEventListener("abort", onOuterAbort);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
get(path, params, signal, timeoutMs) {
|
||||||
|
return request("GET", path, { params, signal, timeoutMs });
|
||||||
|
},
|
||||||
|
post(path, body, signal, timeoutMs) {
|
||||||
|
return request("POST", path, { body, signal, timeoutMs });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
function quoteNeId(neId) {
|
||||||
|
return encodeURIComponent(neId.trim());
|
||||||
|
}
|
||||||
|
|
||||||
|
// src/netx/handlers.ts
|
||||||
|
var EXEC_MAX_COMMANDS = 5;
|
||||||
|
var UME_RAW_FIELD_PRESETS = {
|
||||||
|
brief: [
|
||||||
|
"alarm_alarm_key",
|
||||||
|
"alarm_host_name",
|
||||||
|
"alarm_perceived_severity",
|
||||||
|
"alarm_event_type",
|
||||||
|
"alarm_last_seen_at",
|
||||||
|
"ne_host_name",
|
||||||
|
"ne_user_label",
|
||||||
|
"ne_ne_name",
|
||||||
|
"ne_ip_address",
|
||||||
|
"ne_exists"
|
||||||
|
],
|
||||||
|
evidence: [
|
||||||
|
"alarm_alarm_key",
|
||||||
|
"alarm_host_name",
|
||||||
|
"alarm_object_name",
|
||||||
|
"alarm_event_type",
|
||||||
|
"alarm_native_probable_cause",
|
||||||
|
"alarm_perceived_severity",
|
||||||
|
"alarm_is_cleared",
|
||||||
|
"alarm_time_created",
|
||||||
|
"alarm_last_seen_at",
|
||||||
|
"ne_host_name",
|
||||||
|
"ne_user_label",
|
||||||
|
"ne_ne_name",
|
||||||
|
"ne_ip_address",
|
||||||
|
"ne_connection_status",
|
||||||
|
"ne_exists"
|
||||||
|
],
|
||||||
|
ne_debug: [
|
||||||
|
"alarm_alarm_key",
|
||||||
|
"alarm_ne_id",
|
||||||
|
"alarm_perceived_severity",
|
||||||
|
"alarm_last_seen_at",
|
||||||
|
"ne_user_label",
|
||||||
|
"ne_ne_name",
|
||||||
|
"ne_ip_address",
|
||||||
|
"ne_ipv6_address",
|
||||||
|
"ne_device_level",
|
||||||
|
"ne_host_name",
|
||||||
|
"ne_connection_status",
|
||||||
|
"ne_admin_status",
|
||||||
|
"ne_address_type",
|
||||||
|
"ne_maintain_status",
|
||||||
|
"ne_exists"
|
||||||
|
]
|
||||||
|
};
|
||||||
|
function asRecord(value) {
|
||||||
|
return typeof value === "object" && value !== null && !Array.isArray(value) ? value : {};
|
||||||
|
}
|
||||||
|
function str(args, key, fallback = "") {
|
||||||
|
const v = args[key];
|
||||||
|
if (typeof v === "string")
|
||||||
|
return v;
|
||||||
|
if (typeof v === "number" || typeof v === "boolean")
|
||||||
|
return String(v);
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
function num(args, key) {
|
||||||
|
const v = args[key];
|
||||||
|
return typeof v === "number" && Number.isFinite(v) ? v : undefined;
|
||||||
|
}
|
||||||
|
function bool(args, key) {
|
||||||
|
const v = args[key];
|
||||||
|
return typeof v === "boolean" ? v : undefined;
|
||||||
|
}
|
||||||
|
function strList(args, key) {
|
||||||
|
const v = args[key];
|
||||||
|
if (!Array.isArray(v))
|
||||||
|
return [];
|
||||||
|
return v.map((x) => String(x).trim()).filter((x) => x.length > 0);
|
||||||
|
}
|
||||||
|
function clampInt(value, fallback, min, max) {
|
||||||
|
const n = value === undefined ? fallback : Math.trunc(value);
|
||||||
|
return Math.max(min, Math.min(max, n));
|
||||||
|
}
|
||||||
|
function putStr(params, args, keys) {
|
||||||
|
for (const key of keys) {
|
||||||
|
const v = str(args, key).trim();
|
||||||
|
if (v)
|
||||||
|
params[key] = v;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
async function queryUmeAlarms(client, args, signal) {
|
||||||
|
let page = clampInt(num(args, "page"), 1, 1, 2);
|
||||||
|
const pageSize = clampInt(num(args, "page_size"), 50, 1, 500);
|
||||||
|
const params = { page, page_size: pageSize };
|
||||||
|
putStr(params, args, ["severity", "ne_id", "host_name", "time_from", "time_to"]);
|
||||||
|
const keyword = str(args, "keyword").trim();
|
||||||
|
const neName = str(args, "ne_name").trim();
|
||||||
|
if (keyword)
|
||||||
|
params.keyword = keyword;
|
||||||
|
else if (neName)
|
||||||
|
params.keyword = neName;
|
||||||
|
return client.get("/v1/ume/alarms", params, signal);
|
||||||
|
}
|
||||||
|
async function aggregateUmeAlarmsRaw(client, args, signal) {
|
||||||
|
const params = {};
|
||||||
|
putStr(params, args, [
|
||||||
|
"group_by",
|
||||||
|
"group_by2",
|
||||||
|
"severity",
|
||||||
|
"is_cleared",
|
||||||
|
"ne_id",
|
||||||
|
"event_type",
|
||||||
|
"keyword",
|
||||||
|
"time_from",
|
||||||
|
"time_to",
|
||||||
|
"limit"
|
||||||
|
]);
|
||||||
|
if ("exclude_missing_host" in args) {
|
||||||
|
const flag = bool(args, "exclude_missing_host");
|
||||||
|
if (flag !== undefined)
|
||||||
|
params.exclude_missing_host = flag;
|
||||||
|
}
|
||||||
|
return client.get("/v1/ume/alarms/aggregate/raw", params, signal);
|
||||||
|
}
|
||||||
|
async function aggregateUmeAlarms(client, args, signal) {
|
||||||
|
if (str(args, "group_by").trim())
|
||||||
|
return aggregateUmeAlarmsRaw(client, args, signal);
|
||||||
|
const topNe = clampInt(num(args, "top_ne"), 50, 0, 500);
|
||||||
|
const params = { top_ne: topNe };
|
||||||
|
if ("exclude_missing_host" in args) {
|
||||||
|
const flag = bool(args, "exclude_missing_host");
|
||||||
|
if (flag !== undefined)
|
||||||
|
params.exclude_missing_host = flag;
|
||||||
|
}
|
||||||
|
putStr(params, args, ["severity", "time_from", "time_to"]);
|
||||||
|
return client.get("/v1/ume/alarms/aggregate", params, signal);
|
||||||
|
}
|
||||||
|
async function runUmeDiagnostics(client, _args, signal) {
|
||||||
|
return client.get("/v1/ume/diagnostics", undefined, signal);
|
||||||
|
}
|
||||||
|
async function queryUmeNeInventory(client, args, signal) {
|
||||||
|
const params = {
|
||||||
|
page: clampInt(num(args, "page"), 1, 1, Number.MAX_SAFE_INTEGER),
|
||||||
|
page_size: clampInt(num(args, "page_size"), 50, 1, 500)
|
||||||
|
};
|
||||||
|
putStr(params, args, ["keyword"]);
|
||||||
|
return client.get("/v1/ume/inventory/ne", params, signal);
|
||||||
|
}
|
||||||
|
async function getUmeNe(client, args, signal) {
|
||||||
|
const neId = str(args, "ne_id").trim();
|
||||||
|
if (!neId)
|
||||||
|
return { ok: false, error: "ne_id_required", error_code: "ne_id_required" };
|
||||||
|
return client.get(`/v1/ume/inventory/ne/${quoteNeId(neId)}`, undefined, signal);
|
||||||
|
}
|
||||||
|
async function queryUmeAlarmsRaw(client, args, signal) {
|
||||||
|
const params = {
|
||||||
|
page: clampInt(num(args, "page"), 1, 1, Number.MAX_SAFE_INTEGER),
|
||||||
|
page_size: clampInt(num(args, "page_size"), 50, 1, 500)
|
||||||
|
};
|
||||||
|
putStr(params, args, [
|
||||||
|
"severity",
|
||||||
|
"is_cleared",
|
||||||
|
"ne_id",
|
||||||
|
"event_type",
|
||||||
|
"keyword",
|
||||||
|
"time_from",
|
||||||
|
"time_to",
|
||||||
|
"order_by",
|
||||||
|
"order"
|
||||||
|
]);
|
||||||
|
let fields = strList(args, "select_fields");
|
||||||
|
if (fields.length === 0) {
|
||||||
|
const preset = str(args, "field_preset").trim().toLowerCase();
|
||||||
|
fields = UME_RAW_FIELD_PRESETS[preset] ?? [];
|
||||||
|
}
|
||||||
|
if (fields.length > 0)
|
||||||
|
params.select_fields = fields.join(",");
|
||||||
|
return client.get("/v1/ume/alarms/raw", params, signal);
|
||||||
|
}
|
||||||
|
async function listUmeAlarmFields(client, _args, signal) {
|
||||||
|
return client.get("/v1/ume/alarms/fields", undefined, signal);
|
||||||
|
}
|
||||||
|
async function sqlQueryUme(client, args, signal) {
|
||||||
|
const sql = str(args, "sql").trim();
|
||||||
|
if (!sql)
|
||||||
|
return { ok: false, error: "sql_required" };
|
||||||
|
const limit = clampInt(num(args, "limit"), 200, 1, 2000);
|
||||||
|
const statementTimeoutMs = clampInt(num(args, "statement_timeout_ms"), 0, 0, 30000);
|
||||||
|
return client.post("/v1/sql/ume_query", {
|
||||||
|
sql,
|
||||||
|
limit,
|
||||||
|
statement_timeout_ms: statementTimeoutMs
|
||||||
|
}, signal, 60000);
|
||||||
|
}
|
||||||
|
async function listManagedNe(client, args, signal) {
|
||||||
|
const keyword = str(args, "keyword").trim();
|
||||||
|
const vendor = str(args, "vendor").trim();
|
||||||
|
const connectStatus = str(args, "connect_status").trim();
|
||||||
|
if (!(keyword || vendor || connectStatus)) {
|
||||||
|
return { ok: false, error: "managed_ne_filter_required", error_code: "managed_ne_filter_required" };
|
||||||
|
}
|
||||||
|
if (keyword && keyword.length < 2) {
|
||||||
|
return { ok: false, error: "managed_ne_keyword_too_short", error_code: "managed_ne_keyword_too_short" };
|
||||||
|
}
|
||||||
|
const params = {
|
||||||
|
page: clampInt(num(args, "page"), 1, 1, Number.MAX_SAFE_INTEGER),
|
||||||
|
page_size: clampInt(num(args, "page_size"), 20, 1, 100)
|
||||||
|
};
|
||||||
|
if (keyword)
|
||||||
|
params.keyword = keyword;
|
||||||
|
if (vendor)
|
||||||
|
params.vendor = vendor;
|
||||||
|
if (connectStatus)
|
||||||
|
params.connect_status = connectStatus;
|
||||||
|
return client.get("/v1/managed-ne", params, signal);
|
||||||
|
}
|
||||||
|
async function getManagedNe(client, args, signal) {
|
||||||
|
const neId = (str(args, "ne_id") || str(args, "managed_ne_id") || str(args, "id")).trim();
|
||||||
|
if (!neId) {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
error: "ne_id_required",
|
||||||
|
error_code: "ne_id_required",
|
||||||
|
hint: "Pass managed NE id from listManagedNe/listCliTargets (source=managed). For UME inventory UUIDs use execManagedNe(ume_ne_id=...) or getUmeNe, not getManagedNe.",
|
||||||
|
example: { ne_id: "<managed-ne-uuid-from-listManagedNe>" }
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const out = await client.get(`/v1/managed-ne/${quoteNeId(neId)}`, undefined, signal);
|
||||||
|
if (out.ok === false) {
|
||||||
|
const detail = `${str(out, "detail")}${str(out, "error")}`.toLowerCase();
|
||||||
|
if (detail.includes("404") || detail.includes("not_found") || detail.includes("not found") || out.error === "netx_http_404") {
|
||||||
|
return {
|
||||||
|
...out,
|
||||||
|
hint: "Managed NE not found for this ne_id. Call listManagedNe(keyword=...) or listCliTargets(source=managed) first. If this is a UME ne_id, use execManagedNe(ume_ne_id=...) / getUmeNe instead of getManagedNe."
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
async function execManagedNe(client, args, signal) {
|
||||||
|
const targetsRaw = args.targets;
|
||||||
|
const neIds = strList(args, "ne_ids");
|
||||||
|
const umeNeIds = strList(args, "ume_ne_ids");
|
||||||
|
const sharedCommands = strList(args, "commands");
|
||||||
|
const multi = Array.isArray(targetsRaw) && targetsRaw.length > 0 || neIds.length > 0 || umeNeIds.length > 0;
|
||||||
|
if (multi) {
|
||||||
|
const body2 = {};
|
||||||
|
if (Array.isArray(targetsRaw) && targetsRaw.length > 0) {
|
||||||
|
const cleaned = [];
|
||||||
|
for (const t of targetsRaw) {
|
||||||
|
if (typeof t !== "object" || t === null || Array.isArray(t))
|
||||||
|
continue;
|
||||||
|
const row = t;
|
||||||
|
const item = {};
|
||||||
|
const neId2 = str(row, "ne_id").trim();
|
||||||
|
const umeNeId2 = str(row, "ume_ne_id").trim();
|
||||||
|
if (neId2)
|
||||||
|
item.ne_id = neId2;
|
||||||
|
if (umeNeId2)
|
||||||
|
item.ume_ne_id = umeNeId2;
|
||||||
|
const cmds = Array.isArray(row.commands) ? row.commands.map((c) => String(c).trim()).filter((c) => c.length > 0) : [];
|
||||||
|
if (cmds.length > 0)
|
||||||
|
item.commands = cmds;
|
||||||
|
if (Object.keys(item).length > 0)
|
||||||
|
cleaned.push(item);
|
||||||
|
}
|
||||||
|
body2.targets = cleaned;
|
||||||
|
}
|
||||||
|
if (neIds.length > 0)
|
||||||
|
body2.ne_ids = neIds;
|
||||||
|
if (umeNeIds.length > 0)
|
||||||
|
body2.ume_ne_ids = umeNeIds;
|
||||||
|
if (sharedCommands.length > 0) {
|
||||||
|
if (sharedCommands.length > EXEC_MAX_COMMANDS) {
|
||||||
|
return { ok: false, error: "too_many_commands", error_code: "too_many_commands" };
|
||||||
|
}
|
||||||
|
body2.commands = sharedCommands;
|
||||||
|
}
|
||||||
|
body2.read_timeout_sec = clampInt(num(args, "read_timeout_sec"), 60, 10, 120);
|
||||||
|
const concurrency = num(args, "concurrency");
|
||||||
|
if (concurrency !== undefined)
|
||||||
|
body2.concurrency = clampInt(concurrency, 4, 1, 8);
|
||||||
|
const out2 = await client.post("/v1/managed-ne/exec-batch", body2, signal, 600000);
|
||||||
|
if (out2.ok !== true)
|
||||||
|
return out2;
|
||||||
|
const data2 = asRecord(out2.data);
|
||||||
|
if (data2.ok === false) {
|
||||||
|
return { ok: false, data: data2, error: str(data2, "error", "exec_batch_failed") };
|
||||||
|
}
|
||||||
|
return { ok: true, data: data2 };
|
||||||
|
}
|
||||||
|
const neId = str(args, "ne_id").trim();
|
||||||
|
const umeNeId = str(args, "ume_ne_id").trim();
|
||||||
|
if (Boolean(neId) === Boolean(umeNeId)) {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
error: "exactly_one_of_ne_id_or_ume_ne_id_required",
|
||||||
|
error_code: "exactly_one_of_ne_id_or_ume_ne_id_required",
|
||||||
|
hint: "For one NE pass ne_id OR ume_ne_id. For many NEs pass ne_ids / ume_ne_ids with shared commands, or targets[] with per-NE commands — one call, concurrent on server."
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (sharedCommands.length === 0) {
|
||||||
|
return { ok: false, error: "commands_required", error_code: "commands_required" };
|
||||||
|
}
|
||||||
|
if (sharedCommands.length > EXEC_MAX_COMMANDS) {
|
||||||
|
return { ok: false, error: "too_many_commands", error_code: "too_many_commands" };
|
||||||
|
}
|
||||||
|
const body = {
|
||||||
|
commands: sharedCommands,
|
||||||
|
read_timeout_sec: clampInt(num(args, "read_timeout_sec"), 60, 10, 120)
|
||||||
|
};
|
||||||
|
if (neId)
|
||||||
|
body.ne_id = neId;
|
||||||
|
if (umeNeId)
|
||||||
|
body.ume_ne_id = umeNeId;
|
||||||
|
const out = await client.post("/v1/managed-ne/exec", body, signal, 300000);
|
||||||
|
if (out.ok !== true)
|
||||||
|
return out;
|
||||||
|
const data = asRecord(out.data);
|
||||||
|
if (data.ok === false) {
|
||||||
|
return { ok: false, data, error: str(data, "error", "exec_failed") };
|
||||||
|
}
|
||||||
|
return { ok: true, data };
|
||||||
|
}
|
||||||
|
async function listCliTargets(client, args, signal) {
|
||||||
|
const params = {
|
||||||
|
page: clampInt(num(args, "page"), 1, 1, Number.MAX_SAFE_INTEGER),
|
||||||
|
page_size: clampInt(num(args, "page_size"), 50, 1, 500)
|
||||||
|
};
|
||||||
|
putStr(params, args, ["source", "keyword"]);
|
||||||
|
return client.get("/v1/cli/targets", params, signal);
|
||||||
|
}
|
||||||
|
async function findTopologyPaths(client, args, signal) {
|
||||||
|
const fromUid = str(args, "from_ume_ne_id").trim();
|
||||||
|
const fromMid = str(args, "from_managed_ne_id").trim();
|
||||||
|
const toUid = str(args, "to_ume_ne_id").trim();
|
||||||
|
const toMid = str(args, "to_managed_ne_id").trim();
|
||||||
|
if (Boolean(fromUid) === Boolean(fromMid)) {
|
||||||
|
return { ok: false, error: "exactly_one_of_from_ume_ne_id_or_from_managed_ne_id_required" };
|
||||||
|
}
|
||||||
|
if (Boolean(toUid) === Boolean(toMid)) {
|
||||||
|
return { ok: false, error: "exactly_one_of_to_ume_ne_id_or_to_managed_ne_id_required" };
|
||||||
|
}
|
||||||
|
let detail = str(args, "detail", "summary").trim().toLowerCase() || "summary";
|
||||||
|
if (detail !== "summary" && detail !== "full")
|
||||||
|
detail = "summary";
|
||||||
|
const body = {
|
||||||
|
max_paths: clampInt(num(args, "max_paths"), 3, 1, 10),
|
||||||
|
max_hops: clampInt(num(args, "max_hops"), 6, 1, 12),
|
||||||
|
layer: str(args, "layer", "physical").trim() || "physical",
|
||||||
|
detail
|
||||||
|
};
|
||||||
|
if (fromUid)
|
||||||
|
body.from_ume_ne_id = fromUid;
|
||||||
|
else
|
||||||
|
body.from_managed_ne_id = fromMid;
|
||||||
|
if (toUid)
|
||||||
|
body.to_ume_ne_id = toUid;
|
||||||
|
else
|
||||||
|
body.to_managed_ne_id = toMid;
|
||||||
|
return client.post("/v1/topology/fabric/paths", body, signal, 30000);
|
||||||
|
}
|
||||||
|
|
||||||
|
// src/netx/tools.ts
|
||||||
|
var str2 = (description) => ({ type: "string", ...description ? { description } : {} });
|
||||||
|
var num2 = (description) => ({ type: "number", ...description ? { description } : {} });
|
||||||
|
var bool2 = (description) => ({ type: "boolean", ...description ? { description } : {} });
|
||||||
|
var strArr = (description) => ({
|
||||||
|
type: "array",
|
||||||
|
items: { type: "string" },
|
||||||
|
...description ? { description } : {}
|
||||||
|
});
|
||||||
|
function renderJson(_args, value) {
|
||||||
|
return [{ type: "text", text: JSON.stringify(value, null, 0) }];
|
||||||
|
}
|
||||||
|
var jsonOut = {
|
||||||
|
schema: { type: "json" },
|
||||||
|
render: renderJson
|
||||||
|
};
|
||||||
|
function tool(name, description, parameters, handler, getClient, timeoutMs) {
|
||||||
|
return defineTool({
|
||||||
|
name,
|
||||||
|
description,
|
||||||
|
parameters,
|
||||||
|
output: jsonOut,
|
||||||
|
timeoutMs,
|
||||||
|
isConcurrencySafe: () => true,
|
||||||
|
async execute(args, exec) {
|
||||||
|
const result = await handler(getClient(), args, exec.signal);
|
||||||
|
if (result.ok === false) {
|
||||||
|
throw new Error(JSON.stringify(result));
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
function registerNetxTools(ctx, connection) {
|
||||||
|
const client = createNetxClient({
|
||||||
|
apiUrl: connection.apiUrl,
|
||||||
|
token: connection.token,
|
||||||
|
lang: connection.lang,
|
||||||
|
timeoutMs: Math.min(connection.toolCallTimeoutMs, 45000)
|
||||||
|
});
|
||||||
|
const getClient = () => client;
|
||||||
|
const t = connection.toolCallTimeoutMs;
|
||||||
|
const disposers = [
|
||||||
|
ctx.tools.register(tool("netx__queryUmeAlarms", "Query UME current alarms (each row includes host_name). Supports severity/ne_id/host_name/keyword, last_seen time_from/time_to, pagination. Prefer host_name for display; ne_id is for filters only.", {
|
||||||
|
severity: str2(),
|
||||||
|
ne_id: str2("Filter only; do not show UUID to users"),
|
||||||
|
host_name: str2("Filter by NE host_name"),
|
||||||
|
ne_name: str2("Legacy alias mapped to keyword"),
|
||||||
|
keyword: str2("Substring on cause/object/event. Examples: LOS, Fiber Break, bandwidth, CRC."),
|
||||||
|
time_from: str2("ISO time; filters last_seen_at >="),
|
||||||
|
time_to: str2("ISO time; filters last_seen_at <="),
|
||||||
|
page: num2(),
|
||||||
|
page_size: num2()
|
||||||
|
}, queryUmeAlarms, getClient, t)),
|
||||||
|
ctx.tools.register(tool("netx__aggregateUmeAlarms", "Aggregate UME current alarms (by_severity + top by_ne). If group_by is set, routes to aggregateUmeAlarmsRaw. Always filter severity/keyword/time before paging.", {
|
||||||
|
severity: str2("Optional perceived_severity filter (critical/major/minor/warning)."),
|
||||||
|
top_ne: num2("Max NE buckets (default 50). Ignored when group_by is set."),
|
||||||
|
exclude_missing_host: bool2("Omit missing host_name from by_ne."),
|
||||||
|
time_from: str2(),
|
||||||
|
time_to: str2(),
|
||||||
|
group_by: str2("When set, routes to raw aggregation. Prefer alarm_host_name."),
|
||||||
|
group_by2: str2(),
|
||||||
|
is_cleared: str2(),
|
||||||
|
ne_id: str2(),
|
||||||
|
event_type: str2(),
|
||||||
|
keyword: str2(),
|
||||||
|
limit: num2()
|
||||||
|
}, aggregateUmeAlarms, getClient, t)),
|
||||||
|
ctx.tools.register(tool("netx__runUmeDiagnostics", "UME alarm diagnostics: severity, top_event_types, top_alarm_codes, top_ne, protocol buckets, freshness meta.", {}, runUmeDiagnostics, getClient, t)),
|
||||||
|
ctx.tools.register(tool("netx__queryUmeNeInventory", "Paged UME NE inventory synced in netx (keyword matches ne_id/ne_name/user_label/ip/host_name).", {
|
||||||
|
keyword: str2(),
|
||||||
|
page: num2(),
|
||||||
|
page_size: num2()
|
||||||
|
}, queryUmeNeInventory, getClient, t)),
|
||||||
|
ctx.tools.register(tool("netx__getUmeNe", "Get single UME NE detail by ne_id (UUID).", {
|
||||||
|
ne_id: { type: "string", required: true, description: "UME inventory ne_id (UUID)." }
|
||||||
|
}, getUmeNe, getClient, t)),
|
||||||
|
ctx.tools.register(tool("netx__queryUmeAlarmsRaw", "Power query UME current alarms with full alarm_* + ne_* fields; optional field_preset or select_fields. Use field_preset=evidence for citations.", {
|
||||||
|
severity: str2(),
|
||||||
|
is_cleared: str2(),
|
||||||
|
ne_id: str2(),
|
||||||
|
event_type: str2(),
|
||||||
|
keyword: str2(),
|
||||||
|
time_from: str2(),
|
||||||
|
time_to: str2(),
|
||||||
|
order_by: str2("last_seen_at | time_created | perceived_severity | event_type | ne_id"),
|
||||||
|
order: str2("asc | desc"),
|
||||||
|
select_fields: strArr(),
|
||||||
|
field_preset: str2("brief | evidence | ne_debug"),
|
||||||
|
page: num2(),
|
||||||
|
page_size: num2()
|
||||||
|
}, queryUmeAlarmsRaw, getClient, t)),
|
||||||
|
ctx.tools.register(tool("netx__aggregateUmeAlarmsRaw", "Dynamic aggregation on UME raw fields (group_by/group_by2); prefer alarm_host_name.", {
|
||||||
|
group_by: { type: "string", required: true },
|
||||||
|
group_by2: str2(),
|
||||||
|
severity: str2(),
|
||||||
|
is_cleared: str2(),
|
||||||
|
ne_id: str2(),
|
||||||
|
event_type: str2(),
|
||||||
|
keyword: str2(),
|
||||||
|
time_from: str2(),
|
||||||
|
time_to: str2(),
|
||||||
|
exclude_missing_host: bool2(),
|
||||||
|
limit: num2()
|
||||||
|
}, aggregateUmeAlarmsRaw, getClient, t)),
|
||||||
|
ctx.tools.register(tool("netx__listUmeAlarmFields", "List available fields for UME raw alarm queries.", {}, listUmeAlarmFields, getClient, t)),
|
||||||
|
ctx.tools.register(tool("netx__sqlQueryUme", "Read-only SELECT on UME tables (ume_alarms_current/ume_inventory_ne); server enforces limits. Requires sql:query scope.", {
|
||||||
|
sql: { type: "string", required: true },
|
||||||
|
limit: num2(),
|
||||||
|
statement_timeout_ms: num2()
|
||||||
|
}, sqlQueryUme, getClient, t)),
|
||||||
|
ctx.tools.register(tool("netx__listManagedNe", "List filtered netx managed NEs (keyword/vendor/connect_status required); use before execManagedNe.", {
|
||||||
|
keyword: str2(),
|
||||||
|
vendor: str2(),
|
||||||
|
connect_status: str2("unknown | testing | pass | fail"),
|
||||||
|
page: num2(),
|
||||||
|
page_size: num2()
|
||||||
|
}, listManagedNe, getClient, t)),
|
||||||
|
ctx.tools.register(tool("netx__getManagedNe", "Get one managed NE by managed ne_id (from listManagedNe / listCliTargets source=managed). Do NOT pass UME inventory UUID here.", {
|
||||||
|
ne_id: str2("Managed NE id"),
|
||||||
|
managed_ne_id: str2("Alias for ne_id"),
|
||||||
|
id: str2("Alias for ne_id")
|
||||||
|
}, getManagedNe, getClient, t)),
|
||||||
|
ctx.tools.register(tool("netx__execManagedNe", "Run read-only CLI via netx (show/display/ping/traceroute). Single NE: ne_id OR ume_ne_id + commands. Many NEs: ne_ids[]/ume_ne_ids[] + shared commands, or targets[{ume_ne_id|ne_id, commands}]. Do NOT loop one-NE calls for multi-NE work.", {
|
||||||
|
ne_id: str2(),
|
||||||
|
ume_ne_id: str2(),
|
||||||
|
ne_ids: strArr("Managed NE ids for concurrent batch (shared commands)."),
|
||||||
|
ume_ne_ids: strArr("UME inventory ne_ids for concurrent batch (shared commands)."),
|
||||||
|
targets: {
|
||||||
|
type: "array",
|
||||||
|
description: "Per-NE command sets: each item is one NE (ne_id OR ume_ne_id) with commands[].",
|
||||||
|
items: {
|
||||||
|
type: "object",
|
||||||
|
additionalProperties: false,
|
||||||
|
properties: {
|
||||||
|
ne_id: str2(),
|
||||||
|
ume_ne_id: str2(),
|
||||||
|
commands: strArr()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
commands: strArr("Commands for single NE, or shared commands for batch."),
|
||||||
|
read_timeout_sec: num2("Per-command read timeout (default 60; use 90–120 for slow show)."),
|
||||||
|
concurrency: num2("Parallel NEs for batch mode (1–8, default 4)."),
|
||||||
|
async: bool2("oclaw-only async hint; ignored by native REST client.")
|
||||||
|
}, execManagedNe, getClient, Math.max(t, 300000))),
|
||||||
|
ctx.tools.register(tool("netx__listCliTargets", "List CLI-capable targets (managed NE and/or UME inventory). Call once per session with keyword/source, cache ids, then execManagedNe.", {
|
||||||
|
source: str2("managed | ume | all"),
|
||||||
|
keyword: str2(),
|
||||||
|
page: num2(),
|
||||||
|
page_size: num2()
|
||||||
|
}, listCliTargets, getClient, t)),
|
||||||
|
ctx.tools.register(tool("netx__findTopologyPaths", "Find up to max_paths simple paths between two fabric nodes. For each endpoint provide exactly one of ume_ne_id or managed_ne_id.", {
|
||||||
|
from_ume_ne_id: str2(),
|
||||||
|
from_managed_ne_id: str2(),
|
||||||
|
to_ume_ne_id: str2(),
|
||||||
|
to_managed_ne_id: str2(),
|
||||||
|
max_paths: num2(),
|
||||||
|
max_hops: num2(),
|
||||||
|
layer: str2(),
|
||||||
|
detail: str2("summary | full")
|
||||||
|
}, findTopologyPaths, getClient, t))
|
||||||
|
];
|
||||||
|
return () => {
|
||||||
|
for (const dispose of disposers)
|
||||||
|
dispose();
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// src/index.ts
|
||||||
var name = "netxops";
|
var name = "netxops";
|
||||||
var inject = ["tools"];
|
var inject = ["tools"];
|
||||||
var NETXOPS_SETTINGS_NAMESPACE = "netxops";
|
var NETXOPS_SETTINGS_NAMESPACE = "netxops";
|
||||||
var NETXOPS_PRESET_ID = "netxops";
|
var NETXOPS_PRESET_ID = "netxops";
|
||||||
var DEFAULT_TOKEN_REF = "NETX_API_TOKEN";
|
var DEFAULT_TOKEN_REF = "NETX_API_TOKEN";
|
||||||
var Config2 = z.object({
|
var Config = z.object({
|
||||||
apiUrl: z.string().default("http://127.0.0.1:8890"),
|
apiUrl: z.string().default("http://127.0.0.1:8890"),
|
||||||
lang: z.string().default("zh"),
|
lang: z.string().default("zh"),
|
||||||
pythonCommand: z.string().default("python"),
|
|
||||||
tokenCredentialRef: z.string().role("credential-ref").default(DEFAULT_TOKEN_REF),
|
tokenCredentialRef: z.string().role("credential-ref").default(DEFAULT_TOKEN_REF),
|
||||||
toolCallTimeoutMs: z.number().step(1).min(1000).default(120000),
|
toolCallTimeoutMs: z.number().step(1).min(1000).default(120000),
|
||||||
failOnStartupError: z.boolean().default(false),
|
|
||||||
installAgentPreset: z.boolean().default(true)
|
installAgentPreset: z.boolean().default(true)
|
||||||
});
|
});
|
||||||
function packageRoot() {
|
function packageRoot() {
|
||||||
|
|
@ -62,16 +689,16 @@ async function resolveToken(ctx, refName) {
|
||||||
function installNetxopsSettings(ctx, entry, hooks) {
|
function installNetxopsSettings(ctx, entry, hooks) {
|
||||||
const legacy = DshSettings.installSettingsSection;
|
const legacy = DshSettings.installSettingsSection;
|
||||||
if (typeof legacy === "function") {
|
if (typeof legacy === "function") {
|
||||||
legacy(ctx, NETXOPS_SETTINGS_NAMESPACE, Config2, entry, hooks);
|
legacy(ctx, NETXOPS_SETTINGS_NAMESPACE, Config, entry, hooks);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
ctx.inject(["settings"], (settingsCtx) => {
|
ctx.inject(["settings"], (settingsCtx) => {
|
||||||
settingsCtx.settings.installSection(ctx, NETXOPS_SETTINGS_NAMESPACE, Config2, entry, hooks);
|
settingsCtx.settings.installSection(ctx, NETXOPS_SETTINGS_NAMESPACE, Config, entry, hooks);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
function apply(ctx, config = Config2({})) {
|
function apply(ctx, config = Config({})) {
|
||||||
let source = () => config;
|
let source = () => config;
|
||||||
let mcpFiber;
|
let unregister;
|
||||||
let remounting = Promise.resolve();
|
let remounting = Promise.resolve();
|
||||||
let generation = 0;
|
let generation = 0;
|
||||||
if (config.installAgentPreset) {
|
if (config.installAgentPreset) {
|
||||||
|
|
@ -80,41 +707,21 @@ function apply(ctx, config = Config2({})) {
|
||||||
const remount = () => {
|
const remount = () => {
|
||||||
remounting = remounting.then(async () => {
|
remounting = remounting.then(async () => {
|
||||||
const gen = ++generation;
|
const gen = ++generation;
|
||||||
const previous = mcpFiber;
|
unregister?.();
|
||||||
mcpFiber = undefined;
|
unregister = undefined;
|
||||||
if (previous !== undefined) {
|
|
||||||
try {
|
|
||||||
await previous.dispose();
|
|
||||||
} catch (error) {
|
|
||||||
ctx.logger.warn("netxops: disposing previous mcp-client failed: %s", error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (gen !== generation)
|
if (gen !== generation)
|
||||||
return;
|
return;
|
||||||
const current = source();
|
const current = source();
|
||||||
const token = await resolveToken(ctx, current.tokenCredentialRef);
|
const token = await resolveToken(ctx, current.tokenCredentialRef);
|
||||||
if (gen !== generation)
|
if (gen !== generation)
|
||||||
return;
|
return;
|
||||||
const mcpConfig = McpClient.Config({
|
unregister = registerNetxTools(ctx, {
|
||||||
transport: "stdio",
|
apiUrl: current.apiUrl.replace(/\/$/, ""),
|
||||||
serverName: "netx",
|
token,
|
||||||
command: current.pythonCommand,
|
lang: current.lang,
|
||||||
args: ["-m", "netx_mcp"],
|
toolCallTimeoutMs: current.toolCallTimeoutMs
|
||||||
env: {
|
|
||||||
NETX_API_URL: current.apiUrl.replace(/\/$/, ""),
|
|
||||||
NETX_API_TOKEN: token,
|
|
||||||
NETX_LANG: current.lang
|
|
||||||
},
|
|
||||||
toolCallTimeoutMs: current.toolCallTimeoutMs,
|
|
||||||
failOnStartupError: current.failOnStartupError
|
|
||||||
});
|
});
|
||||||
try {
|
ctx.logger.info("netxops: registered netx__* REST tools → %s", current.apiUrl.replace(/\/$/, ""));
|
||||||
mcpFiber = await ctx.plugin(McpClient, mcpConfig);
|
|
||||||
} catch (error) {
|
|
||||||
ctx.logger.error("netxops: failed to mount mcp-client: %s", error);
|
|
||||||
if (current.failOnStartupError)
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}).catch((error) => {
|
}).catch((error) => {
|
||||||
ctx.logger.error("netxops: remount error: %s", error);
|
ctx.logger.error("netxops: remount error: %s", error);
|
||||||
});
|
});
|
||||||
|
|
@ -134,10 +741,9 @@ function apply(ctx, config = Config2({})) {
|
||||||
});
|
});
|
||||||
ctx.effect(() => () => {
|
ctx.effect(() => () => {
|
||||||
generation += 1;
|
generation += 1;
|
||||||
const fiber = mcpFiber;
|
unregister?.();
|
||||||
mcpFiber = undefined;
|
unregister = undefined;
|
||||||
fiber?.dispose();
|
}, "netxops: dispose netx tools");
|
||||||
}, "netxops: dispose mcp-client");
|
|
||||||
}
|
}
|
||||||
export {
|
export {
|
||||||
name,
|
name,
|
||||||
|
|
@ -147,5 +753,5 @@ export {
|
||||||
NETXOPS_SETTINGS_NAMESPACE,
|
NETXOPS_SETTINGS_NAMESPACE,
|
||||||
NETXOPS_PRESET_ID,
|
NETXOPS_PRESET_ID,
|
||||||
DEFAULT_TOKEN_REF,
|
DEFAULT_TOKEN_REF,
|
||||||
Config2 as Config
|
Config
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
{
|
{
|
||||||
"name": "dsh-netxops",
|
"name": "dsh-netxops",
|
||||||
"version": "0.1.11",
|
"version": "0.1.12",
|
||||||
"description": "DeepSeek Harness Netx Ops: host bridge + Plugins settings card + agent preset",
|
"description": "DeepSeek Harness Netx Ops: native netx__* REST tools + Plugins settings card + agent preset",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"private": false,
|
"private": false,
|
||||||
|
|
@ -43,7 +43,8 @@
|
||||||
"bundle": "bun run scripts/build-host.mjs && bun run scripts/build-client.mjs",
|
"bundle": "bun run scripts/build-host.mjs && bun run scripts/build-client.mjs",
|
||||||
"bundle:host": "bun run scripts/build-host.mjs",
|
"bundle:host": "bun run scripts/build-host.mjs",
|
||||||
"bundle:client": "bun run scripts/build-client.mjs"
|
"bundle:client": "bun run scripts/build-client.mjs"
|
||||||
}, "dsh": {
|
},
|
||||||
|
"dsh": {
|
||||||
"bundle": {
|
"bundle": {
|
||||||
"patch": "./cordis.patch.yml"
|
"patch": "./cordis.patch.yml"
|
||||||
},
|
},
|
||||||
|
|
@ -62,7 +63,6 @@
|
||||||
"@deepseek-ai/schemastery": "*",
|
"@deepseek-ai/schemastery": "*",
|
||||||
"@deepseek-ai/dsh-credentials": "*",
|
"@deepseek-ai/dsh-credentials": "*",
|
||||||
"@deepseek-ai/dsh-settings": "*",
|
"@deepseek-ai/dsh-settings": "*",
|
||||||
"@deepseek-ai/dsh-mcp-client": "*",
|
|
||||||
"@deepseek-ai/dsh-tools": "*",
|
"@deepseek-ai/dsh-tools": "*",
|
||||||
"@deepseek-ai/dsh-persona": "*",
|
"@deepseek-ai/dsh-persona": "*",
|
||||||
"@deepseek-ai/dsh-skill-filesystem": "*",
|
"@deepseek-ai/dsh-skill-filesystem": "*",
|
||||||
|
|
|
||||||
|
|
@ -30,4 +30,4 @@ You are **Netx Ops**, a network operations specialist for ZTE UME / netx.
|
||||||
- `ops-netx-managed-ne-playbook` — managed / UME CLI batch
|
- `ops-netx-managed-ne-playbook` — managed / UME CLI batch
|
||||||
|
|
||||||
## Tools
|
## Tools
|
||||||
`mcp__netx__*` only. Multi-NE CLI = one `execManagedNe` batch.
|
`netx__*` only. Multi-NE CLI = one `execManagedNe` batch.
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
# Netx Ops agent preset — UME alarms / NE inventory / managed CLI.
|
# Netx Ops agent preset — UME alarms / NE inventory / managed CLI.
|
||||||
# Host keeps registries, sandbox, model route; this file owns persona, skills, netx MCP.
|
# Host keeps registries, sandbox, model route; this file owns persona + skills.
|
||||||
|
# netx__* tools are registered on the HOST by package `dsh-netxops`.
|
||||||
|
|
||||||
# ── identity ────────────────────────────────────────────────────────────────
|
# ── identity ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
@ -36,7 +37,7 @@
|
||||||
- Managed SSH/Telnet CLI → `ops-netx-managed-ne-playbook`
|
- Managed SSH/Telnet CLI → `ops-netx-managed-ne-playbook`
|
||||||
|
|
||||||
## Tools
|
## Tools
|
||||||
Call netx MCP as `mcp__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` / `ume_ne_ids` / `targets`).
|
||||||
|
|
||||||
- id: agent-instructions
|
- id: agent-instructions
|
||||||
|
|
@ -63,7 +64,6 @@
|
||||||
- id: tool-skill
|
- id: tool-skill
|
||||||
name: '@deepseek-ai/dsh-tool-skill'
|
name: '@deepseek-ai/dsh-tool-skill'
|
||||||
|
|
||||||
# netx MCP is mounted on the HOST by package `dsh-netxops` (cordis.patch.yml).
|
# netx__* tools are mounted on the HOST by package `dsh-netxops` (cordis.patch.yml).
|
||||||
# The same package copies this preset into `$DSH_HOME/.agent-presets/netxops`
|
# The same package copies this preset into `$DSH_HOME/.agent-presets/netxops`
|
||||||
# on activate — no manual link-preset script for normal installs.
|
# on activate — no manual link-preset script for normal installs.
|
||||||
# Tools appear as mcp__netx__* on the host tool registry.
|
|
||||||
|
|
|
||||||
|
|
@ -15,7 +15,7 @@ Differs from UME inventory (`ops-netx-ume-playbook`): this is **SSH/Telnet** (ZT
|
||||||
|
|
||||||
## Tool order
|
## Tool order
|
||||||
|
|
||||||
Use `mcp__netx__*`.
|
Use `netx__*`.
|
||||||
|
|
||||||
1. **Locate**
|
1. **Locate**
|
||||||
- `listManagedNe`: `keyword`, `connect_status=pass` (preferred)
|
- `listManagedNe`: `keyword`, `connect_status=pass` (preferred)
|
||||||
|
|
@ -27,7 +27,7 @@ Use `mcp__netx__*`.
|
||||||
- **Multi-NE = batch-first (one tool call; server concurrency default 4, max 20)**:
|
- **Multi-NE = batch-first (one tool call; server concurrency default 4, max 20)**:
|
||||||
- Same commands: `ne_ids` / `ume_ne_ids` + shared `commands`
|
- Same commands: `ne_ids` / `ume_ne_ids` + shared `commands`
|
||||||
- Different commands per NE: `targets=[{ume_ne_id|ne_id, commands:[…]}, …]`
|
- Different commands per NE: `targets=[{ume_ne_id|ne_id, commands:[…]}, …]`
|
||||||
- Many single-NE calls in one turn are **serial** on stdio — forbidden for multi-NE work
|
- 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[]`
|
- 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
|
- Timeouts: raise `read_timeout_sec` (default 60; slow 90–120) or fewer commands — no blind retry
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -14,7 +14,7 @@ For any Netx Ops request about UME **alarms** or **NE inventory**, load this ski
|
||||||
|
|
||||||
## Tool names
|
## Tool names
|
||||||
|
|
||||||
Host tool names are `mcp__netx__<camelCase>` (`serverName=netx`):
|
Host tool names are `netx__<camelCase>`:
|
||||||
|
|
||||||
| Purpose | Tool |
|
| Purpose | Tool |
|
||||||
|---------|------|
|
|---------|------|
|
||||||
|
|
|
||||||
|
|
@ -16,7 +16,6 @@ const external = [
|
||||||
'@deepseek-ai/schemastery',
|
'@deepseek-ai/schemastery',
|
||||||
'@deepseek-ai/dsh-credentials',
|
'@deepseek-ai/dsh-credentials',
|
||||||
'@deepseek-ai/dsh-settings',
|
'@deepseek-ai/dsh-settings',
|
||||||
'@deepseek-ai/dsh-mcp-client',
|
|
||||||
'@deepseek-ai/dsh-tools',
|
'@deepseek-ai/dsh-tools',
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
/**
|
/**
|
||||||
* Netx Ops Plugins settings card — apiUrl / lang / python + credential token.
|
* Netx Ops Plugins settings card — apiUrl / lang + credential token.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { useEffect, useRef, useState } from 'react'
|
import { useEffect, useRef, useState } from 'react'
|
||||||
|
|
@ -147,18 +147,6 @@ 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-python"
|
|
||||||
label={t('pythonCommand')}
|
|
||||||
hint={t('pythonCommandHint')}
|
|
||||||
field={state.pythonCommand}
|
|
||||||
overriddenLabel={t('overridden')}
|
|
||||||
resetLabel={t('reset')}
|
|
||||||
invalidLabel={t('invalid')}
|
|
||||||
disabled={disabled}
|
|
||||||
onEdit={(text) => { props.edit('pythonCommand', text) }}
|
|
||||||
onReset={() => { props.resetField('pythonCommand') }}
|
|
||||||
/>
|
|
||||||
<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}
|
||||||
<button
|
<button
|
||||||
|
|
|
||||||
|
|
@ -18,7 +18,6 @@ const API_TOKEN_FIELD = 'apiToken'
|
||||||
export interface NetxopsSettings {
|
export interface NetxopsSettings {
|
||||||
apiUrl?: string
|
apiUrl?: string
|
||||||
lang?: string
|
lang?: string
|
||||||
pythonCommand?: string
|
|
||||||
tokenCredentialRef?: string
|
tokenCredentialRef?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -32,7 +31,6 @@ interface CredentialState {
|
||||||
export interface NetxopsCardState extends CardShell {
|
export interface NetxopsCardState extends CardShell {
|
||||||
apiUrl: CardFieldState
|
apiUrl: CardFieldState
|
||||||
lang: CardFieldState
|
lang: CardFieldState
|
||||||
pythonCommand: CardFieldState
|
|
||||||
apiToken: CardFieldState
|
apiToken: CardFieldState
|
||||||
apiTokenConfigured: boolean
|
apiTokenConfigured: boolean
|
||||||
apiTokenWritable: boolean
|
apiTokenWritable: boolean
|
||||||
|
|
@ -66,7 +64,7 @@ export class NetxopsCardController {
|
||||||
) {
|
) {
|
||||||
this.form = new CardForm(
|
this.form = new CardForm(
|
||||||
scope,
|
scope,
|
||||||
[textField('apiUrl'), textField('lang'), textField('pythonCommand')],
|
[textField('apiUrl'), textField('lang')],
|
||||||
[{ field: API_TOKEN_FIELD, write: text => this.writeToken(text) }],
|
[{ field: API_TOKEN_FIELD, write: text => this.writeToken(text) }],
|
||||||
)
|
)
|
||||||
this.store = this.form.bind(() => this.projection())
|
this.store = this.form.bind(() => this.projection())
|
||||||
|
|
@ -91,7 +89,6 @@ export class NetxopsCardController {
|
||||||
...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'),
|
||||||
pythonCommand: this.form.field('pythonCommand'),
|
|
||||||
apiToken: this.form.field(API_TOKEN_FIELD),
|
apiToken: this.form.field(API_TOKEN_FIELD),
|
||||||
apiTokenConfigured: this.credential.configured,
|
apiTokenConfigured: this.credential.configured,
|
||||||
apiTokenWritable: this.credential.remoteReady && this.credential.writable,
|
apiTokenWritable: this.credential.remoteReady && this.credential.writable,
|
||||||
|
|
|
||||||
|
|
@ -7,8 +7,6 @@ export type NetxopsLocaleKey =
|
||||||
| 'apiUrlHint'
|
| 'apiUrlHint'
|
||||||
| 'lang'
|
| 'lang'
|
||||||
| 'langHint'
|
| 'langHint'
|
||||||
| 'pythonCommand'
|
|
||||||
| 'pythonCommandHint'
|
|
||||||
| 'apiToken'
|
| 'apiToken'
|
||||||
| 'apiTokenHint'
|
| 'apiTokenHint'
|
||||||
| 'apiTokenUnavailable'
|
| 'apiTokenUnavailable'
|
||||||
|
|
@ -28,13 +26,11 @@ 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 mcp__netx__* tools.',
|
description: 'UME API endpoint and bearer token for netx__* tools.',
|
||||||
apiUrl: 'API URL',
|
apiUrl: 'API URL',
|
||||||
apiUrlHint: 'netx REST root, e.g. http://127.0.0.1:8890',
|
apiUrlHint: 'netx REST root, e.g. http://127.0.0.1:8890',
|
||||||
lang: 'Language',
|
lang: 'Language',
|
||||||
langHint: 'Passed as NETX_LANG (zh / en).',
|
langHint: 'Response language hint (zh / en).',
|
||||||
pythonCommand: 'Python command',
|
|
||||||
pythonCommandHint: 'Executable that can run `python -m netx_mcp`.',
|
|
||||||
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.',
|
||||||
|
|
@ -55,13 +51,11 @@ 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,供 mcp__netx__* 工具使用。',
|
description: 'UME API 地址与 Bearer Token,供 netx__* 工具使用。',
|
||||||
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: '传给 MCP 的 NETX_LANG(zh / en)。',
|
langHint: '响应语言提示(zh / en)。',
|
||||||
pythonCommand: 'Python 命令',
|
|
||||||
pythonCommandHint: '能执行 `python -m netx_mcp` 的解释器。',
|
|
||||||
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 在监视凭据文件则无需重启。',
|
||||||
|
|
|
||||||
64
src/index.ts
64
src/index.ts
|
|
@ -1,6 +1,6 @@
|
||||||
/**
|
/**
|
||||||
* Host-plane Netx Ops bridge: settings (apiUrl / lang / python) + credentials
|
* Host-plane Netx Ops: settings (apiUrl / lang) + credentials (NETX_API_TOKEN)
|
||||||
* (NETX_API_TOKEN) drive a dynamically mounted `@deepseek-ai/dsh-mcp-client`.
|
* drive native `netx__*` tools that call the netx REST API directly.
|
||||||
*
|
*
|
||||||
* On activate, the agent preset + skills are copied into
|
* On activate, the agent preset + skills are copied into
|
||||||
* `$DSH_HOME/.agent-presets/netxops` so `dsh plugin add` alone is enough
|
* `$DSH_HOME/.agent-presets/netxops` so `dsh plugin add` alone is enough
|
||||||
|
|
@ -13,18 +13,19 @@ import { cpSync, existsSync, mkdirSync, rmSync, writeFileSync } from 'node:fs'
|
||||||
import { homedir } from 'node:os'
|
import { homedir } from 'node:os'
|
||||||
import { dirname, join } from 'node:path'
|
import { dirname, join } from 'node:path'
|
||||||
import { fileURLToPath } from 'node:url'
|
import { fileURLToPath } from 'node:url'
|
||||||
import type { Context, Fiber } from '@deepseek-ai/cordis'
|
import type { Context } from '@deepseek-ai/cordis'
|
||||||
import z from '@deepseek-ai/schemastery'
|
import z from '@deepseek-ai/schemastery'
|
||||||
import { credentialRef } from '@deepseek-ai/dsh-credentials'
|
import { credentialRef } from '@deepseek-ai/dsh-credentials'
|
||||||
import type {} from '@deepseek-ai/dsh-credentials'
|
import type {} from '@deepseek-ai/dsh-credentials'
|
||||||
import * as DshSettings from '@deepseek-ai/dsh-settings'
|
import * as DshSettings from '@deepseek-ai/dsh-settings'
|
||||||
import type {} from '@deepseek-ai/dsh-settings'
|
import type {} from '@deepseek-ai/dsh-settings'
|
||||||
import * as McpClient from '@deepseek-ai/dsh-mcp-client'
|
import type {} from '@deepseek-ai/dsh-tools'
|
||||||
|
import { registerNetxTools } from './netx/tools.ts'
|
||||||
|
|
||||||
/** Cordis plugin name. */
|
/** Cordis plugin name. */
|
||||||
export const name = 'netxops'
|
export const name = 'netxops'
|
||||||
|
|
||||||
/** MCP tools registry must exist to mount the child client. */
|
/** Tool registry must exist to register `netx__*` tools. */
|
||||||
export const inject = ['tools']
|
export const inject = ['tools']
|
||||||
|
|
||||||
/** Settings / composition namespace (Plugins page join key). */
|
/** Settings / composition namespace (Plugins page join key). */
|
||||||
|
|
@ -40,16 +41,12 @@ export const DEFAULT_TOKEN_REF = 'NETX_API_TOKEN'
|
||||||
export interface Config {
|
export interface Config {
|
||||||
/** netx REST root (no trailing slash required). */
|
/** netx REST root (no trailing slash required). */
|
||||||
apiUrl: string
|
apiUrl: string
|
||||||
/** Passed to MCP as `NETX_LANG`. */
|
/** Passed as `lang` query when starting with `en`. */
|
||||||
lang: string
|
lang: string
|
||||||
/** Executable that can run `python -m netx_mcp`. */
|
|
||||||
pythonCommand: string
|
|
||||||
/** Credential reference for the bearer token (never store the secret here). */
|
/** Credential reference for the bearer token (never store the secret here). */
|
||||||
tokenCredentialRef: string
|
tokenCredentialRef: string
|
||||||
/** Per MCP tool-call timeout (ms). */
|
/** Per tool-call timeout (ms). */
|
||||||
toolCallTimeoutMs: number
|
toolCallTimeoutMs: number
|
||||||
/** Fail activation when MCP cannot connect / sync tools. */
|
|
||||||
failOnStartupError: boolean
|
|
||||||
/**
|
/**
|
||||||
* Copy bundled agent preset + skills into `$DSH_HOME/.agent-presets/netxops`
|
* Copy bundled agent preset + skills into `$DSH_HOME/.agent-presets/netxops`
|
||||||
* on every activate (required for Settings → Agent presets).
|
* on every activate (required for Settings → Agent presets).
|
||||||
|
|
@ -60,10 +57,8 @@ export interface Config {
|
||||||
export const Config: z<Config> = z.object({
|
export const Config: z<Config> = z.object({
|
||||||
apiUrl: z.string().default('http://127.0.0.1:8890'),
|
apiUrl: z.string().default('http://127.0.0.1:8890'),
|
||||||
lang: z.string().default('zh'),
|
lang: z.string().default('zh'),
|
||||||
pythonCommand: z.string().default('python'),
|
|
||||||
tokenCredentialRef: z.string().role('credential-ref').default(DEFAULT_TOKEN_REF),
|
tokenCredentialRef: z.string().role('credential-ref').default(DEFAULT_TOKEN_REF),
|
||||||
toolCallTimeoutMs: z.number().step(1).min(1000).default(120_000),
|
toolCallTimeoutMs: z.number().step(1).min(1000).default(120_000),
|
||||||
failOnStartupError: z.boolean().default(false),
|
|
||||||
installAgentPreset: z.boolean().default(true),
|
installAgentPreset: z.boolean().default(true),
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
@ -154,7 +149,7 @@ function installNetxopsSettings(
|
||||||
*/
|
*/
|
||||||
export function apply(ctx: Context, config: Config = Config({})): void {
|
export function apply(ctx: Context, config: Config = Config({})): void {
|
||||||
let source: () => Config = () => config
|
let source: () => Config = () => config
|
||||||
let mcpFiber: Fiber | undefined
|
let unregister: (() => void) | undefined
|
||||||
let remounting: Promise<void> = Promise.resolve()
|
let remounting: Promise<void> = Promise.resolve()
|
||||||
let generation = 0
|
let generation = 0
|
||||||
|
|
||||||
|
|
@ -165,41 +160,21 @@ export function apply(ctx: Context, config: Config = Config({})): void {
|
||||||
const remount = (): void => {
|
const remount = (): void => {
|
||||||
remounting = remounting.then(async () => {
|
remounting = remounting.then(async () => {
|
||||||
const gen = ++generation
|
const gen = ++generation
|
||||||
const previous = mcpFiber
|
unregister?.()
|
||||||
mcpFiber = undefined
|
unregister = undefined
|
||||||
if (previous !== undefined) {
|
|
||||||
try {
|
|
||||||
await previous.dispose()
|
|
||||||
} catch (error) {
|
|
||||||
ctx.logger.warn('netxops: disposing previous mcp-client failed: %s', error)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (gen !== generation) return
|
if (gen !== generation) return
|
||||||
|
|
||||||
const current = source()
|
const current = source()
|
||||||
const token = await resolveToken(ctx, current.tokenCredentialRef)
|
const token = await resolveToken(ctx, current.tokenCredentialRef)
|
||||||
if (gen !== generation) return
|
if (gen !== generation) return
|
||||||
|
|
||||||
const mcpConfig = McpClient.Config({
|
unregister = registerNetxTools(ctx, {
|
||||||
transport: 'stdio',
|
apiUrl: current.apiUrl.replace(/\/$/, ''),
|
||||||
serverName: 'netx',
|
token,
|
||||||
command: current.pythonCommand,
|
lang: current.lang,
|
||||||
args: ['-m', 'netx_mcp'],
|
|
||||||
env: {
|
|
||||||
NETX_API_URL: current.apiUrl.replace(/\/$/, ''),
|
|
||||||
NETX_API_TOKEN: token,
|
|
||||||
NETX_LANG: current.lang,
|
|
||||||
},
|
|
||||||
toolCallTimeoutMs: current.toolCallTimeoutMs,
|
toolCallTimeoutMs: current.toolCallTimeoutMs,
|
||||||
failOnStartupError: current.failOnStartupError,
|
|
||||||
})
|
})
|
||||||
|
ctx.logger.info('netxops: registered netx__* REST tools → %s', current.apiUrl.replace(/\/$/, ''))
|
||||||
try {
|
|
||||||
mcpFiber = await ctx.plugin(McpClient, mcpConfig)
|
|
||||||
} catch (error) {
|
|
||||||
ctx.logger.error('netxops: failed to mount mcp-client: %s', error)
|
|
||||||
if (current.failOnStartupError) throw error
|
|
||||||
}
|
|
||||||
}).catch((error) => {
|
}).catch((error) => {
|
||||||
ctx.logger.error('netxops: remount error: %s', error)
|
ctx.logger.error('netxops: remount error: %s', error)
|
||||||
})
|
})
|
||||||
|
|
@ -222,8 +197,7 @@ export function apply(ctx: Context, config: Config = Config({})): void {
|
||||||
|
|
||||||
ctx.effect(() => () => {
|
ctx.effect(() => () => {
|
||||||
generation += 1
|
generation += 1
|
||||||
const fiber = mcpFiber
|
unregister?.()
|
||||||
mcpFiber = undefined
|
unregister = undefined
|
||||||
void fiber?.dispose()
|
}, 'netxops: dispose netx tools')
|
||||||
}, 'netxops: dispose mcp-client')
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
322
src/netx/handlers.ts
Normal file
322
src/netx/handlers.ts
Normal file
|
|
@ -0,0 +1,322 @@
|
||||||
|
/**
|
||||||
|
* netx REST tool handlers — port of packages/netx-mcp http_tools.py.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { type NetxClient, quoteNeId, type NetxJson } from './http.ts'
|
||||||
|
|
||||||
|
const EXEC_MAX_COMMANDS = 5
|
||||||
|
|
||||||
|
const UME_RAW_FIELD_PRESETS: Record<string, string[]> = {
|
||||||
|
brief: [
|
||||||
|
'alarm_alarm_key', 'alarm_host_name', 'alarm_perceived_severity', 'alarm_event_type',
|
||||||
|
'alarm_last_seen_at', 'ne_host_name', 'ne_user_label', 'ne_ne_name', 'ne_ip_address', 'ne_exists',
|
||||||
|
],
|
||||||
|
evidence: [
|
||||||
|
'alarm_alarm_key', 'alarm_host_name', 'alarm_object_name', 'alarm_event_type',
|
||||||
|
'alarm_native_probable_cause', 'alarm_perceived_severity', 'alarm_is_cleared',
|
||||||
|
'alarm_time_created', 'alarm_last_seen_at', 'ne_host_name', 'ne_user_label',
|
||||||
|
'ne_ne_name', 'ne_ip_address', 'ne_connection_status', 'ne_exists',
|
||||||
|
],
|
||||||
|
ne_debug: [
|
||||||
|
'alarm_alarm_key', 'alarm_ne_id', 'alarm_perceived_severity', 'alarm_last_seen_at',
|
||||||
|
'ne_user_label', 'ne_ne_name', 'ne_ip_address', 'ne_ipv6_address', 'ne_device_level',
|
||||||
|
'ne_host_name', 'ne_connection_status', 'ne_admin_status', 'ne_address_type',
|
||||||
|
'ne_maintain_status', 'ne_exists',
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
function asRecord(value: 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 putStr(
|
||||||
|
params: Record<string, string | number | boolean>,
|
||||||
|
args: NetxJson,
|
||||||
|
keys: string[],
|
||||||
|
): void {
|
||||||
|
for (const key of keys) {
|
||||||
|
const v = str(args, key).trim()
|
||||||
|
if (v) params[key] = v
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function queryUmeAlarms(client: NetxClient, args: NetxJson, signal?: AbortSignal): Promise<NetxJson> {
|
||||||
|
let page = clampInt(num(args, 'page'), 1, 1, 2)
|
||||||
|
const pageSize = clampInt(num(args, 'page_size'), 50, 1, 500)
|
||||||
|
const params: Record<string, string | number | boolean> = { page, page_size: pageSize }
|
||||||
|
putStr(params, args, ['severity', 'ne_id', 'host_name', 'time_from', 'time_to'])
|
||||||
|
const keyword = str(args, 'keyword').trim()
|
||||||
|
const neName = str(args, 'ne_name').trim()
|
||||||
|
if (keyword) params.keyword = keyword
|
||||||
|
else if (neName) params.keyword = neName
|
||||||
|
return client.get('/v1/ume/alarms', params, signal)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function aggregateUmeAlarmsRaw(client: NetxClient, args: NetxJson, signal?: AbortSignal): Promise<NetxJson> {
|
||||||
|
const params: Record<string, string | number | boolean> = {}
|
||||||
|
putStr(params, args, [
|
||||||
|
'group_by', 'group_by2', 'severity', 'is_cleared', 'ne_id', 'event_type',
|
||||||
|
'keyword', 'time_from', 'time_to', 'limit',
|
||||||
|
])
|
||||||
|
if ('exclude_missing_host' in args) {
|
||||||
|
const flag = bool(args, 'exclude_missing_host')
|
||||||
|
if (flag !== undefined) params.exclude_missing_host = flag
|
||||||
|
}
|
||||||
|
return client.get('/v1/ume/alarms/aggregate/raw', params, signal)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function aggregateUmeAlarms(client: NetxClient, args: NetxJson, signal?: AbortSignal): Promise<NetxJson> {
|
||||||
|
if (str(args, 'group_by').trim()) return aggregateUmeAlarmsRaw(client, args, signal)
|
||||||
|
const topNe = clampInt(num(args, 'top_ne'), 50, 0, 500)
|
||||||
|
const params: Record<string, string | number | boolean> = { top_ne: topNe }
|
||||||
|
if ('exclude_missing_host' in args) {
|
||||||
|
const flag = bool(args, 'exclude_missing_host')
|
||||||
|
if (flag !== undefined) params.exclude_missing_host = flag
|
||||||
|
}
|
||||||
|
putStr(params, args, ['severity', 'time_from', 'time_to'])
|
||||||
|
return client.get('/v1/ume/alarms/aggregate', params, signal)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function runUmeDiagnostics(client: NetxClient, _args: NetxJson, signal?: AbortSignal): Promise<NetxJson> {
|
||||||
|
return client.get('/v1/ume/diagnostics', undefined, signal)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function queryUmeNeInventory(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'), 50, 1, 500),
|
||||||
|
}
|
||||||
|
putStr(params, args, ['keyword'])
|
||||||
|
return client.get('/v1/ume/inventory/ne', params, signal)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getUmeNe(client: NetxClient, args: NetxJson, signal?: AbortSignal): Promise<NetxJson> {
|
||||||
|
const neId = str(args, 'ne_id').trim()
|
||||||
|
if (!neId) return { ok: false, error: 'ne_id_required', error_code: 'ne_id_required' }
|
||||||
|
return client.get(`/v1/ume/inventory/ne/${quoteNeId(neId)}`, undefined, signal)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function queryUmeAlarmsRaw(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'), 50, 1, 500),
|
||||||
|
}
|
||||||
|
putStr(params, args, [
|
||||||
|
'severity', 'is_cleared', 'ne_id', 'event_type', 'keyword',
|
||||||
|
'time_from', 'time_to', 'order_by', 'order',
|
||||||
|
])
|
||||||
|
let fields = strList(args, 'select_fields')
|
||||||
|
if (fields.length === 0) {
|
||||||
|
const preset = str(args, 'field_preset').trim().toLowerCase()
|
||||||
|
fields = UME_RAW_FIELD_PRESETS[preset] ?? []
|
||||||
|
}
|
||||||
|
if (fields.length > 0) params.select_fields = fields.join(',')
|
||||||
|
return client.get('/v1/ume/alarms/raw', params, signal)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function listUmeAlarmFields(client: NetxClient, _args: NetxJson, signal?: AbortSignal): Promise<NetxJson> {
|
||||||
|
return client.get('/v1/ume/alarms/fields', undefined, signal)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function sqlQueryUme(client: NetxClient, args: NetxJson, signal?: AbortSignal): Promise<NetxJson> {
|
||||||
|
const sql = str(args, 'sql').trim()
|
||||||
|
if (!sql) return { ok: false, error: 'sql_required' }
|
||||||
|
const limit = clampInt(num(args, 'limit'), 200, 1, 2000)
|
||||||
|
const statementTimeoutMs = clampInt(num(args, 'statement_timeout_ms'), 0, 0, 30_000)
|
||||||
|
return client.post('/v1/sql/ume_query', {
|
||||||
|
sql,
|
||||||
|
limit,
|
||||||
|
statement_timeout_ms: statementTimeoutMs,
|
||||||
|
}, signal, 60_000)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function listManagedNe(client: NetxClient, args: NetxJson, signal?: AbortSignal): Promise<NetxJson> {
|
||||||
|
const keyword = str(args, 'keyword').trim()
|
||||||
|
const vendor = str(args, 'vendor').trim()
|
||||||
|
const connectStatus = str(args, 'connect_status').trim()
|
||||||
|
if (!(keyword || vendor || connectStatus)) {
|
||||||
|
return { ok: false, error: 'managed_ne_filter_required', error_code: 'managed_ne_filter_required' }
|
||||||
|
}
|
||||||
|
if (keyword && keyword.length < 2) {
|
||||||
|
return { ok: false, error: 'managed_ne_keyword_too_short', error_code: 'managed_ne_keyword_too_short' }
|
||||||
|
}
|
||||||
|
const params: Record<string, string | number | boolean> = {
|
||||||
|
page: clampInt(num(args, 'page'), 1, 1, Number.MAX_SAFE_INTEGER),
|
||||||
|
page_size: clampInt(num(args, 'page_size'), 20, 1, 100),
|
||||||
|
}
|
||||||
|
if (keyword) params.keyword = keyword
|
||||||
|
if (vendor) params.vendor = vendor
|
||||||
|
if (connectStatus) params.connect_status = connectStatus
|
||||||
|
return client.get('/v1/managed-ne', params, signal)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getManagedNe(client: NetxClient, args: NetxJson, signal?: AbortSignal): Promise<NetxJson> {
|
||||||
|
const neId = (
|
||||||
|
str(args, 'ne_id') || str(args, 'managed_ne_id') || str(args, 'id')
|
||||||
|
).trim()
|
||||||
|
if (!neId) {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
error: 'ne_id_required',
|
||||||
|
error_code: 'ne_id_required',
|
||||||
|
hint: 'Pass managed NE id from listManagedNe/listCliTargets (source=managed). For UME inventory UUIDs use execManagedNe(ume_ne_id=...) or getUmeNe, not getManagedNe.',
|
||||||
|
example: { ne_id: '<managed-ne-uuid-from-listManagedNe>' },
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const out = await client.get(`/v1/managed-ne/${quoteNeId(neId)}`, undefined, signal)
|
||||||
|
if (out.ok === false) {
|
||||||
|
const detail = `${str(out, 'detail')}${str(out, 'error')}`.toLowerCase()
|
||||||
|
if (detail.includes('404') || detail.includes('not_found') || detail.includes('not found') || out.error === 'netx_http_404') {
|
||||||
|
return {
|
||||||
|
...out,
|
||||||
|
hint: 'Managed NE not found for this ne_id. Call listManagedNe(keyword=...) or listCliTargets(source=managed) first. If this is a UME ne_id, use execManagedNe(ume_ne_id=...) / getUmeNe instead of getManagedNe.',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function execManagedNe(client: NetxClient, args: NetxJson, signal?: AbortSignal): Promise<NetxJson> {
|
||||||
|
const targetsRaw = args.targets
|
||||||
|
const neIds = strList(args, 'ne_ids')
|
||||||
|
const umeNeIds = strList(args, 'ume_ne_ids')
|
||||||
|
const sharedCommands = strList(args, 'commands')
|
||||||
|
const multi = (Array.isArray(targetsRaw) && targetsRaw.length > 0)
|
||||||
|
|| neIds.length > 0
|
||||||
|
|| umeNeIds.length > 0
|
||||||
|
|
||||||
|
if (multi) {
|
||||||
|
const body: NetxJson = {}
|
||||||
|
if (Array.isArray(targetsRaw) && targetsRaw.length > 0) {
|
||||||
|
const cleaned: NetxJson[] = []
|
||||||
|
for (const t of targetsRaw) {
|
||||||
|
if (typeof t !== 'object' || t === null || Array.isArray(t)) continue
|
||||||
|
const row = t as NetxJson
|
||||||
|
const item: NetxJson = {}
|
||||||
|
const neId = str(row, 'ne_id').trim()
|
||||||
|
const umeNeId = str(row, 'ume_ne_id').trim()
|
||||||
|
if (neId) item.ne_id = neId
|
||||||
|
if (umeNeId) item.ume_ne_id = umeNeId
|
||||||
|
const cmds = Array.isArray(row.commands)
|
||||||
|
? row.commands.map(c => String(c).trim()).filter(c => c.length > 0)
|
||||||
|
: []
|
||||||
|
if (cmds.length > 0) item.commands = cmds
|
||||||
|
if (Object.keys(item).length > 0) cleaned.push(item)
|
||||||
|
}
|
||||||
|
body.targets = cleaned
|
||||||
|
}
|
||||||
|
if (neIds.length > 0) body.ne_ids = neIds
|
||||||
|
if (umeNeIds.length > 0) body.ume_ne_ids = umeNeIds
|
||||||
|
if (sharedCommands.length > 0) {
|
||||||
|
if (sharedCommands.length > EXEC_MAX_COMMANDS) {
|
||||||
|
return { ok: false, error: 'too_many_commands', error_code: 'too_many_commands' }
|
||||||
|
}
|
||||||
|
body.commands = sharedCommands
|
||||||
|
}
|
||||||
|
body.read_timeout_sec = clampInt(num(args, 'read_timeout_sec'), 60, 10, 120)
|
||||||
|
const concurrency = num(args, 'concurrency')
|
||||||
|
if (concurrency !== undefined) body.concurrency = clampInt(concurrency, 4, 1, 8)
|
||||||
|
const out = await client.post('/v1/managed-ne/exec-batch', body, signal, 600_000)
|
||||||
|
if (out.ok !== true) return out
|
||||||
|
const data = asRecord(out.data)
|
||||||
|
if (data.ok === false) {
|
||||||
|
return { ok: false, data, error: str(data, 'error', 'exec_batch_failed') }
|
||||||
|
}
|
||||||
|
return { ok: true, data }
|
||||||
|
}
|
||||||
|
|
||||||
|
const neId = str(args, 'ne_id').trim()
|
||||||
|
const umeNeId = str(args, 'ume_ne_id').trim()
|
||||||
|
if (Boolean(neId) === Boolean(umeNeId)) {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
error: 'exactly_one_of_ne_id_or_ume_ne_id_required',
|
||||||
|
error_code: 'exactly_one_of_ne_id_or_ume_ne_id_required',
|
||||||
|
hint: 'For one NE pass ne_id OR ume_ne_id. For many NEs pass ne_ids / ume_ne_ids with shared commands, or targets[] with per-NE commands — one call, concurrent on server.',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (sharedCommands.length === 0) {
|
||||||
|
return { ok: false, error: 'commands_required', error_code: 'commands_required' }
|
||||||
|
}
|
||||||
|
if (sharedCommands.length > EXEC_MAX_COMMANDS) {
|
||||||
|
return { ok: false, error: 'too_many_commands', error_code: 'too_many_commands' }
|
||||||
|
}
|
||||||
|
const body: NetxJson = {
|
||||||
|
commands: sharedCommands,
|
||||||
|
read_timeout_sec: clampInt(num(args, 'read_timeout_sec'), 60, 10, 120),
|
||||||
|
}
|
||||||
|
if (neId) body.ne_id = neId
|
||||||
|
if (umeNeId) body.ume_ne_id = umeNeId
|
||||||
|
const out = await client.post('/v1/managed-ne/exec', body, signal, 300_000)
|
||||||
|
if (out.ok !== true) return out
|
||||||
|
const data = asRecord(out.data)
|
||||||
|
if (data.ok === false) {
|
||||||
|
return { ok: false, data, error: str(data, 'error', 'exec_failed') }
|
||||||
|
}
|
||||||
|
return { ok: true, data }
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function listCliTargets(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'), 50, 1, 500),
|
||||||
|
}
|
||||||
|
putStr(params, args, ['source', 'keyword'])
|
||||||
|
return client.get('/v1/cli/targets', params, signal)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function findTopologyPaths(client: NetxClient, args: NetxJson, signal?: AbortSignal): Promise<NetxJson> {
|
||||||
|
const fromUid = str(args, 'from_ume_ne_id').trim()
|
||||||
|
const fromMid = str(args, 'from_managed_ne_id').trim()
|
||||||
|
const toUid = str(args, 'to_ume_ne_id').trim()
|
||||||
|
const toMid = str(args, 'to_managed_ne_id').trim()
|
||||||
|
if (Boolean(fromUid) === Boolean(fromMid)) {
|
||||||
|
return { ok: false, error: 'exactly_one_of_from_ume_ne_id_or_from_managed_ne_id_required' }
|
||||||
|
}
|
||||||
|
if (Boolean(toUid) === Boolean(toMid)) {
|
||||||
|
return { ok: false, error: 'exactly_one_of_to_ume_ne_id_or_to_managed_ne_id_required' }
|
||||||
|
}
|
||||||
|
let detail = str(args, 'detail', 'summary').trim().toLowerCase() || 'summary'
|
||||||
|
if (detail !== 'summary' && detail !== 'full') detail = 'summary'
|
||||||
|
const body: NetxJson = {
|
||||||
|
max_paths: clampInt(num(args, 'max_paths'), 3, 1, 10),
|
||||||
|
max_hops: clampInt(num(args, 'max_hops'), 6, 1, 12),
|
||||||
|
layer: str(args, 'layer', 'physical').trim() || 'physical',
|
||||||
|
detail,
|
||||||
|
}
|
||||||
|
if (fromUid) body.from_ume_ne_id = fromUid
|
||||||
|
else body.from_managed_ne_id = fromMid
|
||||||
|
if (toUid) body.to_ume_ne_id = toUid
|
||||||
|
else body.to_managed_ne_id = toMid
|
||||||
|
return client.post('/v1/topology/fabric/paths', body, signal, 30_000)
|
||||||
|
}
|
||||||
125
src/netx/http.ts
Normal file
125
src/netx/http.ts
Normal file
|
|
@ -0,0 +1,125 @@
|
||||||
|
/**
|
||||||
|
* Minimal netx REST client (Bearer + lang), aligned with netx-mcp http_client.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export interface NetxConnection {
|
||||||
|
apiUrl: string
|
||||||
|
token: string
|
||||||
|
lang: string
|
||||||
|
timeoutMs: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export type NetxJson = Record<string, unknown>
|
||||||
|
|
||||||
|
const PROTOCOL_KEY_ZH_TO_EN: Record<string, string> = {
|
||||||
|
其他: 'Other',
|
||||||
|
时钟: 'Clock',
|
||||||
|
'OTN/光': 'OTN/Optical',
|
||||||
|
电源: 'Power',
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Localize a few protocol bucket keys when lang starts with en (match netx-mcp). */
|
||||||
|
function localizePayload(lang: string, data: NetxJson): NetxJson {
|
||||||
|
if (!lang.trim().toLowerCase().startsWith('en')) return data
|
||||||
|
const proto = data.protocol_summary
|
||||||
|
if (!Array.isArray(proto)) return data
|
||||||
|
for (const row of proto) {
|
||||||
|
if (typeof row !== 'object' || row === null || Array.isArray(row)) continue
|
||||||
|
const rec = row as NetxJson
|
||||||
|
const key = typeof rec.key === 'string' ? rec.key : ''
|
||||||
|
const mapped = PROTOCOL_KEY_ZH_TO_EN[key]
|
||||||
|
if (mapped !== undefined) rec.key = mapped
|
||||||
|
}
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
|
function encodeQuery(params: Record<string, string | number | boolean>): string {
|
||||||
|
const sp = new URLSearchParams()
|
||||||
|
for (const [key, value] of Object.entries(params)) {
|
||||||
|
sp.set(key, String(value))
|
||||||
|
}
|
||||||
|
const q = sp.toString()
|
||||||
|
return q.length > 0 ? `?${q}` : ''
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build a client bound to the current settings/credentials snapshot.
|
||||||
|
* @param connection - apiUrl / token / lang / default timeout.
|
||||||
|
* @returns get/post helpers that return `{ ok, data }` or `{ ok: false, error }`.
|
||||||
|
*/
|
||||||
|
export function createNetxClient(connection: NetxConnection) {
|
||||||
|
const base = connection.apiUrl.replace(/\/$/, '')
|
||||||
|
const headers: Record<string, string> = {
|
||||||
|
accept: 'application/json',
|
||||||
|
}
|
||||||
|
if (connection.token.trim().length > 0) {
|
||||||
|
headers.authorization = `Bearer ${connection.token.trim()}`
|
||||||
|
}
|
||||||
|
|
||||||
|
const langParams = (): Record<string, string> => {
|
||||||
|
const lang = connection.lang.trim().toLowerCase()
|
||||||
|
if (lang.startsWith('en')) return { lang: 'en' }
|
||||||
|
return {}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function request(
|
||||||
|
method: string,
|
||||||
|
path: string,
|
||||||
|
options: {
|
||||||
|
params?: Record<string, string | number | boolean>
|
||||||
|
body?: NetxJson
|
||||||
|
timeoutMs?: number
|
||||||
|
signal?: AbortSignal
|
||||||
|
} = {},
|
||||||
|
): Promise<NetxJson> {
|
||||||
|
const merged: Record<string, string | number | boolean> = { ...langParams(), ...options.params }
|
||||||
|
const url = `${base}${path}${encodeQuery(merged)}`
|
||||||
|
const timeoutMs = options.timeoutMs ?? connection.timeoutMs
|
||||||
|
const controller = new AbortController()
|
||||||
|
const timer = setTimeout(() => { controller.abort() }, timeoutMs)
|
||||||
|
const onOuterAbort = () => { controller.abort() }
|
||||||
|
options.signal?.addEventListener('abort', onOuterAbort, { once: true })
|
||||||
|
try {
|
||||||
|
const init: RequestInit = {
|
||||||
|
method,
|
||||||
|
headers: options.body === undefined
|
||||||
|
? headers
|
||||||
|
: { ...headers, 'content-type': 'application/json' },
|
||||||
|
signal: controller.signal,
|
||||||
|
}
|
||||||
|
if (options.body !== undefined) init.body = JSON.stringify(options.body)
|
||||||
|
const resp = await fetch(url, init)
|
||||||
|
const text = await resp.text()
|
||||||
|
if (!resp.ok) {
|
||||||
|
return { ok: false, error: `netx_http_${resp.status}`, detail: text.slice(0, 800) }
|
||||||
|
}
|
||||||
|
const data = text.length > 0 ? JSON.parse(text) as unknown : {}
|
||||||
|
if (typeof data === 'object' && data !== null && !Array.isArray(data)) {
|
||||||
|
return { ok: true, data: localizePayload(connection.lang, data as NetxJson) }
|
||||||
|
}
|
||||||
|
return { ok: true, data: { raw: data } }
|
||||||
|
} catch (error) {
|
||||||
|
const detail = error instanceof Error ? error.message : String(error)
|
||||||
|
return { ok: false, error: 'netx_request_failed', detail: detail.slice(0, 800) }
|
||||||
|
} finally {
|
||||||
|
clearTimeout(timer)
|
||||||
|
options.signal?.removeEventListener('abort', onOuterAbort)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
get(path: string, params?: Record<string, string | number | boolean>, signal?: AbortSignal, timeoutMs?: number) {
|
||||||
|
return request('GET', path, { params, signal, timeoutMs })
|
||||||
|
},
|
||||||
|
post(path: string, body: NetxJson, signal?: AbortSignal, timeoutMs?: number) {
|
||||||
|
return request('POST', path, { body, signal, timeoutMs })
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export type NetxClient = ReturnType<typeof createNetxClient>
|
||||||
|
|
||||||
|
/** Path-segment encode for NE ids (match urllib.parse.quote(..., safe='')). */
|
||||||
|
export function quoteNeId(neId: string): string {
|
||||||
|
return encodeURIComponent(neId.trim())
|
||||||
|
}
|
||||||
273
src/netx/tools.ts
Normal file
273
src/netx/tools.ts
Normal file
|
|
@ -0,0 +1,273 @@
|
||||||
|
/**
|
||||||
|
* Register native DSH tools (`netx__*`) that call netx REST.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { Context } from '@deepseek-ai/cordis'
|
||||||
|
import { defineTool } from '@deepseek-ai/dsh-tools'
|
||||||
|
import { createNetxClient, type NetxClient, type NetxJson } from './http.ts'
|
||||||
|
import * as H from './handlers.ts'
|
||||||
|
|
||||||
|
export interface NetxToolConnection {
|
||||||
|
apiUrl: string
|
||||||
|
token: string
|
||||||
|
lang: string
|
||||||
|
toolCallTimeoutMs: number
|
||||||
|
}
|
||||||
|
|
||||||
|
type Handler = (client: NetxClient, args: NetxJson, signal?: AbortSignal) => Promise<NetxJson>
|
||||||
|
|
||||||
|
const str = (description?: string) => ({ type: 'string' as const, ...(description ? { description } : {}) })
|
||||||
|
const num = (description?: string) => ({ type: 'number' as const, ...(description ? { description } : {}) })
|
||||||
|
const bool = (description?: string) => ({ type: 'boolean' as const, ...(description ? { description } : {}) })
|
||||||
|
const strArr = (description?: string) => ({
|
||||||
|
type: 'array' as const,
|
||||||
|
items: { type: 'string' as const },
|
||||||
|
...(description ? { description } : {}),
|
||||||
|
})
|
||||||
|
|
||||||
|
function renderJson(_args: unknown, value: unknown) {
|
||||||
|
return [{ type: 'text' as const, text: JSON.stringify(value, null, 0) }]
|
||||||
|
}
|
||||||
|
|
||||||
|
const jsonOut = {
|
||||||
|
schema: { type: 'json' as const },
|
||||||
|
render: renderJson,
|
||||||
|
}
|
||||||
|
|
||||||
|
function tool(
|
||||||
|
name: string,
|
||||||
|
description: string,
|
||||||
|
parameters: Record<string, unknown>,
|
||||||
|
handler: Handler,
|
||||||
|
getClient: () => NetxClient,
|
||||||
|
timeoutMs: number,
|
||||||
|
) {
|
||||||
|
return defineTool({
|
||||||
|
name,
|
||||||
|
description,
|
||||||
|
parameters: parameters as never,
|
||||||
|
output: jsonOut,
|
||||||
|
timeoutMs,
|
||||||
|
isConcurrencySafe: () => true,
|
||||||
|
async execute(args, exec) {
|
||||||
|
const result = await handler(getClient(), args as NetxJson, exec.signal)
|
||||||
|
if (result.ok === false) {
|
||||||
|
throw new Error(JSON.stringify(result))
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Register all Netx Ops tools against the current connection snapshot.
|
||||||
|
* @param ctx - host context with `tools`.
|
||||||
|
* @param connection - apiUrl / token / lang / timeout.
|
||||||
|
* @returns disposer that unregisters every tool.
|
||||||
|
*/
|
||||||
|
export function registerNetxTools(ctx: Context, connection: NetxToolConnection): () => void {
|
||||||
|
const client = createNetxClient({
|
||||||
|
apiUrl: connection.apiUrl,
|
||||||
|
token: connection.token,
|
||||||
|
lang: connection.lang,
|
||||||
|
timeoutMs: Math.min(connection.toolCallTimeoutMs, 45_000),
|
||||||
|
})
|
||||||
|
const getClient = () => client
|
||||||
|
const t = connection.toolCallTimeoutMs
|
||||||
|
|
||||||
|
const disposers = [
|
||||||
|
ctx.tools.register(tool(
|
||||||
|
'netx__queryUmeAlarms',
|
||||||
|
'Query UME current alarms (each row includes host_name). Supports severity/ne_id/host_name/keyword, last_seen time_from/time_to, pagination. Prefer host_name for display; ne_id is for filters only.',
|
||||||
|
{
|
||||||
|
severity: str(),
|
||||||
|
ne_id: str('Filter only; do not show UUID to users'),
|
||||||
|
host_name: str('Filter by NE host_name'),
|
||||||
|
ne_name: str('Legacy alias mapped to keyword'),
|
||||||
|
keyword: str('Substring on cause/object/event. Examples: LOS, Fiber Break, bandwidth, CRC.'),
|
||||||
|
time_from: str('ISO time; filters last_seen_at >='),
|
||||||
|
time_to: str('ISO time; filters last_seen_at <='),
|
||||||
|
page: num(),
|
||||||
|
page_size: num(),
|
||||||
|
},
|
||||||
|
H.queryUmeAlarms, getClient, t,
|
||||||
|
)),
|
||||||
|
ctx.tools.register(tool(
|
||||||
|
'netx__aggregateUmeAlarms',
|
||||||
|
'Aggregate UME current alarms (by_severity + top by_ne). If group_by is set, routes to aggregateUmeAlarmsRaw. Always filter severity/keyword/time before paging.',
|
||||||
|
{
|
||||||
|
severity: str('Optional perceived_severity filter (critical/major/minor/warning).'),
|
||||||
|
top_ne: num('Max NE buckets (default 50). Ignored when group_by is set.'),
|
||||||
|
exclude_missing_host: bool('Omit missing host_name from by_ne.'),
|
||||||
|
time_from: str(),
|
||||||
|
time_to: str(),
|
||||||
|
group_by: str('When set, routes to raw aggregation. Prefer alarm_host_name.'),
|
||||||
|
group_by2: str(),
|
||||||
|
is_cleared: str(),
|
||||||
|
ne_id: str(),
|
||||||
|
event_type: str(),
|
||||||
|
keyword: str(),
|
||||||
|
limit: num(),
|
||||||
|
},
|
||||||
|
H.aggregateUmeAlarms, getClient, t,
|
||||||
|
)),
|
||||||
|
ctx.tools.register(tool(
|
||||||
|
'netx__runUmeDiagnostics',
|
||||||
|
'UME alarm diagnostics: severity, top_event_types, top_alarm_codes, top_ne, protocol buckets, freshness meta.',
|
||||||
|
{},
|
||||||
|
H.runUmeDiagnostics, getClient, t,
|
||||||
|
)),
|
||||||
|
ctx.tools.register(tool(
|
||||||
|
'netx__queryUmeNeInventory',
|
||||||
|
'Paged UME NE inventory synced in netx (keyword matches ne_id/ne_name/user_label/ip/host_name).',
|
||||||
|
{
|
||||||
|
keyword: str(),
|
||||||
|
page: num(),
|
||||||
|
page_size: num(),
|
||||||
|
},
|
||||||
|
H.queryUmeNeInventory, getClient, t,
|
||||||
|
)),
|
||||||
|
ctx.tools.register(tool(
|
||||||
|
'netx__getUmeNe',
|
||||||
|
'Get single UME NE detail by ne_id (UUID).',
|
||||||
|
{
|
||||||
|
ne_id: { type: 'string' as const, required: true as const, description: 'UME inventory ne_id (UUID).' },
|
||||||
|
},
|
||||||
|
H.getUmeNe, getClient, t,
|
||||||
|
)),
|
||||||
|
ctx.tools.register(tool(
|
||||||
|
'netx__queryUmeAlarmsRaw',
|
||||||
|
'Power query UME current alarms with full alarm_* + ne_* fields; optional field_preset or select_fields. Use field_preset=evidence for citations.',
|
||||||
|
{
|
||||||
|
severity: str(),
|
||||||
|
is_cleared: str(),
|
||||||
|
ne_id: str(),
|
||||||
|
event_type: str(),
|
||||||
|
keyword: str(),
|
||||||
|
time_from: str(),
|
||||||
|
time_to: str(),
|
||||||
|
order_by: str('last_seen_at | time_created | perceived_severity | event_type | ne_id'),
|
||||||
|
order: str('asc | desc'),
|
||||||
|
select_fields: strArr(),
|
||||||
|
field_preset: str('brief | evidence | ne_debug'),
|
||||||
|
page: num(),
|
||||||
|
page_size: num(),
|
||||||
|
},
|
||||||
|
H.queryUmeAlarmsRaw, getClient, t,
|
||||||
|
)),
|
||||||
|
ctx.tools.register(tool(
|
||||||
|
'netx__aggregateUmeAlarmsRaw',
|
||||||
|
'Dynamic aggregation on UME raw fields (group_by/group_by2); prefer alarm_host_name.',
|
||||||
|
{
|
||||||
|
group_by: { type: 'string' as const, required: true as const },
|
||||||
|
group_by2: str(),
|
||||||
|
severity: str(),
|
||||||
|
is_cleared: str(),
|
||||||
|
ne_id: str(),
|
||||||
|
event_type: str(),
|
||||||
|
keyword: str(),
|
||||||
|
time_from: str(),
|
||||||
|
time_to: str(),
|
||||||
|
exclude_missing_host: bool(),
|
||||||
|
limit: num(),
|
||||||
|
},
|
||||||
|
H.aggregateUmeAlarmsRaw, getClient, t,
|
||||||
|
)),
|
||||||
|
ctx.tools.register(tool(
|
||||||
|
'netx__listUmeAlarmFields',
|
||||||
|
'List available fields for UME raw alarm queries.',
|
||||||
|
{},
|
||||||
|
H.listUmeAlarmFields, getClient, t,
|
||||||
|
)),
|
||||||
|
ctx.tools.register(tool(
|
||||||
|
'netx__sqlQueryUme',
|
||||||
|
'Read-only SELECT on UME tables (ume_alarms_current/ume_inventory_ne); server enforces limits. Requires sql:query scope.',
|
||||||
|
{
|
||||||
|
sql: { type: 'string' as const, required: true as const },
|
||||||
|
limit: num(),
|
||||||
|
statement_timeout_ms: num(),
|
||||||
|
},
|
||||||
|
H.sqlQueryUme, getClient, t,
|
||||||
|
)),
|
||||||
|
ctx.tools.register(tool(
|
||||||
|
'netx__listManagedNe',
|
||||||
|
'List filtered netx managed NEs (keyword/vendor/connect_status required); use before execManagedNe.',
|
||||||
|
{
|
||||||
|
keyword: str(),
|
||||||
|
vendor: str(),
|
||||||
|
connect_status: str('unknown | testing | pass | fail'),
|
||||||
|
page: num(),
|
||||||
|
page_size: num(),
|
||||||
|
},
|
||||||
|
H.listManagedNe, getClient, t,
|
||||||
|
)),
|
||||||
|
ctx.tools.register(tool(
|
||||||
|
'netx__getManagedNe',
|
||||||
|
'Get one managed NE by managed ne_id (from listManagedNe / listCliTargets source=managed). Do NOT pass UME inventory UUID here.',
|
||||||
|
{
|
||||||
|
ne_id: str('Managed NE id'),
|
||||||
|
managed_ne_id: str('Alias for ne_id'),
|
||||||
|
id: str('Alias for ne_id'),
|
||||||
|
},
|
||||||
|
H.getManagedNe, getClient, t,
|
||||||
|
)),
|
||||||
|
ctx.tools.register(tool(
|
||||||
|
'netx__execManagedNe',
|
||||||
|
'Run read-only CLI via netx (show/display/ping/traceroute). Single NE: ne_id OR ume_ne_id + commands. Many NEs: ne_ids[]/ume_ne_ids[] + shared commands, or targets[{ume_ne_id|ne_id, commands}]. Do NOT loop one-NE calls for multi-NE work.',
|
||||||
|
{
|
||||||
|
ne_id: str(),
|
||||||
|
ume_ne_id: str(),
|
||||||
|
ne_ids: strArr('Managed NE ids for concurrent batch (shared commands).'),
|
||||||
|
ume_ne_ids: strArr('UME inventory ne_ids for concurrent batch (shared commands).'),
|
||||||
|
targets: {
|
||||||
|
type: 'array' as const,
|
||||||
|
description: 'Per-NE command sets: each item is one NE (ne_id OR ume_ne_id) with commands[].',
|
||||||
|
items: {
|
||||||
|
type: 'object' as const,
|
||||||
|
additionalProperties: false,
|
||||||
|
properties: {
|
||||||
|
ne_id: str(),
|
||||||
|
ume_ne_id: str(),
|
||||||
|
commands: strArr(),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
commands: strArr('Commands for single NE, or shared commands for batch.'),
|
||||||
|
read_timeout_sec: num('Per-command read timeout (default 60; use 90–120 for slow show).'),
|
||||||
|
concurrency: num('Parallel NEs for batch mode (1–8, default 4).'),
|
||||||
|
async: bool('oclaw-only async hint; ignored by native REST client.'),
|
||||||
|
},
|
||||||
|
H.execManagedNe, getClient, Math.max(t, 300_000),
|
||||||
|
)),
|
||||||
|
ctx.tools.register(tool(
|
||||||
|
'netx__listCliTargets',
|
||||||
|
'List CLI-capable targets (managed NE and/or UME inventory). Call once per session with keyword/source, cache ids, then execManagedNe.',
|
||||||
|
{
|
||||||
|
source: str('managed | ume | all'),
|
||||||
|
keyword: str(),
|
||||||
|
page: num(),
|
||||||
|
page_size: num(),
|
||||||
|
},
|
||||||
|
H.listCliTargets, getClient, t,
|
||||||
|
)),
|
||||||
|
ctx.tools.register(tool(
|
||||||
|
'netx__findTopologyPaths',
|
||||||
|
'Find up to max_paths simple paths between two fabric nodes. For each endpoint provide exactly one of ume_ne_id or managed_ne_id.',
|
||||||
|
{
|
||||||
|
from_ume_ne_id: str(),
|
||||||
|
from_managed_ne_id: str(),
|
||||||
|
to_ume_ne_id: str(),
|
||||||
|
to_managed_ne_id: str(),
|
||||||
|
max_paths: num(),
|
||||||
|
max_hops: num(),
|
||||||
|
layer: str(),
|
||||||
|
detail: str('summary | full'),
|
||||||
|
},
|
||||||
|
H.findTopologyPaths, getClient, t,
|
||||||
|
)),
|
||||||
|
]
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
for (const dispose of disposers) dispose()
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue