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

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

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

View file

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

File diff suppressed because it is too large Load diff