Ship capability groups (nms/common/topology) with shared netx skills.

One group maps to one skill and tools-*; rename NMS tools to *Nms*; sync playbooks from netx/skills.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
oliver 2026-09-06 01:46:07 +08:00
parent 83c3796fbb
commit 3c9a383071
47 changed files with 11790 additions and 680 deletions

View file

@ -112,6 +112,68 @@ function alarmPushTone(phase) {
}
}
// src/client/im-delivery-catalog.ts
var IM_DELIVERY_CATALOG_ENDPOINT = "im-delivery.catalog";
var EMPTY_IM_DELIVERY_CATALOG = {
available: true,
options: [],
hint: "",
loading: false
};
function asImDeliveryCatalog(value) {
if (value === null || typeof value !== "object" || Array.isArray(value)) {
return { ...EMPTY_IM_DELIVERY_CATALOG, available: false };
}
const row = value;
const raw = Array.isArray(row.options) ? row.options : [];
const options = [];
for (const entry of raw) {
if (entry === null || typeof entry !== "object" || Array.isArray(entry))
continue;
const item = entry;
const botId = typeof item.botId === "string" ? item.botId.trim() : "";
const targetId = typeof item.targetId === "string" ? item.targetId.trim() : "";
if (!botId || !targetId)
continue;
options.push({
botId,
targetId,
name: typeof item.name === "string" && item.name.trim() ? item.name.trim() : targetId,
kind: typeof item.kind === "string" ? item.kind : "",
channel: typeof item.channel === "string" ? item.channel : "im"
});
}
return {
available: row.available !== false,
options,
hint: typeof row.hint === "string" ? row.hint : "",
loading: false
};
}
async function fetchImDeliveryCatalog(call, signal) {
const result = await call(NETXOPS_RPC_CHANNEL, IM_DELIVERY_CATALOG_ENDPOINT, {}, signal);
if (result !== null && typeof result === "object" && result.ok === true) {
return asImDeliveryCatalog(result.value);
}
if (result !== null && typeof result === "object" && result.ok === false) {
return {
...EMPTY_IM_DELIVERY_CATALOG,
available: false,
hint: String(result.error?.message ?? "rpc failed")
};
}
return asImDeliveryCatalog(result);
}
function imCatalogOptionKey(botId, targetId) {
return `${botId}::${targetId}`;
}
function parseImCatalogOptionKey(value) {
const at = value.indexOf("::");
if (at <= 0)
return { botId: "", targetId: "" };
return { botId: value.slice(0, at), targetId: value.slice(at + 2) };
}
// src/client/styles.ts
var STYLE_ID = "dsh-netxops-card-css";
var CSS = `
@ -142,11 +204,15 @@ var CSS = `
.dsh-nx-reset{border:none;background:none;padding:0;font:inherit;font-size:12px;line-height:1.5;color:var(--dsw-alias-label-secondary);cursor:pointer}
.dsh-nx-reset:hover:not(:disabled){color:var(--dsw-alias-label-primary)}
.dsh-nx-input{height:34px;padding:0 12px;border:.5px solid var(--dsw-alias-border-l4);border-radius:8px;background:var(--dsw-alias-bg-layer-3);font:inherit;font-size:13px;line-height:1.5;color:var(--dsw-alias-label-primary)}
.dsh-nx-select{appearance:auto;cursor:pointer}
.dsh-nx-input:focus-visible{outline:none;border-color:var(--dsw-alias-brand-primary)}
.dsh-nx-inputInvalid{border-color:var(--dsw-alias-label-error)}
.dsh-nx-hint{margin:0;font-size:12px;line-height:1.5;color:var(--dsw-alias-label-tertiary)}
.dsh-nx-checkRow{display:flex;align-items:flex-start;gap:10px;font-size:12px;line-height:1.5;color:var(--dsw-alias-label-secondary);cursor:pointer}
.dsh-nx-checkRow input{margin-top:2px;flex:none}
.dsh-nx-groupBlock{display:flex;flex-direction:column;gap:8px;padding:8px 0 4px}
.dsh-nx-groupTitle{font-size:12px;font-weight:600;line-height:1.5;color:var(--dsw-alias-label-primary)}
.dsh-nx-groupChecks{display:flex;flex-direction:column;gap:6px;padding-left:2px}
.dsh-nx-invalid{margin:0;font-size:12px;line-height:1.5;color:var(--dsw-alias-label-error)}
.dsh-nx-footer{display:flex;align-items:center;justify-content:flex-end;gap:8px;padding:12px 0 4px;border-top:.5px solid var(--dsw-alias-border-l2)}
.dsh-nx-failed{flex:1;min-width:0;margin:0;font-size:12px;line-height:1.5;color:var(--dsw-alias-label-error)}
@ -247,6 +313,151 @@ function ValueField(props) {
]
});
}
function ImDeliveryPicker(props) {
const options = props.catalog.options;
const selectedKey = props.botId && props.targetId ? imCatalogOptionKey(props.botId, props.targetId) : "";
const matched = options.some((row) => imCatalogOptionKey(row.botId, row.targetId) === selectedKey);
const [manual, setManual] = import_react.useState(() => !!(selectedKey && !matched));
import_react.useEffect(() => {
if (selectedKey && matched)
setManual(false);
else if (selectedKey && !matched)
setManual(true);
}, [selectedKey, matched]);
const selectValue = manual ? "__manual__" : matched ? selectedKey : "";
const showManual = manual || selectedKey && !matched;
return /* @__PURE__ */ jsx_runtime.jsxs(jsx_runtime.Fragment, {
children: [
/* @__PURE__ */ jsx_runtime.jsxs("div", {
className: "dsh-nx-field",
children: [
/* @__PURE__ */ jsx_runtime.jsx("div", {
className: "dsh-nx-fieldHead",
children: /* @__PURE__ */ jsx_runtime.jsx("label", {
className: "dsh-nx-label",
htmlFor: "netxops-im-target",
children: props.labels.target
})
}),
/* @__PURE__ */ jsx_runtime.jsxs("select", {
id: "netxops-im-target",
className: "dsh-nx-input dsh-nx-select",
value: selectValue,
disabled: props.disabled,
onChange: (event) => {
const value = event.target.value;
if (value === "__manual__") {
setManual(true);
return;
}
setManual(false);
if (!value) {
props.onPick("", "");
return;
}
const next = parseImCatalogOptionKey(value);
props.onPick(next.botId, next.targetId);
},
children: [
/* @__PURE__ */ jsx_runtime.jsx("option", {
value: "",
children: options.length === 0 ? props.labels.none : `— ${props.labels.target} —`
}),
options.map((row) => /* @__PURE__ */ jsx_runtime.jsx("option", {
value: imCatalogOptionKey(row.botId, row.targetId),
children: `${row.name} · ${row.channel || "im"} · ${row.targetId}`
}, imCatalogOptionKey(row.botId, row.targetId))),
/* @__PURE__ */ jsx_runtime.jsx("option", {
value: "__manual__",
children: props.labels.manual
})
]
}),
!props.catalog.available || options.length === 0 ? /* @__PURE__ */ jsx_runtime.jsx("p", {
className: "dsh-nx-hint",
children: props.catalog.hint || props.labels.unavailable
}) : null
]
}),
showManual ? /* @__PURE__ */ jsx_runtime.jsxs(jsx_runtime.Fragment, {
children: [
/* @__PURE__ */ jsx_runtime.jsx(ValueField, {
id: "netxops-im-bot-id",
label: props.labels.botId,
hint: props.labels.botHint,
field: props.botField,
overriddenLabel: props.labels.overridden,
resetLabel: props.labels.reset,
invalidLabel: props.labels.invalid,
disabled: props.disabled,
onEdit: props.onEditBot,
onReset: props.onResetBot
}),
/* @__PURE__ */ jsx_runtime.jsx(ValueField, {
id: "netxops-im-target-id",
label: props.labels.targetId,
hint: props.labels.targetHint,
field: props.targetField,
overriddenLabel: props.labels.overridden,
resetLabel: props.labels.reset,
invalidLabel: props.labels.invalid,
disabled: props.disabled,
onEdit: props.onEditTarget,
onReset: props.onResetTarget
})
]
}) : null
]
});
}
function CapabilityGroupBlock(props) {
return /* @__PURE__ */ jsx_runtime.jsxs("div", {
className: "dsh-nx-groupBlock",
children: [
/* @__PURE__ */ jsx_runtime.jsx("div", {
className: "dsh-nx-groupTitle",
children: props.title
}),
/* @__PURE__ */ jsx_runtime.jsxs("div", {
className: "dsh-nx-groupChecks",
children: [
/* @__PURE__ */ jsx_runtime.jsxs("label", {
className: "dsh-nx-checkRow",
children: [
/* @__PURE__ */ jsx_runtime.jsx("input", {
type: "checkbox",
checked: props.inPreset.text === "true",
disabled: props.disabled,
onChange: (event) => {
props.onEditInPreset(event.target.checked);
}
}),
/* @__PURE__ */ jsx_runtime.jsx("span", {
children: props.inPresetLabel
})
]
}),
/* @__PURE__ */ jsx_runtime.jsxs("label", {
className: "dsh-nx-checkRow",
children: [
/* @__PURE__ */ jsx_runtime.jsx("input", {
type: "checkbox",
checked: props.published.text === "true",
disabled: props.disabled,
onChange: (event) => {
props.onEditPublic(event.target.checked);
}
}),
/* @__PURE__ */ jsx_runtime.jsx("span", {
children: props.publicLabel
})
]
})
]
})
]
});
}
function NetxopsCard(props) {
ensureStyles();
const { t } = props;
@ -388,6 +599,80 @@ function NetxopsCard(props) {
props.resetField("lang");
}
}),
/* @__PURE__ */ jsx_runtime.jsx(ValueField, {
id: "netxops-nms-provider",
label: t("nmsProvider"),
hint: t("nmsProviderHint"),
field: state.nmsProvider,
overriddenLabel: t("overridden"),
resetLabel: t("reset"),
invalidLabel: t("invalid"),
disabled,
onEdit: (text) => {
props.edit("nmsProvider", text);
},
onReset: () => {
props.resetField("nmsProvider");
}
}),
/* @__PURE__ */ jsx_runtime.jsxs("div", {
className: "dsh-nx-field",
children: [
/* @__PURE__ */ jsx_runtime.jsx("div", {
className: "dsh-nx-fieldHead",
children: /* @__PURE__ */ jsx_runtime.jsx("span", {
className: "dsh-nx-label",
children: t("capabilityGroups")
})
}),
/* @__PURE__ */ jsx_runtime.jsx("p", {
className: "dsh-nx-hint",
children: t("capabilityGroupsHint")
}),
/* @__PURE__ */ jsx_runtime.jsx(CapabilityGroupBlock, {
title: t("groupNms"),
inPresetLabel: t("groupInPreset"),
publicLabel: t("groupPublic"),
inPreset: state.groupNmsInPreset,
published: state.groupNmsPublic,
disabled,
onEditInPreset: (checked) => {
props.edit("groupNmsInPreset", checked ? "true" : "false");
},
onEditPublic: (checked) => {
props.edit("groupNmsPublic", checked ? "true" : "false");
}
}),
/* @__PURE__ */ jsx_runtime.jsx(CapabilityGroupBlock, {
title: t("groupCommon"),
inPresetLabel: t("groupInPreset"),
publicLabel: t("groupPublic"),
inPreset: state.groupCommonInPreset,
published: state.groupCommonPublic,
disabled,
onEditInPreset: (checked) => {
props.edit("groupCommonInPreset", checked ? "true" : "false");
},
onEditPublic: (checked) => {
props.edit("groupCommonPublic", checked ? "true" : "false");
}
}),
/* @__PURE__ */ jsx_runtime.jsx(CapabilityGroupBlock, {
title: t("groupTopology"),
inPresetLabel: t("groupInPreset"),
publicLabel: t("groupPublic"),
inPreset: state.groupTopologyInPreset,
published: state.groupTopologyPublic,
disabled,
onEditInPreset: (checked) => {
props.edit("groupTopologyInPreset", checked ? "true" : "false");
},
onEditPublic: (checked) => {
props.edit("groupTopologyPublic", checked ? "true" : "false");
}
})
]
}),
/* @__PURE__ */ jsx_runtime.jsxs("div", {
className: "dsh-nx-field",
children: [
@ -558,35 +843,40 @@ function NetxopsCard(props) {
})
]
}),
/* @__PURE__ */ jsx_runtime.jsx(ValueField, {
id: "netxops-im-bot-id",
label: t("imBotId"),
hint: t("imBotIdHint"),
field: state.imBotId,
overriddenLabel: t("overridden"),
resetLabel: t("reset"),
invalidLabel: t("invalid"),
/* @__PURE__ */ jsx_runtime.jsx(ImDeliveryPicker, {
catalog: state.imDeliveryCatalog,
botId: state.imBotId.text,
targetId: state.imTargetId.text,
disabled,
onEdit: (text) => {
labels: {
target: t("imTarget"),
none: t("imTargetNone"),
manual: t("imTargetManual"),
unavailable: t("imCatalogUnavailable"),
botId: t("imBotId"),
botHint: t("imBotIdHint"),
targetId: t("imTargetId"),
targetHint: t("imTargetIdHint"),
overridden: t("overridden"),
reset: t("reset"),
invalid: t("invalid")
},
botField: state.imBotId,
targetField: state.imTargetId,
onPick: (botId, targetId) => {
props.edit("imBotId", botId);
props.edit("imTargetId", targetId);
},
onEditBot: (text) => {
props.edit("imBotId", text);
},
onReset: () => {
props.resetField("imBotId");
}
}),
/* @__PURE__ */ jsx_runtime.jsx(ValueField, {
id: "netxops-im-target-id",
label: t("imTargetId"),
hint: t("imTargetIdHint"),
field: state.imTargetId,
overriddenLabel: t("overridden"),
resetLabel: t("reset"),
invalidLabel: t("invalid"),
disabled,
onEdit: (text) => {
onEditTarget: (text) => {
props.edit("imTargetId", text);
},
onReset: () => {
onResetBot: () => {
props.resetField("imBotId");
},
onResetTarget: () => {
props.resetField("imTargetId");
}
}),
@ -861,14 +1151,23 @@ class NetxopsCardController {
};
rpcCall;
alarmPushStatus = null;
imDeliveryCatalog = { ...EMPTY_IM_DELIVERY_CATALOG };
pollTimer;
pollInFlight = false;
catalogInFlight = false;
constructor(scope, ctx) {
this.scope = scope;
this.ctx = ctx;
this.form = new CardForm(scope, [
textField("apiUrl"),
textField("lang"),
textField("nmsProvider"),
booleanFieldPersistFalse("groupNmsInPreset"),
booleanField("groupNmsPublic"),
booleanFieldPersistFalse("groupCommonInPreset"),
booleanField("groupCommonPublic"),
booleanField("groupTopologyInPreset"),
booleanField("groupTopologyPublic"),
booleanField("alarmPushEnabled"),
booleanFieldPersistFalse("alarmDeliverDsh"),
booleanField("alarmDeliverIm"),
@ -897,20 +1196,29 @@ class NetxopsCardController {
this.rpcCall = call;
if (call === undefined) {
this.stopStatusPoll();
let changed = false;
if (this.alarmPushStatus !== null) {
this.alarmPushStatus = null;
this.store.set(this.projection());
changed = true;
}
if (this.imDeliveryCatalog.options.length > 0 || this.imDeliveryCatalog.available !== true) {
this.imDeliveryCatalog = { ...EMPTY_IM_DELIVERY_CATALOG };
changed = true;
}
if (changed)
this.store.set(this.projection());
return;
}
this.startStatusPoll();
this.refreshAlarmPushStatus();
this.refreshImDeliveryCatalog();
}
startStatusPoll() {
if (this.pollTimer !== undefined)
return;
this.pollTimer = setInterval(() => {
this.refreshAlarmPushStatus();
this.refreshImDeliveryCatalog();
}, STATUS_POLL_MS);
}
stopStatusPoll() {
@ -935,11 +1243,38 @@ class NetxopsCardController {
this.pollInFlight = false;
}
}
async refreshImDeliveryCatalog() {
const call = this.rpcCall;
if (call === undefined || this.catalogInFlight)
return;
this.catalogInFlight = true;
try {
const next = await fetchImDeliveryCatalog(call);
const prev = this.imDeliveryCatalog;
const sameOptions = prev.options.length === next.options.length && prev.options.every((row, index) => {
const other = next.options[index];
return other && row.botId === other.botId && row.targetId === other.targetId && row.name === other.name && row.channel === other.channel;
});
if (prev.available === next.available && prev.hint === next.hint && sameOptions)
return;
this.imDeliveryCatalog = next;
this.store.set(this.projection());
} catch {} finally {
this.catalogInFlight = false;
}
}
projection() {
return {
...this.form.shell(),
apiUrl: this.form.field("apiUrl"),
lang: this.form.field("lang"),
nmsProvider: this.form.field("nmsProvider"),
groupNmsInPreset: this.form.field("groupNmsInPreset"),
groupNmsPublic: this.form.field("groupNmsPublic"),
groupCommonInPreset: this.form.field("groupCommonInPreset"),
groupCommonPublic: this.form.field("groupCommonPublic"),
groupTopologyInPreset: this.form.field("groupTopologyInPreset"),
groupTopologyPublic: this.form.field("groupTopologyPublic"),
alarmPushEnabled: this.form.field("alarmPushEnabled"),
alarmDeliverDsh: this.form.field("alarmDeliverDsh"),
alarmDeliverIm: this.form.field("alarmDeliverIm"),
@ -949,7 +1284,8 @@ class NetxopsCardController {
apiTokenConfigured: this.credential.configured,
apiTokenWritable: this.credential.remoteReady && this.credential.writable,
apiTokenRemoteReady: this.credential.remoteReady,
alarmPushStatus: this.alarmPushStatus
alarmPushStatus: this.alarmPushStatus,
imDeliveryCatalog: this.imDeliveryCatalog
};
}
credentials() {
@ -1014,11 +1350,20 @@ function refOf(snapshot) {
// src/client/locales.ts
var en = {
title: "Netx Ops",
description: "UME API endpoint and bearer token for netx__* tools.",
description: "netx API, bearer token, and capability groups (nms / common / topology).",
apiUrl: "API URL",
apiUrlHint: "netx REST root, e.g. http://127.0.0.1:8890 (also used for alarm subscribe)",
lang: "Language",
langHint: "Response language hint (zh / en).",
capabilityGroups: "Capability groups",
capabilityGroupsHint: "One group ↔ one skill (netx-nms / netx-common / netx-topology). Defaults: nms + common in preset; topology and all public off. Save, then open a new session. Other agents can mount dsh-netxops/tools-nms|common|topology.",
nmsProvider: "nms provider",
nmsProviderHint: "Vendor adapter id. Supported today: zte-ume (REST still /v1/ume/*; model tools are netx__*Nms*).",
groupNms: "nms — skill netx-nms (alarms / inventory / SQL)",
groupCommon: "common — skill netx-common (managed CLI + findTopologyPaths)",
groupTopology: "topology — skill netx-topology (canvas / fabric / dual_unit)",
groupInPreset: "In Netx Ops preset",
groupPublic: "Publish to other presets",
alarmPushEnabled: "Key-alarm push",
alarmPushEnabledHint: "Dial out to netx when a matched key alarm arrives. Choose DSH and/or IM sinks below.",
alarmPushStatus: "Push link",
@ -1033,11 +1378,15 @@ var en = {
alarmDeliverDsh: "Deliver to DSH session",
alarmDeliverDshHint: "Open/follow the sticky「Netx 关键告警」session on this Host.",
alarmDeliverIm: "Deliver to WhatsApp / IM",
alarmDeliverImHint: "Requires dsh-im-ops. Paste botId + targetId from IM → 投递设置 → 复制调用参数.",
alarmDeliverImHint: "Requires dsh-im-ops ≥ops.24. Pick a saved delivery target below (create targets under IM → Delivery settings).",
imBotId: "IM Bot ID",
imBotIdHint: "Opaque botId from IM delivery settings (not a phone number).",
imBotIdHint: "Filled from the picker, or enter manually.",
imTargetId: "IM Target ID",
imTargetIdHint: "Opaque targetId alias (e.g. release-alerts). Create the target in IM first.",
imTargetIdHint: "Filled from the picker, or enter manually.",
imTarget: "Delivery target",
imTargetNone: "(Create a target in IM delivery settings first)",
imTargetManual: "Enter manually…",
imCatalogUnavailable: "Cannot load targets — install/update dsh-im-ops (≥ops.24) and save at least one delivery target.",
apiToken: "API token",
apiTokenHint: "Stored as credential NETX_API_TOKEN (never written into settings). Leave blank to keep the current token.",
apiTokenUnavailable: "This DSH build does not expose remote.credentials. Set the token with scripts/set-netx-token.ps1 (or .sh), then restart is not required if credentials are watched.",
@ -1057,11 +1406,20 @@ var en = {
};
var zh = {
title: "Netx Ops",
description: "UME API 地址与 Bearer Token,供 netx__* 工具使用。",
description: "netx API、Bearer Token,以及能力组(nms / common / topology)。",
apiUrl: "API 地址",
apiUrlHint: "netx REST 根地址,例如 http://127.0.0.1:8890(告警订阅复用同一地址)",
lang: "语言",
langHint: "响应语言提示(zh / en)。",
capabilityGroups: "能力组",
capabilityGroupsHint: "一组一个 skill(netx-nms / netx-common / netx-topology)。默认:nms + common 进入预设;topology 与全部公开关闭。保存后请新开会话。其他 Agent 可挂 dsh-netxops/tools-nms|common|topology。",
nmsProvider: "nms provider",
nmsProviderHint: "厂商适配器 id。当前支持:zte-ume(REST 仍为 /v1/ume/*;模型工具名为 netx__*Nms*)。",
groupNms: "nms — skill netx-nms(告警 / 库存 / SQL)",
groupCommon: "common — skill netx-common(纳管 CLI + findTopologyPaths)",
groupTopology: "topology — skill netx-topology(画布 / Fabric / dual_unit)",
groupInPreset: "在 Netx Ops 预设中启用",
groupPublic: "对其他预设公开",
alarmPushEnabled: "关键告警推送",
alarmPushEnabledHint: "主动连接 netx;匹配到关键告警后按下述开关投递。可只开 DSH、只开 IM,或两者都开。",
alarmPushStatus: "推送链路",
@ -1076,11 +1434,15 @@ var zh = {
alarmDeliverDsh: "投递到 DSH 会话",
alarmDeliverDshHint: "在本机打开/续写 sticky「Netx 关键告警」会话。",
alarmDeliverIm: "投递到 WhatsApp / IM",
alarmDeliverImHint: "需要已安装 dsh-im-ops。请先在 IM「投递设置」新建目标,再粘贴「复制调用参数」里的 botId / targetId。",
alarmDeliverImHint: "需要 dsh-im-ops ≥ops.24。请在下方下拉选择已保存的投递目标(先在 IM「投递设置」新建)。",
imBotId: "IM Bot ID",
imBotIdHint: "来自 IM 投递设置的不透明 botId(不是手机号)。",
imBotIdHint: "由下拉自动填入,也可手动改。",
imTargetId: "IM Target ID",
imTargetIdHint: "投递目标别名(如 release-alerts)。请先在 IM 侧创建目标。",
imTargetIdHint: "由下拉自动填入,也可手动改。",
imTarget: "投递目标",
imTargetNone: "(请先在 IM「投递设置」新建目标)",
imTargetManual: "手动填写…",
imCatalogUnavailable: "无法加载投递目标 — 请安装/更新 dsh-im-ops(≥ops.24)并至少保存一个投递目标。",
apiToken: "API Token",
apiTokenHint: "写入凭据 NETX_API_TOKEN(不会进 settings)。留空表示保留已有 token。",
apiTokenUnavailable: "当前 DSH 未提供 remote.credentials。请用 scripts/set-netx-token.ps1(或 .sh)写入 token;若 harness 在监视凭据文件则无需重启。",