mirror of
https://github.com/hansjone/netxops.git
synced 2026-10-18 04:10:45 +08:00
Add key-alarm push: reuse URL/token, dial netx hub, sticky DSH session (0.1.15).
Plugins card gains an alarm-push toggle; when enabled, netxops subscribes outbound and followups a Netx Ops session without requiring im/WhatsApp. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
027f83dd00
commit
12f7018103
16 changed files with 966 additions and 31 deletions
|
|
@ -16,8 +16,9 @@ dsh web
|
||||||
Then:
|
Then:
|
||||||
|
|
||||||
1. Settings → **Plugins** → **Netx Ops** → API URL / token
|
1. Settings → **Plugins** → **Netx Ops** → API URL / token
|
||||||
2. Settings → **Agent presets** → Custom → **Netx Ops** (installed automatically on first host activate)
|
2. Optional: enable **关键告警推送** — DSH dials out to netx and opens a sticky session on matched key alarms (WhatsApp / im not required)
|
||||||
3. New session → choose **Netx Ops**
|
3. Settings → **Agent presets** → Custom → **Netx Ops** (installed automatically on first host activate)
|
||||||
|
4. New session → choose **Netx Ops**
|
||||||
|
|
||||||
Also need: a reachable **netx API** (no local `pip install netx_mcp`). Details: [docs/INSTALL.md](docs/INSTALL.md).
|
Also need: a reachable **netx API** (no local `pip install netx_mcp`). Details: [docs/INSTALL.md](docs/INSTALL.md).
|
||||||
|
|
||||||
|
|
@ -26,6 +27,7 @@ Also need: a reachable **netx API** (no local `pip install netx_mcp`). Details:
|
||||||
| In the plugin | Outside (data / runtime) |
|
| In the plugin | Outside (data / runtime) |
|
||||||
|---------------|---------------------------|
|
|---------------|---------------------------|
|
||||||
| `netx__*` REST tools (Ops preset only) + settings + credentials | netx HTTP API (URL + token) |
|
| `netx__*` REST tools (Ops preset only) + settings + credentials | netx HTTP API (URL + token) |
|
||||||
|
| Optional key-alarm push (WSS client → sticky DSH session) | netx `/v1/integrations/dsh-alarm/ws` hub |
|
||||||
| Persona + UME / managed-NE skills | |
|
| Persona + UME / managed-NE skills | |
|
||||||
| Agent preset auto-install to `~/.dsh/.agent-presets` | |
|
| Agent preset auto-install to `~/.dsh/.agent-presets` | |
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -13,3 +13,4 @@
|
||||||
tokenCredentialRef: NETX_API_TOKEN
|
tokenCredentialRef: NETX_API_TOKEN
|
||||||
toolCallTimeoutMs: 120000
|
toolCallTimeoutMs: 120000
|
||||||
installAgentPreset: true
|
installAgentPreset: true
|
||||||
|
alarmPushEnabled: false
|
||||||
|
|
|
||||||
|
|
@ -29,10 +29,17 @@ dsh web # or: pnpm dsh web
|
||||||
```
|
```
|
||||||
|
|
||||||
1. **Settings → Plugins → Netx Ops** → API URL (+ token if the field is enabled).
|
1. **Settings → Plugins → Netx Ops** → API URL (+ token if the field is enabled).
|
||||||
Token fallback: `scripts/set-netx-token.ps1` / `.sh`.
|
Token fallback: `scripts/set-netx-token.ps1` / `.sh`.
|
||||||
|
Optional: enable **关键告警推送** so this DSH dials `ws(s)://<apiUrl>/v1/integrations/dsh-alarm/ws` and follows a sticky session when netx matches a key alert.
|
||||||
2. Restart or open Settings → **Agent presets** → Custom → **Netx Ops** should appear after the host plugin has activated once.
|
2. Restart or open Settings → **Agent presets** → Custom → **Netx Ops** should appear after the host plugin has activated once.
|
||||||
3. **New session → preset Netx Ops** → ask e.g. Critical Top / single-host alarms.
|
3. **New session → preset Netx Ops** → ask e.g. Critical Top / single-host alarms.
|
||||||
|
|
||||||
|
## Key-alarm push
|
||||||
|
|
||||||
|
- **netx** (fixed IP) hosts the subscribe hub and fans out matched key alerts.
|
||||||
|
- **netxops** (each DSH) reuses the same URL/token, connects outbound, and delivers into a DSH session.
|
||||||
|
- WhatsApp / `dsh-im` is **not** required for this path.
|
||||||
|
|
||||||
## Verify
|
## Verify
|
||||||
|
|
||||||
1. Plugins card **Netx Ops** visible.
|
1. Plugins card **Netx Ops** visible.
|
||||||
|
|
|
||||||
|
|
@ -82,6 +82,8 @@ var CSS = `
|
||||||
.dsh-nx-input:focus-visible{outline:none;border-color:var(--dsw-alias-brand-primary)}
|
.dsh-nx-input:focus-visible{outline:none;border-color:var(--dsw-alias-brand-primary)}
|
||||||
.dsh-nx-inputInvalid{border-color:var(--dsw-alias-label-error)}
|
.dsh-nx-inputInvalid{border-color:var(--dsw-alias-label-error)}
|
||||||
.dsh-nx-hint{margin:0;font-size:12px;line-height:1.5;color:var(--dsw-alias-label-tertiary)}
|
.dsh-nx-hint{margin:0;font-size:12px;line-height:1.5;color:var(--dsw-alias-label-tertiary)}
|
||||||
|
.dsh-nx-checkRow{display:flex;align-items:flex-start;gap:10px;font-size:12px;line-height:1.5;color:var(--dsw-alias-label-secondary);cursor:pointer}
|
||||||
|
.dsh-nx-checkRow input{margin-top:2px;flex:none}
|
||||||
.dsh-nx-invalid{margin:0;font-size:12px;line-height:1.5;color:var(--dsw-alias-label-error)}
|
.dsh-nx-invalid{margin:0;font-size:12px;line-height:1.5;color:var(--dsw-alias-label-error)}
|
||||||
.dsh-nx-footer{display:flex;align-items:center;justify-content:flex-end;gap:8px;padding:12px 0 4px;border-top:.5px solid var(--dsw-alias-border-l2)}
|
.dsh-nx-footer{display:flex;align-items:center;justify-content:flex-end;gap:8px;padding:12px 0 4px;border-top:.5px solid var(--dsw-alias-border-l2)}
|
||||||
.dsh-nx-failed{flex:1;min-width:0;margin:0;font-size:12px;line-height:1.5;color:var(--dsw-alias-label-error)}
|
.dsh-nx-failed{flex:1;min-width:0;margin:0;font-size:12px;line-height:1.5;color:var(--dsw-alias-label-error)}
|
||||||
|
|
@ -287,6 +289,57 @@ function NetxopsCard(props) {
|
||||||
props.resetField("lang");
|
props.resetField("lang");
|
||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
|
/* @__PURE__ */ jsx_runtime.jsxs("div", {
|
||||||
|
className: "dsh-nx-field",
|
||||||
|
children: [
|
||||||
|
/* @__PURE__ */ jsx_runtime.jsxs("div", {
|
||||||
|
className: "dsh-nx-fieldHead",
|
||||||
|
children: [
|
||||||
|
/* @__PURE__ */ jsx_runtime.jsx("label", {
|
||||||
|
className: "dsh-nx-label",
|
||||||
|
htmlFor: "netxops-alarm-push",
|
||||||
|
children: t("alarmPushEnabled")
|
||||||
|
}),
|
||||||
|
state.alarmPushEnabled.overridden ? /* @__PURE__ */ jsx_runtime.jsxs("span", {
|
||||||
|
className: "dsh-nx-badges",
|
||||||
|
children: [
|
||||||
|
/* @__PURE__ */ jsx_runtime.jsx("span", {
|
||||||
|
className: "dsh-nx-badge",
|
||||||
|
children: t("overridden")
|
||||||
|
}),
|
||||||
|
/* @__PURE__ */ jsx_runtime.jsx("button", {
|
||||||
|
type: "button",
|
||||||
|
className: "dsh-nx-reset",
|
||||||
|
disabled,
|
||||||
|
onClick: () => {
|
||||||
|
props.resetField("alarmPushEnabled");
|
||||||
|
},
|
||||||
|
children: t("reset")
|
||||||
|
})
|
||||||
|
]
|
||||||
|
}) : null
|
||||||
|
]
|
||||||
|
}),
|
||||||
|
/* @__PURE__ */ jsx_runtime.jsxs("label", {
|
||||||
|
className: "dsh-nx-checkRow",
|
||||||
|
htmlFor: "netxops-alarm-push",
|
||||||
|
children: [
|
||||||
|
/* @__PURE__ */ jsx_runtime.jsx("input", {
|
||||||
|
id: "netxops-alarm-push",
|
||||||
|
type: "checkbox",
|
||||||
|
checked: state.alarmPushEnabled.text === "true",
|
||||||
|
disabled,
|
||||||
|
onChange: (event) => {
|
||||||
|
props.edit("alarmPushEnabled", event.target.checked ? "true" : "false");
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
/* @__PURE__ */ jsx_runtime.jsx("span", {
|
||||||
|
children: t("alarmPushEnabledHint")
|
||||||
|
})
|
||||||
|
]
|
||||||
|
})
|
||||||
|
]
|
||||||
|
}),
|
||||||
/* @__PURE__ */ jsx_runtime.jsxs("div", {
|
/* @__PURE__ */ jsx_runtime.jsxs("div", {
|
||||||
className: "dsh-nx-footer",
|
className: "dsh-nx-footer",
|
||||||
children: [
|
children: [
|
||||||
|
|
@ -348,6 +401,22 @@ function textField(field) {
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
function booleanField(field) {
|
||||||
|
return {
|
||||||
|
field,
|
||||||
|
format: (value) => value === true ? "true" : "false",
|
||||||
|
parse: (text) => {
|
||||||
|
const normalized = text.trim().toLowerCase();
|
||||||
|
if (normalized === "true" || normalized === "1" || normalized === "yes") {
|
||||||
|
return { kind: "set", value: true };
|
||||||
|
}
|
||||||
|
if (normalized === "false" || normalized === "0" || normalized === "no" || normalized === "") {
|
||||||
|
return { kind: "clear" };
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
class CardForm {
|
class CardForm {
|
||||||
scope;
|
scope;
|
||||||
|
|
@ -526,7 +595,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")], [{ field: API_TOKEN_FIELD, write: (text) => this.writeToken(text) }]);
|
this.form = new CardForm(scope, [textField("apiUrl"), textField("lang"), booleanField("alarmPushEnabled")], [{ 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();
|
||||||
|
|
@ -550,6 +619,7 @@ 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"),
|
||||||
|
alarmPushEnabled: this.form.field("alarmPushEnabled"),
|
||||||
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,
|
||||||
|
|
@ -620,9 +690,11 @@ var en = {
|
||||||
title: "Netx Ops",
|
title: "Netx Ops",
|
||||||
description: "UME API endpoint and bearer token for 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 (also used for alarm subscribe)",
|
||||||
lang: "Language",
|
lang: "Language",
|
||||||
langHint: "Response language hint (zh / en).",
|
langHint: "Response language hint (zh / en).",
|
||||||
|
alarmPushEnabled: "Key-alarm push",
|
||||||
|
alarmPushEnabledHint: "Dial out to netx and open/follow a DSH session when a matched key alarm arrives. WhatsApp/im is optional and not required.",
|
||||||
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.",
|
||||||
|
|
@ -644,9 +716,11 @@ var zh = {
|
||||||
title: "Netx Ops",
|
title: "Netx Ops",
|
||||||
description: "UME API 地址与 Bearer Token,供 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: "响应语言提示(zh / en)。",
|
langHint: "响应语言提示(zh / en)。",
|
||||||
|
alarmPushEnabled: "关键告警推送",
|
||||||
|
alarmPushEnabledHint: "主动连接 netx;匹配到关键告警后在本机打开/续写一条 DSH 会话。不必安装 WhatsApp / im。",
|
||||||
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 在监视凭据文件则无需重启。",
|
||||||
|
|
|
||||||
334
lib/index.js
334
lib/index.js
|
|
@ -1,12 +1,301 @@
|
||||||
|
import { createRequire } from "node:module";
|
||||||
|
var __require = /* @__PURE__ */ createRequire(import.meta.url);
|
||||||
|
|
||||||
// src/index.ts
|
// src/index.ts
|
||||||
import { cpSync, existsSync, mkdirSync, rmSync, writeFileSync } from "node:fs";
|
import { cpSync, existsSync, mkdirSync, rmSync, writeFileSync } from "node:fs";
|
||||||
import { homedir } from "node:os";
|
import { homedir as homedir2 } from "node:os";
|
||||||
import { dirname, join } from "node:path";
|
import { dirname, join as join2 } from "node:path";
|
||||||
import { fileURLToPath } from "node:url";
|
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";
|
||||||
|
|
||||||
|
// src/netx/alarm-push.ts
|
||||||
|
function alarmSubscribeUrl(apiUrl) {
|
||||||
|
const trimmed = apiUrl.trim().replace(/\/$/, "");
|
||||||
|
if (!trimmed)
|
||||||
|
return "";
|
||||||
|
let url;
|
||||||
|
try {
|
||||||
|
url = new URL(trimmed);
|
||||||
|
} catch {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
url.protocol = url.protocol === "https:" ? "wss:" : "ws:";
|
||||||
|
url.pathname = "/v1/integrations/dsh-alarm/ws";
|
||||||
|
url.search = "";
|
||||||
|
url.hash = "";
|
||||||
|
return url.toString();
|
||||||
|
}
|
||||||
|
function formatAlarmPrompt(payload, lang = "zh") {
|
||||||
|
const action = String(payload.action ?? "").trim().toLowerCase();
|
||||||
|
const actionZh = {
|
||||||
|
inserted: "告警产生",
|
||||||
|
updated: "告警更新",
|
||||||
|
deleted: "告警清除"
|
||||||
|
};
|
||||||
|
const actionEn = {
|
||||||
|
inserted: "Alarm Raised",
|
||||||
|
updated: "Alarm Updated",
|
||||||
|
deleted: "Alarm Cleared"
|
||||||
|
};
|
||||||
|
const ne = payload.ne && typeof payload.ne === "object" ? payload.ne : {};
|
||||||
|
const host = String(ne.host_name ?? "").trim();
|
||||||
|
const ip = String(ne.ip_address ?? "").trim();
|
||||||
|
const neName = String(ne.ne_name ?? ne.user_label ?? "").trim();
|
||||||
|
let device = host || neName || String(payload.ne_id ?? "").trim() || "-";
|
||||||
|
if (ip)
|
||||||
|
device = device === "-" ? ip : `${device} (${ip})`;
|
||||||
|
const label = String(payload.rule_label ?? payload.native_probable_cause ?? "关键告警").trim();
|
||||||
|
if (lang.startsWith("en")) {
|
||||||
|
return [
|
||||||
|
`[UME ${actionEn[action] ?? (action || "Alarm")}] ${label}`,
|
||||||
|
`Device: ${device}`,
|
||||||
|
`Object: ${String(payload.object_name ?? "-").trim()}`,
|
||||||
|
`Severity: ${String(payload.perceived_severity ?? "-").trim()}`,
|
||||||
|
`Cause: ${String(payload.native_probable_cause ?? "-").trim()}`,
|
||||||
|
`Time: ${String(payload.time_created ?? "-").trim()}`,
|
||||||
|
`notificationId: ${String(payload.notification_id ?? "-").trim()}`,
|
||||||
|
`alarm_key: ${String(payload.alarm_key ?? "-").trim()}`,
|
||||||
|
"",
|
||||||
|
"Please analyze this key alarm and suggest next ops steps."
|
||||||
|
].join(`
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
return [
|
||||||
|
`[UME ${actionZh[action] ?? (action || "告警")}] ${label}`,
|
||||||
|
`设备: ${device}`,
|
||||||
|
`对象: ${String(payload.object_name ?? "-").trim()}`,
|
||||||
|
`级别: ${String(payload.perceived_severity ?? "-").trim()}`,
|
||||||
|
`原因: ${String(payload.native_probable_cause ?? "-").trim()}`,
|
||||||
|
`时间: ${String(payload.time_created ?? "-").trim()}`,
|
||||||
|
`notificationId: ${String(payload.notification_id ?? "-").trim()}`,
|
||||||
|
`alarm_key: ${String(payload.alarm_key ?? "-").trim()}`,
|
||||||
|
"",
|
||||||
|
"请分析这条关键告警并给出下一步运维建议。"
|
||||||
|
].join(`
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
function startAlarmPushClient(options) {
|
||||||
|
const log = options.logger ?? console;
|
||||||
|
const wsUrl = alarmSubscribeUrl(options.apiUrl);
|
||||||
|
const token = options.token.trim();
|
||||||
|
if (!wsUrl || !token) {
|
||||||
|
log.warn?.("netxops alarm-push: missing apiUrl or token — not connecting");
|
||||||
|
return () => {};
|
||||||
|
}
|
||||||
|
const WS = options.WebSocketImpl ?? globalThis.WebSocket;
|
||||||
|
if (typeof WS !== "function") {
|
||||||
|
log.error?.("netxops alarm-push: WebSocket is unavailable in this runtime");
|
||||||
|
return () => {};
|
||||||
|
}
|
||||||
|
let closed = false;
|
||||||
|
let socket = null;
|
||||||
|
let reconnectTimer;
|
||||||
|
let attempt = 0;
|
||||||
|
const baseDelay = Math.max(500, options.reconnectMs ?? 2000);
|
||||||
|
const clearReconnect = () => {
|
||||||
|
if (reconnectTimer !== undefined) {
|
||||||
|
clearTimeout(reconnectTimer);
|
||||||
|
reconnectTimer = undefined;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const scheduleReconnect = () => {
|
||||||
|
if (closed)
|
||||||
|
return;
|
||||||
|
clearReconnect();
|
||||||
|
const delay = Math.min(60000, baseDelay * 2 ** Math.min(attempt, 5));
|
||||||
|
attempt += 1;
|
||||||
|
reconnectTimer = setTimeout(() => {
|
||||||
|
connect();
|
||||||
|
}, delay);
|
||||||
|
};
|
||||||
|
const connect = () => {
|
||||||
|
if (closed)
|
||||||
|
return;
|
||||||
|
clearReconnect();
|
||||||
|
try {
|
||||||
|
socket = new WS(wsUrl);
|
||||||
|
} catch (error) {
|
||||||
|
log.warn?.("netxops alarm-push: connect failed:", error);
|
||||||
|
scheduleReconnect();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
socket.addEventListener("open", () => {
|
||||||
|
attempt = 0;
|
||||||
|
socket?.send(JSON.stringify({ type: "auth", token }));
|
||||||
|
});
|
||||||
|
socket.addEventListener("message", (event) => {
|
||||||
|
let msg;
|
||||||
|
try {
|
||||||
|
msg = JSON.parse(String(event.data));
|
||||||
|
} catch {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const type = String(msg.type ?? "").toLowerCase();
|
||||||
|
if (type === "auth-ok") {
|
||||||
|
log.info?.("netxops alarm-push: subscribed to %s", wsUrl);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (type === "auth-fail") {
|
||||||
|
log.error?.("netxops alarm-push: auth failed (%s)", msg.error);
|
||||||
|
socket?.close();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (type === "pong")
|
||||||
|
return;
|
||||||
|
if (type === "event" && String(msg.event ?? "") === "netx.alarm") {
|
||||||
|
const payload = msg.payload && typeof msg.payload === "object" ? msg.payload : {};
|
||||||
|
Promise.resolve(options.onAlarm(payload)).catch((error) => {
|
||||||
|
log.warn?.("netxops alarm-push: handler failed:", error);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
socket.addEventListener("close", () => {
|
||||||
|
socket = null;
|
||||||
|
if (!closed)
|
||||||
|
scheduleReconnect();
|
||||||
|
});
|
||||||
|
socket.addEventListener("error", () => {});
|
||||||
|
};
|
||||||
|
const pingTimer = setInterval(() => {
|
||||||
|
if (socket && socket.readyState === WS.OPEN) {
|
||||||
|
try {
|
||||||
|
socket.send(JSON.stringify({ type: "ping", ts: new Date().toISOString() }));
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
}, 25000);
|
||||||
|
connect();
|
||||||
|
return () => {
|
||||||
|
closed = true;
|
||||||
|
clearReconnect();
|
||||||
|
clearInterval(pingTimer);
|
||||||
|
try {
|
||||||
|
socket?.close();
|
||||||
|
} catch {}
|
||||||
|
socket = null;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// src/netx/alarm-session.ts
|
||||||
|
import { homedir } from "node:os";
|
||||||
|
import { join } from "node:path";
|
||||||
|
var PRESET_ID = "netxops";
|
||||||
|
var PERMISSION_PRESET = "default";
|
||||||
|
var TITLE = "Netx 关键告警";
|
||||||
|
var sticky = null;
|
||||||
|
function resolveWorkspacePath() {
|
||||||
|
const fromEnv = process.env.DSH_HOME?.trim();
|
||||||
|
const home = fromEnv && fromEnv.length > 0 ? fromEnv : join(homedir(), ".dsh");
|
||||||
|
return join(home, "workspaces", "netxops-alarms");
|
||||||
|
}
|
||||||
|
async function deliverAlarmToSession(ctx, payload, lang = "zh") {
|
||||||
|
const prompt = formatAlarmPrompt(payload, lang);
|
||||||
|
const agents = ctx.agents;
|
||||||
|
if (!agents || typeof agents.create !== "function") {
|
||||||
|
ctx.logger.warn("netxops alarm-push: ctx.agents unavailable — enable a profile that mounts agents to receive alarms in a DSH session");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (sticky?.agent && typeof sticky.agent.followup === "function") {
|
||||||
|
try {
|
||||||
|
await followup(ctx, sticky.agent, prompt);
|
||||||
|
return;
|
||||||
|
} catch (error) {
|
||||||
|
ctx.logger.warn("netxops alarm-push: sticky followup failed, recreating session: %s", error);
|
||||||
|
sticky = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await createStickySession(ctx, prompt);
|
||||||
|
}
|
||||||
|
async function followup(ctx, agent, prompt) {
|
||||||
|
let createUserMessage;
|
||||||
|
try {
|
||||||
|
const mod = await import("@deepseek-ai/dsh-llm");
|
||||||
|
createUserMessage = mod.createUserMessage;
|
||||||
|
if (typeof createUserMessage !== "function")
|
||||||
|
throw new Error("createUserMessage missing");
|
||||||
|
const summary = typeof mod.boundContextSummary === "function" ? mod.boundContextSummary("netx key alarm") : "netx key alarm";
|
||||||
|
agent.followup(createUserMessage({
|
||||||
|
content: [{ type: "text", text: prompt }],
|
||||||
|
source: {
|
||||||
|
kind: "webhook",
|
||||||
|
provider: "netx",
|
||||||
|
source: "dsh-alarm-hub",
|
||||||
|
form: "notice",
|
||||||
|
summary
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
} catch (error) {
|
||||||
|
const anyAgent = agent;
|
||||||
|
if (typeof anyAgent.prompt === "function") {
|
||||||
|
await anyAgent.prompt(prompt);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
ctx.logger.warn("netxops alarm-push: cannot build user message (%s)", error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
async function createStickySession(ctx, prompt) {
|
||||||
|
const c = ctx;
|
||||||
|
const agents = c.agents;
|
||||||
|
const agentPresets = c.agentPresets;
|
||||||
|
const workspaceRegistry = c.workspaceRegistry;
|
||||||
|
const permissionPresets = c.permissionPresets;
|
||||||
|
const sessionTitle = c.sessionTitle;
|
||||||
|
const agentDefaultModel = c.agentDefaultModel;
|
||||||
|
if (!agentPresets || typeof agentPresets.resolve !== "function" || !workspaceRegistry || typeof workspaceRegistry.create !== "function") {
|
||||||
|
ctx.logger.warn("netxops alarm-push: agentPresets/workspaceRegistry unavailable — cannot create a DSH session");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (permissionPresets && typeof permissionPresets.resolve === "function") {
|
||||||
|
try {
|
||||||
|
permissionPresets.resolve(PERMISSION_PRESET);
|
||||||
|
} catch {
|
||||||
|
ctx.logger.warn("netxops alarm-push: permission preset %s missing", PERMISSION_PRESET);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const preset = await agentPresets.resolve(PRESET_ID);
|
||||||
|
if (typeof agentPresets.standingKeyFor === "function") {
|
||||||
|
await agentPresets.standingKeyFor(preset.id);
|
||||||
|
}
|
||||||
|
const selected = typeof agentDefaultModel?.currentSelection === "function" ? agentDefaultModel.currentSelection() : { provider: "deepseek", model: "deepseek-chat" };
|
||||||
|
const workspacePath = resolveWorkspacePath();
|
||||||
|
const workspace = await workspaceRegistry.create(workspacePath);
|
||||||
|
const sessionId = `netxops-alarm-${Date.now().toString(36)}`;
|
||||||
|
const handle = await agents.create({
|
||||||
|
sessionId,
|
||||||
|
meta: { cwd: workspace.path, agentPreset: preset.id },
|
||||||
|
agentOptions: { provider: selected.provider, model: selected.model },
|
||||||
|
setup: async (agentCtx) => {
|
||||||
|
if (typeof agentPresets.mount === "function") {
|
||||||
|
await agentPresets.mount(agentCtx, preset.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
if (typeof workspace.attachSession === "function") {
|
||||||
|
await workspace.attachSession(sessionId);
|
||||||
|
}
|
||||||
|
if (permissionPresets && typeof permissionPresets.set === "function") {
|
||||||
|
permissionPresets.set(handle.agent.session, PERMISSION_PRESET);
|
||||||
|
}
|
||||||
|
if (sessionTitle && typeof sessionTitle.rename === "function") {
|
||||||
|
sessionTitle.rename(handle.agent.session, TITLE);
|
||||||
|
}
|
||||||
|
await followup(ctx, handle.agent, prompt);
|
||||||
|
sticky = { sessionId, agent: handle.agent };
|
||||||
|
ctx.logger.info("netxops alarm-push: opened sticky session %s", sessionId);
|
||||||
|
} catch (error) {
|
||||||
|
try {
|
||||||
|
await handle.dispose?.();
|
||||||
|
} catch {}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function resetAlarmSession() {
|
||||||
|
sticky = null;
|
||||||
|
}
|
||||||
|
|
||||||
// src/netx/runtime.ts
|
// src/netx/runtime.ts
|
||||||
var STORE_KEY = Symbol.for("dsh-netxops.connection-store");
|
var STORE_KEY = Symbol.for("dsh-netxops.connection-store");
|
||||||
function store() {
|
function store() {
|
||||||
|
|
@ -36,33 +325,34 @@ var Config = z.object({
|
||||||
lang: z.string().default("zh"),
|
lang: z.string().default("zh"),
|
||||||
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),
|
||||||
installAgentPreset: z.boolean().default(true)
|
installAgentPreset: z.boolean().default(true),
|
||||||
|
alarmPushEnabled: z.boolean().default(false)
|
||||||
});
|
});
|
||||||
function packageRoot() {
|
function packageRoot() {
|
||||||
return join(dirname(fileURLToPath(import.meta.url)), "..");
|
return join2(dirname(fileURLToPath(import.meta.url)), "..");
|
||||||
}
|
}
|
||||||
function resolveDshHome() {
|
function resolveDshHome() {
|
||||||
const fromEnv = process.env.DSH_HOME?.trim();
|
const fromEnv = process.env.DSH_HOME?.trim();
|
||||||
if (fromEnv !== undefined && fromEnv.length > 0)
|
if (fromEnv !== undefined && fromEnv.length > 0)
|
||||||
return fromEnv;
|
return fromEnv;
|
||||||
return join(homedir(), ".dsh");
|
return join2(homedir2(), ".dsh");
|
||||||
}
|
}
|
||||||
function ensureAgentPresetInstalled(logger) {
|
function ensureAgentPresetInstalled(logger) {
|
||||||
const src = join(packageRoot(), "presets", NETXOPS_PRESET_ID);
|
const src = join2(packageRoot(), "presets", NETXOPS_PRESET_ID);
|
||||||
const composition = join(src, "agent.cordis.yml");
|
const composition = join2(src, "agent.cordis.yml");
|
||||||
if (!existsSync(composition)) {
|
if (!existsSync(composition)) {
|
||||||
logger.warn("netxops: bundled preset missing at %s — skip user-preset install", src);
|
logger.warn("netxops: bundled preset missing at %s — skip user-preset install", src);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const destParent = join(resolveDshHome(), ".agent-presets");
|
const destParent = join2(resolveDshHome(), ".agent-presets");
|
||||||
const dest = join(destParent, NETXOPS_PRESET_ID);
|
const dest = join2(destParent, NETXOPS_PRESET_ID);
|
||||||
try {
|
try {
|
||||||
mkdirSync(destParent, { recursive: true });
|
mkdirSync(destParent, { recursive: true });
|
||||||
if (existsSync(dest)) {
|
if (existsSync(dest)) {
|
||||||
rmSync(dest, { recursive: true, force: true });
|
rmSync(dest, { recursive: true, force: true });
|
||||||
}
|
}
|
||||||
cpSync(src, dest, { recursive: true });
|
cpSync(src, dest, { recursive: true });
|
||||||
writeFileSync(join(dest, ".dsh-netxops-managed"), `${new Date().toISOString()}
|
writeFileSync(join2(dest, ".dsh-netxops-managed"), `${new Date().toISOString()}
|
||||||
`, "utf8");
|
`, "utf8");
|
||||||
logger.info("netxops: agent preset installed at %s", dest);
|
logger.info("netxops: agent preset installed at %s", dest);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|
@ -87,9 +377,27 @@ function apply(ctx, config = Config({})) {
|
||||||
let source = () => config;
|
let source = () => config;
|
||||||
let publishing = Promise.resolve();
|
let publishing = Promise.resolve();
|
||||||
let generation = 0;
|
let generation = 0;
|
||||||
|
let stopAlarmPush;
|
||||||
if (config.installAgentPreset) {
|
if (config.installAgentPreset) {
|
||||||
ensureAgentPresetInstalled(ctx.logger);
|
ensureAgentPresetInstalled(ctx.logger);
|
||||||
}
|
}
|
||||||
|
const restartAlarmPush = (apiUrl, token, enabled, lang) => {
|
||||||
|
stopAlarmPush?.();
|
||||||
|
stopAlarmPush = undefined;
|
||||||
|
resetAlarmSession();
|
||||||
|
if (!enabled)
|
||||||
|
return;
|
||||||
|
if (!token.trim()) {
|
||||||
|
ctx.logger.warn("netxops alarm-push: enabled but token is empty — not connecting");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
stopAlarmPush = startAlarmPushClient({
|
||||||
|
apiUrl,
|
||||||
|
token,
|
||||||
|
logger: ctx.logger,
|
||||||
|
onAlarm: (payload) => deliverAlarmToSession(ctx, payload, lang)
|
||||||
|
});
|
||||||
|
};
|
||||||
const publish = () => {
|
const publish = () => {
|
||||||
publishing = publishing.then(async () => {
|
publishing = publishing.then(async () => {
|
||||||
const gen = ++generation;
|
const gen = ++generation;
|
||||||
|
|
@ -105,10 +413,11 @@ function apply(ctx, config = Config({})) {
|
||||||
lang: current.lang,
|
lang: current.lang,
|
||||||
toolCallTimeoutMs: current.toolCallTimeoutMs
|
toolCallTimeoutMs: current.toolCallTimeoutMs
|
||||||
});
|
});
|
||||||
|
restartAlarmPush(apiUrl, token, current.alarmPushEnabled === true, current.lang);
|
||||||
if (!tokenConfigured) {
|
if (!tokenConfigured) {
|
||||||
ctx.logger.warn("netxops: published connection → %s tokenConfigured=false (set credential %s)", apiUrl, current.tokenCredentialRef);
|
ctx.logger.warn("netxops: published connection → %s tokenConfigured=false (set credential %s)", apiUrl, current.tokenCredentialRef);
|
||||||
} else {
|
} else {
|
||||||
ctx.logger.info("netxops: published connection → %s tokenConfigured=true", apiUrl);
|
ctx.logger.info("netxops: published connection → %s tokenConfigured=true alarmPush=%s", apiUrl, current.alarmPushEnabled === true);
|
||||||
}
|
}
|
||||||
}).catch((error) => {
|
}).catch((error) => {
|
||||||
ctx.logger.error("netxops: connection publish error: %s", error);
|
ctx.logger.error("netxops: connection publish error: %s", error);
|
||||||
|
|
@ -129,6 +438,9 @@ function apply(ctx, config = Config({})) {
|
||||||
});
|
});
|
||||||
ctx.effect(() => () => {
|
ctx.effect(() => () => {
|
||||||
generation += 1;
|
generation += 1;
|
||||||
|
stopAlarmPush?.();
|
||||||
|
stopAlarmPush = undefined;
|
||||||
|
resetAlarmSession();
|
||||||
}, "netxops: dispose host bridge");
|
}, "netxops: dispose host bridge");
|
||||||
}
|
}
|
||||||
export {
|
export {
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
{
|
{
|
||||||
"name": "dsh-netxops",
|
"name": "dsh-netxops",
|
||||||
"version": "0.1.14",
|
"version": "0.1.15",
|
||||||
"description": "DeepSeek Harness Netx Ops: Ops-scoped netx__* REST tools + Plugins settings card + agent preset",
|
"description": "DeepSeek Harness Netx Ops: Ops-scoped netx__* REST tools + key-alarm push to DSH sessions + Plugins settings card + agent preset",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"private": false,
|
"private": false,
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,15 @@ const external = [
|
||||||
'@deepseek-ai/dsh-credentials',
|
'@deepseek-ai/dsh-credentials',
|
||||||
'@deepseek-ai/dsh-settings',
|
'@deepseek-ai/dsh-settings',
|
||||||
'@deepseek-ai/dsh-tools',
|
'@deepseek-ai/dsh-tools',
|
||||||
|
'@deepseek-ai/dsh-llm',
|
||||||
|
'@deepseek-ai/dsh-brand',
|
||||||
|
'@deepseek-ai/dsh-agent',
|
||||||
|
'@deepseek-ai/dsh-agent-presets',
|
||||||
|
'@deepseek-ai/dsh-workspace',
|
||||||
|
'@deepseek-ai/dsh-session',
|
||||||
|
'@deepseek-ai/dsh-session-title',
|
||||||
|
'@deepseek-ai/dsh-permission-presets',
|
||||||
|
'@deepseek-ai/dsh-agent-default-model',
|
||||||
]
|
]
|
||||||
|
|
||||||
const entries = [
|
const entries = [
|
||||||
|
|
|
||||||
|
|
@ -147,6 +147,38 @@ 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') }}
|
||||||
/>
|
/>
|
||||||
|
<div className="dsh-nx-field">
|
||||||
|
<div className="dsh-nx-fieldHead">
|
||||||
|
<label className="dsh-nx-label" htmlFor="netxops-alarm-push">{t('alarmPushEnabled')}</label>
|
||||||
|
{state.alarmPushEnabled.overridden
|
||||||
|
? (
|
||||||
|
<span className="dsh-nx-badges">
|
||||||
|
<span className="dsh-nx-badge">{t('overridden')}</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="dsh-nx-reset"
|
||||||
|
disabled={disabled}
|
||||||
|
onClick={() => { props.resetField('alarmPushEnabled') }}
|
||||||
|
>
|
||||||
|
{t('reset')}
|
||||||
|
</button>
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
: null}
|
||||||
|
</div>
|
||||||
|
<label className="dsh-nx-checkRow" htmlFor="netxops-alarm-push">
|
||||||
|
<input
|
||||||
|
id="netxops-alarm-push"
|
||||||
|
type="checkbox"
|
||||||
|
checked={state.alarmPushEnabled.text === 'true'}
|
||||||
|
disabled={disabled}
|
||||||
|
onChange={(event) => {
|
||||||
|
props.edit('alarmPushEnabled', event.target.checked ? 'true' : 'false')
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<span>{t('alarmPushEnabledHint')}</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
<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
|
||||||
|
|
|
||||||
|
|
@ -62,6 +62,24 @@ export function textField(field: string): CardFieldSpec {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Boolean settings field rendered as a checkbox (stores true / clears when false). */
|
||||||
|
export function booleanField(field: string): CardFieldSpec {
|
||||||
|
return {
|
||||||
|
field,
|
||||||
|
format: value => (value === true ? 'true' : 'false'),
|
||||||
|
parse: (text) => {
|
||||||
|
const normalized = text.trim().toLowerCase()
|
||||||
|
if (normalized === 'true' || normalized === '1' || normalized === 'yes') {
|
||||||
|
return { kind: 'set', value: true }
|
||||||
|
}
|
||||||
|
if (normalized === 'false' || normalized === '0' || normalized === 'no' || normalized === '') {
|
||||||
|
return { kind: 'clear' }
|
||||||
|
}
|
||||||
|
return undefined
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export class CardForm<T> {
|
export class CardForm<T> {
|
||||||
private readonly specs: Map<string, CardFieldSpec>
|
private readonly specs: Map<string, CardFieldSpec>
|
||||||
private readonly secretSpecs: Map<string, CardSecretSpec>
|
private readonly secretSpecs: Map<string, CardSecretSpec>
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,7 @@ import type {} from '@deepseek-ai/dsh-api-remotes/client'
|
||||||
import type { SettingsScope, SettingsScopeSnapshot } from '@deepseek-ai/dsh-client-ui-settings/client'
|
import type { SettingsScope, SettingsScopeSnapshot } from '@deepseek-ai/dsh-client-ui-settings/client'
|
||||||
import type { SnapshotStore } from './snapshot-store.ts'
|
import type { SnapshotStore } from './snapshot-store.ts'
|
||||||
import {
|
import {
|
||||||
CardForm, textField,
|
CardForm, textField, booleanField,
|
||||||
type CardActions, type CardFieldState, type CardShell,
|
type CardActions, type CardFieldState, type CardShell,
|
||||||
} from './card-form.ts'
|
} from './card-form.ts'
|
||||||
|
|
||||||
|
|
@ -19,6 +19,7 @@ export interface NetxopsSettings {
|
||||||
apiUrl?: string
|
apiUrl?: string
|
||||||
lang?: string
|
lang?: string
|
||||||
tokenCredentialRef?: string
|
tokenCredentialRef?: string
|
||||||
|
alarmPushEnabled?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
interface CredentialState {
|
interface CredentialState {
|
||||||
|
|
@ -31,6 +32,7 @@ interface CredentialState {
|
||||||
export interface NetxopsCardState extends CardShell {
|
export interface NetxopsCardState extends CardShell {
|
||||||
apiUrl: CardFieldState
|
apiUrl: CardFieldState
|
||||||
lang: CardFieldState
|
lang: CardFieldState
|
||||||
|
alarmPushEnabled: CardFieldState
|
||||||
apiToken: CardFieldState
|
apiToken: CardFieldState
|
||||||
apiTokenConfigured: boolean
|
apiTokenConfigured: boolean
|
||||||
apiTokenWritable: boolean
|
apiTokenWritable: boolean
|
||||||
|
|
@ -64,7 +66,7 @@ export class NetxopsCardController {
|
||||||
) {
|
) {
|
||||||
this.form = new CardForm(
|
this.form = new CardForm(
|
||||||
scope,
|
scope,
|
||||||
[textField('apiUrl'), textField('lang')],
|
[textField('apiUrl'), textField('lang'), booleanField('alarmPushEnabled')],
|
||||||
[{ 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())
|
||||||
|
|
@ -89,6 +91,7 @@ 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'),
|
||||||
|
alarmPushEnabled: this.form.field('alarmPushEnabled'),
|
||||||
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,6 +7,8 @@ export type NetxopsLocaleKey =
|
||||||
| 'apiUrlHint'
|
| 'apiUrlHint'
|
||||||
| 'lang'
|
| 'lang'
|
||||||
| 'langHint'
|
| 'langHint'
|
||||||
|
| 'alarmPushEnabled'
|
||||||
|
| 'alarmPushEnabledHint'
|
||||||
| 'apiToken'
|
| 'apiToken'
|
||||||
| 'apiTokenHint'
|
| 'apiTokenHint'
|
||||||
| 'apiTokenUnavailable'
|
| 'apiTokenUnavailable'
|
||||||
|
|
@ -28,9 +30,11 @@ export const en: Record<NetxopsLocaleKey, string> = {
|
||||||
title: 'Netx Ops',
|
title: 'Netx Ops',
|
||||||
description: 'UME API endpoint and bearer token for 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 (also used for alarm subscribe)',
|
||||||
lang: 'Language',
|
lang: 'Language',
|
||||||
langHint: 'Response language hint (zh / en).',
|
langHint: 'Response language hint (zh / en).',
|
||||||
|
alarmPushEnabled: 'Key-alarm push',
|
||||||
|
alarmPushEnabledHint: 'Dial out to netx and open/follow a DSH session when a matched key alarm arrives. WhatsApp/im is optional and not required.',
|
||||||
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.',
|
||||||
|
|
@ -53,9 +57,11 @@ export const zh: Record<NetxopsLocaleKey, string> = {
|
||||||
title: 'Netx Ops',
|
title: 'Netx Ops',
|
||||||
description: 'UME API 地址与 Bearer Token,供 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: '响应语言提示(zh / en)。',
|
langHint: '响应语言提示(zh / en)。',
|
||||||
|
alarmPushEnabled: '关键告警推送',
|
||||||
|
alarmPushEnabledHint: '主动连接 netx;匹配到关键告警后在本机打开/续写一条 DSH 会话。不必安装 WhatsApp / im。',
|
||||||
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 在监视凭据文件则无需重启。',
|
||||||
|
|
|
||||||
|
|
@ -29,6 +29,8 @@ const CSS = `
|
||||||
.dsh-nx-input:focus-visible{outline:none;border-color:var(--dsw-alias-brand-primary)}
|
.dsh-nx-input:focus-visible{outline:none;border-color:var(--dsw-alias-brand-primary)}
|
||||||
.dsh-nx-inputInvalid{border-color:var(--dsw-alias-label-error)}
|
.dsh-nx-inputInvalid{border-color:var(--dsw-alias-label-error)}
|
||||||
.dsh-nx-hint{margin:0;font-size:12px;line-height:1.5;color:var(--dsw-alias-label-tertiary)}
|
.dsh-nx-hint{margin:0;font-size:12px;line-height:1.5;color:var(--dsw-alias-label-tertiary)}
|
||||||
|
.dsh-nx-checkRow{display:flex;align-items:flex-start;gap:10px;font-size:12px;line-height:1.5;color:var(--dsw-alias-label-secondary);cursor:pointer}
|
||||||
|
.dsh-nx-checkRow input{margin-top:2px;flex:none}
|
||||||
.dsh-nx-invalid{margin:0;font-size:12px;line-height:1.5;color:var(--dsw-alias-label-error)}
|
.dsh-nx-invalid{margin:0;font-size:12px;line-height:1.5;color:var(--dsw-alias-label-error)}
|
||||||
.dsh-nx-footer{display:flex;align-items:center;justify-content:flex-end;gap:8px;padding:12px 0 4px;border-top:.5px solid var(--dsw-alias-border-l2)}
|
.dsh-nx-footer{display:flex;align-items:center;justify-content:flex-end;gap:8px;padding:12px 0 4px;border-top:.5px solid var(--dsw-alias-border-l2)}
|
||||||
.dsh-nx-failed{flex:1;min-width:0;margin:0;font-size:12px;line-height:1.5;color:var(--dsw-alias-label-error)}
|
.dsh-nx-failed{flex:1;min-width:0;margin:0;font-size:12px;line-height:1.5;color:var(--dsw-alias-label-error)}
|
||||||
|
|
|
||||||
49
src/index.ts
49
src/index.ts
|
|
@ -1,11 +1,10 @@
|
||||||
/**
|
/**
|
||||||
* Host-plane Netx Ops: settings (apiUrl / lang) + credentials (NETX_API_TOKEN)
|
* Host-plane Netx Ops: settings (apiUrl / lang / alarm push) + credentials
|
||||||
* publish a connection snapshot; the Ops preset mounts `netx__*` into its own
|
* (NETX_API_TOKEN) publish a connection snapshot; the Ops preset mounts
|
||||||
* tool scope (`dsh-netxops/tools`) so other agents do not see them.
|
* `netx__*` into its own tool scope (`dsh-netxops/tools`).
|
||||||
*
|
*
|
||||||
* On activate, the agent preset + skills are copied into
|
* When「关键告警推送」is on, this host dials out to netx's fixed-IP alarm hub and
|
||||||
* `$DSH_HOME/.agent-presets/netxops` so `dsh plugin add` alone is enough
|
* opens/follows a sticky DSH session (im / WhatsApp is optional and separate).
|
||||||
* (Windows junctions are invisible to DSH's `Dirent.isDirectory()` scan).
|
|
||||||
*
|
*
|
||||||
* @module dsh-netxops
|
* @module dsh-netxops
|
||||||
*/
|
*/
|
||||||
|
|
@ -20,6 +19,8 @@ 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 { startAlarmPushClient } from './netx/alarm-push.ts'
|
||||||
|
import { deliverAlarmToSession, resetAlarmSession } from './netx/alarm-session.ts'
|
||||||
import { publishNetxConnection } from './netx/runtime.ts'
|
import { publishNetxConnection } from './netx/runtime.ts'
|
||||||
|
|
||||||
/** Cordis plugin name. */
|
/** Cordis plugin name. */
|
||||||
|
|
@ -52,6 +53,11 @@ export interface Config {
|
||||||
* on every activate (required for Settings → Agent presets).
|
* on every activate (required for Settings → Agent presets).
|
||||||
*/
|
*/
|
||||||
installAgentPreset: boolean
|
installAgentPreset: boolean
|
||||||
|
/**
|
||||||
|
* Dial out to netx `/v1/integrations/dsh-alarm/ws` and deliver matched key
|
||||||
|
* alarms into a sticky DSH session.
|
||||||
|
*/
|
||||||
|
alarmPushEnabled: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export const Config: z<Config> = z.object({
|
export const Config: z<Config> = z.object({
|
||||||
|
|
@ -60,6 +66,7 @@ export const Config: z<Config> = z.object({
|
||||||
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),
|
||||||
installAgentPreset: z.boolean().default(true),
|
installAgentPreset: z.boolean().default(true),
|
||||||
|
alarmPushEnabled: z.boolean().default(false),
|
||||||
})
|
})
|
||||||
|
|
||||||
/** Package root (parent of `lib/` or `src/` depending on launch). */
|
/** Package root (parent of `lib/` or `src/` depending on launch). */
|
||||||
|
|
@ -144,17 +151,35 @@ function installNetxopsSettings(
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Apply the Netx Ops host bridge (settings + connection publish; tools live on the preset).
|
* Apply the Netx Ops host bridge (settings + connection publish + optional alarm push).
|
||||||
*/
|
*/
|
||||||
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 publishing: Promise<void> = Promise.resolve()
|
let publishing: Promise<void> = Promise.resolve()
|
||||||
let generation = 0
|
let generation = 0
|
||||||
|
let stopAlarmPush: (() => void) | undefined
|
||||||
|
|
||||||
if (config.installAgentPreset) {
|
if (config.installAgentPreset) {
|
||||||
ensureAgentPresetInstalled(ctx.logger)
|
ensureAgentPresetInstalled(ctx.logger)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const restartAlarmPush = (apiUrl: string, token: string, enabled: boolean, lang: string): void => {
|
||||||
|
stopAlarmPush?.()
|
||||||
|
stopAlarmPush = undefined
|
||||||
|
resetAlarmSession()
|
||||||
|
if (!enabled) return
|
||||||
|
if (!token.trim()) {
|
||||||
|
ctx.logger.warn('netxops alarm-push: enabled but token is empty — not connecting')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
stopAlarmPush = startAlarmPushClient({
|
||||||
|
apiUrl,
|
||||||
|
token,
|
||||||
|
logger: ctx.logger,
|
||||||
|
onAlarm: (payload) => deliverAlarmToSession(ctx, payload, lang),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
const publish = (): void => {
|
const publish = (): void => {
|
||||||
publishing = publishing.then(async () => {
|
publishing = publishing.then(async () => {
|
||||||
const gen = ++generation
|
const gen = ++generation
|
||||||
|
|
@ -170,6 +195,7 @@ export function apply(ctx: Context, config: Config = Config({})): void {
|
||||||
lang: current.lang,
|
lang: current.lang,
|
||||||
toolCallTimeoutMs: current.toolCallTimeoutMs,
|
toolCallTimeoutMs: current.toolCallTimeoutMs,
|
||||||
})
|
})
|
||||||
|
restartAlarmPush(apiUrl, token, current.alarmPushEnabled === true, current.lang)
|
||||||
if (!tokenConfigured) {
|
if (!tokenConfigured) {
|
||||||
ctx.logger.warn(
|
ctx.logger.warn(
|
||||||
'netxops: published connection → %s tokenConfigured=false (set credential %s)',
|
'netxops: published connection → %s tokenConfigured=false (set credential %s)',
|
||||||
|
|
@ -177,7 +203,11 @@ export function apply(ctx: Context, config: Config = Config({})): void {
|
||||||
current.tokenCredentialRef,
|
current.tokenCredentialRef,
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
ctx.logger.info('netxops: published connection → %s tokenConfigured=true', apiUrl)
|
ctx.logger.info(
|
||||||
|
'netxops: published connection → %s tokenConfigured=true alarmPush=%s',
|
||||||
|
apiUrl,
|
||||||
|
current.alarmPushEnabled === true,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}).catch((error) => {
|
}).catch((error) => {
|
||||||
ctx.logger.error('netxops: connection publish error: %s', error)
|
ctx.logger.error('netxops: connection publish error: %s', error)
|
||||||
|
|
@ -201,5 +231,8 @@ export function apply(ctx: Context, config: Config = Config({})): void {
|
||||||
|
|
||||||
ctx.effect(() => () => {
|
ctx.effect(() => () => {
|
||||||
generation += 1
|
generation += 1
|
||||||
|
stopAlarmPush?.()
|
||||||
|
stopAlarmPush = undefined
|
||||||
|
resetAlarmSession()
|
||||||
}, 'netxops: dispose host bridge')
|
}, 'netxops: dispose host bridge')
|
||||||
}
|
}
|
||||||
|
|
|
||||||
220
src/netx/alarm-push.ts
Normal file
220
src/netx/alarm-push.ts
Normal file
|
|
@ -0,0 +1,220 @@
|
||||||
|
/**
|
||||||
|
* Convert a netx REST apiUrl into the DSH alarm-subscribe WebSocket URL.
|
||||||
|
* @param apiUrl - e.g. http://192.168.1.10:8890
|
||||||
|
*/
|
||||||
|
export function alarmSubscribeUrl(apiUrl: string): string {
|
||||||
|
const trimmed = apiUrl.trim().replace(/\/$/, '')
|
||||||
|
if (!trimmed) return ''
|
||||||
|
let url: URL
|
||||||
|
try {
|
||||||
|
url = new URL(trimmed)
|
||||||
|
} catch {
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
url.protocol = url.protocol === 'https:' ? 'wss:' : 'ws:'
|
||||||
|
url.pathname = '/v1/integrations/dsh-alarm/ws'
|
||||||
|
url.search = ''
|
||||||
|
url.hash = ''
|
||||||
|
return url.toString()
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface KeyAlarmPayload {
|
||||||
|
action?: string
|
||||||
|
alarm_key?: string
|
||||||
|
notification_id?: string
|
||||||
|
rule_label?: string
|
||||||
|
rule_key?: string
|
||||||
|
native_probable_cause?: string
|
||||||
|
perceived_severity?: string
|
||||||
|
time_created?: string
|
||||||
|
object_name?: string
|
||||||
|
ne_id?: string
|
||||||
|
ne?: Record<string, string>
|
||||||
|
[key: string]: unknown
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Human-readable prompt for a sticky Netx Ops DSH session. */
|
||||||
|
export function formatAlarmPrompt(payload: KeyAlarmPayload, lang = 'zh'): string {
|
||||||
|
const action = String(payload.action ?? '').trim().toLowerCase()
|
||||||
|
const actionZh: Record<string, string> = {
|
||||||
|
inserted: '告警产生',
|
||||||
|
updated: '告警更新',
|
||||||
|
deleted: '告警清除',
|
||||||
|
}
|
||||||
|
const actionEn: Record<string, string> = {
|
||||||
|
inserted: 'Alarm Raised',
|
||||||
|
updated: 'Alarm Updated',
|
||||||
|
deleted: 'Alarm Cleared',
|
||||||
|
}
|
||||||
|
const ne = payload.ne && typeof payload.ne === 'object' ? payload.ne : {}
|
||||||
|
const host = String(ne.host_name ?? '').trim()
|
||||||
|
const ip = String(ne.ip_address ?? '').trim()
|
||||||
|
const neName = String(ne.ne_name ?? ne.user_label ?? '').trim()
|
||||||
|
let device = host || neName || String(payload.ne_id ?? '').trim() || '-'
|
||||||
|
if (ip) device = device === '-' ? ip : `${device} (${ip})`
|
||||||
|
|
||||||
|
const label = String(payload.rule_label ?? payload.native_probable_cause ?? '关键告警').trim()
|
||||||
|
if (lang.startsWith('en')) {
|
||||||
|
return [
|
||||||
|
`[UME ${actionEn[action] ?? (action || 'Alarm')}] ${label}`,
|
||||||
|
`Device: ${device}`,
|
||||||
|
`Object: ${String(payload.object_name ?? '-').trim()}`,
|
||||||
|
`Severity: ${String(payload.perceived_severity ?? '-').trim()}`,
|
||||||
|
`Cause: ${String(payload.native_probable_cause ?? '-').trim()}`,
|
||||||
|
`Time: ${String(payload.time_created ?? '-').trim()}`,
|
||||||
|
`notificationId: ${String(payload.notification_id ?? '-').trim()}`,
|
||||||
|
`alarm_key: ${String(payload.alarm_key ?? '-').trim()}`,
|
||||||
|
'',
|
||||||
|
'Please analyze this key alarm and suggest next ops steps.',
|
||||||
|
].join('\n')
|
||||||
|
}
|
||||||
|
return [
|
||||||
|
`[UME ${actionZh[action] ?? (action || '告警')}] ${label}`,
|
||||||
|
`设备: ${device}`,
|
||||||
|
`对象: ${String(payload.object_name ?? '-').trim()}`,
|
||||||
|
`级别: ${String(payload.perceived_severity ?? '-').trim()}`,
|
||||||
|
`原因: ${String(payload.native_probable_cause ?? '-').trim()}`,
|
||||||
|
`时间: ${String(payload.time_created ?? '-').trim()}`,
|
||||||
|
`notificationId: ${String(payload.notification_id ?? '-').trim()}`,
|
||||||
|
`alarm_key: ${String(payload.alarm_key ?? '-').trim()}`,
|
||||||
|
'',
|
||||||
|
'请分析这条关键告警并给出下一步运维建议。',
|
||||||
|
].join('\n')
|
||||||
|
}
|
||||||
|
|
||||||
|
export type AlarmHandler = (payload: KeyAlarmPayload) => void | Promise<void>
|
||||||
|
|
||||||
|
export interface AlarmPushClientOptions {
|
||||||
|
apiUrl: string
|
||||||
|
token: string
|
||||||
|
logger?: {
|
||||||
|
info?: (...args: unknown[]) => void
|
||||||
|
warn?: (...args: unknown[]) => void
|
||||||
|
error?: (...args: unknown[]) => void
|
||||||
|
}
|
||||||
|
onAlarm: AlarmHandler
|
||||||
|
/** Reconnect delay base in ms (default 2000). */
|
||||||
|
reconnectMs?: number
|
||||||
|
/** WebSocket constructor override (tests). */
|
||||||
|
WebSocketImpl?: typeof WebSocket
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Dial out to netx's fixed-IP alarm hub and forward `netx.alarm` events.
|
||||||
|
* @returns disposer that closes the socket and cancels reconnect.
|
||||||
|
*/
|
||||||
|
export function startAlarmPushClient(options: AlarmPushClientOptions): () => void {
|
||||||
|
const log = options.logger ?? console
|
||||||
|
const wsUrl = alarmSubscribeUrl(options.apiUrl)
|
||||||
|
const token = options.token.trim()
|
||||||
|
if (!wsUrl || !token) {
|
||||||
|
log.warn?.('netxops alarm-push: missing apiUrl or token — not connecting')
|
||||||
|
return () => {}
|
||||||
|
}
|
||||||
|
|
||||||
|
const WS = options.WebSocketImpl ?? globalThis.WebSocket
|
||||||
|
if (typeof WS !== 'function') {
|
||||||
|
log.error?.('netxops alarm-push: WebSocket is unavailable in this runtime')
|
||||||
|
return () => {}
|
||||||
|
}
|
||||||
|
|
||||||
|
let closed = false
|
||||||
|
let socket: WebSocket | null = null
|
||||||
|
let reconnectTimer: ReturnType<typeof setTimeout> | undefined
|
||||||
|
let attempt = 0
|
||||||
|
const baseDelay = Math.max(500, options.reconnectMs ?? 2_000)
|
||||||
|
|
||||||
|
const clearReconnect = (): void => {
|
||||||
|
if (reconnectTimer !== undefined) {
|
||||||
|
clearTimeout(reconnectTimer)
|
||||||
|
reconnectTimer = undefined
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const scheduleReconnect = (): void => {
|
||||||
|
if (closed) return
|
||||||
|
clearReconnect()
|
||||||
|
const delay = Math.min(60_000, baseDelay * (2 ** Math.min(attempt, 5)))
|
||||||
|
attempt += 1
|
||||||
|
reconnectTimer = setTimeout(() => { connect() }, delay)
|
||||||
|
}
|
||||||
|
|
||||||
|
const connect = (): void => {
|
||||||
|
if (closed) return
|
||||||
|
clearReconnect()
|
||||||
|
try {
|
||||||
|
socket = new WS(wsUrl)
|
||||||
|
} catch (error) {
|
||||||
|
log.warn?.('netxops alarm-push: connect failed:', error)
|
||||||
|
scheduleReconnect()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
socket.addEventListener('open', () => {
|
||||||
|
attempt = 0
|
||||||
|
socket?.send(JSON.stringify({ type: 'auth', token }))
|
||||||
|
})
|
||||||
|
|
||||||
|
socket.addEventListener('message', (event) => {
|
||||||
|
let msg: Record<string, unknown>
|
||||||
|
try {
|
||||||
|
msg = JSON.parse(String(event.data)) as Record<string, unknown>
|
||||||
|
} catch {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const type = String(msg.type ?? '').toLowerCase()
|
||||||
|
if (type === 'auth-ok') {
|
||||||
|
log.info?.('netxops alarm-push: subscribed to %s', wsUrl)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (type === 'auth-fail') {
|
||||||
|
log.error?.('netxops alarm-push: auth failed (%s)', msg.error)
|
||||||
|
socket?.close()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (type === 'pong') return
|
||||||
|
if (type === 'event' && String(msg.event ?? '') === 'netx.alarm') {
|
||||||
|
const payload = msg.payload && typeof msg.payload === 'object'
|
||||||
|
? msg.payload as KeyAlarmPayload
|
||||||
|
: {}
|
||||||
|
void Promise.resolve(options.onAlarm(payload)).catch((error) => {
|
||||||
|
log.warn?.('netxops alarm-push: handler failed:', error)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
socket.addEventListener('close', () => {
|
||||||
|
socket = null
|
||||||
|
if (!closed) scheduleReconnect()
|
||||||
|
})
|
||||||
|
|
||||||
|
socket.addEventListener('error', () => {
|
||||||
|
// close handler schedules reconnect
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Keepalive ping while connected.
|
||||||
|
const pingTimer = setInterval(() => {
|
||||||
|
if (socket && socket.readyState === WS.OPEN) {
|
||||||
|
try {
|
||||||
|
socket.send(JSON.stringify({ type: 'ping', ts: new Date().toISOString() }))
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, 25_000)
|
||||||
|
|
||||||
|
connect()
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
closed = true
|
||||||
|
clearReconnect()
|
||||||
|
clearInterval(pingTimer)
|
||||||
|
try {
|
||||||
|
socket?.close()
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
socket = null
|
||||||
|
}
|
||||||
|
}
|
||||||
180
src/netx/alarm-session.ts
Normal file
180
src/netx/alarm-session.ts
Normal file
|
|
@ -0,0 +1,180 @@
|
||||||
|
/**
|
||||||
|
* Deliver a key-alarm into a sticky Netx Ops DSH session (create once, then followup).
|
||||||
|
*
|
||||||
|
* Soft-depends on host services that the web profile usually provides
|
||||||
|
* (agents / agentPresets / workspaceRegistry / permissionPresets / sessionTitle /
|
||||||
|
* agentDefaultModel). When any are missing, delivery is skipped with a warning.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { homedir } from 'node:os'
|
||||||
|
import { join } from 'node:path'
|
||||||
|
import type { Context } from '@deepseek-ai/cordis'
|
||||||
|
import { formatAlarmPrompt, type KeyAlarmPayload } from './alarm-push.ts'
|
||||||
|
|
||||||
|
const PRESET_ID = 'netxops'
|
||||||
|
const PERMISSION_PRESET = 'default'
|
||||||
|
const TITLE = 'Netx 关键告警'
|
||||||
|
|
||||||
|
interface StickyHandle {
|
||||||
|
sessionId: string
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Agent type varies by DSH version
|
||||||
|
agent: any
|
||||||
|
}
|
||||||
|
|
||||||
|
let sticky: StickyHandle | null = null
|
||||||
|
|
||||||
|
function resolveWorkspacePath(): string {
|
||||||
|
const fromEnv = process.env.DSH_HOME?.trim()
|
||||||
|
const home = fromEnv && fromEnv.length > 0 ? fromEnv : join(homedir(), '.dsh')
|
||||||
|
return join(home, 'workspaces', 'netxops-alarms')
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Open or reuse a Netx Ops session and append the alarm as a user followup.
|
||||||
|
* @param ctx - host cordis context (may lack session services on minimal profiles).
|
||||||
|
* @param payload - matched key-alert payload from netx.
|
||||||
|
* @param lang - zh / en prompt flavour.
|
||||||
|
*/
|
||||||
|
export async function deliverAlarmToSession(
|
||||||
|
ctx: Context,
|
||||||
|
payload: KeyAlarmPayload,
|
||||||
|
lang = 'zh',
|
||||||
|
): Promise<void> {
|
||||||
|
const prompt = formatAlarmPrompt(payload, lang)
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
const agents = (ctx as any).agents
|
||||||
|
if (!agents || typeof agents.create !== 'function') {
|
||||||
|
ctx.logger.warn(
|
||||||
|
'netxops alarm-push: ctx.agents unavailable — enable a profile that mounts agents to receive alarms in a DSH session',
|
||||||
|
)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (sticky?.agent && typeof sticky.agent.followup === 'function') {
|
||||||
|
try {
|
||||||
|
await followup(ctx, sticky.agent, prompt)
|
||||||
|
return
|
||||||
|
} catch (error) {
|
||||||
|
ctx.logger.warn('netxops alarm-push: sticky followup failed, recreating session: %s', error)
|
||||||
|
sticky = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await createStickySession(ctx, prompt)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function followup(ctx: Context, agent: { followup: (msg: unknown) => unknown }, prompt: string): Promise<void> {
|
||||||
|
let createUserMessage: ((input: {
|
||||||
|
content: Array<{ type: 'text'; text: string }>
|
||||||
|
source?: Record<string, unknown>
|
||||||
|
}) => unknown) | undefined
|
||||||
|
try {
|
||||||
|
const mod = await import('@deepseek-ai/dsh-llm') as {
|
||||||
|
createUserMessage?: typeof createUserMessage
|
||||||
|
boundContextSummary?: (text: string) => unknown
|
||||||
|
}
|
||||||
|
createUserMessage = mod.createUserMessage
|
||||||
|
if (typeof createUserMessage !== 'function') throw new Error('createUserMessage missing')
|
||||||
|
const summary = typeof mod.boundContextSummary === 'function'
|
||||||
|
? mod.boundContextSummary('netx key alarm')
|
||||||
|
: 'netx key alarm'
|
||||||
|
agent.followup(createUserMessage({
|
||||||
|
content: [{ type: 'text', text: prompt }],
|
||||||
|
source: {
|
||||||
|
kind: 'webhook',
|
||||||
|
provider: 'netx',
|
||||||
|
source: 'dsh-alarm-hub',
|
||||||
|
form: 'notice',
|
||||||
|
summary,
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
} catch (error) {
|
||||||
|
// Fallback: some hosts accept a plain text followup helper on agents.
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
const anyAgent = agent as any
|
||||||
|
if (typeof anyAgent.prompt === 'function') {
|
||||||
|
await anyAgent.prompt(prompt)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ctx.logger.warn('netxops alarm-push: cannot build user message (%s)', error)
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createStickySession(ctx: Context, prompt: string): Promise<void> {
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
const c = ctx as any
|
||||||
|
const agents = c.agents
|
||||||
|
const agentPresets = c.agentPresets
|
||||||
|
const workspaceRegistry = c.workspaceRegistry
|
||||||
|
const permissionPresets = c.permissionPresets
|
||||||
|
const sessionTitle = c.sessionTitle
|
||||||
|
const agentDefaultModel = c.agentDefaultModel
|
||||||
|
|
||||||
|
if (!agentPresets || typeof agentPresets.resolve !== 'function'
|
||||||
|
|| !workspaceRegistry || typeof workspaceRegistry.create !== 'function') {
|
||||||
|
ctx.logger.warn(
|
||||||
|
'netxops alarm-push: agentPresets/workspaceRegistry unavailable — cannot create a DSH session',
|
||||||
|
)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (permissionPresets && typeof permissionPresets.resolve === 'function') {
|
||||||
|
try {
|
||||||
|
permissionPresets.resolve(PERMISSION_PRESET)
|
||||||
|
} catch {
|
||||||
|
ctx.logger.warn('netxops alarm-push: permission preset %s missing', PERMISSION_PRESET)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const preset = await agentPresets.resolve(PRESET_ID)
|
||||||
|
if (typeof agentPresets.standingKeyFor === 'function') {
|
||||||
|
await agentPresets.standingKeyFor(preset.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
const selected = typeof agentDefaultModel?.currentSelection === 'function'
|
||||||
|
? agentDefaultModel.currentSelection()
|
||||||
|
: { provider: 'deepseek', model: 'deepseek-chat' }
|
||||||
|
|
||||||
|
const workspacePath = resolveWorkspacePath()
|
||||||
|
const workspace = await workspaceRegistry.create(workspacePath)
|
||||||
|
const sessionId = `netxops-alarm-${Date.now().toString(36)}`
|
||||||
|
|
||||||
|
const handle = await agents.create({
|
||||||
|
sessionId,
|
||||||
|
meta: { cwd: workspace.path, agentPreset: preset.id },
|
||||||
|
agentOptions: { provider: selected.provider, model: selected.model },
|
||||||
|
setup: async (agentCtx: Context) => {
|
||||||
|
if (typeof agentPresets.mount === 'function') {
|
||||||
|
await agentPresets.mount(agentCtx, preset.id)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (typeof workspace.attachSession === 'function') {
|
||||||
|
await workspace.attachSession(sessionId)
|
||||||
|
}
|
||||||
|
if (permissionPresets && typeof permissionPresets.set === 'function') {
|
||||||
|
permissionPresets.set(handle.agent.session, PERMISSION_PRESET)
|
||||||
|
}
|
||||||
|
if (sessionTitle && typeof sessionTitle.rename === 'function') {
|
||||||
|
sessionTitle.rename(handle.agent.session, TITLE)
|
||||||
|
}
|
||||||
|
await followup(ctx, handle.agent, prompt)
|
||||||
|
sticky = { sessionId, agent: handle.agent }
|
||||||
|
ctx.logger.info('netxops alarm-push: opened sticky session %s', sessionId)
|
||||||
|
} catch (error) {
|
||||||
|
try {
|
||||||
|
await handle.dispose?.()
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Drop the sticky handle (tests / dispose). */
|
||||||
|
export function resetAlarmSession(): void {
|
||||||
|
sticky = null
|
||||||
|
}
|
||||||
36
test/alarm-push.test.mjs
Normal file
36
test/alarm-push.test.mjs
Normal file
|
|
@ -0,0 +1,36 @@
|
||||||
|
import assert from 'node:assert/strict'
|
||||||
|
import { test } from 'node:test'
|
||||||
|
|
||||||
|
import {
|
||||||
|
alarmSubscribeUrl,
|
||||||
|
formatAlarmPrompt,
|
||||||
|
} from '../src/netx/alarm-push.ts'
|
||||||
|
|
||||||
|
test('alarmSubscribeUrl maps http(s) REST roots to the hub path', () => {
|
||||||
|
assert.equal(
|
||||||
|
alarmSubscribeUrl('http://192.168.1.10:8890'),
|
||||||
|
'ws://192.168.1.10:8890/v1/integrations/dsh-alarm/ws',
|
||||||
|
)
|
||||||
|
assert.equal(
|
||||||
|
alarmSubscribeUrl('https://netx.example/'),
|
||||||
|
'wss://netx.example/v1/integrations/dsh-alarm/ws',
|
||||||
|
)
|
||||||
|
assert.equal(alarmSubscribeUrl(''), '')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('formatAlarmPrompt keeps human fields without raw sockets', () => {
|
||||||
|
const text = formatAlarmPrompt({
|
||||||
|
action: 'inserted',
|
||||||
|
rule_label: 'Power Down',
|
||||||
|
object_name: 'PORT-1',
|
||||||
|
perceived_severity: 'critical',
|
||||||
|
native_probable_cause: 'LOS',
|
||||||
|
time_created: '2026-01-01T00:00:00Z',
|
||||||
|
notification_id: 'n1',
|
||||||
|
alarm_key: 'k1',
|
||||||
|
ne: { host_name: 'PE1', ip_address: '10.0.0.1' },
|
||||||
|
}, 'zh')
|
||||||
|
assert.match(text, /告警产生/)
|
||||||
|
assert.match(text, /PE1 \(10\.0\.0\.1\)/)
|
||||||
|
assert.match(text, /请分析这条关键告警/)
|
||||||
|
})
|
||||||
Loading…
Add table
Add a link
Reference in a new issue