mirror of
https://github.com/hansjone/netxops.git
synced 2026-10-18 22:20:46 +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
|
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.
|
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)
|
3. Optional: **导出全部会话** — download one ZIP of every durable session on this Host.
|
||||||
4. New session → choose **Netx Ops**
|
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`).
|
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) |
|
| `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 |
|
| 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`) | |
|
| Persona + skills (`netx-ops` / `netx-topology`) | |
|
||||||
| Agent preset auto-install to `~/.dsh/.agent-presets` | |
|
| Agent preset auto-install to `~/.dsh/.agent-presets` | |
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -25,6 +25,7 @@
|
||||||
alarmDeliverIm: false
|
alarmDeliverIm: false
|
||||||
imBotId: ''
|
imBotId: ''
|
||||||
imTargetId: ''
|
imTargetId: ''
|
||||||
|
imTargets: ''
|
||||||
groupOpsInPreset: true
|
groupOpsInPreset: true
|
||||||
groupOpsPublic: false
|
groupOpsPublic: false
|
||||||
groupTopologyInPreset: 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).
|
1. **Settings → Plugins → Netx Ops** → API URL (+ token if the field is enabled).
|
||||||
Token fallback: `scripts/set-netx-token.ps1` / `.sh`.
|
Token fallback: `scripts/set-netx-token.ps1` / `.sh`.
|
||||||
Optional: enable **关键告警推送** so this DSH dials `ws(s)://<apiUrl>/v1/integrations/dsh-alarm/ws`. 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: 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).
|
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.
|
2. Restart or open Settings → **Agent presets** → Custom → **Netx Ops** should appear after the host plugin has activated once.
|
||||||
3. **New session → preset Netx Ops** → ask e.g. Critical Top / single-host alarms / 「能否登录」.
|
3. **New session → preset Netx Ops** → ask e.g. Critical Top / single-host alarms / 「能否登录」.
|
||||||
|
|
@ -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.
|
- 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.
|
- 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
|
## Verify
|
||||||
|
|
||||||
1. Plugins card **Netx Ops** visible.
|
1. Plugins card **Netx Ops** visible.
|
||||||
2. With key-alarm push on: card badge shows **Connected** (or reconnecting / auth failed with detail).
|
2. With key-alarm push on: card badge shows **Connected** (or reconnecting / auth failed with detail).
|
||||||
3. Agent presets → Custom → **Netx Ops**.
|
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`).
|
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).
|
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) {
|
function imCatalogOptionKey(botId, targetId) {
|
||||||
return `${botId}::${targetId}`;
|
return `${botId}::${targetId}`;
|
||||||
}
|
}
|
||||||
function parseImCatalogOptionKey(value) {
|
|
||||||
const at = value.indexOf("::");
|
// src/netx/im-targets.ts
|
||||||
if (at <= 0)
|
function imTargetKey(botId, targetId) {
|
||||||
return { botId: "", targetId: "" };
|
return `${botId}::${targetId}`;
|
||||||
return { botId: value.slice(0, at), targetId: value.slice(at + 2) };
|
}
|
||||||
|
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
|
// 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-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-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-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-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-footer{display:flex;align-items:center;justify-content:flex-end;gap:8px;padding:12px 0 4px;border-top:.5px solid var(--dsw-alias-border-l2)}
|
||||||
.dsh-nx-failed{flex:1;min-width:0;margin:0;font-size:12px;line-height:1.5;color:var(--dsw-alias-label-error)}
|
.dsh-nx-failed{flex:1;min-width:0;margin:0;font-size:12px;line-height:1.5;color:var(--dsw-alias-label-error)}
|
||||||
.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}
|
.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) {
|
function ImDeliveryPicker(props) {
|
||||||
const options = props.catalog.options;
|
const options = props.catalog.options;
|
||||||
const selectedKey = props.botId && props.targetId ? imCatalogOptionKey(props.botId, props.targetId) : "";
|
const selected = resolveImTargets({
|
||||||
const matched = options.some((row) => imCatalogOptionKey(row.botId, row.targetId) === selectedKey);
|
imTargets: props.targetsJson,
|
||||||
const [manual, setManual] = import_react.useState(() => !!(selectedKey && !matched));
|
imBotId: props.legacyBotId,
|
||||||
import_react.useEffect(() => {
|
imTargetId: props.legacyTargetId
|
||||||
if (selectedKey && matched)
|
});
|
||||||
setManual(false);
|
const selectedKeys = new Set(selected.map((row) => imTargetKey(row.botId, row.targetId)));
|
||||||
else if (selectedKey && !matched)
|
const orphanSelected = selected.filter((row) => !options.some((opt) => imCatalogOptionKey(opt.botId, opt.targetId) === imTargetKey(row.botId, row.targetId)));
|
||||||
setManual(true);
|
return /* @__PURE__ */ jsx_runtime.jsxs("div", {
|
||||||
}, [selectedKey, matched]);
|
className: "dsh-nx-field",
|
||||||
const selectValue = manual ? "__manual__" : matched ? selectedKey : "";
|
|
||||||
const showManual = manual || selectedKey && !matched;
|
|
||||||
return /* @__PURE__ */ jsx_runtime.jsxs(jsx_runtime.Fragment, {
|
|
||||||
children: [
|
children: [
|
||||||
/* @__PURE__ */ jsx_runtime.jsxs("div", {
|
/* @__PURE__ */ jsx_runtime.jsxs("div", {
|
||||||
className: "dsh-nx-field",
|
className: "dsh-nx-fieldHead",
|
||||||
children: [
|
children: [
|
||||||
/* @__PURE__ */ jsx_runtime.jsx("div", {
|
/* @__PURE__ */ jsx_runtime.jsx("span", {
|
||||||
className: "dsh-nx-fieldHead",
|
className: "dsh-nx-label",
|
||||||
children: /* @__PURE__ */ jsx_runtime.jsx("label", {
|
children: props.labels.target
|
||||||
className: "dsh-nx-label",
|
|
||||||
htmlFor: "netxops-im-target",
|
|
||||||
children: props.labels.target
|
|
||||||
})
|
|
||||||
}),
|
}),
|
||||||
/* @__PURE__ */ jsx_runtime.jsxs("select", {
|
selected.length > 0 ? /* @__PURE__ */ jsx_runtime.jsx("span", {
|
||||||
id: "netxops-im-target",
|
className: "dsh-nx-badgeMuted",
|
||||||
className: "dsh-nx-input dsh-nx-select",
|
children: props.labels.selectedCount.replace("{count}", String(selected.length))
|
||||||
value: selectValue,
|
|
||||||
disabled: props.disabled,
|
|
||||||
onChange: (event) => {
|
|
||||||
const value = event.target.value;
|
|
||||||
if (value === "__manual__") {
|
|
||||||
setManual(true);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setManual(false);
|
|
||||||
if (!value) {
|
|
||||||
props.onPick("", "");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const next = parseImCatalogOptionKey(value);
|
|
||||||
props.onPick(next.botId, next.targetId);
|
|
||||||
},
|
|
||||||
children: [
|
|
||||||
/* @__PURE__ */ jsx_runtime.jsx("option", {
|
|
||||||
value: "",
|
|
||||||
children: options.length === 0 ? props.labels.none : `— ${props.labels.target} —`
|
|
||||||
}),
|
|
||||||
options.map((row) => /* @__PURE__ */ jsx_runtime.jsx("option", {
|
|
||||||
value: imCatalogOptionKey(row.botId, row.targetId),
|
|
||||||
children: `${row.name} · ${row.channel || "im"} · ${row.targetId}`
|
|
||||||
}, imCatalogOptionKey(row.botId, row.targetId))),
|
|
||||||
/* @__PURE__ */ jsx_runtime.jsx("option", {
|
|
||||||
value: "__manual__",
|
|
||||||
children: props.labels.manual
|
|
||||||
})
|
|
||||||
]
|
|
||||||
}),
|
|
||||||
!props.catalog.available || options.length === 0 ? /* @__PURE__ */ jsx_runtime.jsx("p", {
|
|
||||||
className: "dsh-nx-hint",
|
|
||||||
children: props.catalog.hint || props.labels.unavailable
|
|
||||||
}) : null
|
}) : 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: [
|
children: [
|
||||||
/* @__PURE__ */ jsx_runtime.jsx(ValueField, {
|
options.map((row) => {
|
||||||
id: "netxops-im-bot-id",
|
const key = imCatalogOptionKey(row.botId, row.targetId);
|
||||||
label: props.labels.botId,
|
const checked = selectedKeys.has(key);
|
||||||
hint: props.labels.botHint,
|
return /* @__PURE__ */ jsx_runtime.jsxs("label", {
|
||||||
field: props.botField,
|
className: "dsh-nx-checkRow",
|
||||||
overriddenLabel: props.labels.overridden,
|
children: [
|
||||||
resetLabel: props.labels.reset,
|
/* @__PURE__ */ jsx_runtime.jsx("input", {
|
||||||
invalidLabel: props.labels.invalid,
|
type: "checkbox",
|
||||||
disabled: props.disabled,
|
checked,
|
||||||
onEdit: props.onEditBot,
|
disabled: props.disabled,
|
||||||
onReset: props.onResetBot
|
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, {
|
orphanSelected.map((row) => {
|
||||||
id: "netxops-im-target-id",
|
const key = imTargetKey(row.botId, row.targetId);
|
||||||
label: props.labels.targetId,
|
return /* @__PURE__ */ jsx_runtime.jsxs("label", {
|
||||||
hint: props.labels.targetHint,
|
className: "dsh-nx-checkRow",
|
||||||
field: props.targetField,
|
children: [
|
||||||
overriddenLabel: props.labels.overridden,
|
/* @__PURE__ */ jsx_runtime.jsx("input", {
|
||||||
resetLabel: props.labels.reset,
|
type: "checkbox",
|
||||||
invalidLabel: props.labels.invalid,
|
checked: true,
|
||||||
disabled: props.disabled,
|
disabled: props.disabled,
|
||||||
onEdit: props.onEditTarget,
|
onChange: (event) => {
|
||||||
onReset: props.onResetTarget
|
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
|
}) : null
|
||||||
]
|
]
|
||||||
});
|
});
|
||||||
|
|
@ -625,10 +679,6 @@ function NetxopsCard(props) {
|
||||||
children: t("capabilityGroups")
|
children: t("capabilityGroups")
|
||||||
})
|
})
|
||||||
}),
|
}),
|
||||||
/* @__PURE__ */ jsx_runtime.jsx("p", {
|
|
||||||
className: "dsh-nx-hint",
|
|
||||||
children: t("capabilityGroupsHint")
|
|
||||||
}),
|
|
||||||
/* @__PURE__ */ jsx_runtime.jsx(CapabilityGroupBlock, {
|
/* @__PURE__ */ jsx_runtime.jsx(CapabilityGroupBlock, {
|
||||||
title: t("groupOps"),
|
title: t("groupOps"),
|
||||||
inPresetLabel: t("groupInPreset"),
|
inPresetLabel: t("groupInPreset"),
|
||||||
|
|
@ -698,23 +748,19 @@ function NetxopsCard(props) {
|
||||||
})
|
})
|
||||||
]
|
]
|
||||||
}),
|
}),
|
||||||
/* @__PURE__ */ jsx_runtime.jsxs("label", {
|
/* @__PURE__ */ jsx_runtime.jsx("label", {
|
||||||
className: "dsh-nx-checkRow",
|
className: "dsh-nx-checkRow",
|
||||||
htmlFor: "netxops-alarm-push",
|
htmlFor: "netxops-alarm-push",
|
||||||
children: [
|
children: /* @__PURE__ */ jsx_runtime.jsx("input", {
|
||||||
/* @__PURE__ */ jsx_runtime.jsx("input", {
|
id: "netxops-alarm-push",
|
||||||
id: "netxops-alarm-push",
|
type: "checkbox",
|
||||||
type: "checkbox",
|
checked: state.alarmPushEnabled.text === "true",
|
||||||
checked: state.alarmPushEnabled.text === "true",
|
disabled,
|
||||||
disabled,
|
"aria-label": t("alarmPushEnabled"),
|
||||||
onChange: (event) => {
|
onChange: (event) => {
|
||||||
props.edit("alarmPushEnabled", event.target.checked ? "true" : "false");
|
props.edit("alarmPushEnabled", event.target.checked ? "true" : "false");
|
||||||
}
|
}
|
||||||
}),
|
})
|
||||||
/* @__PURE__ */ jsx_runtime.jsx("span", {
|
|
||||||
children: t("alarmPushEnabledHint")
|
|
||||||
})
|
|
||||||
]
|
|
||||||
}),
|
}),
|
||||||
pushStatus?.wsUrl ? /* @__PURE__ */ jsx_runtime.jsx("p", {
|
pushStatus?.wsUrl ? /* @__PURE__ */ jsx_runtime.jsx("p", {
|
||||||
className: "dsh-nx-hint",
|
className: "dsh-nx-hint",
|
||||||
|
|
@ -758,114 +804,87 @@ function NetxopsCard(props) {
|
||||||
}) : null
|
}) : null
|
||||||
]
|
]
|
||||||
}),
|
}),
|
||||||
/* @__PURE__ */ jsx_runtime.jsxs("label", {
|
/* @__PURE__ */ jsx_runtime.jsx("label", {
|
||||||
className: "dsh-nx-checkRow",
|
className: "dsh-nx-checkRow",
|
||||||
htmlFor: "netxops-alarm-dsh",
|
htmlFor: "netxops-alarm-dsh",
|
||||||
children: [
|
children: /* @__PURE__ */ jsx_runtime.jsx("input", {
|
||||||
/* @__PURE__ */ jsx_runtime.jsx("input", {
|
id: "netxops-alarm-dsh",
|
||||||
id: "netxops-alarm-dsh",
|
type: "checkbox",
|
||||||
type: "checkbox",
|
checked: state.alarmDeliverDsh.text === "true",
|
||||||
checked: state.alarmDeliverDsh.text === "true",
|
disabled,
|
||||||
disabled,
|
"aria-label": t("alarmDeliverDsh"),
|
||||||
onChange: (event) => {
|
onChange: (event) => {
|
||||||
props.edit("alarmDeliverDsh", event.target.checked ? "true" : "false");
|
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")
|
|
||||||
})
|
|
||||||
]
|
|
||||||
})
|
})
|
||||||
]
|
]
|
||||||
}),
|
}),
|
||||||
/* @__PURE__ */ jsx_runtime.jsx(ImDeliveryPicker, {
|
/* @__PURE__ */ jsx_runtime.jsx(ImDeliveryPicker, {
|
||||||
catalog: state.imDeliveryCatalog,
|
catalog: state.imDeliveryCatalog,
|
||||||
botId: state.imBotId.text,
|
targetsJson: state.imTargets.text,
|
||||||
targetId: state.imTargetId.text,
|
legacyBotId: state.imBotId.text,
|
||||||
|
legacyTargetId: state.imTargetId.text,
|
||||||
disabled,
|
disabled,
|
||||||
labels: {
|
labels: {
|
||||||
target: t("imTarget"),
|
target: t("alarmDeliverIm"),
|
||||||
none: t("imTargetNone"),
|
none: t("imTargetNone"),
|
||||||
manual: t("imTargetManual"),
|
selectedCount: t("imTargetSelectedCount"),
|
||||||
unavailable: t("imCatalogUnavailable"),
|
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,
|
onChange: (targets) => {
|
||||||
targetField: state.imTargetId,
|
props.edit("imTargets", formatImTargetsJson(targets));
|
||||||
onPick: (botId, targetId) => {
|
const first = targets[0];
|
||||||
props.edit("imBotId", botId);
|
props.edit("imBotId", first?.botId ?? "");
|
||||||
props.edit("imTargetId", targetId);
|
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");
|
|
||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
|
/* @__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", {
|
/* @__PURE__ */ jsx_runtime.jsxs("div", {
|
||||||
className: "dsh-nx-footer",
|
className: "dsh-nx-footer",
|
||||||
children: [
|
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
|
// src/client/controller.ts
|
||||||
var NETXOPS_NS = "netxops";
|
var NETXOPS_NS = "netxops";
|
||||||
var DEFAULT_TOKEN_REF = "NETX_API_TOKEN";
|
var DEFAULT_TOKEN_REF = "NETX_API_TOKEN";
|
||||||
|
|
@ -1138,9 +1242,15 @@ class NetxopsCardController {
|
||||||
rpcCall;
|
rpcCall;
|
||||||
alarmPushStatus = null;
|
alarmPushStatus = null;
|
||||||
imDeliveryCatalog = { ...EMPTY_IM_DELIVERY_CATALOG };
|
imDeliveryCatalog = { ...EMPTY_IM_DELIVERY_CATALOG };
|
||||||
|
sessionsExportStatus = null;
|
||||||
|
sessionsExportBusy = false;
|
||||||
|
sessionsExportError = null;
|
||||||
|
sessionsExportLastFile = null;
|
||||||
pollTimer;
|
pollTimer;
|
||||||
pollInFlight = false;
|
pollInFlight = false;
|
||||||
catalogInFlight = false;
|
catalogInFlight = false;
|
||||||
|
exportStatusInFlight = false;
|
||||||
|
exportInFlight;
|
||||||
constructor(scope, ctx) {
|
constructor(scope, ctx) {
|
||||||
this.scope = scope;
|
this.scope = scope;
|
||||||
this.ctx = ctx;
|
this.ctx = ctx;
|
||||||
|
|
@ -1155,6 +1265,7 @@ class NetxopsCardController {
|
||||||
booleanField("alarmPushEnabled"),
|
booleanField("alarmPushEnabled"),
|
||||||
booleanFieldPersistFalse("alarmDeliverDsh"),
|
booleanFieldPersistFalse("alarmDeliverDsh"),
|
||||||
booleanField("alarmDeliverIm"),
|
booleanField("alarmDeliverIm"),
|
||||||
|
textField("imTargets"),
|
||||||
textField("imBotId"),
|
textField("imBotId"),
|
||||||
textField("imTargetId")
|
textField("imTargetId")
|
||||||
], [{ field: API_TOKEN_FIELD, write: (text) => this.writeToken(text) }]);
|
], [{ field: API_TOKEN_FIELD, write: (text) => this.writeToken(text) }]);
|
||||||
|
|
@ -1189,6 +1300,10 @@ class NetxopsCardController {
|
||||||
this.imDeliveryCatalog = { ...EMPTY_IM_DELIVERY_CATALOG };
|
this.imDeliveryCatalog = { ...EMPTY_IM_DELIVERY_CATALOG };
|
||||||
changed = true;
|
changed = true;
|
||||||
}
|
}
|
||||||
|
if (this.sessionsExportStatus !== null) {
|
||||||
|
this.sessionsExportStatus = null;
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
if (changed)
|
if (changed)
|
||||||
this.store.set(this.projection());
|
this.store.set(this.projection());
|
||||||
return;
|
return;
|
||||||
|
|
@ -1196,6 +1311,7 @@ class NetxopsCardController {
|
||||||
this.startStatusPoll();
|
this.startStatusPoll();
|
||||||
this.refreshAlarmPushStatus();
|
this.refreshAlarmPushStatus();
|
||||||
this.refreshImDeliveryCatalog();
|
this.refreshImDeliveryCatalog();
|
||||||
|
this.refreshSessionsExportStatus();
|
||||||
}
|
}
|
||||||
startStatusPoll() {
|
startStatusPoll() {
|
||||||
if (this.pollTimer !== undefined)
|
if (this.pollTimer !== undefined)
|
||||||
|
|
@ -1203,6 +1319,7 @@ class NetxopsCardController {
|
||||||
this.pollTimer = setInterval(() => {
|
this.pollTimer = setInterval(() => {
|
||||||
this.refreshAlarmPushStatus();
|
this.refreshAlarmPushStatus();
|
||||||
this.refreshImDeliveryCatalog();
|
this.refreshImDeliveryCatalog();
|
||||||
|
this.refreshSessionsExportStatus();
|
||||||
}, STATUS_POLL_MS);
|
}, STATUS_POLL_MS);
|
||||||
}
|
}
|
||||||
stopStatusPoll() {
|
stopStatusPoll() {
|
||||||
|
|
@ -1247,6 +1364,42 @@ class NetxopsCardController {
|
||||||
this.catalogInFlight = false;
|
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() {
|
projection() {
|
||||||
return {
|
return {
|
||||||
...this.form.shell(),
|
...this.form.shell(),
|
||||||
|
|
@ -1260,6 +1413,7 @@ class NetxopsCardController {
|
||||||
alarmPushEnabled: this.form.field("alarmPushEnabled"),
|
alarmPushEnabled: this.form.field("alarmPushEnabled"),
|
||||||
alarmDeliverDsh: this.form.field("alarmDeliverDsh"),
|
alarmDeliverDsh: this.form.field("alarmDeliverDsh"),
|
||||||
alarmDeliverIm: this.form.field("alarmDeliverIm"),
|
alarmDeliverIm: this.form.field("alarmDeliverIm"),
|
||||||
|
imTargets: this.form.field("imTargets"),
|
||||||
imBotId: this.form.field("imBotId"),
|
imBotId: this.form.field("imBotId"),
|
||||||
imTargetId: this.form.field("imTargetId"),
|
imTargetId: this.form.field("imTargetId"),
|
||||||
apiToken: this.form.field(API_TOKEN_FIELD),
|
apiToken: this.form.field(API_TOKEN_FIELD),
|
||||||
|
|
@ -1267,7 +1421,11 @@ class NetxopsCardController {
|
||||||
apiTokenWritable: this.credential.remoteReady && this.credential.writable,
|
apiTokenWritable: this.credential.remoteReady && this.credential.writable,
|
||||||
apiTokenRemoteReady: this.credential.remoteReady,
|
apiTokenRemoteReady: this.credential.remoteReady,
|
||||||
alarmPushStatus: this.alarmPushStatus,
|
alarmPushStatus: this.alarmPushStatus,
|
||||||
imDeliveryCatalog: this.imDeliveryCatalog
|
imDeliveryCatalog: this.imDeliveryCatalog,
|
||||||
|
sessionsExportStatus: this.sessionsExportStatus,
|
||||||
|
sessionsExportBusy: this.sessionsExportBusy,
|
||||||
|
sessionsExportError: this.sessionsExportError,
|
||||||
|
sessionsExportLastFile: this.sessionsExportLastFile
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
credentials() {
|
credentials() {
|
||||||
|
|
@ -1313,7 +1471,13 @@ class NetxopsCardController {
|
||||||
this.readCredential();
|
this.readCredential();
|
||||||
}
|
}
|
||||||
inject() {
|
inject() {
|
||||||
return { hooks: { netxopsCard: this.store }, ...this.form.actions() };
|
return {
|
||||||
|
hooks: { netxopsCard: this.store },
|
||||||
|
...this.form.actions(),
|
||||||
|
exportAllSessions: () => {
|
||||||
|
this.exportAllSessions();
|
||||||
|
}
|
||||||
|
};
|
||||||
}
|
}
|
||||||
async writeToken(value) {
|
async writeToken(value) {
|
||||||
const api = this.credentials();
|
const api = this.credentials();
|
||||||
|
|
@ -1332,22 +1496,20 @@ function refOf(snapshot) {
|
||||||
// src/client/locales.ts
|
// src/client/locales.ts
|
||||||
var en = {
|
var en = {
|
||||||
title: "Netx Ops",
|
title: "Netx Ops",
|
||||||
description: "netx API, bearer token, and capability groups (ops / topology).",
|
description: "API, token, capabilities, and alarm delivery.",
|
||||||
apiUrl: "API URL",
|
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",
|
lang: "Language",
|
||||||
langHint: "Response language hint (zh / en).",
|
langHint: "zh / en",
|
||||||
capabilityGroups: "Capability groups",
|
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",
|
||||||
nmsProvider: "nms provider",
|
nmsProviderHint: "zte-ume",
|
||||||
nmsProviderHint: "Vendor adapter id. Supported today: zte-ume (REST still /v1/ume/*; model tools are netx__*Nms*).",
|
groupOps: "ops",
|
||||||
groupOps: "ops — skill netx-ops (NMS alarms/inventory + managed CLI login + paths)",
|
groupTopology: "topology",
|
||||||
groupTopology: "topology — skill netx-topology (canvas / fabric / dual_unit)",
|
|
||||||
groupInPreset: "In Netx Ops preset",
|
groupInPreset: "In Netx Ops preset",
|
||||||
groupPublic: "Publish to other presets",
|
groupPublic: "Publish to other presets",
|
||||||
alarmPushEnabled: "Key-alarm push",
|
alarmPushEnabled: "Key-alarm push",
|
||||||
alarmPushEnabledHint: "Dial out to netx when a matched key alarm arrives. Choose DSH and/or IM sinks below.",
|
alarmPushStatus: "Status",
|
||||||
alarmPushStatus: "Push link",
|
|
||||||
alarmPushPhaseDisabled: "Off",
|
alarmPushPhaseDisabled: "Off",
|
||||||
alarmPushPhaseIdle: "Idle",
|
alarmPushPhaseIdle: "Idle",
|
||||||
alarmPushPhaseConnecting: "Connecting…",
|
alarmPushPhaseConnecting: "Connecting…",
|
||||||
|
|
@ -1357,52 +1519,49 @@ var en = {
|
||||||
alarmPushPhaseAuthFailed: "Auth failed",
|
alarmPushPhaseAuthFailed: "Auth failed",
|
||||||
alarmPushPhaseError: "Error",
|
alarmPushPhaseError: "Error",
|
||||||
alarmDeliverDsh: "Deliver to DSH session",
|
alarmDeliverDsh: "Deliver to DSH session",
|
||||||
alarmDeliverDshHint: "Open/follow the sticky「Netx 关键告警」session on this Host.",
|
|
||||||
alarmDeliverIm: "Deliver to WhatsApp / IM",
|
alarmDeliverIm: "Deliver to WhatsApp / IM",
|
||||||
alarmDeliverImHint: "Requires dsh-im-ops ≥ops.24. Pick a saved delivery target below (create targets under IM → Delivery settings).",
|
imTargetNone: "No delivery targets yet — create one in IM delivery settings.",
|
||||||
imBotId: "IM Bot ID",
|
imTargetSelectedCount: "{count} selected",
|
||||||
imBotIdHint: "Filled from the picker, or enter manually.",
|
imCatalogUnavailable: "Cannot load IM targets (install/update dsh-im-ops).",
|
||||||
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.",
|
|
||||||
apiToken: "API token",
|
apiToken: "API token",
|
||||||
apiTokenHint: "Stored as credential NETX_API_TOKEN (never written into settings). Leave blank to keep the current token.",
|
apiTokenHint: "Stored as NETX_API_TOKEN. Leave blank to keep current.",
|
||||||
apiTokenUnavailable: "This DSH build does not expose remote.credentials. Set the token with scripts/set-netx-token.ps1 (or .sh), then restart is not required if credentials are watched.",
|
apiTokenUnavailable: "remote.credentials unavailable — set NETX_API_TOKEN via script.",
|
||||||
apiTokenSet: "Configured",
|
apiTokenSet: "Configured",
|
||||||
apiTokenUnset: "Not set",
|
apiTokenUnset: "Not set",
|
||||||
|
sessionsExport: "Export all sessions",
|
||||||
|
sessionsExportCount: "{count} sessions",
|
||||||
|
sessionsExportUnavailable: "Export unavailable",
|
||||||
|
sessionsExportButton: "Download ZIP",
|
||||||
|
sessionsExportBusy: "Preparing…",
|
||||||
|
sessionsExportDone: "Downloaded: {file}",
|
||||||
overridden: "Overridden",
|
overridden: "Overridden",
|
||||||
reset: "Reset",
|
reset: "Reset",
|
||||||
invalid: "Invalid value",
|
invalid: "Invalid",
|
||||||
expand: "Expand",
|
expand: "Expand",
|
||||||
collapse: "Collapse",
|
collapse: "Collapse",
|
||||||
unsaved: "Unsaved",
|
unsaved: "Unsaved",
|
||||||
readOnly: "This document is read-only.",
|
readOnly: "Read-only",
|
||||||
saveFailed: "Save failed — drafts kept for correction.",
|
saveFailed: "Save failed",
|
||||||
discard: "Discard",
|
discard: "Discard",
|
||||||
save: "Save",
|
save: "Save",
|
||||||
saving: "Saving…"
|
saving: "Saving…"
|
||||||
};
|
};
|
||||||
var zh = {
|
var zh = {
|
||||||
title: "Netx Ops",
|
title: "Netx Ops",
|
||||||
description: "netx API、Bearer Token,以及能力组(ops / topology)。",
|
description: "API、Token、能力组与告警投递。",
|
||||||
apiUrl: "API 地址",
|
apiUrl: "API 地址",
|
||||||
apiUrlHint: "netx REST 根地址,例如 http://127.0.0.1:8890(告警订阅复用同一地址)",
|
apiUrlHint: "例如 http://127.0.0.1:8890",
|
||||||
lang: "语言",
|
lang: "语言",
|
||||||
langHint: "响应语言提示(zh / en)。",
|
langHint: "zh / en",
|
||||||
capabilityGroups: "能力组",
|
capabilityGroups: "能力组",
|
||||||
capabilityGroupsHint: "一组一个 skill。ops = netx-ops(告警 + 纳管登录 + 路径);topology = netx-topology。默认 ops 开;topology / 公开关。保存后新会话生效。其他 Agent:dsh-netxops/tools-ops|topology。",
|
nmsProvider: "NMS provider",
|
||||||
nmsProvider: "nms provider",
|
nmsProviderHint: "zte-ume",
|
||||||
nmsProviderHint: "厂商适配器 id。当前支持:zte-ume(REST 仍为 /v1/ume/*;模型工具名为 netx__*Nms*)。",
|
groupOps: "ops",
|
||||||
groupOps: "ops — skill netx-ops(NMS 告警/库存 + 纳管 CLI 登录 + 路径)",
|
groupTopology: "topology",
|
||||||
groupTopology: "topology — skill netx-topology(画布 / Fabric / dual_unit)",
|
|
||||||
groupInPreset: "在 Netx Ops 预设中启用",
|
groupInPreset: "在 Netx Ops 预设中启用",
|
||||||
groupPublic: "对其他预设公开",
|
groupPublic: "对其他预设公开",
|
||||||
alarmPushEnabled: "关键告警推送",
|
alarmPushEnabled: "关键告警推送",
|
||||||
alarmPushEnabledHint: "主动连接 netx;匹配到关键告警后按下述开关投递。可只开 DSH、只开 IM,或两者都开。",
|
alarmPushStatus: "状态",
|
||||||
alarmPushStatus: "推送链路",
|
|
||||||
alarmPushPhaseDisabled: "未开启",
|
alarmPushPhaseDisabled: "未开启",
|
||||||
alarmPushPhaseIdle: "空闲",
|
alarmPushPhaseIdle: "空闲",
|
||||||
alarmPushPhaseConnecting: "连接中…",
|
alarmPushPhaseConnecting: "连接中…",
|
||||||
|
|
@ -1412,30 +1571,29 @@ var zh = {
|
||||||
alarmPushPhaseAuthFailed: "鉴权失败",
|
alarmPushPhaseAuthFailed: "鉴权失败",
|
||||||
alarmPushPhaseError: "异常",
|
alarmPushPhaseError: "异常",
|
||||||
alarmDeliverDsh: "投递到 DSH 会话",
|
alarmDeliverDsh: "投递到 DSH 会话",
|
||||||
alarmDeliverDshHint: "在本机打开/续写 sticky「Netx 关键告警」会话。",
|
|
||||||
alarmDeliverIm: "投递到 WhatsApp / IM",
|
alarmDeliverIm: "投递到 WhatsApp / IM",
|
||||||
alarmDeliverImHint: "需要 dsh-im-ops ≥ops.24。请在下方下拉选择已保存的投递目标(先在 IM「投递设置」新建)。",
|
imTargetNone: "暂无投递目标 — 请先在 IM「投递设置」新建。",
|
||||||
imBotId: "IM Bot ID",
|
imTargetSelectedCount: "已选 {count} 个",
|
||||||
imBotIdHint: "由下拉自动填入,也可手动改。",
|
imCatalogUnavailable: "无法加载投递目标(请安装/更新 dsh-im-ops)。",
|
||||||
imTargetId: "IM Target ID",
|
|
||||||
imTargetIdHint: "由下拉自动填入,也可手动改。",
|
|
||||||
imTarget: "投递目标",
|
|
||||||
imTargetNone: "(请先在 IM「投递设置」新建目标)",
|
|
||||||
imTargetManual: "手动填写…",
|
|
||||||
imCatalogUnavailable: "无法加载投递目标 — 请安装/更新 dsh-im-ops(≥ops.24)并至少保存一个投递目标。",
|
|
||||||
apiToken: "API Token",
|
apiToken: "API Token",
|
||||||
apiTokenHint: "写入凭据 NETX_API_TOKEN(不会进 settings)。留空表示保留已有 token。",
|
apiTokenHint: "写入凭据 NETX_API_TOKEN;留空表示保留已有。",
|
||||||
apiTokenUnavailable: "当前 DSH 未提供 remote.credentials。请用 scripts/set-netx-token.ps1(或 .sh)写入 token;若 harness 在监视凭据文件则无需重启。",
|
apiTokenUnavailable: "未提供 remote.credentials — 请用脚本写入 NETX_API_TOKEN。",
|
||||||
apiTokenSet: "已配置",
|
apiTokenSet: "已配置",
|
||||||
apiTokenUnset: "未设置",
|
apiTokenUnset: "未设置",
|
||||||
|
sessionsExport: "导出全部会话",
|
||||||
|
sessionsExportCount: "{count} 个会话",
|
||||||
|
sessionsExportUnavailable: "无法导出",
|
||||||
|
sessionsExportButton: "下载 ZIP",
|
||||||
|
sessionsExportBusy: "准备中…",
|
||||||
|
sessionsExportDone: "已下载:{file}",
|
||||||
overridden: "已覆盖",
|
overridden: "已覆盖",
|
||||||
reset: "重置",
|
reset: "重置",
|
||||||
invalid: "无效值",
|
invalid: "无效",
|
||||||
expand: "展开",
|
expand: "展开",
|
||||||
collapse: "收起",
|
collapse: "收起",
|
||||||
unsaved: "未保存",
|
unsaved: "未保存",
|
||||||
readOnly: "当前文档只读。",
|
readOnly: "只读",
|
||||||
saveFailed: "保存失败,草稿已保留以便修改。",
|
saveFailed: "保存失败",
|
||||||
discard: "丢弃",
|
discard: "丢弃",
|
||||||
save: "保存",
|
save: "保存",
|
||||||
saving: "保存中…"
|
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",
|
"name": "dsh-netxops",
|
||||||
"version": "0.1.24",
|
"version": "0.1.26",
|
||||||
"description": "DeepSeek Harness Netx Ops: capability groups ops/topology (one group ↔ one skill)",
|
"description": "DeepSeek Harness Netx Ops: capability groups ops/topology + multi IM delivery + session export",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"private": false,
|
"private": false,
|
||||||
|
|
@ -89,7 +89,8 @@
|
||||||
"react": "^18.2.0 || ^19.0.0"
|
"react": "^18.2.0 || ^19.0.0"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@deepseek-ai/schemastery": "*"
|
"@deepseek-ai/schemastery": "*",
|
||||||
|
"fflate": "^0.8.2"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/react": "~18.3.1",
|
"@types/react": "~18.3.1",
|
||||||
|
|
|
||||||
|
|
@ -9,9 +9,15 @@ import type { NetxopsLocaleKey } from './locales.ts'
|
||||||
import type { CardFieldState } from './card-form.ts'
|
import type { CardFieldState } from './card-form.ts'
|
||||||
import {
|
import {
|
||||||
imCatalogOptionKey,
|
imCatalogOptionKey,
|
||||||
parseImCatalogOptionKey,
|
|
||||||
type ImDeliveryCatalog,
|
type ImDeliveryCatalog,
|
||||||
} from './im-delivery-catalog.ts'
|
} 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 { alarmPushTone, type AlarmPushPhase } from './alarm-push-status-view.ts'
|
||||||
import { ensureStyles } from './styles.ts'
|
import { ensureStyles } from './styles.ts'
|
||||||
import type {} from '@deepseek-ai/dsh-client-ui-settings-plugins/client'
|
import type {} from '@deepseek-ai/dsh-client-ui-settings-plugins/client'
|
||||||
|
|
@ -95,117 +101,98 @@ function ValueField(props: {
|
||||||
|
|
||||||
function ImDeliveryPicker(props: {
|
function ImDeliveryPicker(props: {
|
||||||
catalog: ImDeliveryCatalog
|
catalog: ImDeliveryCatalog
|
||||||
botId: string
|
targetsJson: string
|
||||||
targetId: string
|
legacyBotId: string
|
||||||
|
legacyTargetId: string
|
||||||
disabled: boolean
|
disabled: boolean
|
||||||
labels: {
|
labels: {
|
||||||
target: string
|
target: string
|
||||||
none: string
|
none: string
|
||||||
manual: string
|
selectedCount: string
|
||||||
unavailable: string
|
unavailable: string
|
||||||
botId: string
|
|
||||||
botHint: string
|
|
||||||
targetId: string
|
|
||||||
targetHint: string
|
|
||||||
overridden: string
|
|
||||||
reset: string
|
|
||||||
invalid: string
|
|
||||||
}
|
}
|
||||||
botField: CardFieldState
|
onChange: (targets: ImDeliveryTarget[]) => void
|
||||||
targetField: CardFieldState
|
|
||||||
onPick: (botId: string, targetId: string) => void
|
|
||||||
onEditBot: (text: string) => void
|
|
||||||
onEditTarget: (text: string) => void
|
|
||||||
onResetBot: () => void
|
|
||||||
onResetTarget: () => void
|
|
||||||
}) {
|
}) {
|
||||||
const options = props.catalog.options
|
const options = props.catalog.options
|
||||||
const selectedKey = props.botId && props.targetId
|
const selected = resolveImTargets({
|
||||||
? imCatalogOptionKey(props.botId, props.targetId)
|
imTargets: props.targetsJson,
|
||||||
: ''
|
imBotId: props.legacyBotId,
|
||||||
const matched = options.some((row) => imCatalogOptionKey(row.botId, row.targetId) === selectedKey)
|
imTargetId: props.legacyTargetId,
|
||||||
const [manual, setManual] = useState(() => !!(selectedKey && !matched))
|
})
|
||||||
useEffect(() => {
|
const selectedKeys = new Set(selected.map((row) => imTargetKey(row.botId, row.targetId)))
|
||||||
if (selectedKey && matched) setManual(false)
|
|
||||||
else if (selectedKey && !matched) setManual(true)
|
// Orphan legacy / JSON targets not present in the live catalog still show as checked rows.
|
||||||
}, [selectedKey, matched])
|
const orphanSelected = selected.filter(
|
||||||
const selectValue = manual ? '__manual__' : (matched ? selectedKey : '')
|
(row) => !options.some(
|
||||||
const showManual = manual || (selectedKey && !matched)
|
(opt) => imCatalogOptionKey(opt.botId, opt.targetId) === imTargetKey(row.botId, row.targetId),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<div className="dsh-nx-field">
|
||||||
<div className="dsh-nx-field">
|
<div className="dsh-nx-fieldHead">
|
||||||
<div className="dsh-nx-fieldHead">
|
<span className="dsh-nx-label">{props.labels.target}</span>
|
||||||
<label className="dsh-nx-label" htmlFor="netxops-im-target">{props.labels.target}</label>
|
{selected.length > 0
|
||||||
</div>
|
? (
|
||||||
<select
|
<span className="dsh-nx-badgeMuted">
|
||||||
id="netxops-im-target"
|
{props.labels.selectedCount.replace('{count}', String(selected.length))}
|
||||||
className="dsh-nx-input dsh-nx-select"
|
</span>
|
||||||
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>
|
|
||||||
: null}
|
: null}
|
||||||
</div>
|
</div>
|
||||||
{showManual
|
{options.length === 0 && orphanSelected.length === 0
|
||||||
? (
|
? (
|
||||||
<>
|
<p className="dsh-nx-hint">
|
||||||
<ValueField
|
{!props.catalog.available
|
||||||
id="netxops-im-bot-id"
|
? (props.catalog.hint || props.labels.unavailable)
|
||||||
label={props.labels.botId}
|
: props.labels.none}
|
||||||
hint={props.labels.botHint}
|
</p>
|
||||||
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}
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
)
|
)
|
||||||
|
: (
|
||||||
|
<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}
|
: null}
|
||||||
</>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -362,7 +349,6 @@ export function NetxopsCard(props: NetxopsCardProps) {
|
||||||
<div className="dsh-nx-fieldHead">
|
<div className="dsh-nx-fieldHead">
|
||||||
<span className="dsh-nx-label">{t('capabilityGroups')}</span>
|
<span className="dsh-nx-label">{t('capabilityGroups')}</span>
|
||||||
</div>
|
</div>
|
||||||
<p className="dsh-nx-hint">{t('capabilityGroupsHint')}</p>
|
|
||||||
<CapabilityGroupBlock
|
<CapabilityGroupBlock
|
||||||
title={t('groupOps')}
|
title={t('groupOps')}
|
||||||
inPresetLabel={t('groupInPreset')}
|
inPresetLabel={t('groupInPreset')}
|
||||||
|
|
@ -427,11 +413,11 @@ export function NetxopsCard(props: NetxopsCardProps) {
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
checked={state.alarmPushEnabled.text === 'true'}
|
checked={state.alarmPushEnabled.text === 'true'}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
|
aria-label={t('alarmPushEnabled')}
|
||||||
onChange={(event) => {
|
onChange={(event) => {
|
||||||
props.edit('alarmPushEnabled', event.target.checked ? 'true' : 'false')
|
props.edit('alarmPushEnabled', event.target.checked ? 'true' : 'false')
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<span>{t('alarmPushEnabledHint')}</span>
|
|
||||||
</label>
|
</label>
|
||||||
{pushStatus?.wsUrl
|
{pushStatus?.wsUrl
|
||||||
? <p className="dsh-nx-hint">{pushStatus.wsUrl}</p>
|
? <p className="dsh-nx-hint">{pushStatus.wsUrl}</p>
|
||||||
|
|
@ -460,69 +446,77 @@ export function NetxopsCard(props: NetxopsCardProps) {
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
checked={state.alarmDeliverDsh.text === 'true'}
|
checked={state.alarmDeliverDsh.text === 'true'}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
|
aria-label={t('alarmDeliverDsh')}
|
||||||
onChange={(event) => {
|
onChange={(event) => {
|
||||||
props.edit('alarmDeliverDsh', event.target.checked ? 'true' : 'false')
|
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>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
<ImDeliveryPicker
|
<ImDeliveryPicker
|
||||||
catalog={state.imDeliveryCatalog}
|
catalog={state.imDeliveryCatalog}
|
||||||
botId={state.imBotId.text}
|
targetsJson={state.imTargets.text}
|
||||||
targetId={state.imTargetId.text}
|
legacyBotId={state.imBotId.text}
|
||||||
|
legacyTargetId={state.imTargetId.text}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
labels={{
|
labels={{
|
||||||
target: t('imTarget'),
|
target: t('alarmDeliverIm'),
|
||||||
none: t('imTargetNone'),
|
none: t('imTargetNone'),
|
||||||
manual: t('imTargetManual'),
|
selectedCount: t('imTargetSelectedCount'),
|
||||||
unavailable: t('imCatalogUnavailable'),
|
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}
|
onChange={(targets) => {
|
||||||
targetField={state.imTargetId}
|
props.edit('imTargets', formatImTargetsJson(targets))
|
||||||
onPick={(botId, targetId) => {
|
const first = targets[0]
|
||||||
props.edit('imBotId', botId)
|
props.edit('imBotId', first?.botId ?? '')
|
||||||
props.edit('imTargetId', targetId)
|
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">
|
<div className="dsh-nx-footer">
|
||||||
{state.failed ? <p className="dsh-nx-failed" role="status">{t('saveFailed')}</p> : null}
|
{state.failed ? <p className="dsh-nx-failed" role="status">{t('saveFailed')}</p> : null}
|
||||||
<button
|
<button
|
||||||
|
|
|
||||||
|
|
@ -20,6 +20,11 @@ import {
|
||||||
fetchImDeliveryCatalog,
|
fetchImDeliveryCatalog,
|
||||||
type ImDeliveryCatalog,
|
type ImDeliveryCatalog,
|
||||||
} from './im-delivery-catalog.ts'
|
} from './im-delivery-catalog.ts'
|
||||||
|
import {
|
||||||
|
downloadAllSessionsExport,
|
||||||
|
fetchSessionsExportStatus,
|
||||||
|
type SessionsExportStatus,
|
||||||
|
} from './sessions-export-view.ts'
|
||||||
|
|
||||||
export const NETXOPS_NS = 'netxops'
|
export const NETXOPS_NS = 'netxops'
|
||||||
const DEFAULT_TOKEN_REF = 'NETX_API_TOKEN'
|
const DEFAULT_TOKEN_REF = 'NETX_API_TOKEN'
|
||||||
|
|
@ -33,6 +38,8 @@ export interface NetxopsSettings {
|
||||||
alarmPushEnabled?: boolean
|
alarmPushEnabled?: boolean
|
||||||
alarmDeliverDsh?: boolean
|
alarmDeliverDsh?: boolean
|
||||||
alarmDeliverIm?: boolean
|
alarmDeliverIm?: boolean
|
||||||
|
/** JSON array of `{ botId, targetId }` — multi WhatsApp / IM sinks. */
|
||||||
|
imTargets?: string
|
||||||
imBotId?: string
|
imBotId?: string
|
||||||
imTargetId?: string
|
imTargetId?: string
|
||||||
groupOpsInPreset?: boolean
|
groupOpsInPreset?: boolean
|
||||||
|
|
@ -60,6 +67,7 @@ export interface NetxopsCardState extends CardShell {
|
||||||
alarmPushEnabled: CardFieldState
|
alarmPushEnabled: CardFieldState
|
||||||
alarmDeliverDsh: CardFieldState
|
alarmDeliverDsh: CardFieldState
|
||||||
alarmDeliverIm: CardFieldState
|
alarmDeliverIm: CardFieldState
|
||||||
|
imTargets: CardFieldState
|
||||||
imBotId: CardFieldState
|
imBotId: CardFieldState
|
||||||
imTargetId: CardFieldState
|
imTargetId: CardFieldState
|
||||||
apiToken: CardFieldState
|
apiToken: CardFieldState
|
||||||
|
|
@ -70,12 +78,22 @@ export interface NetxopsCardState extends CardShell {
|
||||||
alarmPushStatus: AlarmPushStatus | null
|
alarmPushStatus: AlarmPushStatus | null
|
||||||
/** Saved IM delivery targets for the picker (soft-depends on dsh-im-ops). */
|
/** Saved IM delivery targets for the picker (soft-depends on dsh-im-ops). */
|
||||||
imDeliveryCatalog: ImDeliveryCatalog
|
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 {
|
export interface NetxopsCardFace extends CardActions {
|
||||||
hooks: {
|
hooks: {
|
||||||
netxopsCard: SnapshotStore<NetxopsCardState>
|
netxopsCard: SnapshotStore<NetxopsCardState>
|
||||||
}
|
}
|
||||||
|
/** Download every durable session on this Host as one ZIP. */
|
||||||
|
exportAllSessions: () => void
|
||||||
}
|
}
|
||||||
|
|
||||||
type CredentialsRemote = {
|
type CredentialsRemote = {
|
||||||
|
|
@ -95,9 +113,15 @@ export class NetxopsCardController {
|
||||||
private rpcCall: AlarmPushRpcCall | undefined
|
private rpcCall: AlarmPushRpcCall | undefined
|
||||||
private alarmPushStatus: AlarmPushStatus | null = null
|
private alarmPushStatus: AlarmPushStatus | null = null
|
||||||
private imDeliveryCatalog: ImDeliveryCatalog = { ...EMPTY_IM_DELIVERY_CATALOG }
|
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 pollTimer: ReturnType<typeof setInterval> | undefined
|
||||||
private pollInFlight = false
|
private pollInFlight = false
|
||||||
private catalogInFlight = false
|
private catalogInFlight = false
|
||||||
|
private exportStatusInFlight = false
|
||||||
|
private exportInFlight: Promise<void> | undefined
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private readonly scope: SettingsScope<NetxopsSettings>,
|
private readonly scope: SettingsScope<NetxopsSettings>,
|
||||||
|
|
@ -116,6 +140,7 @@ export class NetxopsCardController {
|
||||||
booleanField('alarmPushEnabled'),
|
booleanField('alarmPushEnabled'),
|
||||||
booleanFieldPersistFalse('alarmDeliverDsh'),
|
booleanFieldPersistFalse('alarmDeliverDsh'),
|
||||||
booleanField('alarmDeliverIm'),
|
booleanField('alarmDeliverIm'),
|
||||||
|
textField('imTargets'),
|
||||||
textField('imBotId'),
|
textField('imBotId'),
|
||||||
textField('imTargetId'),
|
textField('imTargetId'),
|
||||||
],
|
],
|
||||||
|
|
@ -155,12 +180,17 @@ export class NetxopsCardController {
|
||||||
this.imDeliveryCatalog = { ...EMPTY_IM_DELIVERY_CATALOG }
|
this.imDeliveryCatalog = { ...EMPTY_IM_DELIVERY_CATALOG }
|
||||||
changed = true
|
changed = true
|
||||||
}
|
}
|
||||||
|
if (this.sessionsExportStatus !== null) {
|
||||||
|
this.sessionsExportStatus = null
|
||||||
|
changed = true
|
||||||
|
}
|
||||||
if (changed) this.store.set(this.projection())
|
if (changed) this.store.set(this.projection())
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
this.startStatusPoll()
|
this.startStatusPoll()
|
||||||
void this.refreshAlarmPushStatus()
|
void this.refreshAlarmPushStatus()
|
||||||
void this.refreshImDeliveryCatalog()
|
void this.refreshImDeliveryCatalog()
|
||||||
|
void this.refreshSessionsExportStatus()
|
||||||
}
|
}
|
||||||
|
|
||||||
private startStatusPoll(): void {
|
private startStatusPoll(): void {
|
||||||
|
|
@ -168,6 +198,7 @@ export class NetxopsCardController {
|
||||||
this.pollTimer = setInterval(() => {
|
this.pollTimer = setInterval(() => {
|
||||||
void this.refreshAlarmPushStatus()
|
void this.refreshAlarmPushStatus()
|
||||||
void this.refreshImDeliveryCatalog()
|
void this.refreshImDeliveryCatalog()
|
||||||
|
void this.refreshSessionsExportStatus()
|
||||||
}, STATUS_POLL_MS)
|
}, 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 {
|
private projection(): NetxopsCardState {
|
||||||
return {
|
return {
|
||||||
...this.form.shell(),
|
...this.form.shell(),
|
||||||
|
|
@ -245,6 +324,7 @@ export class NetxopsCardController {
|
||||||
alarmPushEnabled: this.form.field('alarmPushEnabled'),
|
alarmPushEnabled: this.form.field('alarmPushEnabled'),
|
||||||
alarmDeliverDsh: this.form.field('alarmDeliverDsh'),
|
alarmDeliverDsh: this.form.field('alarmDeliverDsh'),
|
||||||
alarmDeliverIm: this.form.field('alarmDeliverIm'),
|
alarmDeliverIm: this.form.field('alarmDeliverIm'),
|
||||||
|
imTargets: this.form.field('imTargets'),
|
||||||
imBotId: this.form.field('imBotId'),
|
imBotId: this.form.field('imBotId'),
|
||||||
imTargetId: this.form.field('imTargetId'),
|
imTargetId: this.form.field('imTargetId'),
|
||||||
apiToken: this.form.field(API_TOKEN_FIELD),
|
apiToken: this.form.field(API_TOKEN_FIELD),
|
||||||
|
|
@ -253,6 +333,10 @@ export class NetxopsCardController {
|
||||||
apiTokenRemoteReady: this.credential.remoteReady,
|
apiTokenRemoteReady: this.credential.remoteReady,
|
||||||
alarmPushStatus: this.alarmPushStatus,
|
alarmPushStatus: this.alarmPushStatus,
|
||||||
imDeliveryCatalog: this.imDeliveryCatalog,
|
imDeliveryCatalog: this.imDeliveryCatalog,
|
||||||
|
sessionsExportStatus: this.sessionsExportStatus,
|
||||||
|
sessionsExportBusy: this.sessionsExportBusy,
|
||||||
|
sessionsExportError: this.sessionsExportError,
|
||||||
|
sessionsExportLastFile: this.sessionsExportLastFile,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -303,7 +387,11 @@ export class NetxopsCardController {
|
||||||
}
|
}
|
||||||
|
|
||||||
inject(): NetxopsCardFace {
|
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> {
|
private async writeToken(value: string): Promise<boolean> {
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,6 @@ export type NetxopsLocaleKey =
|
||||||
| 'lang'
|
| 'lang'
|
||||||
| 'langHint'
|
| 'langHint'
|
||||||
| 'capabilityGroups'
|
| 'capabilityGroups'
|
||||||
| 'capabilityGroupsHint'
|
|
||||||
| 'nmsProvider'
|
| 'nmsProvider'
|
||||||
| 'nmsProviderHint'
|
| 'nmsProviderHint'
|
||||||
| 'groupOps'
|
| 'groupOps'
|
||||||
|
|
@ -16,7 +15,6 @@ export type NetxopsLocaleKey =
|
||||||
| 'groupInPreset'
|
| 'groupInPreset'
|
||||||
| 'groupPublic'
|
| 'groupPublic'
|
||||||
| 'alarmPushEnabled'
|
| 'alarmPushEnabled'
|
||||||
| 'alarmPushEnabledHint'
|
|
||||||
| 'alarmPushStatus'
|
| 'alarmPushStatus'
|
||||||
| 'alarmPushPhaseDisabled'
|
| 'alarmPushPhaseDisabled'
|
||||||
| 'alarmPushPhaseIdle'
|
| 'alarmPushPhaseIdle'
|
||||||
|
|
@ -27,22 +25,21 @@ export type NetxopsLocaleKey =
|
||||||
| 'alarmPushPhaseAuthFailed'
|
| 'alarmPushPhaseAuthFailed'
|
||||||
| 'alarmPushPhaseError'
|
| 'alarmPushPhaseError'
|
||||||
| 'alarmDeliverDsh'
|
| 'alarmDeliverDsh'
|
||||||
| 'alarmDeliverDshHint'
|
|
||||||
| 'alarmDeliverIm'
|
| 'alarmDeliverIm'
|
||||||
| 'alarmDeliverImHint'
|
|
||||||
| 'imBotId'
|
|
||||||
| 'imBotIdHint'
|
|
||||||
| 'imTargetId'
|
|
||||||
| 'imTargetIdHint'
|
|
||||||
| 'imTarget'
|
|
||||||
| 'imTargetNone'
|
| 'imTargetNone'
|
||||||
| 'imTargetManual'
|
| 'imTargetSelectedCount'
|
||||||
| 'imCatalogUnavailable'
|
| 'imCatalogUnavailable'
|
||||||
| 'apiToken'
|
| 'apiToken'
|
||||||
| 'apiTokenHint'
|
| 'apiTokenHint'
|
||||||
| 'apiTokenUnavailable'
|
| 'apiTokenUnavailable'
|
||||||
| 'apiTokenSet'
|
| 'apiTokenSet'
|
||||||
| 'apiTokenUnset'
|
| 'apiTokenUnset'
|
||||||
|
| 'sessionsExport'
|
||||||
|
| 'sessionsExportCount'
|
||||||
|
| 'sessionsExportUnavailable'
|
||||||
|
| 'sessionsExportButton'
|
||||||
|
| 'sessionsExportBusy'
|
||||||
|
| 'sessionsExportDone'
|
||||||
| 'overridden'
|
| 'overridden'
|
||||||
| 'reset'
|
| 'reset'
|
||||||
| 'invalid'
|
| 'invalid'
|
||||||
|
|
@ -57,22 +54,20 @@ export type NetxopsLocaleKey =
|
||||||
|
|
||||||
export const en: Record<NetxopsLocaleKey, string> = {
|
export const en: Record<NetxopsLocaleKey, string> = {
|
||||||
title: 'Netx Ops',
|
title: 'Netx Ops',
|
||||||
description: 'netx API, bearer token, and capability groups (ops / topology).',
|
description: 'API, token, capabilities, and alarm delivery.',
|
||||||
apiUrl: 'API URL',
|
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',
|
lang: 'Language',
|
||||||
langHint: 'Response language hint (zh / en).',
|
langHint: 'zh / en',
|
||||||
capabilityGroups: 'Capability groups',
|
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',
|
||||||
nmsProvider: 'nms provider',
|
nmsProviderHint: 'zte-ume',
|
||||||
nmsProviderHint: 'Vendor adapter id. Supported today: zte-ume (REST still /v1/ume/*; model tools are netx__*Nms*).',
|
groupOps: 'ops',
|
||||||
groupOps: 'ops — skill netx-ops (NMS alarms/inventory + managed CLI login + paths)',
|
groupTopology: 'topology',
|
||||||
groupTopology: 'topology — skill netx-topology (canvas / fabric / dual_unit)',
|
|
||||||
groupInPreset: 'In Netx Ops preset',
|
groupInPreset: 'In Netx Ops preset',
|
||||||
groupPublic: 'Publish to other presets',
|
groupPublic: 'Publish to other presets',
|
||||||
alarmPushEnabled: 'Key-alarm push',
|
alarmPushEnabled: 'Key-alarm push',
|
||||||
alarmPushEnabledHint: 'Dial out to netx when a matched key alarm arrives. Choose DSH and/or IM sinks below.',
|
alarmPushStatus: 'Status',
|
||||||
alarmPushStatus: 'Push link',
|
|
||||||
alarmPushPhaseDisabled: 'Off',
|
alarmPushPhaseDisabled: 'Off',
|
||||||
alarmPushPhaseIdle: 'Idle',
|
alarmPushPhaseIdle: 'Idle',
|
||||||
alarmPushPhaseConnecting: 'Connecting…',
|
alarmPushPhaseConnecting: 'Connecting…',
|
||||||
|
|
@ -82,30 +77,29 @@ export const en: Record<NetxopsLocaleKey, string> = {
|
||||||
alarmPushPhaseAuthFailed: 'Auth failed',
|
alarmPushPhaseAuthFailed: 'Auth failed',
|
||||||
alarmPushPhaseError: 'Error',
|
alarmPushPhaseError: 'Error',
|
||||||
alarmDeliverDsh: 'Deliver to DSH session',
|
alarmDeliverDsh: 'Deliver to DSH session',
|
||||||
alarmDeliverDshHint: 'Open/follow the sticky「Netx 关键告警」session on this Host.',
|
|
||||||
alarmDeliverIm: 'Deliver to WhatsApp / IM',
|
alarmDeliverIm: 'Deliver to WhatsApp / IM',
|
||||||
alarmDeliverImHint: 'Requires dsh-im-ops ≥ops.24. Pick a saved delivery target below (create targets under IM → Delivery settings).',
|
imTargetNone: 'No delivery targets yet — create one in IM delivery settings.',
|
||||||
imBotId: 'IM Bot ID',
|
imTargetSelectedCount: '{count} selected',
|
||||||
imBotIdHint: 'Filled from the picker, or enter manually.',
|
imCatalogUnavailable: 'Cannot load IM targets (install/update dsh-im-ops).',
|
||||||
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.',
|
|
||||||
apiToken: 'API token',
|
apiToken: 'API token',
|
||||||
apiTokenHint: 'Stored as credential NETX_API_TOKEN (never written into settings). Leave blank to keep the current token.',
|
apiTokenHint: 'Stored as NETX_API_TOKEN. Leave blank to keep current.',
|
||||||
apiTokenUnavailable: 'This DSH build does not expose remote.credentials. Set the token with scripts/set-netx-token.ps1 (or .sh), then restart is not required if credentials are watched.',
|
apiTokenUnavailable: 'remote.credentials unavailable — set NETX_API_TOKEN via script.',
|
||||||
apiTokenSet: 'Configured',
|
apiTokenSet: 'Configured',
|
||||||
apiTokenUnset: 'Not set',
|
apiTokenUnset: 'Not set',
|
||||||
|
sessionsExport: 'Export all sessions',
|
||||||
|
sessionsExportCount: '{count} sessions',
|
||||||
|
sessionsExportUnavailable: 'Export unavailable',
|
||||||
|
sessionsExportButton: 'Download ZIP',
|
||||||
|
sessionsExportBusy: 'Preparing…',
|
||||||
|
sessionsExportDone: 'Downloaded: {file}',
|
||||||
overridden: 'Overridden',
|
overridden: 'Overridden',
|
||||||
reset: 'Reset',
|
reset: 'Reset',
|
||||||
invalid: 'Invalid value',
|
invalid: 'Invalid',
|
||||||
expand: 'Expand',
|
expand: 'Expand',
|
||||||
collapse: 'Collapse',
|
collapse: 'Collapse',
|
||||||
unsaved: 'Unsaved',
|
unsaved: 'Unsaved',
|
||||||
readOnly: 'This document is read-only.',
|
readOnly: 'Read-only',
|
||||||
saveFailed: 'Save failed — drafts kept for correction.',
|
saveFailed: 'Save failed',
|
||||||
discard: 'Discard',
|
discard: 'Discard',
|
||||||
save: 'Save',
|
save: 'Save',
|
||||||
saving: 'Saving…',
|
saving: 'Saving…',
|
||||||
|
|
@ -113,22 +107,20 @@ export const en: Record<NetxopsLocaleKey, string> = {
|
||||||
|
|
||||||
export const zh: Record<NetxopsLocaleKey, string> = {
|
export const zh: Record<NetxopsLocaleKey, string> = {
|
||||||
title: 'Netx Ops',
|
title: 'Netx Ops',
|
||||||
description: 'netx API、Bearer Token,以及能力组(ops / topology)。',
|
description: 'API、Token、能力组与告警投递。',
|
||||||
apiUrl: 'API 地址',
|
apiUrl: 'API 地址',
|
||||||
apiUrlHint: 'netx REST 根地址,例如 http://127.0.0.1:8890(告警订阅复用同一地址)',
|
apiUrlHint: '例如 http://127.0.0.1:8890',
|
||||||
lang: '语言',
|
lang: '语言',
|
||||||
langHint: '响应语言提示(zh / en)。',
|
langHint: 'zh / en',
|
||||||
capabilityGroups: '能力组',
|
capabilityGroups: '能力组',
|
||||||
capabilityGroupsHint: '一组一个 skill。ops = netx-ops(告警 + 纳管登录 + 路径);topology = netx-topology。默认 ops 开;topology / 公开关。保存后新会话生效。其他 Agent:dsh-netxops/tools-ops|topology。',
|
nmsProvider: 'NMS provider',
|
||||||
nmsProvider: 'nms provider',
|
nmsProviderHint: 'zte-ume',
|
||||||
nmsProviderHint: '厂商适配器 id。当前支持:zte-ume(REST 仍为 /v1/ume/*;模型工具名为 netx__*Nms*)。',
|
groupOps: 'ops',
|
||||||
groupOps: 'ops — skill netx-ops(NMS 告警/库存 + 纳管 CLI 登录 + 路径)',
|
groupTopology: 'topology',
|
||||||
groupTopology: 'topology — skill netx-topology(画布 / Fabric / dual_unit)',
|
|
||||||
groupInPreset: '在 Netx Ops 预设中启用',
|
groupInPreset: '在 Netx Ops 预设中启用',
|
||||||
groupPublic: '对其他预设公开',
|
groupPublic: '对其他预设公开',
|
||||||
alarmPushEnabled: '关键告警推送',
|
alarmPushEnabled: '关键告警推送',
|
||||||
alarmPushEnabledHint: '主动连接 netx;匹配到关键告警后按下述开关投递。可只开 DSH、只开 IM,或两者都开。',
|
alarmPushStatus: '状态',
|
||||||
alarmPushStatus: '推送链路',
|
|
||||||
alarmPushPhaseDisabled: '未开启',
|
alarmPushPhaseDisabled: '未开启',
|
||||||
alarmPushPhaseIdle: '空闲',
|
alarmPushPhaseIdle: '空闲',
|
||||||
alarmPushPhaseConnecting: '连接中…',
|
alarmPushPhaseConnecting: '连接中…',
|
||||||
|
|
@ -138,30 +130,29 @@ export const zh: Record<NetxopsLocaleKey, string> = {
|
||||||
alarmPushPhaseAuthFailed: '鉴权失败',
|
alarmPushPhaseAuthFailed: '鉴权失败',
|
||||||
alarmPushPhaseError: '异常',
|
alarmPushPhaseError: '异常',
|
||||||
alarmDeliverDsh: '投递到 DSH 会话',
|
alarmDeliverDsh: '投递到 DSH 会话',
|
||||||
alarmDeliverDshHint: '在本机打开/续写 sticky「Netx 关键告警」会话。',
|
|
||||||
alarmDeliverIm: '投递到 WhatsApp / IM',
|
alarmDeliverIm: '投递到 WhatsApp / IM',
|
||||||
alarmDeliverImHint: '需要 dsh-im-ops ≥ops.24。请在下方下拉选择已保存的投递目标(先在 IM「投递设置」新建)。',
|
imTargetNone: '暂无投递目标 — 请先在 IM「投递设置」新建。',
|
||||||
imBotId: 'IM Bot ID',
|
imTargetSelectedCount: '已选 {count} 个',
|
||||||
imBotIdHint: '由下拉自动填入,也可手动改。',
|
imCatalogUnavailable: '无法加载投递目标(请安装/更新 dsh-im-ops)。',
|
||||||
imTargetId: 'IM Target ID',
|
|
||||||
imTargetIdHint: '由下拉自动填入,也可手动改。',
|
|
||||||
imTarget: '投递目标',
|
|
||||||
imTargetNone: '(请先在 IM「投递设置」新建目标)',
|
|
||||||
imTargetManual: '手动填写…',
|
|
||||||
imCatalogUnavailable: '无法加载投递目标 — 请安装/更新 dsh-im-ops(≥ops.24)并至少保存一个投递目标。',
|
|
||||||
apiToken: 'API Token',
|
apiToken: 'API Token',
|
||||||
apiTokenHint: '写入凭据 NETX_API_TOKEN(不会进 settings)。留空表示保留已有 token。',
|
apiTokenHint: '写入凭据 NETX_API_TOKEN;留空表示保留已有。',
|
||||||
apiTokenUnavailable: '当前 DSH 未提供 remote.credentials。请用 scripts/set-netx-token.ps1(或 .sh)写入 token;若 harness 在监视凭据文件则无需重启。',
|
apiTokenUnavailable: '未提供 remote.credentials — 请用脚本写入 NETX_API_TOKEN。',
|
||||||
apiTokenSet: '已配置',
|
apiTokenSet: '已配置',
|
||||||
apiTokenUnset: '未设置',
|
apiTokenUnset: '未设置',
|
||||||
|
sessionsExport: '导出全部会话',
|
||||||
|
sessionsExportCount: '{count} 个会话',
|
||||||
|
sessionsExportUnavailable: '无法导出',
|
||||||
|
sessionsExportButton: '下载 ZIP',
|
||||||
|
sessionsExportBusy: '准备中…',
|
||||||
|
sessionsExportDone: '已下载:{file}',
|
||||||
overridden: '已覆盖',
|
overridden: '已覆盖',
|
||||||
reset: '重置',
|
reset: '重置',
|
||||||
invalid: '无效值',
|
invalid: '无效',
|
||||||
expand: '展开',
|
expand: '展开',
|
||||||
collapse: '收起',
|
collapse: '收起',
|
||||||
unsaved: '未保存',
|
unsaved: '未保存',
|
||||||
readOnly: '当前文档只读。',
|
readOnly: '只读',
|
||||||
saveFailed: '保存失败,草稿已保留以便修改。',
|
saveFailed: '保存失败',
|
||||||
discard: '丢弃',
|
discard: '丢弃',
|
||||||
save: '保存',
|
save: '保存',
|
||||||
saving: '保存中…',
|
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-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-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-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-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-footer{display:flex;align-items:center;justify-content:flex-end;gap:8px;padding:12px 0 4px;border-top:.5px solid var(--dsw-alias-border-l2)}
|
||||||
.dsh-nx-failed{flex:1;min-width:0;margin:0;font-size:12px;line-height:1.5;color:var(--dsw-alias-label-error)}
|
.dsh-nx-failed{flex:1;min-width:0;margin:0;font-size:12px;line-height:1.5;color:var(--dsw-alias-label-error)}
|
||||||
.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}
|
.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
|
* 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).
|
* 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
|
* @module dsh-netxops
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
|
@ -34,7 +38,14 @@ import {
|
||||||
type NetxCapabilityGroupSettingsFields,
|
type NetxCapabilityGroupSettingsFields,
|
||||||
} from './netx/capability-groups.ts'
|
} from './netx/capability-groups.ts'
|
||||||
import { registerGroupSkills } from './netx/group-skills.ts'
|
import { registerGroupSkills } from './netx/group-skills.ts'
|
||||||
|
import { resolveImTargets } from './netx/im-targets.ts'
|
||||||
import { publishNetxConnection, getNetxConnection, watchNetxConnection } from './netx/runtime.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'
|
import { registerNetxTools } from './netx/tools.ts'
|
||||||
|
|
||||||
/** Cordis plugin name. */
|
/** Cordis plugin name. */
|
||||||
|
|
@ -78,13 +89,18 @@ export interface Config {
|
||||||
/** When push is on, also followup the sticky Netx Ops DSH session (default). */
|
/** When push is on, also followup the sticky Netx Ops DSH session (default). */
|
||||||
alarmDeliverDsh: boolean
|
alarmDeliverDsh: boolean
|
||||||
/**
|
/**
|
||||||
* When push is on, also send via `ctx.dshIm.send(imBotId, imTargetId, …)`.
|
* When push is on, also send via `ctx.dshIm.send` to every configured target.
|
||||||
* Requires dsh-im-ops and a saved delivery target.
|
* Derived from selected sinks (`imTargets` / legacy pair); empty list means off.
|
||||||
*/
|
*/
|
||||||
alarmDeliverIm: boolean
|
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
|
imBotId: string
|
||||||
/** Opaque target id from the same copy payload. */
|
/** Opaque target id from the same copy payload (legacy single-target). */
|
||||||
imTargetId: string
|
imTargetId: string
|
||||||
/**
|
/**
|
||||||
* NMS provider adapter id. Supported today: `zte-ume`
|
* NMS provider adapter id. Supported today: `zte-ume`
|
||||||
|
|
@ -110,6 +126,7 @@ export const Config: z<Config> = z.object({
|
||||||
alarmPushEnabled: z.boolean().default(false),
|
alarmPushEnabled: z.boolean().default(false),
|
||||||
alarmDeliverDsh: z.boolean().default(true),
|
alarmDeliverDsh: z.boolean().default(true),
|
||||||
alarmDeliverIm: z.boolean().default(false),
|
alarmDeliverIm: z.boolean().default(false),
|
||||||
|
imTargets: z.string().default(''),
|
||||||
imBotId: z.string().default(''),
|
imBotId: z.string().default(''),
|
||||||
imTargetId: z.string().default(''),
|
imTargetId: z.string().default(''),
|
||||||
nmsProvider: z.string().default('zte-ume'),
|
nmsProvider: z.string().default('zte-ume'),
|
||||||
|
|
@ -234,9 +251,12 @@ export function apply(ctx: Context, config: Config = Config({})): void {
|
||||||
}
|
}
|
||||||
const lang = current.lang
|
const lang = current.lang
|
||||||
const deliverDsh = current.alarmDeliverDsh !== false
|
const deliverDsh = current.alarmDeliverDsh !== false
|
||||||
const deliverIm = current.alarmDeliverIm === true
|
const imTargets = resolveImTargets({
|
||||||
const imBotId = current.imBotId ?? ''
|
imTargets: current.imTargets ?? '',
|
||||||
const imTargetId = current.imTargetId ?? ''
|
imBotId: current.imBotId ?? '',
|
||||||
|
imTargetId: current.imTargetId ?? '',
|
||||||
|
})
|
||||||
|
const deliverIm = imTargets.length > 0
|
||||||
stopAlarmPush = startAlarmPushClient({
|
stopAlarmPush = startAlarmPushClient({
|
||||||
apiUrl,
|
apiUrl,
|
||||||
token,
|
token,
|
||||||
|
|
@ -247,8 +267,7 @@ export function apply(ctx: Context, config: Config = Config({})): void {
|
||||||
}
|
}
|
||||||
await deliverAlarmToIm(ctx, payload, {
|
await deliverAlarmToIm(ctx, payload, {
|
||||||
enabled: deliverIm,
|
enabled: deliverIm,
|
||||||
botId: imBotId,
|
targets: imTargets,
|
||||||
targetId: imTargetId,
|
|
||||||
lang,
|
lang,
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
|
|
@ -286,12 +305,17 @@ export function apply(ctx: Context, config: Config = Config({})): void {
|
||||||
current.tokenCredentialRef,
|
current.tokenCredentialRef,
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
|
const imSinkCount = resolveImTargets({
|
||||||
|
imTargets: current.imTargets ?? '',
|
||||||
|
imBotId: current.imBotId ?? '',
|
||||||
|
imTargetId: current.imTargetId ?? '',
|
||||||
|
}).length
|
||||||
ctx.logger.info(
|
ctx.logger.info(
|
||||||
'netxops: published connection → %s tokenConfigured=true alarmPush=%s dsh=%s im=%s public=[%s]',
|
'netxops: published connection → %s tokenConfigured=true alarmPush=%s dsh=%s im=%s public=[%s]',
|
||||||
apiUrl,
|
apiUrl,
|
||||||
current.alarmPushEnabled === true,
|
current.alarmPushEnabled === true,
|
||||||
current.alarmDeliverDsh !== false,
|
current.alarmDeliverDsh !== false,
|
||||||
current.alarmDeliverIm === true,
|
imSinkCount,
|
||||||
groupsForPlane(groups, 'public').join(',') || '(none)',
|
groupsForPlane(groups, 'public').join(',') || '(none)',
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
@ -371,61 +395,94 @@ export function apply(ctx: Context, config: Config = Config({})): void {
|
||||||
}, 'netxops: dispose public skills')
|
}, '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) => {
|
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') {
|
if (!rpc || typeof rpc.handle !== 'function') {
|
||||||
connCtx.logger.warn('netxops: connection.rpc.handle unavailable — alarm status UI disabled')
|
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(
|
const fetchApi = connection?.fetch
|
||||||
NETXOPS_RPC_CHANNEL,
|
if (!fetchApi || typeof fetchApi.register !== 'function') {
|
||||||
async (endpoint: string) => {
|
connCtx.logger.warn('netxops: connection.fetch.register unavailable — sessions export download disabled')
|
||||||
if (endpoint === 'alarm-push.status') {
|
} else {
|
||||||
return { ok: true, value: getAlarmPushStatus() }
|
connCtx.effect(() => {
|
||||||
}
|
const dispose = fetchApi.register({
|
||||||
if (endpoint === 'im-delivery.catalog') {
|
path: NETXOPS_SESSIONS_EXPORT_PATH,
|
||||||
type DshImCatalog = { listDeliveryCatalog?: () => Promise<unknown> }
|
methods: ['GET', 'HEAD'],
|
||||||
const fromGet = typeof (ctx as { get?: (name: string) => unknown }).get === 'function'
|
fetch: async (request) => {
|
||||||
? (ctx as { get: (name: string) => unknown }).get('dshIm') as DshImCatalog | undefined
|
if (request.method === 'HEAD') {
|
||||||
: undefined
|
return sessionsExportHeadResponse(ctx, request)
|
||||||
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 {
|
return sessionsExportResponse(ctx, request)
|
||||||
const options = await im.listDeliveryCatalog()
|
},
|
||||||
return {
|
})
|
||||||
ok: true,
|
return () => { void dispose() }
|
||||||
value: {
|
}, 'netxops: sessions export fetch')
|
||||||
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')
|
|
||||||
})
|
})
|
||||||
|
|
||||||
ctx.effect(() => () => {
|
ctx.effect(() => () => {
|
||||||
|
|
|
||||||
|
|
@ -1,14 +1,25 @@
|
||||||
/**
|
/**
|
||||||
* Optional IM sink for key alarms (soft-depends on dsh-im `ctx.dshIm`).
|
* 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 type { Context } from '@deepseek-ai/cordis'
|
||||||
import { formatAlarmPrompt, type KeyAlarmPayload } from './alarm-push.ts'
|
import { formatAlarmPrompt, type KeyAlarmPayload } from './alarm-push.ts'
|
||||||
|
import {
|
||||||
|
normalizeImTarget,
|
||||||
|
resolveImTargets,
|
||||||
|
type ImDeliveryTarget,
|
||||||
|
} from './im-targets.ts'
|
||||||
|
|
||||||
export interface AlarmImDeliveryOptions {
|
export interface AlarmImDeliveryOptions {
|
||||||
enabled: boolean
|
enabled: boolean
|
||||||
botId: string
|
/** Preferred multi-target list (JSON settings or already parsed). */
|
||||||
targetId: string
|
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
|
lang: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -16,11 +27,32 @@ type DshIm = {
|
||||||
send: (botId: string, targetId: string, text: string, opts?: { signal?: AbortSignal }) => Promise<unknown>
|
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.
|
* Send a formatted key-alarm text through proactive IM delivery when configured.
|
||||||
* @param ctx - host cordis context (may lack `dshIm`).
|
* @param ctx - host cordis context (may lack `dshIm`).
|
||||||
* @param payload - matched alarm.
|
* @param payload - matched alarm.
|
||||||
* @param options - settings snapshot for IM sink.
|
* @param options - settings snapshot for IM sink(s).
|
||||||
*/
|
*/
|
||||||
export async function deliverAlarmToIm(
|
export async function deliverAlarmToIm(
|
||||||
ctx: Context,
|
ctx: Context,
|
||||||
|
|
@ -28,11 +60,10 @@ export async function deliverAlarmToIm(
|
||||||
options: AlarmImDeliveryOptions,
|
options: AlarmImDeliveryOptions,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
if (!options.enabled) return
|
if (!options.enabled) return
|
||||||
const botId = options.botId.trim()
|
const targets = resolveTargets(options)
|
||||||
const targetId = options.targetId.trim()
|
if (targets.length === 0) {
|
||||||
if (!botId || !targetId) {
|
|
||||||
ctx.logger.warn(
|
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
|
return
|
||||||
}
|
}
|
||||||
|
|
@ -49,10 +80,24 @@ export async function deliverAlarmToIm(
|
||||||
}
|
}
|
||||||
|
|
||||||
const text = formatAlarmPrompt(payload, options.lang)
|
const text = formatAlarmPrompt(payload, options.lang)
|
||||||
try {
|
const results = await Promise.allSettled(
|
||||||
await im.send(botId, targetId, text)
|
targets.map((target) => im.send(target.botId, target.targetId, text)),
|
||||||
ctx.logger.info('netxops alarm-im: sent to botId=%s targetId=%s', botId, targetId)
|
)
|
||||||
} catch (error) {
|
results.forEach((result, index) => {
|
||||||
ctx.logger.warn('netxops alarm-im: send failed: %s', error)
|
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