Add session bulk export and multi-target IM alarm delivery.

Ops can download every Host session as a ZIP for HQ analysis, fan key alarms to multiple WhatsApp targets, and the Plugins card copy stays short.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
oliver 2026-09-06 14:11:54 +08:00
parent eb8299d155
commit 92279c1220
18 changed files with 3062 additions and 606 deletions

View file

@ -22,8 +22,9 @@ Then:
1. Settings → **Plugins** → **Netx Ops** → API URL / token / capability groups
2. Optional: enable **关键告警推送** — DSH dials out to netx; choose **Deliver to DSH session** and/or **Deliver to WhatsApp / IM** (paste `botId`+`targetId` from IM 投递设置). The card shows live WSS status.
3. Settings → **Agent presets** → Custom → **Netx Ops** (installed automatically on first host activate)
4. New session → choose **Netx Ops**
3. Optional: **导出全部会话** — download one ZIP of every durable session on this Host.
4. Settings → **Agent presets** → Custom → **Netx Ops** (installed automatically on first host activate)
5. New session → choose **Netx Ops**
Also need: a reachable **netx API** (no local `pip install netx_mcp`).
@ -35,6 +36,7 @@ Also need: a reachable **netx API** (no local `pip install netx_mcp`).
|---------------|---------------------------|
| `netx__*` REST tools (groups: ops / topology; one group ↔ one skill; optional host publish + `tools-ops|topology`) + settings + credentials | netx HTTP API (URL + token) |
| Optional key-alarm push (WSS client → sticky DSH session) | netx `/v1/integrations/dsh-alarm/ws` hub |
| **Export all sessions** (Plugins card → browser ZIP download) | this Host’s session store (JSONL) |
| Persona + skills (`netx-ops` / `netx-topology`) | |
| Agent preset auto-install to `~/.dsh/.agent-presets` | |

View file

@ -25,6 +25,7 @@
alarmDeliverIm: false
imBotId: ''
imTargetId: ''
imTargets: ''
groupOpsInPreset: true
groupOpsPublic: false
groupTopologyInPreset: false

View file

@ -37,6 +37,7 @@ One command, three **direct** profile bundles. Do **not** nest IM/cron under net
1. **Settings → Plugins → Netx Ops** → API URL (+ token if the field is enabled).
Token fallback: `scripts/set-netx-token.ps1` / `.sh`.
Optional: enable **关键告警推送** so this DSH dials `ws(s)://<apiUrl>/v1/integrations/dsh-alarm/ws`. Choose **投递到 DSH 会话** and/or **投递到 WhatsApp / IM**. For IM, install `dsh-im-ops`, create a delivery target, then paste `imBotId` / `imTargetId`. The card header shows live WSS status.
Optional: **导出全部会话** downloads `dsh-sessions-<host>-<utc>.zip` via `GET /api/netxops.sessions.export` (browser download; works for cloud Hosts).
Capability groups: leave default for **ops**, or enable **topology** / **对其他预设公开** (new sessions after save).
2. Restart or open Settings → **Agent presets** → Custom → **Netx Ops** should appear after the host plugin has activated once.
3. **New session → preset Netx Ops** → ask e.g. Critical Top / single-host alarms / 「能否登录」.
@ -48,12 +49,20 @@ One command, three **direct** profile bundles. Do **not** nest IM/cron under net
- Live status is exposed on the Plugins card via Connection RPC (`/netxops` → `alarm-push.status`); no curl required for day-to-day checks.
- WhatsApp / `dsh-im` is **optional** for the DSH session path; required only when IM delivery is checked.
## Export all sessions (HQ pickup)
- **Where:** Settings → Plugins → **Netx Ops** → **导出全部会话**.
- **What:** browser downloads one ZIP — `manifest.json` plus `sessions/<id>/session.jsonl` from this Host’s `sessionPersistence`.
- **Why:** each edge / cloud harness keeps its own sessions; Ops downloads from the Web UI and ships archives to HQ.
- **Limits:** needs a JSONL (raw-artifact) persistence backend; SQLite backends refuse export.
## Verify
1. Plugins card **Netx Ops** visible.
2. With key-alarm push on: card badge shows **Connected** (or reconnecting / auth failed with detail).
3. Agent presets → Custom → **Netx Ops**.
4. Tools include `netx__queryNmsAlarms` / `netx__execManagedNe` / `netx__findTopologyPaths` (**ops** → skill `netx-ops`). Canvas / dual_unit need **topology** (`netx-topology`).
5. Export section shows a session count; **下载 ZIP** starts a browser download.
See [TOOL_MAP.md](TOOL_MAP.md).

View file

