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