mirror of
https://github.com/hansjone/netxops.git
synced 2026-10-09 02:40:45 +08:00
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:
parent
eb8299d155
commit
92279c1220
18 changed files with 3062 additions and 606 deletions
|
|
@ -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` | |
|
||||
|
||||
|
|
|
|||
|
|
@ -25,6 +25,7 @@
|
|||
alarmDeliverIm: false
|
||||
imBotId: ''
|
||||
imTargetId: ''
|
||||
imTargets: ''
|
||||
groupOpsInPreset: true
|
||||
groupOpsPublic: false
|
||||
groupTopologyInPreset: false
|
||||
|
|
|
|||
|
|
@ -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).
|
||||
|
||||
|
|
|
|||
660
lib/client.js
660
lib/client.js
|
|
@ -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: "保存中…"
|
||||
|
|
|
|||
1320
lib/index.js
1320
lib/index.js
File diff suppressed because it is too large
Load diff
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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> {
|
||||
|
|
|
|||
|
|
@ -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: '保存中…',
|
||||
|
|
|
|||
136
src/client/sessions-export-view.ts
Normal file
136
src/client/sessions-export-view.ts
Normal 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,
|
||||
}
|
||||
}
|
||||
|
|
@ -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}
|
||||
|
|
|
|||
177
src/index.ts
177
src/index.ts
|
|
@ -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(() => () => {
|
||||
|
|
|
|||
|
|
@ -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
125
src/netx/im-targets.ts
Normal 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
479
src/netx/session-export.ts
Normal 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),
|
||||
},
|
||||
})
|
||||
}
|
||||
14
src/session-export-shared.ts
Normal file
14
src/session-export-shared.ts
Normal 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
82
test/im-targets.test.mjs
Normal 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, /告警产生/)
|
||||
})
|
||||
88
test/session-export.test.mjs
Normal file
88
test/session-export.test.mjs
Normal 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 })
|
||||
}
|
||||
})
|
||||
Loading…
Add table
Add a link
Reference in a new issue