@ -167,11 +167,79 @@ async function fetchImDeliveryCatalog(call, signal) {
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/netx/im-targets.ts
function imTargetKey(botId, targetId) {
return `${botId}::${targetId}`;
}
function normalizeImTarget(input) {
if (input === null || typeof input !== "object" || Array.isArray(input))
return null;
const row = input;
const botId = typeof row.botId === "string" ? row.botId.trim() : "";
const targetId = typeof row.targetId === "string" ? row.targetId.trim() : "";
if (!botId || !targetId)
return null;
return { botId, targetId };
}
function parseImTargetsJson(text) {
const trimmed = text.trim();
if (!trimmed)
return [];
let parsed;
try {
parsed = JSON.parse(trimmed);
} catch {
return [];
}
if (!Array.isArray(parsed))
return [];
const out = [];
const seen = new Set;
for (const entry of parsed) {
const target = normalizeImTarget(entry);
if (!target)
continue;
const key = imTargetKey(target.botId, target.targetId);
if (seen.has(key))
continue;
seen.add(key);
out.push(target);
}
return out;
}
function formatImTargetsJson(targets) {
const seen = new Set;
const rows = [];
for (const entry of targets) {
const target = normalizeImTarget(entry);
if (!target)
continue;
const key = imTargetKey(target.botId, target.targetId);
if (seen.has(key))
continue;
seen.add(key);
rows.push(target);
}
return rows.length === 0 ? "" : JSON.stringify(rows);
}
function resolveImTargets(settings) {
const fromList = parseImTargetsJson(typeof settings.imTargets === "string" ? settings.imTargets : "");
if (fromList.length > 0)
return fromList;
const legacy = normalizeImTarget({
botId: settings.imBotId,
targetId: settings.imTargetId
});
return legacy ? [legacy] : [];
}
function setImTargetSelected(current, target, selected) {
const key = imTargetKey(target.botId, target.targetId);
const without = current.filter((row) => imTargetKey(row.botId, row.targetId) !== key);
if (!selected)
return without;
const normalized = normalizeImTarget(target);
return normalized ? [...without, normalized] : without;
}
// src/client/styles.ts
@ -213,7 +281,11 @@ var CSS = `
.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-imTargetList{display:flex;flex-direction:column;gap:8px;padding:4px 0 2px}
.dsh-nx-invalid{margin:0;font-size:12px;line-height:1.5;color:var(--dsw-alias-label-error)}
.dsh-nx-exportRow{display:flex;align-items:center;gap:8px;padding-top:4px}
.dsh-nx-export{border-color:var(--dsw-alias-border-l2);background:none;color:var(--dsw-alias-label-primary)}
.dsh-nx-export:hover:not(:disabled){border-color:var(--dsw-alias-label-dimmed)}
.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-btn{appearance:none;border:1px solid transparent;border-radius:8px;padding:5px 14px;font:inherit;font-size:13px;line-height:1.5;cursor:pointer}
@ -315,97 +387,79 @@ 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, {
const selected = resolveImTargets({
imTargets: props.targetsJson,
imBotId: props.legacyBotId,
imTargetId: props.legacyTargetId
});
const selectedKeys = new Set(selected.map((row) => imTargetKey(row.botId, row.targetId)));
const orphanSelected = selected.filter((row) => !options.some((opt) => imCatalogOptionKey(opt.botId, opt.targetId) === imTargetKey(row.botId, row.targetId)));
return /* @__PURE__ */ jsx_runtime.jsxs("div", {
className: "dsh-nx-field",
children: [
/* @__PURE__ */ jsx_runtime.jsxs("div", {
className: "dsh-nx-field",
className: "dsh-nx-fieldHead",
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.jsx("span", {
className: "dsh-nx-label",
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
selected.length > 0 ? /* @__PURE__ */ jsx_runtime.jsx("span", {
className: "dsh-nx-badgeMuted",
children: props.labels.selectedCount.replace("{count}", String(selected.length))
}) : null
]
}),
showManual ? /* @__PURE__ */ jsx_runtime.jsxs(jsx_runtime.Fragment, {
options.length === 0 && orphanSelected.length === 0 ? /* @__PURE__ */ jsx_runtime.jsx("p", {
className: "dsh-nx-hint",
children: !props.catalog.available ? props.catalog.hint || props.labels.unavailable : props.labels.none
}) : /* @__PURE__ */ jsx_runtime.jsxs("div", {
className: "dsh-nx-imTargetList",
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
options.map((row) => {
const key = imCatalogOptionKey(row.botId, row.targetId);
const checked = selectedKeys.has(key);
return /* @__PURE__ */ jsx_runtime.jsxs("label", {
className: "dsh-nx-checkRow",
children: [
/* @__PURE__ */ jsx_runtime.jsx("input", {
type: "checkbox",
checked,
disabled: props.disabled,
onChange: (event) => {
props.onChange(setImTargetSelected(selected, { botId: row.botId, targetId: row.targetId }, event.target.checked));
}
}),
/* @__PURE__ */ jsx_runtime.jsx("span", {
children: `${row.name} · ${row.channel || "im"} · ${row.targetId}`
})
]
}, key);
}),
/* @__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
orphanSelected.map((row) => {
const key = imTargetKey(row.botId, row.targetId);
return /* @__PURE__ */ jsx_runtime.jsxs("label", {
className: "dsh-nx-checkRow",
children: [
/* @__PURE__ */ jsx_runtime.jsx("input", {
type: "checkbox",
checked: true,
disabled: props.disabled,
onChange: (event) => {
props.onChange(setImTargetSelected(selected, row, event.target.checked));
}
}),
/* @__PURE__ */ jsx_runtime.jsx("span", {
children: `${row.botId} · ${row.targetId}`
})
]
}, `orphan-${key}`);
})
]
}),
!props.catalog.available && options.length > 0 ? /* @__PURE__ */ jsx_runtime.jsx("p", {
className: "dsh-nx-hint",
children: props.catalog.hint || props.labels.unavailable
}) : null
]
});
@ -625,10 +679,6 @@ function NetxopsCard(props) {
children: t("capabilityGroups")
})
}),
/* @__PURE__ */ jsx_runtime.jsx("p", {
className: "dsh-nx-hint",
children: t("capabilityGroupsHint")
}),
/* @__PURE__ */ jsx_runtime.jsx(CapabilityGroupBlock, {
title: t("groupOps"),
inPresetLabel: t("groupInPreset"),
@ -698,23 +748,19 @@ function NetxopsCard(props) {
})
]
}),
/* @__PURE__ */ jsx_runtime.jsxs("label", {
/* @__PURE__ */ jsx_runtime.jsx("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")
})
]
children: /* @__PURE__ */ jsx_runtime.jsx("input", {
id: "netxops-alarm-push",
type: "checkbox",
checked: state.alarmPushEnabled.text === "true",
disabled,
"aria-label": t("alarmPushEnabled"),
onChange: (event) => {
props.edit("alarmPushEnabled", event.target.checked ? "true" : "false");
}
})
}),
pushStatus?.wsUrl ? /* @__PURE__ */ jsx_runtime.jsx("p", {
className: "dsh-nx-hint",
@ -758,114 +804,87 @@ function NetxopsCard(props) {
}) : null
]
}),
/* @__PURE__ */ jsx_runtime.jsxs("label", {
/* @__PURE__ */ jsx_runtime.jsx("label", {
className: "dsh-nx-checkRow",
htmlFor: "netxops-alarm-dsh",
children: [
/* @__PURE__ */ jsx_runtime.jsx("input", {
id: "netxops-alarm-dsh",
type: "checkbox",
checked: state.alarmDeliverDsh.text === "true",
disabled,
onChange: (event) => {
props.edit("alarmDeliverDsh", event.target.checked ? "true" : "false");
}
}),
/* @__PURE__ */ jsx_runtime.jsx("span", {
children: t("alarmDeliverDshHint")
})
]
})
]
}),
/* @__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-im",
children: t("alarmDeliverIm")
}),
state.alarmDeliverIm.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("alarmDeliverIm");
},
children: t("reset")
})
]
}) : null
]
}),
/* @__PURE__ */ jsx_runtime.jsxs("label", {
className: "dsh-nx-checkRow",
htmlFor: "netxops-alarm-im",
children: [
/* @__PURE__ */ jsx_runtime.jsx("input", {
id: "netxops-alarm-im",
type: "checkbox",
checked: state.alarmDeliverIm.text === "true",
disabled,
onChange: (event) => {
props.edit("alarmDeliverIm", event.target.checked ? "true" : "false");
}
}),
/* @__PURE__ */ jsx_runtime.jsx("span", {
children: t("alarmDeliverImHint")
})
]
children: /* @__PURE__ */ jsx_runtime.jsx("input", {
id: "netxops-alarm-dsh",
type: "checkbox",
checked: state.alarmDeliverDsh.text === "true",
disabled,
"aria-label": t("alarmDeliverDsh"),
onChange: (event) => {
props.edit("alarmDeliverDsh", event.target.checked ? "true" : "false");
}
})
})
]
}),
/* @__PURE__ */ jsx_runtime.jsx(ImDeliveryPicker, {
catalog: state.imDeliveryCatalog,
botId: state.imBotId.text,
targetId: state.imTargetId.text,
targetsJson: state.imTargets.text,
legacyBotId: state.imBotId.text,
legacyTargetId: state.imTargetId.text,
disabled,
labels: {
target: t("imTarget"),
target: t("alarmDeliverIm"),
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")
selectedCount: t("imTargetSelectedCount"),
unavailable: t("imCatalogUnavailable")
},
botField: state.imBotId,
targetField: state.imTargetId,
onPick: (botId, targetId) => {
props.edit("imBotId", botId);
props.edit("imTargetId", targetId);
},
onEditBot: (text) => {
props.edit("imBotId", text);
},
onEditTarget: (text) => {
props.edit("imTargetId", text);
},
onResetBot: () => {
props.resetField("imBotId");
},
onResetTarget: () => {
props.resetField("imTargetId");
onChange: (targets) => {
props.edit("imTargets", formatImTargetsJson(targets));
const first = targets[0];
props.edit("imBotId", first?.botId ?? "");
props.edit("imTargetId", first?.targetId ?? "");
props.edit("alarmDeliverIm", targets.length > 0 ? "true" : "false");
}
}),
/* @__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("sessionsExport")
})
}),
state.sessionsExportStatus === null ? /* @__PURE__ */ jsx_runtime.jsx("p", {
className: "dsh-nx-hint",
children: t("sessionsExportUnavailable")
}) : state.sessionsExportStatus.available ? /* @__PURE__ */ jsx_runtime.jsx("p", {
className: "dsh-nx-hint",
children: t("sessionsExportCount").replace("{count}", String(state.sessionsExportStatus.sessionCount))
}) : /* @__PURE__ */ jsx_runtime.jsx("p", {
className: "dsh-nx-invalid",
role: "status",
children: state.sessionsExportStatus.reason || t("sessionsExportUnavailable")
}),
state.sessionsExportError ? /* @__PURE__ */ jsx_runtime.jsx("p", {
className: "dsh-nx-invalid",
role: "status",
children: state.sessionsExportError
}) : null,
state.sessionsExportLastFile && !state.sessionsExportBusy && !state.sessionsExportError ? /* @__PURE__ */ jsx_runtime.jsx("p", {
className: "dsh-nx-hint",
role: "status",
children: t("sessionsExportDone").replace("{file}", state.sessionsExportLastFile)
}) : null,
/* @__PURE__ */ jsx_runtime.jsx("div", {
className: "dsh-nx-exportRow",
children: /* @__PURE__ */ jsx_runtime.jsx("button", {
type: "button",
className: "dsh-nx-btn dsh-nx-export",
disabled: state.sessionsExportBusy || state.sessionsExportStatus?.available !== true,
onClick: () => {
props.exportAllSessions();
},
children: t(state.sessionsExportBusy ? "sessionsExportBusy" : "sessionsExportButton")
})
})
]
}),
/* @__PURE__ */ jsx_runtime.jsxs("div", {
className: "dsh-nx-footer",
children: [
@ -1118,6 +1137,91 @@ class CardForm {
}
}
// src/session-export-shared.ts
var NETXOPS_SESSIONS_EXPORT_PATH = "/api/netxops.sessions.export";
// src/client/sessions-export-view.ts
var SESSIONS_EXPORT_STATUS_ENDPOINT = "sessions.export.status";
var EMPTY_STATUS = {
available: false,
sessionCount: 0,
supportsRawArtifacts: false,
reason: "RPC unavailable"
};
function asSessionsExportStatus(value) {
if (value === null || typeof value !== "object" || Array.isArray(value)) {
return { ...EMPTY_STATUS };
}
const row = value;
return {
available: row.available === true,
sessionCount: typeof row.sessionCount === "number" ? row.sessionCount : 0,
supportsRawArtifacts: row.supportsRawArtifacts === true,
reason: typeof row.reason === "string" ? row.reason : undefined
};
}
async function fetchSessionsExportStatus(call, signal) {
const result = await call(NETXOPS_RPC_CHANNEL, SESSIONS_EXPORT_STATUS_ENDPOINT, {}, signal);
if (result !== null && typeof result === "object" && result.ok === true) {
return asSessionsExportStatus(result.value);
}
if (result !== null && typeof result === "object" && result.ok === false) {
return {
...EMPTY_STATUS,
reason: String(result.error?.message ?? "rpc failed")
};
}
return asSessionsExportStatus(result);
}
function hostBase() {
const origin = globalThis.location?.origin;
return origin !== undefined && origin !== "null" ? origin : "http://dsh.internal";
}
function saveBlobDownload(blob, filename) {
const objectUrl = URL.createObjectURL(blob);
try {
const anchor = document.createElement("a");
anchor.href = objectUrl;
anchor.download = filename;
anchor.rel = "noopener";
anchor.style.display = "none";
document.body.appendChild(anchor);
anchor.click();
anchor.remove();
} finally {
globalThis.setTimeout(() => {
URL.revokeObjectURL(objectUrl);
}, 60000);
}
}
async function downloadAllSessionsExport(fetcher = fetch, save = saveBlobDownload) {
const url = new URL(NETXOPS_SESSIONS_EXPORT_PATH, hostBase());
const response = await fetcher(url, {
method: "GET",
credentials: "include"
});
if (!response.ok) {
const fromHeader = response.headers.get("x-netxops-export-error") ?? "";
const detail = fromHeader || await response.text().catch(() => "");
throw new Error(`Export failed: HTTP ${response.status}${detail === "" ? "" : ` ${detail}`}`);
}
const disposition = response.headers.get("content-disposition") ?? "";
const matched = /filename="([^"]+)"/i.exec(disposition);
const filename = matched?.[1] && matched[1].length > 0 ? matched[1] : "dsh-sessions-export.zip";
const countHeader = response.headers.get("x-netxops-session-count");
const sessionCount = countHeader !== null && countHeader !== "" ? Number.parseInt(countHeader, 10) : 0;
const blob = await response.blob();
if (blob.size <= 0) {
throw new Error("Export failed: empty ZIP body");
}
save(blob, filename);
return {
filename,
sessionCount: Number.isFinite(sessionCount) ? sessionCount : 0,
bytes: blob.size
};
}
// src/client/controller.ts
var NETXOPS_NS = "netxops";
var DEFAULT_TOKEN_REF = "NETX_API_TOKEN";
@ -1138,9 +1242,15 @@ class NetxopsCardController {
rpcCall;
alarmPushStatus = null;
imDeliveryCatalog = { ...EMPTY_IM_DELIVERY_CATALOG };
sessionsExportStatus = null;
sessionsExportBusy = false;
sessionsExportError = null;
sessionsExportLastFile = null;
pollTimer;
pollInFlight = false;
catalogInFlight = false;
exportStatusInFlight = false;
exportInFlight;
constructor(scope, ctx) {
this.scope = scope;
this.ctx = ctx;
@ -1155,6 +1265,7 @@ class NetxopsCardController {
booleanField("alarmPushEnabled"),
booleanFieldPersistFalse("alarmDeliverDsh"),
booleanField("alarmDeliverIm"),
textField("imTargets"),
textField("imBotId"),
textField("imTargetId")
], [{ field: API_TOKEN_FIELD, write: (text) => this.writeToken(text) }]);
@ -1189,6 +1300,10 @@ class NetxopsCardController {
this.imDeliveryCatalog = { ...EMPTY_IM_DELIVERY_CATALOG };
changed = true;
}
if (this.sessionsExportStatus !== null) {
this.sessionsExportStatus = null;
changed = true;
}
if (changed)
this.store.set(this.projection());
return;
@ -1196,6 +1311,7 @@ class NetxopsCardController {
this.startStatusPoll();
this.refreshAlarmPushStatus();
this.refreshImDeliveryCatalog();
this.refreshSessionsExportStatus();
}
startStatusPoll() {
if (this.pollTimer !== undefined)
@ -1203,6 +1319,7 @@ class NetxopsCardController {
this.pollTimer = setInterval(() => {
this.refreshAlarmPushStatus();
this.refreshImDeliveryCatalog();
this.refreshSessionsExportStatus();
}, STATUS_POLL_MS);
}
stopStatusPoll() {
@ -1247,6 +1364,42 @@ class NetxopsCardController {
this.catalogInFlight = false;
}
}
async refreshSessionsExportStatus() {
const call = this.rpcCall;
if (call === undefined || this.exportStatusInFlight)
return;
this.exportStatusInFlight = true;
try {
const next = await fetchSessionsExportStatus(call);
const prev = this.sessionsExportStatus;
if (prev && prev.available === next.available && prev.sessionCount === next.sessionCount && prev.supportsRawArtifacts === next.supportsRawArtifacts && prev.reason === next.reason)
return;
this.sessionsExportStatus = next;
this.store.set(this.projection());
} catch {} finally {
this.exportStatusInFlight = false;
}
}
exportAllSessions() {
if (this.exportInFlight !== undefined || this.sessionsExportBusy)
return;
if (this.sessionsExportStatus?.available !== true)
return;
this.sessionsExportBusy = true;
this.sessionsExportError = null;
this.store.set(this.projection());
this.exportInFlight = downloadAllSessionsExport().then((result) => {
this.sessionsExportLastFile = result.filename;
this.sessionsExportError = null;
}).catch((error) => {
this.sessionsExportError = error instanceof Error ? error.message : String(error);
}).finally(() => {
this.sessionsExportBusy = false;
this.exportInFlight = undefined;
this.store.set(this.projection());
this.refreshSessionsExportStatus();
});
}
projection() {
return {
...this.form.shell(),
@ -1260,6 +1413,7 @@ class NetxopsCardController {
alarmPushEnabled: this.form.field("alarmPushEnabled"),
alarmDeliverDsh: this.form.field("alarmDeliverDsh"),
alarmDeliverIm: this.form.field("alarmDeliverIm"),
imTargets: this.form.field("imTargets"),
imBotId: this.form.field("imBotId"),
imTargetId: this.form.field("imTargetId"),
apiToken: this.form.field(API_TOKEN_FIELD),
@ -1267,7 +1421,11 @@ class NetxopsCardController {
apiTokenWritable: this.credential.remoteReady && this.credential.writable,
apiTokenRemoteReady: this.credential.remoteReady,
alarmPushStatus: this.alarmPushStatus,
imDeliveryCatalog: this.imDeliveryCatalog
imDeliveryCatalog: this.imDeliveryCatalog,
sessionsExportStatus: this.sessionsExportStatus,
sessionsExportBusy: this.sessionsExportBusy,
sessionsExportError: this.sessionsExportError,
sessionsExportLastFile: this.sessionsExportLastFile
};
}
credentials() {
@ -1313,7 +1471,13 @@ class NetxopsCardController {
this.readCredential();
}
inject() {
return { hooks: { netxopsCard: this.store }, ...this.form.actions() };
return {
hooks: { netxopsCard: this.store },
...this.form.actions(),
exportAllSessions: () => {
this.exportAllSessions();
}
};
}
async writeToken(value) {
const api = this.credentials();
@ -1332,22 +1496,20 @@ function refOf(snapshot) {
// src/client/locales.ts
var en = {
title: "Netx Ops",
description: "netx API, bearer token, and capability groups (ops / topology).",
description: "API, token, capabilities, and alarm delivery.",
apiUrl: "API URL",
apiUrlHint: "netx REST root, e.g. http://127.0.0.1:8890 (also used for alarm subscribe)",
apiUrlHint: "e.g. http://127.0.0.1:8890",
lang: "Language",
langHint: "Response language hint (zh / en).",
langHint: "zh / en",
capabilityGroups: "Capability groups",
capabilityGroupsHint: "One group ↔ one skill. ops = netx-ops (alarms + CLI login + paths); topology = netx-topology. Defaults: ops on; topology and public off. New sessions after save. Other agents: dsh-netxops/tools-ops|topology.",
nmsProvider: "nms provider",
nmsProviderHint: "Vendor adapter id. Supported today: zte-ume (REST still /v1/ume/*; model tools are netx__*Nms*).",
groupOps: "ops — skill netx-ops (NMS alarms/inventory + managed CLI login + paths)",
groupTopology: "topology — skill netx-topology (canvas / fabric / dual_unit)",
nmsProvider: "NMS provider",
nmsProviderHint: "zte-ume",
groupOps: "ops",
groupTopology: "topology",
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",
alarmPushStatus: "Status",
alarmPushPhaseDisabled: "Off",
alarmPushPhaseIdle: "Idle",
alarmPushPhaseConnecting: "Connecting…",
@ -1357,52 +1519,49 @@ var en = {
alarmPushPhaseAuthFailed: "Auth failed",
alarmPushPhaseError: "Error",
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 ≥ops.24. Pick a saved delivery target below (create targets under IM → Delivery settings).",
imBotId: "IM Bot ID",
imBotIdHint: "Filled from the picker, or enter manually.",
imTargetId: "IM Target ID",
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.",
imTargetNone: "No delivery targets yet — create one in IM delivery settings.",
imTargetSelectedCount: "{count} selected",
imCatalogUnavailable: "Cannot load IM targets (install/update dsh-im-ops).",
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.",
apiTokenHint: "Stored as NETX_API_TOKEN. Leave blank to keep current.",
apiTokenUnavailable: "remote.credentials unavailable — set NETX_API_TOKEN via script.",
apiTokenSet: "Configured",
apiTokenUnset: "Not set",
sessionsExport: "Export all sessions",
sessionsExportCount: "{count} sessions",
sessionsExportUnavailable: "Export unavailable",
sessionsExportButton: "Download ZIP",
sessionsExportBusy: "Preparing…",
sessionsExportDone: "Downloaded: {file}",
overridden: "Overridden",
reset: "Reset",
invalid: "Invalid value",
invalid: "Invalid",
expand: "Expand",
collapse: "Collapse",
unsaved: "Unsaved",
readOnly: "This document is read-only.",
saveFailed: "Save failed — drafts kept for correction.",
readOnly: "Read-only",
saveFailed: "Save failed",
discard: "Discard",
save: "Save",
saving: "Saving…"
};
var zh = {
title: "Netx Ops",
description: "netx API、Bearer Token,以及能力组(ops / topology)。",
description: "API、Token、能力组与告警投递。",
apiUrl: "API 地址",
apiUrlHint: "netx REST 根地址,例如 http://127.0.0.1:8890(告警订阅复用同一地址)",
apiUrlHint: "例如 http://127.0.0.1:8890",
lang: "语言",
langHint: "响应语言提示(zh / en)。",
langHint: "zh / en",
capabilityGroups: "能力组",
capabilityGroupsHint: "一组一个 skill。ops = netx-ops(告警 + 纳管登录 + 路径);topology = netx-topology。默认 ops 开;topology / 公开关。保存后新会话生效。其他 Agent:dsh-netxops/tools-ops|topology。",
nmsProvider: "nms provider",
nmsProviderHint: "厂商适配器 id。当前支持:zte-ume(REST 仍为 /v1/ume/*;模型工具名为 netx__*Nms*)。",
groupOps: "ops — skill netx-ops(NMS 告警/库存 + 纳管 CLI 登录 + 路径)",
groupTopology: "topology — skill netx-topology(画布 / Fabric / dual_unit)",
nmsProvider: "NMS provider",
nmsProviderHint: "zte-ume",
groupOps: "ops",
groupTopology: "topology",
groupInPreset: "在 Netx Ops 预设中启用",
groupPublic: "对其他预设公开",
alarmPushEnabled: "关键告警推送",
alarmPushEnabledHint: "主动连接 netx;匹配到关键告警后按下述开关投递。可只开 DSH、只开 IM,或两者都开。",
alarmPushStatus: "推送链路",
alarmPushStatus: "状态",
alarmPushPhaseDisabled: "未开启",
alarmPushPhaseIdle: "空闲",
alarmPushPhaseConnecting: "连接中…",
@ -1412,30 +1571,29 @@ var zh = {
alarmPushPhaseAuthFailed: "鉴权失败",
alarmPushPhaseError: "异常",
alarmDeliverDsh: "投递到 DSH 会话",
alarmDeliverDshHint: "在本机打开/续写 sticky「Netx 关键告警」会话。",
alarmDeliverIm: "投递到 WhatsApp / IM",
alarmDeliverImHint: "需要 dsh-im-ops ≥ops.24。请在下方下拉选择已保存的投递目标(先在 IM「投递设置」新建)。",
imBotId: "IM Bot ID",
imBotIdHint: "由下拉自动填入,也可手动改。",
imTargetId: "IM Target ID",
imTargetIdHint: "由下拉自动填入,也可手动改。",
imTarget: "投递目标",
imTargetNone: "(请先在 IM「投递设置」新建目标)",
imTargetManual: "手动填写…",
imCatalogUnavailable: "无法加载投递目标 — 请安装/更新 dsh-im-ops(≥ops.24)并至少保存一个投递目标。",
imTargetNone: "暂无投递目标 — 请先在 IM「投递设置」新建。",
imTargetSelectedCount: "已选 {count} 个",
imCatalogUnavailable: "无法加载投递目标(请安装/更新 dsh-im-ops)。",
apiToken: "API Token",
apiTokenHint: "写入凭据 NETX_API_TOKEN(不会进 settings)。留空表示保留已有 token。",
apiTokenUnavailable: "当前 DSH 未提供 remote.credentials。请用 scripts/set-netx-token.ps1(或 .sh)写入 token;若 harness 在监视凭据文件则无需重启。",
apiTokenHint: "写入凭据 NETX_API_TOKEN;留空表示保留已有。",
apiTokenUnavailable: "未提供 remote.credentials — 请用脚本写入 NETX_API_TOKEN。",
apiTokenSet: "已配置",
apiTokenUnset: "未设置",
sessionsExport: "导出全部会话",
sessionsExportCount: "{count} 个会话",
sessionsExportUnavailable: "无法导出",
sessionsExportButton: "下载 ZIP",
sessionsExportBusy: "准备中…",
sessionsExportDone: "已下载:{file}",
overridden: "已覆盖",
reset: "重置",
invalid: "无效值",
invalid: "无效",
expand: "展开",
collapse: "收起",
unsaved: "未保存",
readOnly: "当前文档只读。",
saveFailed: "保存失败,草稿已保留以便修改。",
readOnly: "只读",
saveFailed: "保存失败",
discard: "丢弃",
save: "保存",
saving: "保存中…"

File diff suppressed because it is too large Load diff

View file

@ -1,7 +1,7 @@
{
"name": "dsh-netxops",
"version": "0.1.24",
"description": "DeepSeek Harness Netx Ops: capability groups ops/topology (one group ↔ one skill)",
"version": "0.1.26",
"description": "DeepSeek Harness Netx Ops: capability groups ops/topology + multi IM delivery + session export",
"license": "MIT",
"type": "module",
"private": false,
@ -89,7 +89,8 @@
"react": "^18.2.0 || ^19.0.0"
},
"dependencies": {
"@deepseek-ai/schemastery": "*"
"@deepseek-ai/schemastery": "*",
"fflate": "^0.8.2"
},
"devDependencies": {
"@types/react": "~18.3.1",

View file

@ -9,9 +9,15 @@ import type { NetxopsLocaleKey } from './locales.ts'
import type { CardFieldState } from './card-form.ts'
import {
imCatalogOptionKey,
parseImCatalogOptionKey,
type ImDeliveryCatalog,
} from './im-delivery-catalog.ts'
import {
formatImTargetsJson,
imTargetKey,
resolveImTargets,
setImTargetSelected,
type ImDeliveryTarget,
} from '../netx/im-targets.ts'
import { alarmPushTone, type AlarmPushPhase } from './alarm-push-status-view.ts'
import { ensureStyles } from './styles.ts'
import type {} from '@deepseek-ai/dsh-client-ui-settings-plugins/client'
@ -95,117 +101,98 @@ function ValueField(props: {
function ImDeliveryPicker(props: {
catalog: ImDeliveryCatalog
botId: string
targetId: string
targetsJson: string
legacyBotId: string
legacyTargetId: string
disabled: boolean
labels: {
target: string
none: string
manual: string
selectedCount: string
unavailable: string
botId: string
botHint: string
targetId: string
targetHint: string
overridden: string
reset: string
invalid: string
}
botField: CardFieldState
targetField: CardFieldState
onPick: (botId: string, targetId: string) => void
onEditBot: (text: string) => void
onEditTarget: (text: string) => void
onResetBot: () => void
onResetTarget: () => void
onChange: (targets: ImDeliveryTarget[]) => void
}) {
const options = props.catalog.options
const selectedKey = props.botId && props.targetId
? imCatalogOptionKey(props.botId, props.targetId)
: ''
const matched = options.some((row) => imCatalogOptionKey(row.botId, row.targetId) === selectedKey)
const [manual, setManual] = useState(() => !!(selectedKey && !matched))
useEffect(() => {
if (selectedKey && matched) setManual(false)
else if (selectedKey && !matched) setManual(true)
}, [selectedKey, matched])
const selectValue = manual ? '__manual__' : (matched ? selectedKey : '')
const showManual = manual || (selectedKey && !matched)
const selected = resolveImTargets({
imTargets: props.targetsJson,
imBotId: props.legacyBotId,
imTargetId: props.legacyTargetId,
})
const selectedKeys = new Set(selected.map((row) => imTargetKey(row.botId, row.targetId)))
// Orphan legacy / JSON targets not present in the live catalog still show as checked rows.
const orphanSelected = selected.filter(
(row) => !options.some(
(opt) => imCatalogOptionKey(opt.botId, opt.targetId) === imTargetKey(row.botId, row.targetId),
),
)
return (
<>
<div className="dsh-nx-field">
<div className="dsh-nx-fieldHead">
<label className="dsh-nx-label" htmlFor="netxops-im-target">{props.labels.target}</label>
</div>
<select
id="netxops-im-target"
className="dsh-nx-input dsh-nx-select"
value={selectValue}
disabled={props.disabled}
onChange={(event) => {
const value = event.target.value
if (value === '__manual__') {
setManual(true)
return
}
setManual(false)
if (!value) {
props.onPick('', '')
return
}
const next = parseImCatalogOptionKey(value)
props.onPick(next.botId, next.targetId)
}}
>
<option value="">
{options.length === 0 ? props.labels.none : `— ${props.labels.target} —`}
</option>
{options.map((row) => (
<option
key={imCatalogOptionKey(row.botId, row.targetId)}
value={imCatalogOptionKey(row.botId, row.targetId)}
>
{`${row.name} · ${row.channel || 'im'} · ${row.targetId}`}
</option>
))}
<option value="__manual__">{props.labels.manual}</option>
</select>
{!props.catalog.available || options.length === 0
? <p className="dsh-nx-hint">{props.catalog.hint || props.labels.unavailable}</p>
<div className="dsh-nx-field">
<div className="dsh-nx-fieldHead">
<span className="dsh-nx-label">{props.labels.target}</span>
{selected.length > 0
? (
<span className="dsh-nx-badgeMuted">
{props.labels.selectedCount.replace('{count}', String(selected.length))}
</span>
)
: null}
</div>
{showManual
{options.length === 0 && orphanSelected.length === 0
? (
<>
<ValueField
id="netxops-im-bot-id"
label={props.labels.botId}
hint={props.labels.botHint}
field={props.botField}
overriddenLabel={props.labels.overridden}
resetLabel={props.labels.reset}
invalidLabel={props.labels.invalid}
disabled={props.disabled}
onEdit={props.onEditBot}
onReset={props.onResetBot}
/>
<ValueField
id="netxops-im-target-id"
label={props.labels.targetId}
hint={props.labels.targetHint}
field={props.targetField}
overriddenLabel={props.labels.overridden}
resetLabel={props.labels.reset}
invalidLabel={props.labels.invalid}
disabled={props.disabled}
onEdit={props.onEditTarget}
onReset={props.onResetTarget}
/>
</>
<p className="dsh-nx-hint">
{!props.catalog.available
? (props.catalog.hint || props.labels.unavailable)
: props.labels.none}
</p>
)
: (
<div className="dsh-nx-imTargetList">
{options.map((row) => {
const key = imCatalogOptionKey(row.botId, row.targetId)
const checked = selectedKeys.has(key)
return (
<label key={key} className="dsh-nx-checkRow">
<input
type="checkbox"
checked={checked}
disabled={props.disabled}
onChange={(event) => {
props.onChange(setImTargetSelected(
selected,
{ botId: row.botId, targetId: row.targetId },
event.target.checked,
))
}}
/>
<span>{`${row.name} · ${row.channel || 'im'} · ${row.targetId}`}</span>
</label>
)
})}
{orphanSelected.map((row) => {
const key = imTargetKey(row.botId, row.targetId)
return (
<label key={`orphan-${key}`} className="dsh-nx-checkRow">
<input
type="checkbox"
checked
disabled={props.disabled}
onChange={(event) => {
props.onChange(setImTargetSelected(selected, row, event.target.checked))
}}
/>
<span>{`${row.botId} · ${row.targetId}`}</span>
</label>
)
})}
</div>
)}
{!props.catalog.available && options.length > 0
? <p className="dsh-nx-hint">{props.catalog.hint || props.labels.unavailable}</p>
: null}
</>
</div>
)
}
@ -362,7 +349,6 @@ export function NetxopsCard(props: NetxopsCardProps) {
<div className="dsh-nx-fieldHead">
<span className="dsh-nx-label">{t('capabilityGroups')}</span>
</div>
<p className="dsh-nx-hint">{t('capabilityGroupsHint')}</p>
<CapabilityGroupBlock
title={t('groupOps')}
inPresetLabel={t('groupInPreset')}
@ -427,11 +413,11 @@ export function NetxopsCard(props: NetxopsCardProps) {
type="checkbox"
checked={state.alarmPushEnabled.text === 'true'}
disabled={disabled}
aria-label={t('alarmPushEnabled')}
onChange={(event) => {
props.edit('alarmPushEnabled', event.target.checked ? 'true' : 'false')
}}
/>
<span>{t('alarmPushEnabledHint')}</span>
</label>
{pushStatus?.wsUrl
? <p className="dsh-nx-hint">{pushStatus.wsUrl}</p>
@ -460,69 +446,77 @@ export function NetxopsCard(props: NetxopsCardProps) {
type="checkbox"
checked={state.alarmDeliverDsh.text === 'true'}
disabled={disabled}
aria-label={t('alarmDeliverDsh')}
onChange={(event) => {
props.edit('alarmDeliverDsh', event.target.checked ? 'true' : 'false')
}}
/>
<span>{t('alarmDeliverDshHint')}</span>
</label>
</div>
<div className="dsh-nx-field">
<div className="dsh-nx-fieldHead">
<label className="dsh-nx-label" htmlFor="netxops-alarm-im">{t('alarmDeliverIm')}</label>
{state.alarmDeliverIm.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('alarmDeliverIm') }}>
{t('reset')}
</button>
</span>
)
: null}
</div>
<label className="dsh-nx-checkRow" htmlFor="netxops-alarm-im">
<input
id="netxops-alarm-im"
type="checkbox"
checked={state.alarmDeliverIm.text === 'true'}
disabled={disabled}
onChange={(event) => {
props.edit('alarmDeliverIm', event.target.checked ? 'true' : 'false')
}}
/>
<span>{t('alarmDeliverImHint')}</span>
</label>
</div>
<ImDeliveryPicker
catalog={state.imDeliveryCatalog}
botId={state.imBotId.text}
targetId={state.imTargetId.text}
targetsJson={state.imTargets.text}
legacyBotId={state.imBotId.text}
legacyTargetId={state.imTargetId.text}
disabled={disabled}
labels={{
target: t('imTarget'),
target: t('alarmDeliverIm'),
none: t('imTargetNone'),
manual: t('imTargetManual'),
selectedCount: t('imTargetSelectedCount'),
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)
onChange={(targets) => {
props.edit('imTargets', formatImTargetsJson(targets))
const first = targets[0]
props.edit('imBotId', first?.botId ?? '')
props.edit('imTargetId', first?.targetId ?? '')
props.edit('alarmDeliverIm', targets.length > 0 ? 'true' : 'false')
}}
onEditBot={(text) => { props.edit('imBotId', text) }}
onEditTarget={(text) => { props.edit('imTargetId', text) }}
onResetBot={() => { props.resetField('imBotId') }}
onResetTarget={() => { props.resetField('imTargetId') }}
/>
<div className="dsh-nx-field">
<div className="dsh-nx-fieldHead">
<span className="dsh-nx-label">{t('sessionsExport')}</span>
</div>
{state.sessionsExportStatus === null
? <p className="dsh-nx-hint">{t('sessionsExportUnavailable')}</p>
: state.sessionsExportStatus.available
? (
<p className="dsh-nx-hint">
{t('sessionsExportCount').replace(
'{count}',
String(state.sessionsExportStatus.sessionCount),
)}
</p>
)
: (
<p className="dsh-nx-invalid" role="status">
{state.sessionsExportStatus.reason || t('sessionsExportUnavailable')}
</p>
)}
{state.sessionsExportError
? <p className="dsh-nx-invalid" role="status">{state.sessionsExportError}</p>
: null}
{state.sessionsExportLastFile && !state.sessionsExportBusy && !state.sessionsExportError
? (
<p className="dsh-nx-hint" role="status">
{t('sessionsExportDone').replace('{file}', state.sessionsExportLastFile)}
</p>
)
: null}
<div className="dsh-nx-exportRow">
<button
type="button"
className="dsh-nx-btn dsh-nx-export"
disabled={
state.sessionsExportBusy
|| state.sessionsExportStatus?.available !== true
}
onClick={() => { props.exportAllSessions() }}
>
{t(state.sessionsExportBusy ? 'sessionsExportBusy' : 'sessionsExportButton')}
</button>
</div>
</div>
<div className="dsh-nx-footer">
{state.failed ? <p className="dsh-nx-failed" role="status">{t('saveFailed')}</p> : null}
<button

View file

@ -20,6 +20,11 @@ import {
fetchImDeliveryCatalog,
type ImDeliveryCatalog,
} from './im-delivery-catalog.ts'
import {
downloadAllSessionsExport,
fetchSessionsExportStatus,
type SessionsExportStatus,
} from './sessions-export-view.ts'
export const NETXOPS_NS = 'netxops'
const DEFAULT_TOKEN_REF = 'NETX_API_TOKEN'
@ -33,6 +38,8 @@ export interface NetxopsSettings {
alarmPushEnabled?: boolean
alarmDeliverDsh?: boolean
alarmDeliverIm?: boolean
/** JSON array of `{ botId, targetId }` — multi WhatsApp / IM sinks. */
imTargets?: string
imBotId?: string
imTargetId?: string
groupOpsInPreset?: boolean
@ -60,6 +67,7 @@ export interface NetxopsCardState extends CardShell {
alarmPushEnabled: CardFieldState
alarmDeliverDsh: CardFieldState
alarmDeliverIm: CardFieldState
imTargets: CardFieldState
imBotId: CardFieldState
imTargetId: CardFieldState
apiToken: CardFieldState
@ -70,12 +78,22 @@ export interface NetxopsCardState extends CardShell {
alarmPushStatus: AlarmPushStatus | null
/** Saved IM delivery targets for the picker (soft-depends on dsh-im-ops). */
imDeliveryCatalog: ImDeliveryCatalog
/** Bulk session-export readiness from Host RPC; null when RPC is absent. */
sessionsExportStatus: SessionsExportStatus | null
/** In-flight bulk export download. */
sessionsExportBusy: boolean
/** Last bulk export error message, if any. */
sessionsExportError: string | null
/** Last successful download filename. */
sessionsExportLastFile: string | null
}
export interface NetxopsCardFace extends CardActions {
hooks: {
netxopsCard: SnapshotStore<NetxopsCardState>
}
/** Download every durable session on this Host as one ZIP. */
exportAllSessions: () => void
}
type CredentialsRemote = {
@ -95,9 +113,15 @@ export class NetxopsCardController {
private rpcCall: AlarmPushRpcCall | undefined
private alarmPushStatus: AlarmPushStatus | null = null
private imDeliveryCatalog: ImDeliveryCatalog = { ...EMPTY_IM_DELIVERY_CATALOG }
private sessionsExportStatus: SessionsExportStatus | null = null
private sessionsExportBusy = false
private sessionsExportError: string | null = null
private sessionsExportLastFile: string | null = null
private pollTimer: ReturnType<typeof setInterval> | undefined
private pollInFlight = false
private catalogInFlight = false
private exportStatusInFlight = false
private exportInFlight: Promise<void> | undefined
constructor(
private readonly scope: SettingsScope<NetxopsSettings>,
@ -116,6 +140,7 @@ export class NetxopsCardController {
booleanField('alarmPushEnabled'),
booleanFieldPersistFalse('alarmDeliverDsh'),
booleanField('alarmDeliverIm'),
textField('imTargets'),
textField('imBotId'),
textField('imTargetId'),
],
@ -155,12 +180,17 @@ export class NetxopsCardController {
this.imDeliveryCatalog = { ...EMPTY_IM_DELIVERY_CATALOG }
changed = true
}
if (this.sessionsExportStatus !== null) {
this.sessionsExportStatus = null
changed = true
}
if (changed) this.store.set(this.projection())
return
}
this.startStatusPoll()
void this.refreshAlarmPushStatus()
void this.refreshImDeliveryCatalog()
void this.refreshSessionsExportStatus()
}
private startStatusPoll(): void {
@ -168,6 +198,7 @@ export class NetxopsCardController {
this.pollTimer = setInterval(() => {
void this.refreshAlarmPushStatus()
void this.refreshImDeliveryCatalog()
void this.refreshSessionsExportStatus()
}, STATUS_POLL_MS)
}
@ -232,6 +263,54 @@ export class NetxopsCardController {
}
}
private async refreshSessionsExportStatus(): Promise<void> {
const call = this.rpcCall
if (call === undefined || this.exportStatusInFlight) return
this.exportStatusInFlight = true
try {
const next = await fetchSessionsExportStatus(call)
const prev = this.sessionsExportStatus
if (
prev
&& prev.available === next.available
&& prev.sessionCount === next.sessionCount
&& prev.supportsRawArtifacts === next.supportsRawArtifacts
&& prev.reason === next.reason
) return
this.sessionsExportStatus = next
this.store.set(this.projection())
} catch {
// Keep last good snapshot; next poll retries.
} finally {
this.exportStatusInFlight = false
}
}
/**
* Download every durable session as one ZIP through the browser download manager.
*/
exportAllSessions(): void {
if (this.exportInFlight !== undefined || this.sessionsExportBusy) return
if (this.sessionsExportStatus?.available !== true) return
this.sessionsExportBusy = true
this.sessionsExportError = null
this.store.set(this.projection())
this.exportInFlight = downloadAllSessionsExport()
.then((result) => {
this.sessionsExportLastFile = result.filename
this.sessionsExportError = null
})
.catch((error: unknown) => {
this.sessionsExportError = error instanceof Error ? error.message : String(error)
})
.finally(() => {
this.sessionsExportBusy = false
this.exportInFlight = undefined
this.store.set(this.projection())
void this.refreshSessionsExportStatus()
})
}
private projection(): NetxopsCardState {
return {
...this.form.shell(),
@ -245,6 +324,7 @@ export class NetxopsCardController {
alarmPushEnabled: this.form.field('alarmPushEnabled'),
alarmDeliverDsh: this.form.field('alarmDeliverDsh'),
alarmDeliverIm: this.form.field('alarmDeliverIm'),
imTargets: this.form.field('imTargets'),
imBotId: this.form.field('imBotId'),
imTargetId: this.form.field('imTargetId'),
apiToken: this.form.field(API_TOKEN_FIELD),
@ -253,6 +333,10 @@ export class NetxopsCardController {
apiTokenRemoteReady: this.credential.remoteReady,
alarmPushStatus: this.alarmPushStatus,
imDeliveryCatalog: this.imDeliveryCatalog,
sessionsExportStatus: this.sessionsExportStatus,
sessionsExportBusy: this.sessionsExportBusy,
sessionsExportError: this.sessionsExportError,
sessionsExportLastFile: this.sessionsExportLastFile,
}
}
@ -303,7 +387,11 @@ export class NetxopsCardController {
}
inject(): NetxopsCardFace {
return { hooks: { netxopsCard: this.store }, ...this.form.actions() }
return {
hooks: { netxopsCard: this.store },
...this.form.actions(),
exportAllSessions: () => { this.exportAllSessions() },
}
}
private async writeToken(value: string): Promise<boolean> {

View file

@ -8,7 +8,6 @@ export type NetxopsLocaleKey =
| 'lang'
| 'langHint'
| 'capabilityGroups'
| 'capabilityGroupsHint'
| 'nmsProvider'
| 'nmsProviderHint'
| 'groupOps'
@ -16,7 +15,6 @@ export type NetxopsLocaleKey =
| 'groupInPreset'
| 'groupPublic'
| 'alarmPushEnabled'
| 'alarmPushEnabledHint'
| 'alarmPushStatus'
| 'alarmPushPhaseDisabled'
| 'alarmPushPhaseIdle'
@ -27,22 +25,21 @@ export type NetxopsLocaleKey =
| 'alarmPushPhaseAuthFailed'
| 'alarmPushPhaseError'
| 'alarmDeliverDsh'
| 'alarmDeliverDshHint'
| 'alarmDeliverIm'
| 'alarmDeliverImHint'
| 'imBotId'
| 'imBotIdHint'
| 'imTargetId'
| 'imTargetIdHint'
| 'imTarget'
| 'imTargetNone'
| 'imTargetManual'
| 'imTargetSelectedCount'
| 'imCatalogUnavailable'
| 'apiToken'
| 'apiTokenHint'
| 'apiTokenUnavailable'
| 'apiTokenSet'
| 'apiTokenUnset'
| 'sessionsExport'
| 'sessionsExportCount'
| 'sessionsExportUnavailable'
| 'sessionsExportButton'
| 'sessionsExportBusy'
| 'sessionsExportDone'
| 'overridden'
| 'reset'
| 'invalid'
@ -57,22 +54,20 @@ export type NetxopsLocaleKey =
export const en: Record<NetxopsLocaleKey, string> = {
title: 'Netx Ops',
description: 'netx API, bearer token, and capability groups (ops / topology).',
description: 'API, token, capabilities, and alarm delivery.',
apiUrl: 'API URL',
apiUrlHint: 'netx REST root, e.g. http://127.0.0.1:8890 (also used for alarm subscribe)',
apiUrlHint: 'e.g. http://127.0.0.1:8890',
lang: 'Language',
langHint: 'Response language hint (zh / en).',
langHint: 'zh / en',
capabilityGroups: 'Capability groups',
capabilityGroupsHint: 'One group ↔ one skill. ops = netx-ops (alarms + CLI login + paths); topology = netx-topology. Defaults: ops on; topology and public off. New sessions after save. Other agents: dsh-netxops/tools-ops|topology.',
nmsProvider: 'nms provider',
nmsProviderHint: 'Vendor adapter id. Supported today: zte-ume (REST still /v1/ume/*; model tools are netx__*Nms*).',
groupOps: 'ops — skill netx-ops (NMS alarms/inventory + managed CLI login + paths)',
groupTopology: 'topology — skill netx-topology (canvas / fabric / dual_unit)',
nmsProvider: 'NMS provider',
nmsProviderHint: 'zte-ume',
groupOps: 'ops',
groupTopology: 'topology',
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',
alarmPushStatus: 'Status',
alarmPushPhaseDisabled: 'Off',
alarmPushPhaseIdle: 'Idle',
alarmPushPhaseConnecting: 'Connecting…',
@ -82,30 +77,29 @@ export const en: Record<NetxopsLocaleKey, string> = {
alarmPushPhaseAuthFailed: 'Auth failed',
alarmPushPhaseError: 'Error',
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 ≥ops.24. Pick a saved delivery target below (create targets under IM → Delivery settings).',
imBotId: 'IM Bot ID',
imBotIdHint: 'Filled from the picker, or enter manually.',
imTargetId: 'IM Target ID',
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.',
imTargetNone: 'No delivery targets yet — create one in IM delivery settings.',
imTargetSelectedCount: '{count} selected',
imCatalogUnavailable: 'Cannot load IM targets (install/update dsh-im-ops).',
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.',
apiTokenHint: 'Stored as NETX_API_TOKEN. Leave blank to keep current.',
apiTokenUnavailable: 'remote.credentials unavailable — set NETX_API_TOKEN via script.',
apiTokenSet: 'Configured',
apiTokenUnset: 'Not set',
sessionsExport: 'Export all sessions',
sessionsExportCount: '{count} sessions',
sessionsExportUnavailable: 'Export unavailable',
sessionsExportButton: 'Download ZIP',
sessionsExportBusy: 'Preparing…',
sessionsExportDone: 'Downloaded: {file}',
overridden: 'Overridden',
reset: 'Reset',
invalid: 'Invalid value',
invalid: 'Invalid',
expand: 'Expand',
collapse: 'Collapse',
unsaved: 'Unsaved',
readOnly: 'This document is read-only.',
saveFailed: 'Save failed — drafts kept for correction.',
readOnly: 'Read-only',
saveFailed: 'Save failed',
discard: 'Discard',
save: 'Save',
saving: 'Saving…',
@ -113,22 +107,20 @@ export const en: Record<NetxopsLocaleKey, string> = {
export const zh: Record<NetxopsLocaleKey, string> = {
title: 'Netx Ops',
description: 'netx API、Bearer Token,以及能力组(ops / topology)。',
description: 'API、Token、能力组与告警投递。',
apiUrl: 'API 地址',
apiUrlHint: 'netx REST 根地址,例如 http://127.0.0.1:8890(告警订阅复用同一地址)',
apiUrlHint: '例如 http://127.0.0.1:8890',
lang: '语言',
langHint: '响应语言提示(zh / en)。',
langHint: 'zh / en',
capabilityGroups: '能力组',
capabilityGroupsHint: '一组一个 skill。ops = netx-ops(告警 + 纳管登录 + 路径);topology = netx-topology。默认 ops 开;topology / 公开关。保存后新会话生效。其他 Agent:dsh-netxops/tools-ops|topology。',
nmsProvider: 'nms provider',
nmsProviderHint: '厂商适配器 id。当前支持:zte-ume(REST 仍为 /v1/ume/*;模型工具名为 netx__*Nms*)。',
groupOps: 'ops — skill netx-ops(NMS 告警/库存 + 纳管 CLI 登录 + 路径)',
groupTopology: 'topology — skill netx-topology(画布 / Fabric / dual_unit)',
nmsProvider: 'NMS provider',
nmsProviderHint: 'zte-ume',
groupOps: 'ops',
groupTopology: 'topology',
groupInPreset: '在 Netx Ops 预设中启用',
groupPublic: '对其他预设公开',
alarmPushEnabled: '关键告警推送',
alarmPushEnabledHint: '主动连接 netx;匹配到关键告警后按下述开关投递。可只开 DSH、只开 IM,或两者都开。',
alarmPushStatus: '推送链路',
alarmPushStatus: '状态',
alarmPushPhaseDisabled: '未开启',
alarmPushPhaseIdle: '空闲',
alarmPushPhaseConnecting: '连接中…',
@ -138,30 +130,29 @@ export const zh: Record<NetxopsLocaleKey, string> = {
alarmPushPhaseAuthFailed: '鉴权失败',
alarmPushPhaseError: '异常',
alarmDeliverDsh: '投递到 DSH 会话',
alarmDeliverDshHint: '在本机打开/续写 sticky「Netx 关键告警」会话。',
alarmDeliverIm: '投递到 WhatsApp / IM',
alarmDeliverImHint: '需要 dsh-im-ops ≥ops.24。请在下方下拉选择已保存的投递目标(先在 IM「投递设置」新建)。',
imBotId: 'IM Bot ID',
imBotIdHint: '由下拉自动填入,也可手动改。',
imTargetId: 'IM Target ID',
imTargetIdHint: '由下拉自动填入,也可手动改。',
imTarget: '投递目标',
imTargetNone: '(请先在 IM「投递设置」新建目标)',
imTargetManual: '手动填写…',
imCatalogUnavailable: '无法加载投递目标 — 请安装/更新 dsh-im-ops(≥ops.24)并至少保存一个投递目标。',
imTargetNone: '暂无投递目标 — 请先在 IM「投递设置」新建。',
imTargetSelectedCount: '已选 {count} 个',
imCatalogUnavailable: '无法加载投递目标(请安装/更新 dsh-im-ops)。',
apiToken: 'API Token',
apiTokenHint: '写入凭据 NETX_API_TOKEN(不会进 settings)。留空表示保留已有 token。',
apiTokenUnavailable: '当前 DSH 未提供 remote.credentials。请用 scripts/set-netx-token.ps1(或 .sh)写入 token;若 harness 在监视凭据文件则无需重启。',
apiTokenHint: '写入凭据 NETX_API_TOKEN;留空表示保留已有。',
apiTokenUnavailable: '未提供 remote.credentials — 请用脚本写入 NETX_API_TOKEN。',
apiTokenSet: '已配置',
apiTokenUnset: '未设置',
sessionsExport: '导出全部会话',
sessionsExportCount: '{count} 个会话',
sessionsExportUnavailable: '无法导出',
sessionsExportButton: '下载 ZIP',
sessionsExportBusy: '准备中…',
sessionsExportDone: '已下载:{file}',
overridden: '已覆盖',
reset: '重置',
invalid: '无效值',
invalid: '无效',
expand: '展开',
collapse: '收起',
unsaved: '未保存',
readOnly: '当前文档只读。',
saveFailed: '保存失败,草稿已保留以便修改。',
readOnly: '只读',
saveFailed: '保存失败',
discard: '丢弃',
save: '保存',
saving: '保存中…',

View file

@ -0,0 +1,136 @@
/**
* Plugins-card helpers for bulk session export status + browser ZIP download.
*/
import {
NETXOPS_RPC_CHANNEL,
type AlarmPushRpcCall,
} from './alarm-push-status-view.ts'
import {
NETXOPS_SESSIONS_EXPORT_PATH,
type SessionsExportStatus,
} from '../session-export-shared.ts'
export type { SessionsExportStatus }
export { NETXOPS_SESSIONS_EXPORT_PATH }
export const SESSIONS_EXPORT_STATUS_ENDPOINT = 'sessions.export.status'
const EMPTY_STATUS: SessionsExportStatus = {
available: false,
sessionCount: 0,
supportsRawArtifacts: false,
reason: 'RPC unavailable',
}
/**
* Normalize a host export-status snapshot for the card.
* @param value - RPC value or unknown.
*/
export function asSessionsExportStatus(value: unknown): SessionsExportStatus {
if (value === null || typeof value !== 'object' || Array.isArray(value)) {
return { ...EMPTY_STATUS }
}
const row = value as Record<string, unknown>
return {
available: row.available === true,
sessionCount: typeof row.sessionCount === 'number' ? row.sessionCount : 0,
supportsRawArtifacts: row.supportsRawArtifacts === true,
reason: typeof row.reason === 'string' ? row.reason : undefined,
}
}
/**
* Read bulk-export readiness from Host RPC.
* @param call - `connection.rpc.call`.
* @param signal - optional abort.
*/
export async function fetchSessionsExportStatus(
call: AlarmPushRpcCall,
signal?: AbortSignal,
): Promise<SessionsExportStatus> {
const result = await call(NETXOPS_RPC_CHANNEL, SESSIONS_EXPORT_STATUS_ENDPOINT, {}, signal)
if (result !== null && typeof result === 'object' && (result as { ok?: boolean }).ok === true) {
return asSessionsExportStatus((result as { value?: unknown }).value)
}
if (result !== null && typeof result === 'object' && (result as { ok?: boolean }).ok === false) {
return {
...EMPTY_STATUS,
reason: String((result as { error?: { message?: string } }).error?.message ?? 'rpc failed'),
}
}
return asSessionsExportStatus(result)
}
/** Resolve the browser Host base (null-origin fallback matches DSH download helpers). */
function hostBase(): string {
const origin = (globalThis as { location?: { origin?: string } }).location?.origin
return origin !== undefined && origin !== 'null' ? origin : 'http://dsh.internal'
}
/**
* Save a Blob through the browser download manager (works for cloud Hosts:
* the ZIP lands on the operator machine, not on the remote `$DSH_HOME`).
* @param blob - ZIP bytes already fetched with credentials.
* @param filename - suggested download filename.
*/
export function saveBlobDownload(blob: Blob, filename: string): void {
const objectUrl = URL.createObjectURL(blob)
try {
const anchor = document.createElement('a')
anchor.href = objectUrl
anchor.download = filename
anchor.rel = 'noopener'
anchor.style.display = 'none'
document.body.appendChild(anchor)
anchor.click()
anchor.remove()
} finally {
// Keep the blob URL alive briefly so the download manager can start reading.
globalThis.setTimeout(() => {
URL.revokeObjectURL(objectUrl)
}, 60_000)
}
}
/**
* Fetch the bulk sessions ZIP and hand it to the browser download manager.
* Uses a credentialed GET + blob URL (not a bare `<a href=/api/...>` click after
* await), so cloud deployments and Chromium download UX both work.
* @param fetcher - browser fetch (authenticated same-origin).
* @param save - download trigger for the received blob.
*/
export async function downloadAllSessionsExport(
fetcher: (input: string | URL, init?: RequestInit) => Promise<Response> = fetch,
save: (blob: Blob, filename: string) => void = saveBlobDownload,
): Promise<{ filename: string; sessionCount: number; bytes: number }> {
const url = new URL(NETXOPS_SESSIONS_EXPORT_PATH, hostBase())
const response = await fetcher(url, {
method: 'GET',
credentials: 'include',
})
if (!response.ok) {
const fromHeader = response.headers.get('x-netxops-export-error') ?? ''
const detail = fromHeader || (await response.text().catch(() => ''))
throw new Error(`Export failed: HTTP ${response.status}${detail === '' ? '' : ` ${detail}`}`)
}
const disposition = response.headers.get('content-disposition') ?? ''
const matched = /filename="([^"]+)"/i.exec(disposition)
const filename = matched?.[1] && matched[1].length > 0
? matched[1]
: 'dsh-sessions-export.zip'
const countHeader = response.headers.get('x-netxops-session-count')
const sessionCount = countHeader !== null && countHeader !== ''
? Number.parseInt(countHeader, 10)
: 0
const blob = await response.blob()
if (blob.size <= 0) {
throw new Error('Export failed: empty ZIP body')
}
save(blob, filename)
return {
filename,
sessionCount: Number.isFinite(sessionCount) ? sessionCount : 0,
bytes: blob.size,
}
}

View file

@ -39,7 +39,11 @@ const CSS = `
.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-imTargetList{display:flex;flex-direction:column;gap:8px;padding:4px 0 2px}
.dsh-nx-invalid{margin:0;font-size:12px;line-height:1.5;color:var(--dsw-alias-label-error)}
.dsh-nx-exportRow{display:flex;align-items:center;gap:8px;padding-top:4px}
.dsh-nx-export{border-color:var(--dsw-alias-border-l2);background:none;color:var(--dsw-alias-label-primary)}
.dsh-nx-export:hover:not(:disabled){border-color:var(--dsw-alias-label-dimmed)}
.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-btn{appearance:none;border:1px solid transparent;border-radius:8px;padding:5px 14px;font:inherit;font-size:13px;line-height:1.5;cursor:pointer}

View file

@ -7,6 +7,10 @@
* When「关键告警推送」is on, this host dials out to netx's fixed-IP alarm hub and
* opens/follows a sticky DSH session (im / WhatsApp is optional and separate).
*
* Ops can download every durable session on this Host as one ZIP
* (`GET /api/netxops.sessions.export`) for HQ analysis — including cloud Hosts,
* where the browser receives the archive.
*
* @module dsh-netxops
*/
@ -34,7 +38,14 @@ import {
type NetxCapabilityGroupSettingsFields,
} from './netx/capability-groups.ts'
import { registerGroupSkills } from './netx/group-skills.ts'
import { resolveImTargets } from './netx/im-targets.ts'
import { publishNetxConnection, getNetxConnection, watchNetxConnection } from './netx/runtime.ts'
import {
getSessionsExportStatus,
NETXOPS_SESSIONS_EXPORT_PATH,
sessionsExportHeadResponse,
sessionsExportResponse,
} from './netx/session-export.ts'
import { registerNetxTools } from './netx/tools.ts'
/** Cordis plugin name. */
@ -78,13 +89,18 @@ export interface Config {
/** When push is on, also followup the sticky Netx Ops DSH session (default). */
alarmDeliverDsh: boolean
/**
* When push is on, also send via `ctx.dshIm.send(imBotId, imTargetId, …)`.
* Requires dsh-im-ops and a saved delivery target.
* When push is on, also send via `ctx.dshIm.send` to every configured target.
* Derived from selected sinks (`imTargets` / legacy pair); empty list means off.
*/
alarmDeliverIm: boolean
/** Opaque bot id from IM「投递设置」→ 复制调用参数. */
/**
* JSON array of `{ botId, targetId }` sinks. Preferred over the legacy
* single-target fields below when non-empty.
*/
imTargets: string
/** Opaque bot id from IM「投递设置」→ 复制调用参数 (legacy single-target). */
imBotId: string
/** Opaque target id from the same copy payload. */
/** Opaque target id from the same copy payload (legacy single-target). */
imTargetId: string
/**
* NMS provider adapter id. Supported today: `zte-ume`
@ -110,6 +126,7 @@ export const Config: z<Config> = z.object({
alarmPushEnabled: z.boolean().default(false),
alarmDeliverDsh: z.boolean().default(true),
alarmDeliverIm: z.boolean().default(false),
imTargets: z.string().default(''),
imBotId: z.string().default(''),
imTargetId: z.string().default(''),
nmsProvider: z.string().default('zte-ume'),
@ -234,9 +251,12 @@ export function apply(ctx: Context, config: Config = Config({})): void {
}
const lang = current.lang
const deliverDsh = current.alarmDeliverDsh !== false
const deliverIm = current.alarmDeliverIm === true
const imBotId = current.imBotId ?? ''
const imTargetId = current.imTargetId ?? ''
const imTargets = resolveImTargets({
imTargets: current.imTargets ?? '',
imBotId: current.imBotId ?? '',
imTargetId: current.imTargetId ?? '',
})
const deliverIm = imTargets.length > 0
stopAlarmPush = startAlarmPushClient({
apiUrl,
token,
@ -247,8 +267,7 @@ export function apply(ctx: Context, config: Config = Config({})): void {
}
await deliverAlarmToIm(ctx, payload, {
enabled: deliverIm,
botId: imBotId,
targetId: imTargetId,
targets: imTargets,
lang,
})
},
@ -286,12 +305,17 @@ export function apply(ctx: Context, config: Config = Config({})): void {
current.tokenCredentialRef,
)
} else {
const imSinkCount = resolveImTargets({
imTargets: current.imTargets ?? '',
imBotId: current.imBotId ?? '',
imTargetId: current.imTargetId ?? '',
}).length
ctx.logger.info(
'netxops: published connection → %s tokenConfigured=true alarmPush=%s dsh=%s im=%s public=[%s]',
apiUrl,
current.alarmPushEnabled === true,
current.alarmDeliverDsh !== false,
current.alarmDeliverIm === true,
imSinkCount,
groupsForPlane(groups, 'public').join(',') || '(none)',
)
}
@ -371,61 +395,94 @@ export function apply(ctx: Context, config: Config = Config({})): void {
}, 'netxops: dispose public skills')
})
// Browser card polls alarm-push WSS status + IM delivery catalog through Connection RPC.
// Browser card: alarm-push status + IM catalog (RPC) and all-sessions ZIP (Fetch).
ctx.inject(['connection'], (connCtx) => {
const rpc = connCtx.connection?.rpc
const connection = connCtx.connection as {
rpc?: { handle?: (channel: string, handler: (endpoint: string) => Promise<unknown>) => (() => void) | Promise<void> }
fetch?: {
register?: (route: {
readonly path: string
readonly methods: readonly ('GET' | 'HEAD')[]
readonly fetch: (request: Request) => Promise<Response>
}) => () => Promise<void>
}
} | undefined
const rpc = connection?.rpc
if (!rpc || typeof rpc.handle !== 'function') {
connCtx.logger.warn('netxops: connection.rpc.handle unavailable — alarm status UI disabled')
return
} else {
connCtx.effect(() => {
const dispose = rpc.handle(
NETXOPS_RPC_CHANNEL,
async (endpoint: string) => {
if (endpoint === 'alarm-push.status') {
return { ok: true, value: getAlarmPushStatus() }
}
if (endpoint === 'sessions.export.status') {
const value = await getSessionsExportStatus(ctx)
return { ok: true, value }
}
if (endpoint === 'im-delivery.catalog') {
type DshImCatalog = { listDeliveryCatalog?: () => Promise<unknown> }
const fromGet = typeof (ctx as { get?: (name: string) => unknown }).get === 'function'
? (ctx as { get: (name: string) => unknown }).get('dshIm') as DshImCatalog | undefined
: undefined
const im = fromGet ?? (ctx as { dshIm?: DshImCatalog }).dshIm
if (!im || typeof im.listDeliveryCatalog !== 'function') {
return {
ok: true,
value: {
available: false,
options: [],
hint: 'dsh-im-ops missing or outdated — install ≥ops.24 for delivery picker',
},
}
}
try {
const options = await im.listDeliveryCatalog()
return {
ok: true,
value: {
available: true,
options: Array.isArray(options) ? options : [],
},
}
} catch (error) {
return {
ok: true,
value: {
available: false,
options: [],
hint: error instanceof Error ? error.message : String(error),
},
}
}
}
return { ok: false, error: { code: 'bad-request', message: 'Unknown endpoint.' } }
},
)
return () => { void dispose() }
}, 'netxops: alarm-push status rpc')
}
connCtx.effect(() => {
const dispose = rpc.handle(
NETXOPS_RPC_CHANNEL,
async (endpoint: string) => {
if (endpoint === 'alarm-push.status') {
return { ok: true, value: getAlarmPushStatus() }
}
if (endpoint === 'im-delivery.catalog') {
type DshImCatalog = { listDeliveryCatalog?: () => Promise<unknown> }
const fromGet = typeof (ctx as { get?: (name: string) => unknown }).get === 'function'
? (ctx as { get: (name: string) => unknown }).get('dshIm') as DshImCatalog | undefined
: undefined
const im = fromGet ?? (ctx as { dshIm?: DshImCatalog }).dshIm
if (!im || typeof im.listDeliveryCatalog !== 'function') {
return {
ok: true,
value: {
available: false,
options: [],
hint: 'dsh-im-ops missing or outdated — install ≥ops.24 for delivery picker',
},
}
const fetchApi = connection?.fetch
if (!fetchApi || typeof fetchApi.register !== 'function') {
connCtx.logger.warn('netxops: connection.fetch.register unavailable — sessions export download disabled')
} else {
connCtx.effect(() => {
const dispose = fetchApi.register({
path: NETXOPS_SESSIONS_EXPORT_PATH,
methods: ['GET', 'HEAD'],
fetch: async (request) => {
if (request.method === 'HEAD') {
return sessionsExportHeadResponse(ctx, request)
}
try {
const options = await im.listDeliveryCatalog()
return {
ok: true,
value: {
available: true,
options: Array.isArray(options) ? options : [],
},
}
} catch (error) {
return {
ok: true,
value: {
available: false,
options: [],
hint: error instanceof Error ? error.message : String(error),
},
}
}
}
return { ok: false, error: { code: 'bad-request', message: 'Unknown endpoint.' } }
},
)
return () => { void dispose() }
}, 'netxops: alarm-push status rpc')
return sessionsExportResponse(ctx, request)
},
})
return () => { void dispose() }
}, 'netxops: sessions export fetch')
}
})
ctx.effect(() => () => {

View file

@ -1,14 +1,25 @@
/**
* Optional IM sink for key alarms (soft-depends on dsh-im `ctx.dshIm`).
* Fan-out: one alarm can be delivered to every configured WhatsApp / IM target.
*/
import type { Context } from '@deepseek-ai/cordis'
import { formatAlarmPrompt, type KeyAlarmPayload } from './alarm-push.ts'
import {
normalizeImTarget,
resolveImTargets,
type ImDeliveryTarget,
} from './im-targets.ts'
export interface AlarmImDeliveryOptions {
enabled: boolean
botId: string
targetId: string
/** Preferred multi-target list (JSON settings or already parsed). */
targets?: readonly ImDeliveryTarget[]
/** Legacy single-target fields (used when `targets` is empty). */
botId?: string
targetId?: string
/** Raw `imTargets` settings string (optional; used with legacy fields). */
imTargets?: string
lang: string
}
@ -16,11 +27,32 @@ type DshIm = {
send: (botId: string, targetId: string, text: string, opts?: { signal?: AbortSignal }) => Promise<unknown>
}
function resolveTargets(options: AlarmImDeliveryOptions): ImDeliveryTarget[] {
if (options.targets && options.targets.length > 0) {
const out: ImDeliveryTarget[] = []
const seen = new Set<string>()
for (const entry of options.targets) {
const target = normalizeImTarget(entry)
if (!target) continue
const key = `${target.botId}::${target.targetId}`
if (seen.has(key)) continue
seen.add(key)
out.push(target)
}
return out
}
return resolveImTargets({
imTargets: options.imTargets,
imBotId: options.botId,
imTargetId: options.targetId,
})
}
/**
* Send a formatted key-alarm text through proactive IM delivery when configured.
* @param ctx - host cordis context (may lack `dshIm`).
* @param payload - matched alarm.
* @param options - settings snapshot for IM sink.
* @param options - settings snapshot for IM sink(s).
*/
export async function deliverAlarmToIm(
ctx: Context,
@ -28,11 +60,10 @@ export async function deliverAlarmToIm(
options: AlarmImDeliveryOptions,
): Promise<void> {
if (!options.enabled) return
const botId = options.botId.trim()
const targetId = options.targetId.trim()
if (!botId || !targetId) {
const targets = resolveTargets(options)
if (targets.length === 0) {
ctx.logger.warn(
'netxops alarm-im: enabled but imBotId/imTargetId empty — skip IM delivery (create a target in IM 投递设置 and paste botId+targetId)',
'netxops alarm-im: enabled but no delivery targets — skip IM delivery (pick one or more targets under Netx Ops → IM)',
)
return
}
@ -49,10 +80,24 @@ export async function deliverAlarmToIm(
}
const text = formatAlarmPrompt(payload, options.lang)
try {
await im.send(botId, targetId, text)
ctx.logger.info('netxops alarm-im: sent to botId=%s targetId=%s', botId, targetId)
} catch (error) {
ctx.logger.warn('netxops alarm-im: send failed: %s', error)
}
const results = await Promise.allSettled(
targets.map((target) => im.send(target.botId, target.targetId, text)),
)
results.forEach((result, index) => {
const target = targets[index]!
if (result.status === 'fulfilled') {
ctx.logger.info(
'netxops alarm-im: sent to botId=%s targetId=%s',
target.botId,
target.targetId,
)
return
}
ctx.logger.warn(
'netxops alarm-im: send failed botId=%s targetId=%s: %s',
target.botId,
target.targetId,
result.reason,
)
})
}

125
src/netx/im-targets.ts Normal file
View file

@ -0,0 +1,125 @@
/**
* Parse / format / resolve IM delivery target lists for multi-sink alarm push.
*/
export interface ImDeliveryTarget {
botId: string
targetId: string
}
/**
* Stable key for one bot+target pair.
* @param botId - opaque bot id.
* @param targetId - opaque target id.
*/
export function imTargetKey(botId: string, targetId: string): string {
return `${botId}::${targetId}`
}
/**
* Parse {@link imTargetKey}.
* @param value - encoded key.
*/
export function parseImTargetKey(value: string): ImDeliveryTarget {
const at = value.indexOf('::')
if (at <= 0) return { botId: '', targetId: '' }
return { botId: value.slice(0, at), targetId: value.slice(at + 2) }
}
/**
* Normalize one target; empty strings become absent.
* @param input - raw target.
*/
export function normalizeImTarget(input: unknown): ImDeliveryTarget | null {
if (input === null || typeof input !== 'object' || Array.isArray(input)) return null
const row = input as Record<string, unknown>
const botId = typeof row.botId === 'string' ? row.botId.trim() : ''
const targetId = typeof row.targetId === 'string' ? row.targetId.trim() : ''
if (!botId || !targetId) return null
return { botId, targetId }
}
/**
* Parse the settings JSON string for `imTargets`.
* @param text - stored settings value.
*/
export function parseImTargetsJson(text: string): ImDeliveryTarget[] {
const trimmed = text.trim()
if (!trimmed) return []
let parsed: unknown
try {
parsed = JSON.parse(trimmed)
} catch {
return []
}
if (!Array.isArray(parsed)) return []
const out: ImDeliveryTarget[] = []
const seen = new Set<string>()
for (const entry of parsed) {
const target = normalizeImTarget(entry)
if (!target) continue
const key = imTargetKey(target.botId, target.targetId)
if (seen.has(key)) continue
seen.add(key)
out.push(target)
}
return out
}
/**
* Serialize targets for the `imTargets` settings field.
* @param targets - selected sinks.
*/
export function formatImTargetsJson(targets: readonly ImDeliveryTarget[]): string {
const seen = new Set<string>()
const rows: ImDeliveryTarget[] = []
for (const entry of targets) {
const target = normalizeImTarget(entry)
if (!target) continue
const key = imTargetKey(target.botId, target.targetId)
if (seen.has(key)) continue
seen.add(key)
rows.push(target)
}
return rows.length === 0 ? '' : JSON.stringify(rows)
}
/**
* Resolve effective sinks: prefer `imTargets` JSON; else legacy single bot/target pair.
* @param settings - settings snapshot fields.
*/
export function resolveImTargets(settings: {
imTargets?: string
imBotId?: string
imTargetId?: string
}): ImDeliveryTarget[] {
const fromList = parseImTargetsJson(
typeof settings.imTargets === 'string' ? settings.imTargets : '',
)
if (fromList.length > 0) return fromList
const legacy = normalizeImTarget({
botId: settings.imBotId,
targetId: settings.imTargetId,
})
return legacy ? [legacy] : []
}
/**
* Toggle one catalog option in the selected set.
* @param current - current selection.
* @param target - option to toggle.
* @param selected - whether it should be selected after the toggle.
*/
export function setImTargetSelected(
current: readonly ImDeliveryTarget[],
target: ImDeliveryTarget,
selected: boolean,
): ImDeliveryTarget[] {
const key = imTargetKey(target.botId, target.targetId)
const without = current.filter(
(row) => imTargetKey(row.botId, row.targetId) !== key,
)
if (!selected) return without
const normalized = normalizeImTarget(target)
return normalized ? [...without, normalized] : without
}

479
src/netx/session-export.ts Normal file
View file

@ -0,0 +1,479 @@
/**
* Bulk session-log export for Ops: pack every durable session on this Host
* into one ZIP for offline HQ analysis. Soft-depends on sessionPersistence
* (JSONL raw artifacts); live sessions are flushed when a SessionStore is present.
*/
import { mkdirSync, writeFileSync } from 'node:fs'
import { homedir, hostname as osHostname } from 'node:os'
import { join } from 'node:path'
import { strToU8, zipSync, Zip, ZipDeflate } from 'fflate'
import type { Context } from '@deepseek-ai/cordis'
import {
NETXOPS_SESSIONS_EXPORT_PATH,
type SessionsExportStatus,
} from '../session-export-shared.ts'
export { NETXOPS_SESSIONS_EXPORT_PATH }
export type { SessionsExportStatus }
/** Default DEFLATE level for each ZIP entry. */
const COMPRESSION_LEVEL = 6
/** How many code units of artifact text one zip push carries. */
const PUSH_CHUNK_CODE_UNITS = 1 << 16
/** Subdir under `$DSH_HOME` where Ops bulk exports land. */
export const SESSIONS_EXPORT_DIRNAME = 'exports'
/** Duck-typed SessionPersistence surface used by export (no hard dep on dsh-session). */
export interface SessionPersistenceExportApi {
readonly supportsRawArtifacts: boolean
list: (signal?: AbortSignal) => Promise<readonly SessionExportHeader[]>
readRaw: (
id: string,
signal?: AbortSignal,
) => Promise<SessionExportRawArtifact | undefined>
}
/** Minimal durable session header fields copied into the export manifest. */
export interface SessionExportHeader {
readonly id: string
readonly version: number
readonly createdAt: number
readonly cwd?: string
readonly parentSession?: string
readonly origin?: string
readonly agentPreset?: string
readonly delegationDepth?: number
}
/** One verbatim persistence artifact. */
export interface SessionExportRawArtifact {
readonly meta: SessionExportHeader
readonly filename: string
readonly content: string
}
/** Optional live SessionStore flush barrier. */
interface SessionStoreFlushApi {
get?: (id: string) => unknown
flush?: (session: unknown) => Promise<void>
}
/** One exported file inside the bulk archive. */
type ZipTextEntry = { readonly path: string; readonly content: string }
/** Result of writing the bulk archive under `$DSH_HOME/exports`. */
export interface SessionsExportDiskResult {
readonly path: string
readonly filename: string
readonly exportDir: string
readonly sessionCountListed: number
readonly sessionCountIncluded: number
readonly sessionCountSkipped: number
readonly bytes: number
}
/** Harness home used for sessions + exports. */
export function resolveDshHome(): string {
const fromEnv = process.env.DSH_HOME?.trim()
if (fromEnv !== undefined && fromEnv.length > 0) return fromEnv
return join(homedir(), '.dsh')
}
/**
* Resolve sessionPersistence from the host context when the web profile mounted it.
* @param ctx - cordis host context.
*/
export function resolveSessionPersistence(ctx: Context): SessionPersistenceExportApi | undefined {
const fromGet = typeof (ctx as { get?: (name: string) => unknown }).get === 'function'
? (ctx as { get: (name: string) => unknown }).get('sessionPersistence')
: undefined
const persistence = (fromGet
?? (ctx as { sessionPersistence?: SessionPersistenceExportApi }).sessionPersistence) as
| SessionPersistenceExportApi
| undefined
if (!persistence || typeof persistence.list !== 'function' || typeof persistence.readRaw !== 'function') {
return undefined
}
return persistence
}
/**
* Resolve the optional live session store for flush-before-read.
* @param ctx - cordis host context.
*/
function resolveSessionStore(ctx: Context): SessionStoreFlushApi | undefined {
const fromGet = typeof (ctx as { get?: (name: string) => unknown }).get === 'function'
? (ctx as { get: (name: string) => unknown }).get('sessions')
: undefined
const sessions = (fromGet ?? (ctx as { sessions?: SessionStoreFlushApi }).sessions) as
| SessionStoreFlushApi
| undefined
if (!sessions || typeof sessions.get !== 'function' || typeof sessions.flush !== 'function') {
return undefined
}
return sessions
}
/**
* Preflight: whether bulk export can run and how many sessions would ship.
* @param ctx - cordis host context.
* @param signal - optional cancellation.
*/
export async function getSessionsExportStatus(
ctx: Context,
signal?: AbortSignal,
): Promise<SessionsExportStatus> {
const persistence = resolveSessionPersistence(ctx)
if (!persistence) {
return {
available: false,
sessionCount: 0,
supportsRawArtifacts: false,
reason: 'sessionPersistence unavailable — mount a JSONL session backend (web profile default)',
}
}
if (!persistence.supportsRawArtifacts) {
return {
available: false,
sessionCount: 0,
supportsRawArtifacts: false,
reason: 'persistence backend does not expose per-session raw artifacts (SQLite export unsupported)',
}
}
try {
const headers = await persistence.list(signal)
signal?.throwIfAborted()
return {
available: true,
sessionCount: headers.length,
supportsRawArtifacts: true,
}
} catch (error) {
return {
available: false,
sessionCount: 0,
supportsRawArtifacts: true,
reason: error instanceof Error ? error.message : String(error),
}
}
}
/**
* Sanitize one path segment for ZIP entry names.
* @param id - raw session id or hostname fragment.
*/
export function safePathSegment(id: string): string {
return id.replace(/[^A-Za-z0-9_-]/g, '_')
}
/**
* Browser download filename for a bulk export.
* @param exportedAt - Date used for the timestamp stamp.
* @param host - optional host label (defaults to os.hostname()).
*/
export function sessionsExportZipFilename(exportedAt: Date = new Date(), host = osHostname()): string {
const stamp = [
exportedAt.getUTCFullYear(),
String(exportedAt.getUTCMonth() + 1).padStart(2, '0'),
String(exportedAt.getUTCDate()).padStart(2, '0'),
'-',
String(exportedAt.getUTCHours()).padStart(2, '0'),
String(exportedAt.getUTCMinutes()).padStart(2, '0'),
String(exportedAt.getUTCSeconds()).padStart(2, '0'),
].join('')
return `dsh-sessions-${safePathSegment(host)}-${stamp}.zip`
}
async function flushLiveSession(
sessions: SessionStoreFlushApi | undefined,
id: string,
signal?: AbortSignal,
): Promise<void> {
signal?.throwIfAborted()
if (!sessions?.get || !sessions.flush) return
const live = sessions.get(id)
if (live === undefined || live === null) return
await sessions.flush(live)
signal?.throwIfAborted()
}
/**
* Yield ZIP text entries: manifest first, then each session artifact under sessions/<id>/.
* Missing artifacts are recorded in the manifest and skipped (fail-soft for Ops pickup).
* @param ctx - cordis host context.
* @param signal - cancellation.
*/
export async function* sessionsExportEntries(
ctx: Context,
signal?: AbortSignal,
): AsyncGenerator<ZipTextEntry> {
const persistence = resolveSessionPersistence(ctx)
if (!persistence) {
throw new Error('sessionPersistence unavailable')
}
if (!persistence.supportsRawArtifacts) {
throw new Error('persistence backend does not expose per-session raw artifacts')
}
const sessions = resolveSessionStore(ctx)
const headers = await persistence.list(signal)
signal?.throwIfAborted()
const exportedAt = new Date().toISOString()
const host = osHostname()
const included: Array<{
id: string
path: string
createdAt: number
cwd?: string
agentPreset?: string
parentSession?: string
origin?: string
}> = []
const skipped: Array<{ id: string; reason: string }> = []
const artifactEntries: ZipTextEntry[] = []
for (const header of headers) {
signal?.throwIfAborted()
const id = String(header.id)
try {
await flushLiveSession(sessions, id, signal)
const raw = await persistence.readRaw(id, signal)
signal?.throwIfAborted()
if (raw === undefined) {
skipped.push({ id, reason: 'no stored artifact' })
continue
}
const filename = raw.filename && raw.filename.length > 0 ? raw.filename : 'session.jsonl'
const path = `sessions/${safePathSegment(id)}/${filename}`
artifactEntries.push({ path, content: raw.content })
included.push({
id,
path,
createdAt: header.createdAt,
cwd: header.cwd,
agentPreset: header.agentPreset,
parentSession: header.parentSession !== undefined ? String(header.parentSession) : undefined,
origin: header.origin,
})
} catch (error) {
skipped.push({
id,
reason: error instanceof Error ? error.message : String(error),
})
}
}
const manifest = {
kind: 'dsh-netxops-sessions-export',
version: 1,
exportedAt,
hostname: host,
sessionCountListed: headers.length,
sessionCountIncluded: included.length,
sessionCountSkipped: skipped.length,
sessions: included,
skipped,
}
yield {
path: 'manifest.json',
content: `${JSON.stringify(manifest, null, 2)}\n`,
}
for (const entry of artifactEntries) {
signal?.throwIfAborted()
yield entry
}
}
/**
* Push artifact text into a deflate stream in bounded chunks without splitting
* surrogate pairs (lone high surrogates would corrupt UTF-8 as U+FFFD).
*/
async function pushArtifactChunks(
deflate: ZipDeflate,
content: string,
signal: AbortSignal,
): Promise<void> {
const encoder = new TextEncoder()
let offset = 0
let finalChunk: boolean
do {
signal.throwIfAborted()
let end = Math.min(offset + PUSH_CHUNK_CODE_UNITS, content.length)
if (end < content.length && end - offset > 1) {
const last = content.charCodeAt(end - 1)
if (last >= 0xd800 && last <= 0xdbff) end -= 1
}
finalChunk = end >= content.length
deflate.push(encoder.encode(content.slice(offset, end)), finalChunk)
offset = end
} while (!finalChunk)
}
/**
* Stream the bulk sessions ZIP as a WHATWG ReadableStream.
* @param ctx - cordis host context.
* @param signal - request / consumer cancellation.
*/
export function streamSessionsExportZip(
ctx: Context,
signal: AbortSignal,
): ReadableStream<Uint8Array> {
const consumerAbort = new AbortController()
const producerSignal = AbortSignal.any([signal, consumerAbort.signal])
let zip: Zip | undefined
let zipTerminated = false
const terminateZip = (): void => {
if (zip === undefined || zipTerminated) return
zipTerminated = true
zip.terminate()
}
return new ReadableStream<Uint8Array>({
start(controller) {
const archive = new Zip((error, data, final) => {
if (error) {
controller.error(error)
return
}
if (data.byteLength > 0) controller.enqueue(data)
if (final) controller.close()
})
zip = archive
void (async () => {
try {
for await (const entry of sessionsExportEntries(ctx, producerSignal)) {
const deflate = new ZipDeflate(entry.path, { level: COMPRESSION_LEVEL })
archive.add(deflate)
await pushArtifactChunks(deflate, entry.content, producerSignal)
}
archive.end()
} catch (error) {
terminateZip()
controller.error(error instanceof Error ? error : new Error(String(error)))
}
})()
},
cancel(reason) {
consumerAbort.abort(
reason instanceof Error ? reason : new Error('sessions export stream cancelled'),
)
terminateZip()
},
})
}
/**
* Write every durable session into `$DSH_HOME/exports/<filename>.zip`.
* This is the Ops-primary path: the absolute file location is returned so
* operators can copy the archive off the Host without relying on the browser
* download manager (which often swallows programmatic `<a download>` clicks).
* @param ctx - cordis host context.
* @param signal - optional cancellation.
*/
export async function writeSessionsExportZip(
ctx: Context,
signal?: AbortSignal,
): Promise<SessionsExportDiskResult> {
const status = await getSessionsExportStatus(ctx, signal)
if (!status.available) {
throw new Error(status.reason ?? 'sessions export unavailable')
}
const files: Record<string, Uint8Array> = {}
let included = 0
let skipped = 0
let listed = 0
for await (const entry of sessionsExportEntries(ctx, signal)) {
files[entry.path] = strToU8(entry.content)
if (entry.path === 'manifest.json') {
try {
const manifest = JSON.parse(entry.content) as {
sessionCountListed?: number
sessionCountIncluded?: number
sessionCountSkipped?: number
}
listed = typeof manifest.sessionCountListed === 'number' ? manifest.sessionCountListed : 0
included = typeof manifest.sessionCountIncluded === 'number' ? manifest.sessionCountIncluded : 0
skipped = typeof manifest.sessionCountSkipped === 'number' ? manifest.sessionCountSkipped : 0
} catch {
// Manifest parse is best-effort; ZIP still ships.
}
}
}
const zipped = zipSync(files, { level: COMPRESSION_LEVEL })
const exportDir = join(resolveDshHome(), SESSIONS_EXPORT_DIRNAME)
mkdirSync(exportDir, { recursive: true })
const filename = sessionsExportZipFilename()
const path = join(exportDir, filename)
writeFileSync(path, zipped)
return {
path,
filename,
exportDir,
sessionCountListed: listed || status.sessionCount,
sessionCountIncluded: included,
sessionCountSkipped: skipped,
bytes: zipped.byteLength,
}
}
/**
* Build the HTTP Response for GET /api/netxops.sessions.export.
* HEAD should use {@link sessionsExportHeadResponse} so listing does not start ZIP work.
* @param ctx - cordis host context.
* @param request - incoming fetch request.
*/
export async function sessionsExportResponse(
ctx: Context,
request: Request,
): Promise<Response> {
const status = await getSessionsExportStatus(ctx, request.signal)
if (!status.available) {
return new Response(status.reason ?? 'sessions export unavailable', {
status: status.supportsRawArtifacts === false && status.reason?.includes('raw artifacts')
? 501
: 500,
})
}
const filename = sessionsExportZipFilename()
const body = streamSessionsExportZip(ctx, request.signal)
return new Response(body, {
headers: {
'content-type': 'application/zip',
'content-disposition': `attachment; filename="${filename}"`,
'x-netxops-session-count': String(status.sessionCount),
},
})
}
/**
* Build the HTTP Response for HEAD /api/netxops.sessions.export (preflight only).
* @param ctx - cordis host context.
* @param request - incoming fetch request.
*/
export async function sessionsExportHeadResponse(
ctx: Context,
request: Request,
): Promise<Response> {
const status = await getSessionsExportStatus(ctx, request.signal)
if (!status.available) {
return new Response(null, {
status: status.supportsRawArtifacts === false && status.reason?.includes('raw artifacts')
? 501
: 500,
headers: {
'x-netxops-export-error': status.reason ?? 'sessions export unavailable',
},
})
}
const filename = sessionsExportZipFilename()
return new Response(null, {
status: 200,
headers: {
'content-type': 'application/zip',
'content-disposition': `attachment; filename="${filename}"`,
'x-netxops-session-count': String(status.sessionCount),
},
})
}

View file

@ -0,0 +1,14 @@
/**
* Shared bulk session-export constants and status types (safe for host + browser).
*/
/** Authenticated browser download path for the all-sessions archive. */
export const NETXOPS_SESSIONS_EXPORT_PATH = '/api/netxops.sessions.export'
/** Preflight / RPC snapshot for the Plugins card. */
export interface SessionsExportStatus {
readonly available: boolean
readonly sessionCount: number
readonly supportsRawArtifacts: boolean
readonly reason?: string
}

82
test/im-targets.test.mjs Normal file
View file

@ -0,0 +1,82 @@
import assert from 'node:assert/strict'
import { test } from 'node:test'
import {
formatImTargetsJson,
parseImTargetsJson,
resolveImTargets,
setImTargetSelected,
} from '../src/netx/im-targets.ts'
import { deliverAlarmToIm } from '../src/netx/alarm-im.ts'
test('parseImTargetsJson dedupes and drops incomplete rows', () => {
const rows = parseImTargetsJson(JSON.stringify([
{ botId: 'b1', targetId: 't1' },
{ botId: 'b1', targetId: 't1' },
{ botId: 'b2', targetId: '' },
{ botId: 'b2', targetId: 't2' },
]))
assert.deepEqual(rows, [
{ botId: 'b1', targetId: 't1' },
{ botId: 'b2', targetId: 't2' },
])
})
test('resolveImTargets prefers imTargets JSON over legacy pair', () => {
assert.deepEqual(resolveImTargets({
imTargets: formatImTargetsJson([
{ botId: 'b1', targetId: 'g1' },
{ botId: 'b1', targetId: 'g2' },
]),
imBotId: 'legacy',
imTargetId: 'old',
}), [
{ botId: 'b1', targetId: 'g1' },
{ botId: 'b1', targetId: 'g2' },
])
assert.deepEqual(resolveImTargets({
imTargets: '',
imBotId: 'legacy',
imTargetId: 'old',
}), [{ botId: 'legacy', targetId: 'old' }])
})
test('setImTargetSelected toggles membership', () => {
const one = setImTargetSelected([], { botId: 'b', targetId: 't' }, true)
assert.deepEqual(one, [{ botId: 'b', targetId: 't' }])
assert.deepEqual(setImTargetSelected(one, { botId: 'b', targetId: 't' }, false), [])
})
test('deliverAlarmToIm fans out to every target', async () => {
const sent = []
const ctx = {
logger: { warn() {}, info() {} },
get: () => ({
send: async (botId, targetId, text) => {
sent.push({ botId, targetId, text })
},
}),
}
await deliverAlarmToIm(ctx, {
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' },
}, {
enabled: true,
lang: 'zh',
targets: [
{ botId: 'bot', targetId: 'g1' },
{ botId: 'bot', targetId: 'g2' },
],
})
assert.equal(sent.length, 2)
assert.equal(sent[0].targetId, 'g1')
assert.equal(sent[1].targetId, 'g2')
assert.match(sent[0].text, /告警产生/)
})

View file

@ -0,0 +1,88 @@
import assert from 'node:assert/strict'
import { test } from 'node:test'
import {
safePathSegment,
sessionsExportEntries,
sessionsExportZipFilename,
} from '../src/netx/session-export.ts'
test('safePathSegment strips path separators and dots', () => {
assert.equal(safePathSegment('../a/b'), '___a_b')
assert.equal(safePathSegment('netxops-alarm-abc'), 'netxops-alarm-abc')
})
test('sessionsExportZipFilename stamps host and UTC time', () => {
const name = sessionsExportZipFilename(new Date('2026-09-06T04:59:00.000Z'), 'edge-01')
assert.equal(name, 'dsh-sessions-edge-01-20260906-045900.zip')
})
test('sessionsExportEntries writes manifest then artifacts; skips missing', async () => {
const persistence = {
supportsRawArtifacts: true,
list: async () => ([
{ id: 's1', version: 0, createdAt: 1, agentPreset: 'netxops' },
{ id: 'missing', version: 0, createdAt: 2 },
]),
readRaw: async (id) => {
if (id === 'missing') return undefined
return {
meta: { id, version: 0, createdAt: 1, agentPreset: 'netxops' },
filename: 'session.jsonl',
content: '{"type":"header"}\n',
}
},
}
const ctx = {
get: (name) => (name === 'sessionPersistence' ? persistence : undefined),
}
const entries = []
for await (const entry of sessionsExportEntries(ctx, undefined)) {
entries.push(entry)
}
assert.equal(entries.length, 2)
assert.equal(entries[0].path, 'manifest.json')
const manifest = JSON.parse(entries[0].content)
assert.equal(manifest.kind, 'dsh-netxops-sessions-export')
assert.equal(manifest.sessionCountListed, 2)
assert.equal(manifest.sessionCountIncluded, 1)
assert.equal(manifest.sessionCountSkipped, 1)
assert.equal(entries[1].path, 'sessions/s1/session.jsonl')
assert.match(entries[1].content, /header/)
})
test('writeSessionsExportZip lands under exports/ with a real zip', async () => {
const { mkdtempSync, readFileSync, rmSync } = await import('node:fs')
const { tmpdir } = await import('node:os')
const { join } = await import('node:path')
const { unzipSync, strFromU8 } = await import('fflate')
const home = mkdtempSync(join(tmpdir(), 'netxops-export-'))
const previous = process.env.DSH_HOME
process.env.DSH_HOME = home
try {
const persistence = {
supportsRawArtifacts: true,
list: async () => ([{ id: 's1', version: 0, createdAt: 1 }]),
readRaw: async () => ({
meta: { id: 's1', version: 0, createdAt: 1 },
filename: 'session.jsonl',
content: '{"type":"header"}\n',
}),
}
const ctx = {
get: (name) => (name === 'sessionPersistence' ? persistence : undefined),
}
const { writeSessionsExportZip } = await import('../src/netx/session-export.ts')
const result = await writeSessionsExportZip(ctx)
assert.equal(result.sessionCountIncluded, 1)
assert.ok(result.path.includes(`${join('exports')}`))
assert.ok(result.bytes > 0)
const unzipped = unzipSync(readFileSync(result.path))
assert.ok(unzipped['manifest.json'])
assert.match(strFromU8(unzipped['sessions/s1/session.jsonl']), /header/)
} finally {
if (previous === undefined) delete process.env.DSH_HOME
else process.env.DSH_HOME = previous
rmSync(home, { recursive: true, force: true })
}
})