From a4993b240776db8e97204afc551255c98c6776fd Mon Sep 17 00:00:00 2001 From: oliver Date: Wed, 9 Sep 2026 22:09:47 +0800 Subject: [PATCH] Add Netx Ops i18n and promote settings to a dedicated section. Localize alarm prompts and UI reason codes for zh/en, and restyle the settings page like uds-auth as its own settings.section. Co-authored-by: Cursor --- lib/client.js | 899 ++++++++++++++++------------- lib/index.js | 111 ++-- package.json | 1 - src/client/NetxopsCard.tsx | 664 +++++++++++---------- src/client/controller.ts | 28 +- src/client/im-delivery-catalog.ts | 6 + src/client/index.ts | 27 +- src/client/locales.ts | 50 +- src/client/sessions-export-view.ts | 66 ++- src/client/styles.ts | 97 ++-- src/index.ts | 1 + src/netx/alarm-push.ts | 45 +- src/netx/alarm-session.ts | 8 +- src/netx/i18n.ts | 90 +++ src/netx/session-export.ts | 3 + src/session-export-shared.ts | 16 + test/alarm-push.test.mjs | 18 + test/i18n.test.mjs | 39 ++ 18 files changed, 1329 insertions(+), 840 deletions(-) create mode 100644 src/netx/i18n.ts create mode 100644 test/i18n.test.mjs diff --git a/lib/client.js b/lib/client.js index 3db25e4..ede14d0 100644 --- a/lib/client.js +++ b/lib/client.js @@ -147,6 +147,7 @@ function asImDeliveryCatalog(value) { available: row.available !== false, options, hint: typeof row.hint === "string" ? row.hint : "", + reasonCode: row.reasonCode === "im_catalog_unavailable" || row.reasonCode === "rpc_failed" ? row.reasonCode : undefined, loading: false }; } @@ -159,6 +160,7 @@ async function fetchImDeliveryCatalog(call, signal) { return { ...EMPTY_IM_DELIVERY_CATALOG, available: false, + reasonCode: "rpc_failed", hint: String(result.error?.message ?? "rpc failed") }; } @@ -243,56 +245,57 @@ function setImTargetSelected(current, target, selected) { } // src/client/styles.ts -var STYLE_ID = "dsh-netxops-card-css"; +var STYLE_ID = "dsh-netxops-settings-css"; var CSS = ` -.dsh-nx-card{list-style:none;border:.5px solid var(--dsw-alias-border-l4);border-radius:16px;background:var(--dsw-alias-bg-layer-3);transition:border-color .16s,background .16s} -.dsh-nx-card:hover{border-color:var(--dsw-alias-label-dimmed)} -.dsh-nx-cardOpen{background:var(--dsw-alias-bg-layer-2);border-color:var(--dsw-alias-label-dimmed)} -.dsh-nx-header{width:100%;appearance:none;border:0;background:none;font:inherit;color:inherit;text-align:left;cursor:pointer;display:flex;align-items:center;gap:12px;padding:14px 16px;border-radius:12px} -.dsh-nx-header:focus-visible{outline:2px solid var(--dsw-alias-brand-primary);outline-offset:-2px} -.dsh-nx-headText{flex:1;min-width:0;display:flex;flex-direction:column;gap:4px} -.dsh-nx-name{font-size:15px;font-weight:600;line-height:1.4;color:var(--dsw-alias-label-primary)} -.dsh-nx-desc{font-size:13px;line-height:1.5;color:var(--dsw-alias-label-tertiary)} -.dsh-nx-pending{flex:none;border-radius:999px;padding:1px 8px;font-size:11px;line-height:17px;font-weight:500;white-space:nowrap;background:var(--dsw-alias-bg-module-platform);color:var(--dsw-alias-label-secondary)} -.dsh-nx-status{flex:none;border-radius:999px;padding:1px 8px;font-size:11px;line-height:17px;font-weight:500;white-space:nowrap;background:var(--dsw-alias-bg-module-platform);color:var(--dsw-alias-label-tertiary)} -.dsh-nx-statusOk{color:var(--dsw-alias-label-primary);background:color-mix(in srgb, var(--dsw-alias-label-primary) 12%, transparent)} -.dsh-nx-statusWarn{color:var(--dsw-alias-label-secondary);background:var(--dsw-alias-bg-module-platform)} -.dsh-nx-statusErr{color:var(--dsw-alias-label-error);background:color-mix(in srgb, var(--dsw-alias-label-error) 12%, transparent)} -.dsh-nx-chevron{flex:none;color:var(--dsw-alias-label-tertiary);transition:transform .16s} -.dsh-nx-chevronOpen{transform:rotate(180deg)} -.dsh-nx-body{border-top:.5px solid var(--dsw-alias-border-l2);margin:0 16px;padding-bottom:8px} -.dsh-nx-readOnly{margin:12px 0 0;font-size:12px;line-height:1.5;color:var(--dsw-alias-label-tertiary)} -.dsh-nx-field{display:flex;flex-direction:column;gap:6px;padding:12px 0} -.dsh-nx-field+.dsh-nx-field{border-top:.5px solid var(--dsw-alias-border-l2)} -.dsh-nx-fieldHead{display:flex;align-items:center;gap:8px} -.dsh-nx-label{flex:1;min-width:0;font-size:13px;font-weight:500;line-height:1.5;color:var(--dsw-alias-label-primary)} -.dsh-nx-badges{display:inline-flex;align-items:center;gap:8px} -.dsh-nx-badge{border-radius:999px;padding:1px 8px;font-size:11px;line-height:17px;white-space:nowrap;font-weight:500;background:var(--dsw-alias-bg-module-platform);color:var(--dsw-alias-label-secondary)} -.dsh-nx-badgeMuted{border-radius:999px;padding:1px 8px;font-size:11px;line-height:17px;white-space:nowrap;color:var(--dsw-alias-label-tertiary)} -.dsh-nx-reset{border:none;background:none;padding:0;font:inherit;font-size:12px;line-height:1.5;color:var(--dsw-alias-label-secondary);cursor:pointer} -.dsh-nx-reset:hover:not(:disabled){color:var(--dsw-alias-label-primary)} -.dsh-nx-input{height:34px;padding:0 12px;border:.5px solid var(--dsw-alias-border-l4);border-radius:8px;background:var(--dsw-alias-bg-layer-3);font:inherit;font-size:13px;line-height:1.5;color:var(--dsw-alias-label-primary)} -.dsh-nx-select{appearance:auto;cursor:pointer} -.dsh-nx-input:focus-visible{outline:none;border-color:var(--dsw-alias-brand-primary)} -.dsh-nx-inputInvalid{border-color:var(--dsw-alias-label-error)} -.dsh-nx-hint{margin:0;font-size:12px;line-height:1.5;color:var(--dsw-alias-label-tertiary)} -.dsh-nx-checkRow{display:flex;align-items:flex-start;gap:10px;font-size:12px;line-height:1.5;color:var(--dsw-alias-label-secondary);cursor:pointer} -.dsh-nx-checkRow input{margin-top:2px;flex:none} -.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-settings{box-sizing:border-box;display:flex;flex-direction:column;gap:20px;min-height:0;padding:4px 4px 24px;color:var(--dsw-alias-label-primary,#1f2329)} +.dsh-nx-settings>header{display:flex;flex-direction:column;gap:4px} +.dsh-nx-settings h2{margin:0;font-size:20px;font-weight:600;line-height:28px} +.dsh-nx-settings-intro{margin:0;font-size:13px;line-height:1.5;color:var(--dsw-alias-label-secondary,#646a73)} +.dsh-nx-settings-card{border:1px solid var(--dsw-alias-border-l2,#dee0e3);border-radius:12px;padding:16px;background:var(--dsw-alias-bg-layer-1,transparent)} +.dsh-nx-settings-card h3{margin:0 0 12px;font-size:15px;font-weight:600;line-height:22px} +.dsh-nx-settings-field{display:flex;flex-direction:column;gap:4px;margin-bottom:12px} +.dsh-nx-settings-field:last-child{margin-bottom:0} +.dsh-nx-settings-field>label,.dsh-nx-field-label{font-size:12px;line-height:18px;color:var(--dsw-alias-label-secondary,#646a73)} +.dsh-nx-field-head{display:flex;align-items:center;justify-content:space-between;gap:8px} +.dsh-nx-field-head>label,.dsh-nx-field-head>.dsh-nx-field-label{flex:1;min-width:0} +.dsh-nx-settings-field input[type=text], +.dsh-nx-settings-field input[type=password], +.dsh-nx-settings-field input:not([type]){ + box-sizing:border-box;width:100%;padding:8px 10px;border:1px solid var(--dsw-alias-border-l2,#dfe1e5);border-radius:6px;font:inherit;font-size:13px;line-height:20px;background:var(--dsw-alias-bg-module-platform,#f4f5f7);color:inherit +} +.dsh-nx-settings-field input:focus-visible{outline:2px solid var(--dsw-alias-state-business-primary,#3370ff);outline-offset:1px} +.dsh-nx-settings-field input:disabled{opacity:.55;cursor:default} +.dsh-nx-inputInvalid{border-color:var(--dsw-alias-state-error-primary,#d54941)!important} +.dsh-nx-hint{margin:0;font-size:12px;line-height:1.5;color:var(--dsw-alias-label-tertiary,#8f959e)} +.dsh-nx-invalid{margin:0;font-size:12px;line-height:1.5;color:var(--dsw-alias-state-error-primary,#d54941)} +.dsh-nx-badges{display:inline-flex;align-items:center;gap:8px;flex:none} +.dsh-nx-badge{border-radius:999px;padding:1px 8px;font-size:11px;line-height:17px;white-space:nowrap;font-weight:500;background:var(--dsw-alias-bg-module-platform,#f4f5f7);color:var(--dsw-alias-label-secondary,#646a73)} +.dsh-nx-badgeMuted{border-radius:999px;padding:1px 8px;font-size:11px;line-height:17px;white-space:nowrap;color:var(--dsw-alias-label-tertiary,#8f959e)} +.dsh-nx-status{border-radius:999px;padding:1px 8px;font-size:11px;line-height:17px;font-weight:500;white-space:nowrap;background:var(--dsw-alias-bg-module-platform,#f4f5f7);color:var(--dsw-alias-label-tertiary,#8f959e)} +.dsh-nx-statusOk{color:var(--dsw-alias-state-success-primary,#20a162);background:rgba(32,161,98,.12)} +.dsh-nx-statusWarn{color:var(--dsw-alias-state-warn-primary,#d97706);background:rgba(217,119,6,.12)} +.dsh-nx-statusErr{color:var(--dsw-alias-state-error-primary,#d54941);background:rgba(213,73,65,.12)} +.dsh-nx-reset{border:none;background:none;padding:0;font:inherit;font-size:12px;line-height:1.5;color:var(--dsw-alias-state-business-primary,#3370ff);cursor:pointer} +.dsh-nx-reset:hover:not(:disabled){opacity:.85} +.dsh-nx-reset:disabled{opacity:.4;cursor:default} +.dsh-nx-checkRow{display:flex;align-items:flex-start;gap:10px;margin:0;font-size:13px;line-height:1.5;color:var(--dsw-alias-label-primary,#1f2329);cursor:pointer} +.dsh-nx-checkRow input{margin-top:3px;flex:none} +.dsh-nx-groupBlock{display:flex;flex-direction:column;gap:8px;padding:4px 0 8px} +.dsh-nx-groupBlock+.dsh-nx-groupBlock{border-top:1px solid var(--dsw-alias-border-l1,#eef0f3);padding-top:12px} +.dsh-nx-groupTitle{font-size:13px;font-weight:600;line-height:1.5;color:var(--dsw-alias-label-primary,#1f2329)} +.dsh-nx-groupChecks{display:flex;flex-direction:column;gap:8px;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} -.dsh-nx-discard{border-color:var(--dsw-alias-border-l2);background:none;color:var(--dsw-alias-label-secondary)} -.dsh-nx-discard:hover:not(:disabled){color:var(--dsw-alias-label-primary);border-color:var(--dsw-alias-label-dimmed)} -.dsh-nx-save{background:var(--dsw-alias-label-primary);color:var(--dsw-alias-bg-layer-3)} +.dsh-nx-settings-actions{display:flex;gap:8px;align-items:center;flex-wrap:wrap;margin-top:4px} +.dsh-nx-settings-msg{font-size:12px;color:var(--dsw-alias-label-secondary,#646a73)} +.dsh-nx-settings-msg.ok{color:var(--dsw-alias-state-success-primary,#20a162)} +.dsh-nx-settings-msg.err{color:var(--dsw-alias-state-error-primary,#d54941)} +.dsh-nx-settings-empty{padding:24px;text-align:center;color:var(--dsw-alias-label-tertiary,#8f959e);font-size:13px} +.dsh-nx-btn{appearance:none;display:inline-flex;align-items:center;justify-content:center;border:1px solid var(--dsw-alias-border-l2,#dfe1e5);border-radius:4px;padding:8px 12px;font:inherit;font-size:13px;line-height:20px;cursor:pointer;background:var(--dsw-alias-bg-module-platform,#f4f5f7);color:var(--dsw-alias-label-primary,#1f2329)} +.dsh-nx-btn:hover:not(:disabled){background:var(--dsw-alias-interactive-bg-hover,#f7f8fa)} .dsh-nx-btn:disabled{opacity:.4;cursor:default} +.dsh-nx-btn-primary{background:var(--dsw-alias-state-business-primary,#3370ff);color:#fff;border-color:transparent} +.dsh-nx-btn-primary:hover:not(:disabled){opacity:.9;background:var(--dsw-alias-state-business-primary,#3370ff)} +.dsh-nx-btn-ghost{background:transparent} `; function ensureStyles() { if (typeof document === "undefined") @@ -305,6 +308,139 @@ function ensureStyles() { document.head.appendChild(tag); } +// 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, + reasonCode: "rpc_unavailable", + reason: "RPC unavailable" +}; +var REASON_CODES = new Set([ + "rpc_unavailable", + "rpc_failed", + "no_persistence", + "no_raw_artifacts", + "list_failed", + "http_failed", + "empty_body" +]); +function asReasonCode(value) { + return typeof value === "string" && REASON_CODES.has(value) ? value : undefined; +} +function sessionsExportReasonLocaleKey(code) { + switch (code) { + case "no_persistence": + return "sessionsExportNoPersistence"; + case "no_raw_artifacts": + return "sessionsExportNoRawArtifacts"; + case "list_failed": + return "sessionsExportListFailed"; + case "rpc_unavailable": + return "sessionsExportRpcUnavailable"; + case "rpc_failed": + return "sessionsExportRpcFailed"; + case "http_failed": + return "sessionsExportHttpFailed"; + case "empty_body": + return "sessionsExportEmpty"; + default: + return "sessionsExportUnavailable"; + } +} +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, + reasonCode: asReasonCode(row.reasonCode), + 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, + reasonCode: "rpc_failed", + 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); + } +} + +class SessionsExportDownloadError extends Error { + code; + status; + detail; + constructor(code, message, extra = {}) { + super(message); + this.name = "SessionsExportDownloadError"; + this.code = code; + this.status = extra.status; + this.detail = extra.detail ?? ""; + } +} +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 SessionsExportDownloadError("http_failed", `Export failed: HTTP ${response.status}${detail === "" ? "" : ` ${detail}`}`, { status: response.status, 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 SessionsExportDownloadError("empty_body", "Export failed: empty ZIP body"); + } + save(blob, filename); + return { + filename, + sessionCount: Number.isFinite(sessionCount) ? sessionCount : 0, + bytes: blob.size + }; +} + // src/client/NetxopsCard.tsx var jsx_runtime = require("react/jsx-runtime"); function phaseLocaleKey(phase) { @@ -337,15 +473,39 @@ function StatusBadge(props) { children: props.label }); } +function fillTemplate(template, vars) { + return template.replace(/\{(\w+)\}/g, (match, name) => Object.prototype.hasOwnProperty.call(vars, name) ? vars[name] : match); +} +function exportStatusMessage(t, status) { + const key = sessionsExportReasonLocaleKey(status.reasonCode); + const template = t(key); + if (key === "sessionsExportListFailed") { + return fillTemplate(template, { detail: status.reason || "" }); + } + return status.reasonCode ? template : status.reason || template; +} +function exportErrorMessage(t, error) { + const key = sessionsExportReasonLocaleKey(error.code); + const template = t(key); + if (error.code === "http_failed") { + const detail = error.detail.trim() === "" ? "" : ` ${error.detail.trim()}`; + return fillTemplate(template, { + status: error.status !== undefined ? String(error.status) : "", + detail + }); + } + if (error.code === "empty_body") + return template; + return error.fallback || template; +} function ValueField(props) { return /* @__PURE__ */ jsx_runtime.jsxs("div", { - className: "dsh-nx-field", + className: "dsh-nx-settings-field", children: [ /* @__PURE__ */ jsx_runtime.jsxs("div", { - className: "dsh-nx-fieldHead", + className: "dsh-nx-field-head", children: [ /* @__PURE__ */ jsx_runtime.jsx("label", { - className: "dsh-nx-label", htmlFor: props.id, children: props.label }), @@ -369,7 +529,7 @@ function ValueField(props) { }), /* @__PURE__ */ jsx_runtime.jsx("input", { id: props.id, - className: props.field.invalid ? "dsh-nx-input dsh-nx-inputInvalid" : "dsh-nx-input", + className: props.field.invalid ? "dsh-nx-inputInvalid" : undefined, type: "text", value: props.field.text, disabled: props.disabled, @@ -395,13 +555,13 @@ function ImDeliveryPicker(props) { 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", + className: "dsh-nx-settings-field", children: [ /* @__PURE__ */ jsx_runtime.jsxs("div", { - className: "dsh-nx-fieldHead", + className: "dsh-nx-field-head", children: [ /* @__PURE__ */ jsx_runtime.jsx("span", { - className: "dsh-nx-label", + className: "dsh-nx-field-label", children: props.labels.target }), selected.length > 0 ? /* @__PURE__ */ jsx_runtime.jsx("span", { @@ -412,7 +572,7 @@ function ImDeliveryPicker(props) { }), 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 + children: !props.catalog.available ? props.catalog.reasonCode ? props.labels.unavailable : props.catalog.hint || props.labels.unavailable : props.labels.none }) : /* @__PURE__ */ jsx_runtime.jsxs("div", { className: "dsh-nx-imTargetList", children: [ @@ -459,7 +619,7 @@ function ImDeliveryPicker(props) { }), !props.catalog.available && options.length > 0 ? /* @__PURE__ */ jsx_runtime.jsx("p", { className: "dsh-nx-hint", - children: props.catalog.hint || props.labels.unavailable + children: props.catalog.reasonCode ? props.labels.unavailable : props.catalog.hint || props.labels.unavailable }) : null ] }); @@ -512,86 +672,127 @@ function CapabilityGroupBlock(props) { ] }); } +function ToggleField(props) { + return /* @__PURE__ */ jsx_runtime.jsxs("div", { + className: "dsh-nx-settings-field", + children: [ + /* @__PURE__ */ jsx_runtime.jsxs("div", { + className: "dsh-nx-field-head", + children: [ + /* @__PURE__ */ jsx_runtime.jsx("label", { + htmlFor: props.id, + children: props.label + }), + /* @__PURE__ */ jsx_runtime.jsxs("span", { + className: "dsh-nx-badges", + children: [ + props.trailing, + props.overridden ? /* @__PURE__ */ jsx_runtime.jsxs(jsx_runtime.Fragment, { + children: [ + /* @__PURE__ */ jsx_runtime.jsx("span", { + className: "dsh-nx-badge", + children: props.overriddenLabel + }), + /* @__PURE__ */ jsx_runtime.jsx("button", { + type: "button", + className: "dsh-nx-reset", + disabled: props.disabled, + onClick: props.onReset, + children: props.resetLabel + }) + ] + }) : null + ] + }) + ] + }), + /* @__PURE__ */ jsx_runtime.jsx("label", { + className: "dsh-nx-checkRow", + htmlFor: props.id, + children: /* @__PURE__ */ jsx_runtime.jsx("input", { + id: props.id, + type: "checkbox", + checked: props.checked, + disabled: props.disabled, + "aria-label": props.label, + onChange: (event) => { + props.onToggle(event.target.checked); + } + }) + }), + props.hint, + props.error + ] + }); +} function NetxopsCard(props) { ensureStyles(); const { t } = props; const state = props.useNetxopsCard((snapshot) => snapshot); - const [open, setOpen] = import_react.useState(false); + const [flash, setFlash] = import_react.useState(null); const saveStarted = import_react.useRef(false); import_react.useEffect(() => { if (state.saving) { saveStarted.current = true; + setFlash(null); return; } if (!saveStarted.current) return; saveStarted.current = false; - if (!state.dirty && !state.failed) - setOpen(false); + if (state.failed) { + setFlash("err"); + return; + } + if (!state.dirty) + setFlash("ok"); }, [state.dirty, state.failed, state.saving]); - if (!state.available) - return null; - const title = t("title"); + if (!state.available) { + return /* @__PURE__ */ jsx_runtime.jsx("section", { + className: "dsh-nx-settings", + "aria-label": t("title"), + children: /* @__PURE__ */ jsx_runtime.jsx("div", { + className: "dsh-nx-settings-empty", + children: t("sessionsExportUnavailable") + }) + }); + } const disabled = !state.writable; const blocked = !state.dirty || state.invalid || state.saving; const pushStatus = state.alarmPushStatus; - const showHeaderStatus = pushStatus !== null && (pushStatus.enabled || pushStatus.phase !== "disabled"); - return /* @__PURE__ */ jsx_runtime.jsxs("li", { - className: open ? "dsh-nx-card dsh-nx-cardOpen" : "dsh-nx-card", + return /* @__PURE__ */ jsx_runtime.jsxs("section", { + className: "dsh-nx-settings", + "aria-label": t("title"), children: [ - /* @__PURE__ */ jsx_runtime.jsxs("button", { - type: "button", - className: "dsh-nx-header", - "aria-expanded": open, - "aria-label": `${t(open ? "collapse" : "expand")}: ${title}`, - onClick: () => { - setOpen(!open); - }, + /* @__PURE__ */ jsx_runtime.jsxs("header", { children: [ - /* @__PURE__ */ jsx_runtime.jsxs("span", { - className: "dsh-nx-headText", - children: [ - /* @__PURE__ */ jsx_runtime.jsx("span", { - className: "dsh-nx-name", - children: title - }), - /* @__PURE__ */ jsx_runtime.jsx("span", { - className: "dsh-nx-desc", - children: t("description") - }) - ] + /* @__PURE__ */ jsx_runtime.jsx("h2", { + children: t("title") }), - showHeaderStatus && pushStatus ? /* @__PURE__ */ jsx_runtime.jsx(StatusBadge, { - phase: pushStatus.phase, - label: t(phaseLocaleKey(pushStatus.phase)) - }) : null, - state.dirty ? /* @__PURE__ */ jsx_runtime.jsx("span", { - className: "dsh-nx-pending", - children: t("unsaved") - }) : null, - /* @__PURE__ */ jsx_runtime.jsx("span", { - className: open ? "dsh-nx-chevron dsh-nx-chevronOpen" : "dsh-nx-chevron", - "aria-hidden": true, - children: "▾" + /* @__PURE__ */ jsx_runtime.jsx("p", { + className: "dsh-nx-settings-intro", + children: t("description") }) ] }), - open ? /* @__PURE__ */ jsx_runtime.jsxs("div", { - className: "dsh-nx-body", + !state.writable ? /* @__PURE__ */ jsx_runtime.jsx("p", { + className: "dsh-nx-hint", + role: "status", + children: t("readOnly") + }) : null, + /* @__PURE__ */ jsx_runtime.jsxs("div", { + className: "dsh-nx-settings-card", children: [ - !state.writable ? /* @__PURE__ */ jsx_runtime.jsx("p", { - className: "dsh-nx-readOnly", - role: "status", - children: t("readOnly") - }) : null, + /* @__PURE__ */ jsx_runtime.jsx("h3", { + children: t("sectionConnection") + }), /* @__PURE__ */ jsx_runtime.jsxs("div", { - className: "dsh-nx-field", + className: "dsh-nx-settings-field", children: [ /* @__PURE__ */ jsx_runtime.jsxs("div", { - className: "dsh-nx-fieldHead", + className: "dsh-nx-field-head", children: [ /* @__PURE__ */ jsx_runtime.jsx("label", { - className: "dsh-nx-label", htmlFor: "netxops-api-token", children: t("apiToken") }), @@ -606,7 +807,6 @@ function NetxopsCard(props) { }), /* @__PURE__ */ jsx_runtime.jsx("input", { id: "netxops-api-token", - className: "dsh-nx-input", type: "password", autoComplete: "off", value: state.apiToken.text, @@ -668,157 +868,93 @@ function NetxopsCard(props) { onReset: () => { props.resetField("nmsProvider"); } + }) + ] + }), + /* @__PURE__ */ jsx_runtime.jsxs("div", { + className: "dsh-nx-settings-card", + children: [ + /* @__PURE__ */ jsx_runtime.jsx("h3", { + children: t("sectionCapabilities") }), - /* @__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("capabilityGroups") - }) - }), - /* @__PURE__ */ jsx_runtime.jsx(CapabilityGroupBlock, { - title: t("groupOps"), - inPresetLabel: t("groupInPreset"), - publicLabel: t("groupPublic"), - inPreset: state.groupOpsInPreset, - published: state.groupOpsPublic, - disabled, - onEditInPreset: (checked) => { - props.edit("groupOpsInPreset", checked ? "true" : "false"); - }, - onEditPublic: (checked) => { - props.edit("groupOpsPublic", checked ? "true" : "false"); - } - }), - /* @__PURE__ */ jsx_runtime.jsx(CapabilityGroupBlock, { - title: t("groupTopology"), - inPresetLabel: t("groupInPreset"), - publicLabel: t("groupPublic"), - inPreset: state.groupTopologyInPreset, - published: state.groupTopologyPublic, - disabled, - onEditInPreset: (checked) => { - props.edit("groupTopologyInPreset", checked ? "true" : "false"); - }, - onEditPublic: (checked) => { - props.edit("groupTopologyPublic", checked ? "true" : "false"); - } - }) - ] + /* @__PURE__ */ jsx_runtime.jsx(CapabilityGroupBlock, { + title: t("groupOps"), + inPresetLabel: t("groupInPreset"), + publicLabel: t("groupPublic"), + inPreset: state.groupOpsInPreset, + published: state.groupOpsPublic, + disabled, + onEditInPreset: (checked) => { + props.edit("groupOpsInPreset", checked ? "true" : "false"); + }, + onEditPublic: (checked) => { + props.edit("groupOpsPublic", checked ? "true" : "false"); + } }), - /* @__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-push", - children: t("alarmPushEnabled") - }), - /* @__PURE__ */ jsx_runtime.jsxs("span", { - className: "dsh-nx-badges", - children: [ - pushStatus ? /* @__PURE__ */ jsx_runtime.jsx(StatusBadge, { - phase: pushStatus.phase, - label: `${t("alarmPushStatus")}: ${t(phaseLocaleKey(pushStatus.phase))}` - }) : null, - state.alarmPushEnabled.overridden ? /* @__PURE__ */ jsx_runtime.jsxs(jsx_runtime.Fragment, { - 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("alarmPushEnabled"); - }, - children: t("reset") - }) - ] - }) : null - ] - }) - ] - }), - /* @__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, - "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", - children: pushStatus.wsUrl - }) : null, - pushStatus?.lastError ? /* @__PURE__ */ jsx_runtime.jsx("p", { - className: "dsh-nx-invalid", - role: "status", - children: pushStatus.lastError - }) : null - ] + /* @__PURE__ */ jsx_runtime.jsx(CapabilityGroupBlock, { + title: t("groupTopology"), + inPresetLabel: t("groupInPreset"), + publicLabel: t("groupPublic"), + inPreset: state.groupTopologyInPreset, + published: state.groupTopologyPublic, + disabled, + onEditInPreset: (checked) => { + props.edit("groupTopologyInPreset", checked ? "true" : "false"); + }, + onEditPublic: (checked) => { + props.edit("groupTopologyPublic", checked ? "true" : "false"); + } + }) + ] + }), + /* @__PURE__ */ jsx_runtime.jsxs("div", { + className: "dsh-nx-settings-card", + children: [ + /* @__PURE__ */ jsx_runtime.jsx("h3", { + children: t("sectionAlarms") }), - /* @__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-dsh", - children: t("alarmDeliverDsh") - }), - state.alarmDeliverDsh.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("alarmDeliverDsh"); - }, - children: t("reset") - }) - ] - }) : null - ] - }), - /* @__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, - "aria-label": t("alarmDeliverDsh"), - onChange: (event) => { - props.edit("alarmDeliverDsh", event.target.checked ? "true" : "false"); - } - }) - }) - ] + /* @__PURE__ */ jsx_runtime.jsx(ToggleField, { + id: "netxops-alarm-push", + label: t("alarmPushEnabled"), + checked: state.alarmPushEnabled.text === "true", + disabled, + overridden: state.alarmPushEnabled.overridden, + overriddenLabel: t("overridden"), + resetLabel: t("reset"), + onToggle: (checked) => { + props.edit("alarmPushEnabled", checked ? "true" : "false"); + }, + onReset: () => { + props.resetField("alarmPushEnabled"); + }, + trailing: pushStatus ? /* @__PURE__ */ jsx_runtime.jsx(StatusBadge, { + phase: pushStatus.phase, + label: `${t("alarmPushStatus")}: ${t(phaseLocaleKey(pushStatus.phase))}` + }) : null, + hint: pushStatus?.wsUrl ? /* @__PURE__ */ jsx_runtime.jsx("p", { + className: "dsh-nx-hint", + children: pushStatus.wsUrl + }) : null, + error: pushStatus?.lastError ? /* @__PURE__ */ jsx_runtime.jsx("p", { + className: "dsh-nx-invalid", + role: "status", + children: pushStatus.lastError + }) : null + }), + /* @__PURE__ */ jsx_runtime.jsx(ToggleField, { + id: "netxops-alarm-dsh", + label: t("alarmDeliverDsh"), + checked: state.alarmDeliverDsh.text === "true", + disabled, + overridden: state.alarmDeliverDsh.overridden, + overriddenLabel: t("overridden"), + resetLabel: t("reset"), + onToggle: (checked) => { + props.edit("alarmDeliverDsh", checked ? "true" : "false"); + }, + onReset: () => { + props.resetField("alarmDeliverDsh"); + } }), /* @__PURE__ */ jsx_runtime.jsx(ImDeliveryPicker, { catalog: state.imDeliveryCatalog, @@ -839,16 +975,21 @@ function NetxopsCard(props) { props.edit("imTargetId", first?.targetId ?? ""); props.edit("alarmDeliverIm", targets.length > 0 ? "true" : "false"); } + }) + ] + }), + /* @__PURE__ */ jsx_runtime.jsxs("div", { + className: "dsh-nx-settings-card", + children: [ + /* @__PURE__ */ jsx_runtime.jsx("h3", { + children: t("sectionExport") }), /* @__PURE__ */ jsx_runtime.jsxs("div", { - className: "dsh-nx-field", + className: "dsh-nx-settings-field", children: [ - /* @__PURE__ */ jsx_runtime.jsx("div", { - className: "dsh-nx-fieldHead", - children: /* @__PURE__ */ jsx_runtime.jsx("span", { - className: "dsh-nx-label", - children: t("sessionsExport") - }) + /* @__PURE__ */ jsx_runtime.jsx("span", { + className: "dsh-nx-field-label", + children: t("sessionsExport") }), state.sessionsExportStatus === null ? /* @__PURE__ */ jsx_runtime.jsx("p", { className: "dsh-nx-hint", @@ -857,14 +998,13 @@ function NetxopsCard(props) { 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") + className: "dsh-nx-hint", + children: exportStatusMessage(t, state.sessionsExportStatus) }), state.sessionsExportError ? /* @__PURE__ */ jsx_runtime.jsx("p", { className: "dsh-nx-invalid", role: "status", - children: state.sessionsExportError + children: exportErrorMessage(t, state.sessionsExportError) }) : null, state.sessionsExportLastFile && !state.sessionsExportBusy && !state.sessionsExportError ? /* @__PURE__ */ jsx_runtime.jsx("p", { className: "dsh-nx-hint", @@ -872,10 +1012,10 @@ function NetxopsCard(props) { children: t("sessionsExportDone").replace("{file}", state.sessionsExportLastFile) }) : null, /* @__PURE__ */ jsx_runtime.jsx("div", { - className: "dsh-nx-exportRow", + className: "dsh-nx-settings-actions", children: /* @__PURE__ */ jsx_runtime.jsx("button", { type: "button", - className: "dsh-nx-btn dsh-nx-export", + className: "dsh-nx-btn", disabled: state.sessionsExportBusy || state.sessionsExportStatus?.available !== true, onClick: () => { props.exportAllSessions(); @@ -884,33 +1024,44 @@ function NetxopsCard(props) { }) }) ] - }), - /* @__PURE__ */ jsx_runtime.jsxs("div", { - className: "dsh-nx-footer", - children: [ - state.failed ? /* @__PURE__ */ jsx_runtime.jsx("p", { - className: "dsh-nx-failed", - role: "status", - children: t("saveFailed") - }) : null, - /* @__PURE__ */ jsx_runtime.jsx("button", { - type: "button", - className: "dsh-nx-btn dsh-nx-discard", - disabled: !state.dirty || state.saving, - onClick: props.discard, - children: t("discard") - }), - /* @__PURE__ */ jsx_runtime.jsx("button", { - type: "button", - className: "dsh-nx-btn dsh-nx-save", - disabled: blocked, - onClick: props.save, - children: t(state.saving ? "saving" : "save") - }) - ] }) ] - }) : null + }), + /* @__PURE__ */ jsx_runtime.jsxs("div", { + className: "dsh-nx-settings-actions", + children: [ + /* @__PURE__ */ jsx_runtime.jsx("button", { + type: "button", + className: "dsh-nx-btn dsh-nx-btn-primary", + disabled: blocked, + onClick: props.save, + children: t(state.saving ? "saving" : "save") + }), + /* @__PURE__ */ jsx_runtime.jsx("button", { + type: "button", + className: "dsh-nx-btn dsh-nx-btn-ghost", + disabled: !state.dirty || state.saving, + onClick: () => { + props.discard(); + setFlash(null); + }, + children: t("discard") + }), + state.dirty && !state.saving ? /* @__PURE__ */ jsx_runtime.jsx("span", { + className: "dsh-nx-settings-msg", + children: t("unsaved") + }) : null, + flash === "ok" && !state.dirty ? /* @__PURE__ */ jsx_runtime.jsx("span", { + className: "dsh-nx-settings-msg ok", + children: t("configSaved") + }) : null, + flash === "err" || state.failed ? /* @__PURE__ */ jsx_runtime.jsx("span", { + className: "dsh-nx-settings-msg err", + role: "status", + children: t("saveFailed") + }) : null + ] + }) ] }); } @@ -1137,91 +1288,6 @@ 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"; @@ -1392,7 +1458,20 @@ class NetxopsCardController { this.sessionsExportLastFile = result.filename; this.sessionsExportError = null; }).catch((error) => { - this.sessionsExportError = error instanceof Error ? error.message : String(error); + if (error instanceof SessionsExportDownloadError) { + this.sessionsExportError = { + code: error.code, + status: error.status, + detail: error.detail, + fallback: error.message + }; + return; + } + this.sessionsExportError = { + code: "http_failed", + detail: "", + fallback: error instanceof Error ? error.message : String(error) + }; }).finally(() => { this.sessionsExportBusy = false; this.exportInFlight = undefined; @@ -1497,6 +1576,10 @@ function refOf(snapshot) { var en = { title: "Netx Ops", description: "API, token, capabilities, and alarm delivery.", + sectionConnection: "Connection", + sectionCapabilities: "Capability groups", + sectionAlarms: "Key-alarm delivery", + sectionExport: "Session export", apiUrl: "API URL", apiUrlHint: "e.g. http://127.0.0.1:8890", lang: "Language", @@ -1531,30 +1614,40 @@ var en = { sessionsExport: "Export all sessions", sessionsExportCount: "{count} sessions", sessionsExportUnavailable: "Export unavailable", + sessionsExportNoPersistence: "Session store unavailable (JSONL backend required).", + sessionsExportNoRawArtifacts: "This persistence backend cannot export raw session files.", + sessionsExportListFailed: "Failed to list sessions: {detail}", + sessionsExportRpcUnavailable: "Cannot reach Host RPC for export status.", + sessionsExportRpcFailed: "Export status RPC failed.", + sessionsExportHttpFailed: "Export failed: HTTP {status}{detail}", + sessionsExportEmpty: "Export failed: empty ZIP body.", sessionsExportButton: "Download ZIP", sessionsExportBusy: "Preparing…", sessionsExportDone: "Downloaded: {file}", overridden: "Overridden", reset: "Reset", invalid: "Invalid", - expand: "Expand", - collapse: "Collapse", - unsaved: "Unsaved", + unsaved: "Unsaved changes", readOnly: "Read-only", saveFailed: "Save failed", discard: "Discard", save: "Save", - saving: "Saving…" + saving: "Saving…", + configSaved: "Saved" }; var zh = { title: "Netx Ops", description: "API、Token、能力组与告警投递。", + sectionConnection: "连接", + sectionCapabilities: "能力组", + sectionAlarms: "关键告警投递", + sectionExport: "会话导出", apiUrl: "API 地址", apiUrlHint: "例如 http://127.0.0.1:8890", lang: "语言", langHint: "zh / en", capabilityGroups: "能力组", - nmsProvider: "NMS provider", + nmsProvider: "NMS 提供方", nmsProviderHint: "zte-ume", groupOps: "ops", groupTopology: "topology", @@ -1583,20 +1676,26 @@ var zh = { sessionsExport: "导出全部会话", sessionsExportCount: "{count} 个会话", sessionsExportUnavailable: "无法导出", + sessionsExportNoPersistence: "会话持久化不可用(需要 JSONL 后端)。", + sessionsExportNoRawArtifacts: "当前持久化后端不支持导出原始会话文件。", + sessionsExportListFailed: "列出会话失败:{detail}", + sessionsExportRpcUnavailable: "无法通过 Host RPC 查询导出状态。", + sessionsExportRpcFailed: "导出状态 RPC 失败。", + sessionsExportHttpFailed: "导出失败:HTTP {status}{detail}", + sessionsExportEmpty: "导出失败:ZIP 为空。", sessionsExportButton: "下载 ZIP", sessionsExportBusy: "准备中…", sessionsExportDone: "已下载:{file}", overridden: "已覆盖", reset: "重置", invalid: "无效", - expand: "展开", - collapse: "收起", - unsaved: "未保存", + unsaved: "有未保存更改", readOnly: "只读", saveFailed: "保存失败", discard: "丢弃", save: "保存", - saving: "保存中…" + saving: "保存中…", + configSaved: "已保存" }; // src/client/index.ts @@ -1608,8 +1707,20 @@ var inject = [ "settingsScope" ]; function apply(ctx) { - ctx.effect(() => ctx.locale.register(LOCALE_NS, { zh, en }), "netxops: locales"); + ctx.effect(() => { + try { + return ctx.locale.register(LOCALE_NS, { zh, en }); + } catch { + const offZh = ctx.locale.register(LOCALE_NS, "zh", zh); + const offEn = ctx.locale.register(LOCALE_NS, "en", en); + return () => { + offZh?.(); + offEn?.(); + }; + } + }, "netxops: locales"); const card = new NetxopsCardController(ctx.settingsScope.bind({ namespace: NETXOPS_NS }), ctx); + const t = ctx.locale.bind(LOCALE_NS); ctx.inject(["remote.credentials"], (credCtx) => { card.setCredentialsAvailable(true); credCtx.effect(() => { @@ -1633,9 +1744,11 @@ function apply(ctx) { card.setAlarmPushRpc(undefined); }, "netxops: clear alarm-push rpc"); }); - ctx.slots.inject("settings.plugin.item", () => ctx.slots.register({ - name: "settings.plugin.item", - key: NETXOPS_NS, + ctx.slots.inject("settings.section", () => ctx.slots.register({ + name: "settings.section", + id: NETXOPS_NS, + order: 24, + label: () => t("title"), locale: LOCALE_NS, inject: () => card.inject() }, NetxopsCard)); diff --git a/lib/index.js b/lib/index.js index 2fb34f3..4b89ca2 100644 --- a/lib/index.js +++ b/lib/index.js @@ -50,6 +50,63 @@ function resetAlarmPushStatus() { publishAlarmPushStatus(emptyStatus({ phase: "disabled", enabled: false })); } +// src/netx/i18n.ts +var ZH = { + "alarm.sessionTitle": "Netx 关键告警", + "alarm.defaultLabel": "关键告警", + "alarm.action.inserted": "告警产生", + "alarm.action.updated": "告警更新", + "alarm.action.deleted": "告警清除", + "alarm.action.fallback": "告警", + "alarm.field.device": "设备", + "alarm.field.object": "对象", + "alarm.field.severity": "级别", + "alarm.field.cause": "原因", + "alarm.field.time": "时间", + "alarm.analyzeHint": "请分析这条关键告警并给出下一步运维建议。" +}; +var EN = { + "alarm.sessionTitle": "Netx key alarm", + "alarm.defaultLabel": "Key alarm", + "alarm.action.inserted": "Alarm Raised", + "alarm.action.updated": "Alarm Updated", + "alarm.action.deleted": "Alarm Cleared", + "alarm.action.fallback": "Alarm", + "alarm.field.device": "Device", + "alarm.field.object": "Object", + "alarm.field.severity": "Severity", + "alarm.field.cause": "Cause", + "alarm.field.time": "Time", + "alarm.analyzeHint": "Please analyze this key alarm and suggest next ops steps." +}; +var TABLES = { + zh: ZH, + en: EN +}; +function normalizeNetxLocale(lang) { + const normalized = String(lang ?? "").trim().toLowerCase(); + if (normalized === "english" || /^en(?:[-_].*)?$/u.test(normalized)) + return "en"; + return "zh"; +} +function tHost(key, lang = "zh", vars) { + const locale = normalizeNetxLocale(lang); + const template = TABLES[locale][key] ?? TABLES.zh[key] ?? key; + if (vars === undefined) + return template; + return template.replace(/\{(\w+)\}/g, (match, name) => Object.prototype.hasOwnProperty.call(vars, name) ? String(vars[name]) : match); +} +function alarmActionLabel(action, lang) { + const normalized = String(action ?? "").trim().toLowerCase(); + if (normalized === "inserted") + return tHost("alarm.action.inserted", lang); + if (normalized === "updated") + return tHost("alarm.action.updated", lang); + if (normalized === "deleted") + return tHost("alarm.action.deleted", lang); + return normalized || tHost("alarm.action.fallback", lang); +} + // src/netx/alarm-push.ts function alarmSubscribeUrl(apiUrl) { const trimmed = apiUrl.trim().replace(/\/$/, ""); @@ -69,16 +126,6 @@ function alarmSubscribeUrl(apiUrl) { } function formatAlarmPrompt(payload, lang = "zh") { const action = String(payload.action ?? "").trim().toLowerCase(); - const actionZh = { - inserted: "告警产生", - updated: "告警更新", - deleted: "告警清除" - }; - const actionEn = { - inserted: "Alarm Raised", - updated: "Alarm Updated", - deleted: "Alarm Cleared" - }; const ne = payload.ne && typeof payload.ne === "object" ? payload.ne : {}; const host = String(ne.host_name ?? "").trim(); const ip = String(ne.ip_address ?? "").trim(); @@ -86,33 +133,18 @@ function formatAlarmPrompt(payload, lang = "zh") { let device = host || neName || String(payload.ne_id ?? "").trim() || "-"; if (ip) device = device === "-" ? ip : `${device} (${ip})`; - const label = String(payload.rule_label ?? payload.native_probable_cause ?? "关键告警").trim(); - if (lang.startsWith("en")) { - return [ - `[NMS ${actionEn[action] ?? (action || "Alarm")}] ${label}`, - `Device: ${device}`, - `Object: ${String(payload.object_name ?? "-").trim()}`, - `Severity: ${String(payload.perceived_severity ?? "-").trim()}`, - `Cause: ${String(payload.native_probable_cause ?? "-").trim()}`, - `Time: ${String(payload.time_created ?? "-").trim()}`, - `notificationId: ${String(payload.notification_id ?? "-").trim()}`, - `alarm_key: ${String(payload.alarm_key ?? "-").trim()}`, - "", - "Please analyze this key alarm and suggest next ops steps." - ].join(` -`); - } + const label = String(payload.rule_label ?? payload.native_probable_cause ?? tHost("alarm.defaultLabel", lang)).trim(); return [ - `[NMS ${actionZh[action] ?? (action || "告警")}] ${label}`, - `设备: ${device}`, - `对象: ${String(payload.object_name ?? "-").trim()}`, - `级别: ${String(payload.perceived_severity ?? "-").trim()}`, - `原因: ${String(payload.native_probable_cause ?? "-").trim()}`, - `时间: ${String(payload.time_created ?? "-").trim()}`, + `[NMS ${alarmActionLabel(action, lang)}] ${label}`, + `${tHost("alarm.field.device", lang)}: ${device}`, + `${tHost("alarm.field.object", lang)}: ${String(payload.object_name ?? "-").trim()}`, + `${tHost("alarm.field.severity", lang)}: ${String(payload.perceived_severity ?? "-").trim()}`, + `${tHost("alarm.field.cause", lang)}: ${String(payload.native_probable_cause ?? "-").trim()}`, + `${tHost("alarm.field.time", lang)}: ${String(payload.time_created ?? "-").trim()}`, `notificationId: ${String(payload.notification_id ?? "-").trim()}`, `alarm_key: ${String(payload.alarm_key ?? "-").trim()}`, "", - "请分析这条关键告警并给出下一步运维建议。" + tHost("alarm.analyzeHint", lang) ].join(` `); } @@ -368,7 +400,6 @@ import { homedir } from "node:os"; import { join } from "node:path"; var PRESET_ID = "netxops"; var PERMISSION_PRESET = "default"; -var TITLE = "Netx 关键告警"; var sticky = null; var deliveryChain = Promise.resolve(); function resolveWorkspacePath() { @@ -402,7 +433,7 @@ async function deliverAlarmToSession(ctx, payload, lang = "zh") { await disposeSticky(previous); } } - await createStickySession(ctx, prompt); + await createStickySession(ctx, prompt, lang); }; const next = deliveryChain.then(run, run); deliveryChain = next.then(() => { @@ -440,7 +471,7 @@ async function followup(ctx, agent, prompt) { throw error; } } -async function createStickySession(ctx, prompt) { +async function createStickySession(ctx, prompt, lang = "zh") { const c = ctx; const agents = c.agents; const agentPresets = c.agentPresets; @@ -485,7 +516,7 @@ async function createStickySession(ctx, prompt) { permissionPresets.set(handle.agent.session, PERMISSION_PRESET); } if (sessionTitle && typeof sessionTitle.rename === "function") { - sessionTitle.rename(handle.agent.session, TITLE); + sessionTitle.rename(handle.agent.session, tHost("alarm.sessionTitle", lang)); } await followup(ctx, handle.agent, prompt); sticky = { @@ -777,7 +808,7 @@ function watchNetxConnection(listener) { // src/netx/session-export.ts import { homedir as homedir2, hostname as osHostname } from "node:os"; -// node_modules/fflate/esm/index.mjs +// node_modules/.pnpm/fflate@0.8.3/node_modules/fflate/esm/index.mjs import { createRequire as createRequire2 } from "module"; var require2 = createRequire2("/"); var _a; @@ -1629,6 +1660,7 @@ async function getSessionsExportStatus(ctx, signal) { available: false, sessionCount: 0, supportsRawArtifacts: false, + reasonCode: "no_persistence", reason: "sessionPersistence unavailable — mount a JSONL session backend (web profile default)" }; } @@ -1637,6 +1669,7 @@ async function getSessionsExportStatus(ctx, signal) { available: false, sessionCount: 0, supportsRawArtifacts: false, + reasonCode: "no_raw_artifacts", reason: "persistence backend does not expose per-session raw artifacts (SQLite export unsupported)" }; } @@ -1653,6 +1686,7 @@ async function getSessionsExportStatus(ctx, signal) { available: false, sessionCount: 0, supportsRawArtifacts: true, + reasonCode: "list_failed", reason: error instanceof Error ? error.message : String(error) }; } @@ -3571,6 +3605,7 @@ function apply(ctx, config = Config({})) { value: { available: false, options: [], + reasonCode: "im_catalog_unavailable", hint: "dsh-im-ops missing or outdated — install ≥ops.24 for delivery picker" } }; diff --git a/package.json b/package.json index 3b05387..ea75791 100644 --- a/package.json +++ b/package.json @@ -64,7 +64,6 @@ "inject": [ "@deepseek-ai/dsh-client-locale", "@deepseek-ai/dsh-client-ui-settings", - "@deepseek-ai/dsh-client-ui-settings-plugins", "@deepseek-ai/dsh-api-remotes" ] } diff --git a/src/client/NetxopsCard.tsx b/src/client/NetxopsCard.tsx index 549f070..b1edd99 100644 --- a/src/client/NetxopsCard.tsx +++ b/src/client/NetxopsCard.tsx @@ -1,8 +1,8 @@ /** - * Netx Ops Plugins settings card — apiUrl / lang + credential token. + * Netx Ops settings section — uds-auth visual language (page header + cards). */ -import { useEffect, useRef, useState } from 'react' +import { useEffect, useRef, useState, type ReactNode } from 'react' import type { InjectFace, PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots' import type { NetxopsCardFace, NetxopsCardState } from './controller.ts' import type { NetxopsLocaleKey } from './locales.ts' @@ -20,7 +20,8 @@ import { } from '../netx/im-targets.ts' import { alarmPushTone, type AlarmPushPhase } from './alarm-push-status-view.ts' import { ensureStyles } from './styles.ts' -import type {} from '@deepseek-ai/dsh-client-ui-settings-plugins/client' +import { sessionsExportReasonLocaleKey } from './sessions-export-view.ts' +import type {} from '@deepseek-ai/dsh-client-ui-settings/client' function phaseLocaleKey(phase: AlarmPushPhase): NetxopsLocaleKey { switch (phase) { @@ -51,8 +52,43 @@ function StatusBadge(props: { return {props.label} } +function fillTemplate(template: string, vars: Record): string { + return template.replace(/\{(\w+)\}/g, (match, name: string) => ( + Object.prototype.hasOwnProperty.call(vars, name) ? vars[name]! : match + )) +} + +function exportStatusMessage( + t: (key: NetxopsLocaleKey) => string, + status: NonNullable, +): string { + const key = sessionsExportReasonLocaleKey(status.reasonCode) + const template = t(key) + if (key === 'sessionsExportListFailed') { + return fillTemplate(template, { detail: status.reason || '' }) + } + return status.reasonCode ? template : (status.reason || template) +} + +function exportErrorMessage( + t: (key: NetxopsLocaleKey) => string, + error: NonNullable, +): string { + const key = sessionsExportReasonLocaleKey(error.code) + const template = t(key) + if (error.code === 'http_failed') { + const detail = error.detail.trim() === '' ? '' : ` ${error.detail.trim()}` + return fillTemplate(template, { + status: error.status !== undefined ? String(error.status) : '', + detail, + }) + } + if (error.code === 'empty_body') return template + return error.fallback || template +} + export type NetxopsCardProps = - PropsRuntime<'settings.plugin.item'> + PropsRuntime<'settings.section'> & PropsLocale<'settings.netxops'> & InjectFace @@ -69,9 +105,9 @@ function ValueField(props: { onReset: () => void }) { return ( -
-
- +
+
+ {props.field.overridden ? ( @@ -85,7 +121,7 @@ function ValueField(props: {
imTargetKey(row.botId, row.targetId))) - - // Orphan legacy / JSON targets not present in the live catalog still show as checked rows. const orphanSelected = selected.filter( (row) => !options.some( (opt) => imCatalogOptionKey(opt.botId, opt.targetId) === imTargetKey(row.botId, row.targetId), @@ -129,9 +163,9 @@ function ImDeliveryPicker(props: { ) return ( -
-
- {props.labels.target} +
+
+ {props.labels.target} {selected.length > 0 ? ( @@ -144,7 +178,9 @@ function ImDeliveryPicker(props: { ? (

{!props.catalog.available - ? (props.catalog.hint || props.labels.unavailable) + ? (props.catalog.reasonCode + ? props.labels.unavailable + : (props.catalog.hint || props.labels.unavailable)) : props.labels.none}

) @@ -190,7 +226,13 @@ function ImDeliveryPicker(props: {
)} {!props.catalog.available && options.length > 0 - ?

{props.catalog.hint || props.labels.unavailable}

+ ? ( +

+ {props.catalog.reasonCode + ? props.labels.unavailable + : (props.catalog.hint || props.labels.unavailable)} +

+ ) : null}
) @@ -233,312 +275,334 @@ function CapabilityGroupBlock(props: { ) } +function ToggleField(props: { + id: string + label: string + checked: boolean + disabled: boolean + overridden: boolean + overriddenLabel: string + resetLabel: string + onToggle: (checked: boolean) => void + onReset: () => void + trailing?: ReactNode + hint?: ReactNode + error?: ReactNode +}) { + return ( +
+
+ + + {props.trailing} + {props.overridden + ? ( + <> + {props.overriddenLabel} + + + ) + : null} + +
+ + {props.hint} + {props.error} +
+ ) +} + export function NetxopsCard(props: NetxopsCardProps) { ensureStyles() const { t } = props const state: NetxopsCardState = props.useNetxopsCard(snapshot => snapshot) - const [open, setOpen] = useState(false) + const [flash, setFlash] = useState<'ok' | 'err' | null>(null) const saveStarted = useRef(false) useEffect(() => { if (state.saving) { saveStarted.current = true + setFlash(null) return } if (!saveStarted.current) return saveStarted.current = false - if (!state.dirty && !state.failed) setOpen(false) + if (state.failed) { + setFlash('err') + return + } + if (!state.dirty) setFlash('ok') }, [state.dirty, state.failed, state.saving]) - if (!state.available) return null + if (!state.available) { + return ( +
+
{t('sessionsExportUnavailable')}
+
+ ) + } - const title = t('title' satisfies NetxopsLocaleKey) const disabled = !state.writable const blocked = !state.dirty || state.invalid || state.saving const pushStatus = state.alarmPushStatus - const showHeaderStatus = pushStatus !== null - && (pushStatus.enabled || pushStatus.phase !== 'disabled') return ( -
  • - - {open - ? ( -
    - {!state.writable ?

    {t('readOnly')}

    : null} -
    -
    - - - - {state.apiTokenConfigured ? t('apiTokenSet') : t('apiTokenUnset')} - - -
    - { props.edit('apiToken', event.target.value) }} - /> -

    - {state.apiTokenRemoteReady ? t('apiTokenHint') : t('apiTokenUnavailable')} -

    -
    - { props.edit('apiUrl', text) }} - onReset={() => { props.resetField('apiUrl') }} - /> - { props.edit('lang', text) }} - onReset={() => { props.resetField('lang') }} - /> - { props.edit('nmsProvider', text) }} - onReset={() => { props.resetField('nmsProvider') }} - /> -
    -
    - {t('capabilityGroups')} -
    - { - props.edit('groupOpsInPreset', checked ? 'true' : 'false') - }} - onEditPublic={(checked) => { - props.edit('groupOpsPublic', checked ? 'true' : 'false') - }} - /> - { - props.edit('groupTopologyInPreset', checked ? 'true' : 'false') - }} - onEditPublic={(checked) => { - props.edit('groupTopologyPublic', checked ? 'true' : 'false') - }} - /> -
    -
    -
    - - - {pushStatus - ? ( - - ) - : null} - {state.alarmPushEnabled.overridden - ? ( - <> - {t('overridden')} - - - ) - : null} - -
    - - {pushStatus?.wsUrl - ?

    {pushStatus.wsUrl}

    - : null} - {pushStatus?.lastError - ?

    {pushStatus.lastError}

    - : null} -
    -
    -
    - - {state.alarmDeliverDsh.overridden - ? ( - - {t('overridden')} - - - ) - : null} -
    - -
    - { - 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') - }} - /> -
    -
    - {t('sessionsExport')} -
    - {state.sessionsExportStatus === null - ?

    {t('sessionsExportUnavailable')}

    - : state.sessionsExportStatus.available - ? ( -

    - {t('sessionsExportCount').replace( - '{count}', - String(state.sessionsExportStatus.sessionCount), - )} -

    - ) - : ( -

    - {state.sessionsExportStatus.reason || t('sessionsExportUnavailable')} -

    - )} - {state.sessionsExportError - ?

    {state.sessionsExportError}

    - : null} - {state.sessionsExportLastFile && !state.sessionsExportBusy && !state.sessionsExportError - ? ( -

    - {t('sessionsExportDone').replace('{file}', state.sessionsExportLastFile)} -

    - ) - : null} -
    - -
    -
    -
    - {state.failed ?

    {t('saveFailed')}

    : null} - - -
    -
    - ) +
    +
    +

    {t('title')}

    +

    {t('description')}

    +
    + + {!state.writable + ?

    {t('readOnly')}

    : null} -
  • + +
    +

    {t('sectionConnection')}

    +
    +
    + + + + {state.apiTokenConfigured ? t('apiTokenSet') : t('apiTokenUnset')} + + +
    + { props.edit('apiToken', event.target.value) }} + /> +

    + {state.apiTokenRemoteReady ? t('apiTokenHint') : t('apiTokenUnavailable')} +

    +
    + { props.edit('apiUrl', text) }} + onReset={() => { props.resetField('apiUrl') }} + /> + { props.edit('lang', text) }} + onReset={() => { props.resetField('lang') }} + /> + { props.edit('nmsProvider', text) }} + onReset={() => { props.resetField('nmsProvider') }} + /> +
    + +
    +

    {t('sectionCapabilities')}

    + { + props.edit('groupOpsInPreset', checked ? 'true' : 'false') + }} + onEditPublic={(checked) => { + props.edit('groupOpsPublic', checked ? 'true' : 'false') + }} + /> + { + props.edit('groupTopologyInPreset', checked ? 'true' : 'false') + }} + onEditPublic={(checked) => { + props.edit('groupTopologyPublic', checked ? 'true' : 'false') + }} + /> +
    + +
    +

    {t('sectionAlarms')}

    + { + props.edit('alarmPushEnabled', checked ? 'true' : 'false') + }} + onReset={() => { props.resetField('alarmPushEnabled') }} + trailing={pushStatus + ? ( + + ) + : null} + hint={pushStatus?.wsUrl ?

    {pushStatus.wsUrl}

    : null} + error={pushStatus?.lastError + ?

    {pushStatus.lastError}

    + : null} + /> + { + props.edit('alarmDeliverDsh', checked ? 'true' : 'false') + }} + onReset={() => { props.resetField('alarmDeliverDsh') }} + /> + { + 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') + }} + /> +
    + +
    +

    {t('sectionExport')}

    +
    + {t('sessionsExport')} + {state.sessionsExportStatus === null + ?

    {t('sessionsExportUnavailable')}

    + : state.sessionsExportStatus.available + ? ( +

    + {t('sessionsExportCount').replace( + '{count}', + String(state.sessionsExportStatus.sessionCount), + )} +

    + ) + : ( +

    + {exportStatusMessage(t, state.sessionsExportStatus)} +

    + )} + {state.sessionsExportError + ? ( +

    + {exportErrorMessage(t, state.sessionsExportError)} +

    + ) + : null} + {state.sessionsExportLastFile && !state.sessionsExportBusy && !state.sessionsExportError + ? ( +

    + {t('sessionsExportDone').replace('{file}', state.sessionsExportLastFile)} +

    + ) + : null} +
    + +
    +
    +
    + +
    + + + {state.dirty && !state.saving + ? {t('unsaved')} + : null} + {flash === 'ok' && !state.dirty + ? {t('configSaved')} + : null} + {(flash === 'err' || state.failed) + ? {t('saveFailed')} + : null} +
    + ) } diff --git a/src/client/controller.ts b/src/client/controller.ts index 8a9270b..d57a292 100644 --- a/src/client/controller.ts +++ b/src/client/controller.ts @@ -23,8 +23,10 @@ import { import { downloadAllSessionsExport, fetchSessionsExportStatus, + SessionsExportDownloadError, type SessionsExportStatus, } from './sessions-export-view.ts' +import type { SessionsExportReasonCode } from '../session-export-shared.ts' export const NETXOPS_NS = 'netxops' const DEFAULT_TOKEN_REF = 'NETX_API_TOKEN' @@ -82,8 +84,13 @@ export interface NetxopsCardState extends CardShell { sessionsExportStatus: SessionsExportStatus | null /** In-flight bulk export download. */ sessionsExportBusy: boolean - /** Last bulk export error message, if any. */ - sessionsExportError: string | null + /** Last bulk export error (stable code + optional detail for locale templates). */ + sessionsExportError: { + code: SessionsExportReasonCode + status?: number + detail: string + fallback: string + } | null /** Last successful download filename. */ sessionsExportLastFile: string | null } @@ -115,7 +122,7 @@ export class NetxopsCardController { private imDeliveryCatalog: ImDeliveryCatalog = { ...EMPTY_IM_DELIVERY_CATALOG } private sessionsExportStatus: SessionsExportStatus | null = null private sessionsExportBusy = false - private sessionsExportError: string | null = null + private sessionsExportError: NetxopsCardState['sessionsExportError'] = null private sessionsExportLastFile: string | null = null private pollTimer: ReturnType | undefined private pollInFlight = false @@ -301,7 +308,20 @@ export class NetxopsCardController { this.sessionsExportError = null }) .catch((error: unknown) => { - this.sessionsExportError = error instanceof Error ? error.message : String(error) + if (error instanceof SessionsExportDownloadError) { + this.sessionsExportError = { + code: error.code, + status: error.status, + detail: error.detail, + fallback: error.message, + } + return + } + this.sessionsExportError = { + code: 'http_failed', + detail: '', + fallback: error instanceof Error ? error.message : String(error), + } }) .finally(() => { this.sessionsExportBusy = false diff --git a/src/client/im-delivery-catalog.ts b/src/client/im-delivery-catalog.ts index 60dc9eb..8877d86 100644 --- a/src/client/im-delivery-catalog.ts +++ b/src/client/im-delivery-catalog.ts @@ -21,6 +21,8 @@ export interface ImDeliveryCatalog { available: boolean options: ImDeliveryCatalogOption[] hint: string + /** Stable code so the card can use locale keys instead of English hints. */ + reasonCode?: 'im_catalog_unavailable' | 'rpc_failed' loading: boolean } @@ -59,6 +61,9 @@ export function asImDeliveryCatalog(value: unknown): ImDeliveryCatalog { available: row.available !== false, options, hint: typeof row.hint === 'string' ? row.hint : '', + reasonCode: row.reasonCode === 'im_catalog_unavailable' || row.reasonCode === 'rpc_failed' + ? row.reasonCode + : undefined, loading: false, } } @@ -79,6 +84,7 @@ export async function fetchImDeliveryCatalog( return { ...EMPTY_IM_DELIVERY_CATALOG, available: false, + reasonCode: 'rpc_failed', hint: String((result as { error?: { message?: string } }).error?.message ?? 'rpc failed'), } } diff --git a/src/client/index.ts b/src/client/index.ts index a84b039..0f16ad0 100644 --- a/src/client/index.ts +++ b/src/client/index.ts @@ -1,5 +1,5 @@ /** - * Browser half — Settings → Plugins → Netx Ops card. + * Browser half — Settings → Netx Ops section (uds-auth-style page). * * Do not hard-inject `remote.credentials`: shipped `@deepseek-ai/dsh` 0.1.1-rc.2 * remotes assembly does not mount that namespace (Models/Plugins cards only @@ -11,7 +11,6 @@ import type {} from '@deepseek-ai/dsh-client-locale/client' import type {} from '@deepseek-ai/dsh-client-ui-settings/client' import type {} from '@deepseek-ai/dsh-client-ui-renderer/client' import type {} from '@deepseek-ai/dsh-api-remotes/client' -import type {} from '@deepseek-ai/dsh-client-ui-settings-plugins/client' import { NetxopsCard } from './NetxopsCard.tsx' import { NETXOPS_NS, NetxopsCardController } from './controller.ts' import { en, zh, type NetxopsLocaleKey } from './locales.ts' @@ -24,7 +23,7 @@ declare module '@deepseek-ai/dsh-client-ui-slots' { const LOCALE_NS = 'settings.netxops' -/** Match shipped ui-settings-plugins inject (no remote.credentials). */ +/** Match shipped settings shell inject (no remote.credentials). */ export const inject = [ 'slots', 'locale', @@ -33,12 +32,24 @@ export const inject = [ ] export function apply(ctx: ClientContext): void { - ctx.effect(() => ctx.locale.register(LOCALE_NS, { zh, en }), 'netxops: locales') + ctx.effect(() => { + try { + return ctx.locale.register(LOCALE_NS, { zh, en }) + } catch { + const offZh = ctx.locale.register(LOCALE_NS, 'zh', zh) + const offEn = ctx.locale.register(LOCALE_NS, 'en', en) + return () => { + offZh?.() + offEn?.() + } + } + }, 'netxops: locales') const card = new NetxopsCardController( ctx.settingsScope.bind({ namespace: NETXOPS_NS }), ctx, ) + const t = ctx.locale.bind(LOCALE_NS) as (key: NetxopsLocaleKey) => string // Optional: newer remotes that mount credentials unlock the token field. ctx.inject(['remote.credentials'], (credCtx) => { @@ -67,9 +78,11 @@ export function apply(ctx: ClientContext): void { }, 'netxops: clear alarm-push rpc') }) - ctx.slots.inject('settings.plugin.item', () => ctx.slots.register({ - name: 'settings.plugin.item', - key: NETXOPS_NS, + ctx.slots.inject('settings.section', () => ctx.slots.register({ + name: 'settings.section', + id: NETXOPS_NS, + order: 24, + label: () => t('title'), locale: LOCALE_NS, inject: () => card.inject(), }, NetxopsCard)) diff --git a/src/client/locales.ts b/src/client/locales.ts index 0780e28..f107849 100644 --- a/src/client/locales.ts +++ b/src/client/locales.ts @@ -1,8 +1,12 @@ -/** Locale keys for the Netx Ops Plugins settings card. */ +/** Locale keys for the Netx Ops settings section. */ export type NetxopsLocaleKey = | 'title' | 'description' + | 'sectionConnection' + | 'sectionCapabilities' + | 'sectionAlarms' + | 'sectionExport' | 'apiUrl' | 'apiUrlHint' | 'lang' @@ -37,24 +41,34 @@ export type NetxopsLocaleKey = | 'sessionsExport' | 'sessionsExportCount' | 'sessionsExportUnavailable' + | 'sessionsExportNoPersistence' + | 'sessionsExportNoRawArtifacts' + | 'sessionsExportListFailed' + | 'sessionsExportRpcUnavailable' + | 'sessionsExportRpcFailed' + | 'sessionsExportHttpFailed' + | 'sessionsExportEmpty' | 'sessionsExportButton' | 'sessionsExportBusy' | 'sessionsExportDone' | 'overridden' | 'reset' | 'invalid' - | 'expand' - | 'collapse' | 'unsaved' | 'readOnly' | 'saveFailed' | 'discard' | 'save' | 'saving' + | 'configSaved' export const en: Record = { title: 'Netx Ops', description: 'API, token, capabilities, and alarm delivery.', + sectionConnection: 'Connection', + sectionCapabilities: 'Capability groups', + sectionAlarms: 'Key-alarm delivery', + sectionExport: 'Session export', apiUrl: 'API URL', apiUrlHint: 'e.g. http://127.0.0.1:8890', lang: 'Language', @@ -89,31 +103,41 @@ export const en: Record = { sessionsExport: 'Export all sessions', sessionsExportCount: '{count} sessions', sessionsExportUnavailable: 'Export unavailable', + sessionsExportNoPersistence: 'Session store unavailable (JSONL backend required).', + sessionsExportNoRawArtifacts: 'This persistence backend cannot export raw session files.', + sessionsExportListFailed: 'Failed to list sessions: {detail}', + sessionsExportRpcUnavailable: 'Cannot reach Host RPC for export status.', + sessionsExportRpcFailed: 'Export status RPC failed.', + sessionsExportHttpFailed: 'Export failed: HTTP {status}{detail}', + sessionsExportEmpty: 'Export failed: empty ZIP body.', sessionsExportButton: 'Download ZIP', sessionsExportBusy: 'Preparing…', sessionsExportDone: 'Downloaded: {file}', overridden: 'Overridden', reset: 'Reset', invalid: 'Invalid', - expand: 'Expand', - collapse: 'Collapse', - unsaved: 'Unsaved', + unsaved: 'Unsaved changes', readOnly: 'Read-only', saveFailed: 'Save failed', discard: 'Discard', save: 'Save', saving: 'Saving…', + configSaved: 'Saved', } export const zh: Record = { title: 'Netx Ops', description: 'API、Token、能力组与告警投递。', + sectionConnection: '连接', + sectionCapabilities: '能力组', + sectionAlarms: '关键告警投递', + sectionExport: '会话导出', apiUrl: 'API 地址', apiUrlHint: '例如 http://127.0.0.1:8890', lang: '语言', langHint: 'zh / en', capabilityGroups: '能力组', - nmsProvider: 'NMS provider', + nmsProvider: 'NMS 提供方', nmsProviderHint: 'zte-ume', groupOps: 'ops', groupTopology: 'topology', @@ -142,18 +166,24 @@ export const zh: Record = { sessionsExport: '导出全部会话', sessionsExportCount: '{count} 个会话', sessionsExportUnavailable: '无法导出', + sessionsExportNoPersistence: '会话持久化不可用(需要 JSONL 后端)。', + sessionsExportNoRawArtifacts: '当前持久化后端不支持导出原始会话文件。', + sessionsExportListFailed: '列出会话失败:{detail}', + sessionsExportRpcUnavailable: '无法通过 Host RPC 查询导出状态。', + sessionsExportRpcFailed: '导出状态 RPC 失败。', + sessionsExportHttpFailed: '导出失败:HTTP {status}{detail}', + sessionsExportEmpty: '导出失败:ZIP 为空。', sessionsExportButton: '下载 ZIP', sessionsExportBusy: '准备中…', sessionsExportDone: '已下载:{file}', overridden: '已覆盖', reset: '重置', invalid: '无效', - expand: '展开', - collapse: '收起', - unsaved: '未保存', + unsaved: '有未保存更改', readOnly: '只读', saveFailed: '保存失败', discard: '丢弃', save: '保存', saving: '保存中…', + configSaved: '已保存', } diff --git a/src/client/sessions-export-view.ts b/src/client/sessions-export-view.ts index b95db92..bbb6f54 100644 --- a/src/client/sessions-export-view.ts +++ b/src/client/sessions-export-view.ts @@ -8,10 +8,12 @@ import { } from './alarm-push-status-view.ts' import { NETXOPS_SESSIONS_EXPORT_PATH, + type SessionsExportReasonCode, type SessionsExportStatus, } from '../session-export-shared.ts' +import type { NetxopsLocaleKey } from './locales.ts' -export type { SessionsExportStatus } +export type { SessionsExportStatus, SessionsExportReasonCode } export { NETXOPS_SESSIONS_EXPORT_PATH } export const SESSIONS_EXPORT_STATUS_ENDPOINT = 'sessions.export.status' @@ -20,9 +22,42 @@ const EMPTY_STATUS: SessionsExportStatus = { available: false, sessionCount: 0, supportsRawArtifacts: false, + reasonCode: 'rpc_unavailable', reason: 'RPC unavailable', } +const REASON_CODES = new Set([ + 'rpc_unavailable', + 'rpc_failed', + 'no_persistence', + 'no_raw_artifacts', + 'list_failed', + 'http_failed', + 'empty_body', +]) + +function asReasonCode(value: unknown): SessionsExportReasonCode | undefined { + return typeof value === 'string' && REASON_CODES.has(value as SessionsExportReasonCode) + ? value as SessionsExportReasonCode + : undefined +} + +/** Map a status reasonCode to a client locale key. */ +export function sessionsExportReasonLocaleKey( + code: SessionsExportReasonCode | undefined, +): NetxopsLocaleKey { + switch (code) { + case 'no_persistence': return 'sessionsExportNoPersistence' + case 'no_raw_artifacts': return 'sessionsExportNoRawArtifacts' + case 'list_failed': return 'sessionsExportListFailed' + case 'rpc_unavailable': return 'sessionsExportRpcUnavailable' + case 'rpc_failed': return 'sessionsExportRpcFailed' + case 'http_failed': return 'sessionsExportHttpFailed' + case 'empty_body': return 'sessionsExportEmpty' + default: return 'sessionsExportUnavailable' + } +} + /** * Normalize a host export-status snapshot for the card. * @param value - RPC value or unknown. @@ -36,6 +71,7 @@ export function asSessionsExportStatus(value: unknown): SessionsExportStatus { available: row.available === true, sessionCount: typeof row.sessionCount === 'number' ? row.sessionCount : 0, supportsRawArtifacts: row.supportsRawArtifacts === true, + reasonCode: asReasonCode(row.reasonCode), reason: typeof row.reason === 'string' ? row.reason : undefined, } } @@ -56,6 +92,7 @@ export async function fetchSessionsExportStatus( if (result !== null && typeof result === 'object' && (result as { ok?: boolean }).ok === false) { return { ...EMPTY_STATUS, + reasonCode: 'rpc_failed', reason: String((result as { error?: { message?: string } }).error?.message ?? 'rpc failed'), } } @@ -93,6 +130,25 @@ export function saveBlobDownload(blob: Blob, filename: string): void { } } +/** Browser download failure with a stable code for UI i18n. */ +export class SessionsExportDownloadError extends Error { + readonly code: Extract + readonly status?: number + readonly detail: string + + constructor( + code: Extract, + message: string, + extra: { status?: number, detail?: string } = {}, + ) { + super(message) + this.name = 'SessionsExportDownloadError' + this.code = code + this.status = extra.status + this.detail = extra.detail ?? '' + } +} + /** * Fetch the bulk sessions ZIP and hand it to the browser download manager. * Uses a credentialed GET + blob URL (not a bare `` click after @@ -112,7 +168,11 @@ export async function downloadAllSessionsExport( 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}`}`) + throw new SessionsExportDownloadError( + 'http_failed', + `Export failed: HTTP ${response.status}${detail === '' ? '' : ` ${detail}`}`, + { status: response.status, detail }, + ) } const disposition = response.headers.get('content-disposition') ?? '' const matched = /filename="([^"]+)"/i.exec(disposition) @@ -125,7 +185,7 @@ export async function downloadAllSessionsExport( : 0 const blob = await response.blob() if (blob.size <= 0) { - throw new Error('Export failed: empty ZIP body') + throw new SessionsExportDownloadError('empty_body', 'Export failed: empty ZIP body') } save(blob, filename) return { diff --git a/src/client/styles.ts b/src/client/styles.ts index fae24c3..389610e 100644 --- a/src/client/styles.ts +++ b/src/client/styles.ts @@ -1,56 +1,57 @@ -/** Inject shared Netx Ops card styles once. */ +/** Inject shared Netx Ops settings-section styles once (uds-auth visual language). */ -const STYLE_ID = 'dsh-netxops-card-css' +const STYLE_ID = 'dsh-netxops-settings-css' const CSS = ` -.dsh-nx-card{list-style:none;border:.5px solid var(--dsw-alias-border-l4);border-radius:16px;background:var(--dsw-alias-bg-layer-3);transition:border-color .16s,background .16s} -.dsh-nx-card:hover{border-color:var(--dsw-alias-label-dimmed)} -.dsh-nx-cardOpen{background:var(--dsw-alias-bg-layer-2);border-color:var(--dsw-alias-label-dimmed)} -.dsh-nx-header{width:100%;appearance:none;border:0;background:none;font:inherit;color:inherit;text-align:left;cursor:pointer;display:flex;align-items:center;gap:12px;padding:14px 16px;border-radius:12px} -.dsh-nx-header:focus-visible{outline:2px solid var(--dsw-alias-brand-primary);outline-offset:-2px} -.dsh-nx-headText{flex:1;min-width:0;display:flex;flex-direction:column;gap:4px} -.dsh-nx-name{font-size:15px;font-weight:600;line-height:1.4;color:var(--dsw-alias-label-primary)} -.dsh-nx-desc{font-size:13px;line-height:1.5;color:var(--dsw-alias-label-tertiary)} -.dsh-nx-pending{flex:none;border-radius:999px;padding:1px 8px;font-size:11px;line-height:17px;font-weight:500;white-space:nowrap;background:var(--dsw-alias-bg-module-platform);color:var(--dsw-alias-label-secondary)} -.dsh-nx-status{flex:none;border-radius:999px;padding:1px 8px;font-size:11px;line-height:17px;font-weight:500;white-space:nowrap;background:var(--dsw-alias-bg-module-platform);color:var(--dsw-alias-label-tertiary)} -.dsh-nx-statusOk{color:var(--dsw-alias-label-primary);background:color-mix(in srgb, var(--dsw-alias-label-primary) 12%, transparent)} -.dsh-nx-statusWarn{color:var(--dsw-alias-label-secondary);background:var(--dsw-alias-bg-module-platform)} -.dsh-nx-statusErr{color:var(--dsw-alias-label-error);background:color-mix(in srgb, var(--dsw-alias-label-error) 12%, transparent)} -.dsh-nx-chevron{flex:none;color:var(--dsw-alias-label-tertiary);transition:transform .16s} -.dsh-nx-chevronOpen{transform:rotate(180deg)} -.dsh-nx-body{border-top:.5px solid var(--dsw-alias-border-l2);margin:0 16px;padding-bottom:8px} -.dsh-nx-readOnly{margin:12px 0 0;font-size:12px;line-height:1.5;color:var(--dsw-alias-label-tertiary)} -.dsh-nx-field{display:flex;flex-direction:column;gap:6px;padding:12px 0} -.dsh-nx-field+.dsh-nx-field{border-top:.5px solid var(--dsw-alias-border-l2)} -.dsh-nx-fieldHead{display:flex;align-items:center;gap:8px} -.dsh-nx-label{flex:1;min-width:0;font-size:13px;font-weight:500;line-height:1.5;color:var(--dsw-alias-label-primary)} -.dsh-nx-badges{display:inline-flex;align-items:center;gap:8px} -.dsh-nx-badge{border-radius:999px;padding:1px 8px;font-size:11px;line-height:17px;white-space:nowrap;font-weight:500;background:var(--dsw-alias-bg-module-platform);color:var(--dsw-alias-label-secondary)} -.dsh-nx-badgeMuted{border-radius:999px;padding:1px 8px;font-size:11px;line-height:17px;white-space:nowrap;color:var(--dsw-alias-label-tertiary)} -.dsh-nx-reset{border:none;background:none;padding:0;font:inherit;font-size:12px;line-height:1.5;color:var(--dsw-alias-label-secondary);cursor:pointer} -.dsh-nx-reset:hover:not(:disabled){color:var(--dsw-alias-label-primary)} -.dsh-nx-input{height:34px;padding:0 12px;border:.5px solid var(--dsw-alias-border-l4);border-radius:8px;background:var(--dsw-alias-bg-layer-3);font:inherit;font-size:13px;line-height:1.5;color:var(--dsw-alias-label-primary)} -.dsh-nx-select{appearance:auto;cursor:pointer} -.dsh-nx-input:focus-visible{outline:none;border-color:var(--dsw-alias-brand-primary)} -.dsh-nx-inputInvalid{border-color:var(--dsw-alias-label-error)} -.dsh-nx-hint{margin:0;font-size:12px;line-height:1.5;color:var(--dsw-alias-label-tertiary)} -.dsh-nx-checkRow{display:flex;align-items:flex-start;gap:10px;font-size:12px;line-height:1.5;color:var(--dsw-alias-label-secondary);cursor:pointer} -.dsh-nx-checkRow input{margin-top:2px;flex:none} -.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-settings{box-sizing:border-box;display:flex;flex-direction:column;gap:20px;min-height:0;padding:4px 4px 24px;color:var(--dsw-alias-label-primary,#1f2329)} +.dsh-nx-settings>header{display:flex;flex-direction:column;gap:4px} +.dsh-nx-settings h2{margin:0;font-size:20px;font-weight:600;line-height:28px} +.dsh-nx-settings-intro{margin:0;font-size:13px;line-height:1.5;color:var(--dsw-alias-label-secondary,#646a73)} +.dsh-nx-settings-card{border:1px solid var(--dsw-alias-border-l2,#dee0e3);border-radius:12px;padding:16px;background:var(--dsw-alias-bg-layer-1,transparent)} +.dsh-nx-settings-card h3{margin:0 0 12px;font-size:15px;font-weight:600;line-height:22px} +.dsh-nx-settings-field{display:flex;flex-direction:column;gap:4px;margin-bottom:12px} +.dsh-nx-settings-field:last-child{margin-bottom:0} +.dsh-nx-settings-field>label,.dsh-nx-field-label{font-size:12px;line-height:18px;color:var(--dsw-alias-label-secondary,#646a73)} +.dsh-nx-field-head{display:flex;align-items:center;justify-content:space-between;gap:8px} +.dsh-nx-field-head>label,.dsh-nx-field-head>.dsh-nx-field-label{flex:1;min-width:0} +.dsh-nx-settings-field input[type=text], +.dsh-nx-settings-field input[type=password], +.dsh-nx-settings-field input:not([type]){ + box-sizing:border-box;width:100%;padding:8px 10px;border:1px solid var(--dsw-alias-border-l2,#dfe1e5);border-radius:6px;font:inherit;font-size:13px;line-height:20px;background:var(--dsw-alias-bg-module-platform,#f4f5f7);color:inherit +} +.dsh-nx-settings-field input:focus-visible{outline:2px solid var(--dsw-alias-state-business-primary,#3370ff);outline-offset:1px} +.dsh-nx-settings-field input:disabled{opacity:.55;cursor:default} +.dsh-nx-inputInvalid{border-color:var(--dsw-alias-state-error-primary,#d54941)!important} +.dsh-nx-hint{margin:0;font-size:12px;line-height:1.5;color:var(--dsw-alias-label-tertiary,#8f959e)} +.dsh-nx-invalid{margin:0;font-size:12px;line-height:1.5;color:var(--dsw-alias-state-error-primary,#d54941)} +.dsh-nx-badges{display:inline-flex;align-items:center;gap:8px;flex:none} +.dsh-nx-badge{border-radius:999px;padding:1px 8px;font-size:11px;line-height:17px;white-space:nowrap;font-weight:500;background:var(--dsw-alias-bg-module-platform,#f4f5f7);color:var(--dsw-alias-label-secondary,#646a73)} +.dsh-nx-badgeMuted{border-radius:999px;padding:1px 8px;font-size:11px;line-height:17px;white-space:nowrap;color:var(--dsw-alias-label-tertiary,#8f959e)} +.dsh-nx-status{border-radius:999px;padding:1px 8px;font-size:11px;line-height:17px;font-weight:500;white-space:nowrap;background:var(--dsw-alias-bg-module-platform,#f4f5f7);color:var(--dsw-alias-label-tertiary,#8f959e)} +.dsh-nx-statusOk{color:var(--dsw-alias-state-success-primary,#20a162);background:rgba(32,161,98,.12)} +.dsh-nx-statusWarn{color:var(--dsw-alias-state-warn-primary,#d97706);background:rgba(217,119,6,.12)} +.dsh-nx-statusErr{color:var(--dsw-alias-state-error-primary,#d54941);background:rgba(213,73,65,.12)} +.dsh-nx-reset{border:none;background:none;padding:0;font:inherit;font-size:12px;line-height:1.5;color:var(--dsw-alias-state-business-primary,#3370ff);cursor:pointer} +.dsh-nx-reset:hover:not(:disabled){opacity:.85} +.dsh-nx-reset:disabled{opacity:.4;cursor:default} +.dsh-nx-checkRow{display:flex;align-items:flex-start;gap:10px;margin:0;font-size:13px;line-height:1.5;color:var(--dsw-alias-label-primary,#1f2329);cursor:pointer} +.dsh-nx-checkRow input{margin-top:3px;flex:none} +.dsh-nx-groupBlock{display:flex;flex-direction:column;gap:8px;padding:4px 0 8px} +.dsh-nx-groupBlock+.dsh-nx-groupBlock{border-top:1px solid var(--dsw-alias-border-l1,#eef0f3);padding-top:12px} +.dsh-nx-groupTitle{font-size:13px;font-weight:600;line-height:1.5;color:var(--dsw-alias-label-primary,#1f2329)} +.dsh-nx-groupChecks{display:flex;flex-direction:column;gap:8px;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} -.dsh-nx-discard{border-color:var(--dsw-alias-border-l2);background:none;color:var(--dsw-alias-label-secondary)} -.dsh-nx-discard:hover:not(:disabled){color:var(--dsw-alias-label-primary);border-color:var(--dsw-alias-label-dimmed)} -.dsh-nx-save{background:var(--dsw-alias-label-primary);color:var(--dsw-alias-bg-layer-3)} +.dsh-nx-settings-actions{display:flex;gap:8px;align-items:center;flex-wrap:wrap;margin-top:4px} +.dsh-nx-settings-msg{font-size:12px;color:var(--dsw-alias-label-secondary,#646a73)} +.dsh-nx-settings-msg.ok{color:var(--dsw-alias-state-success-primary,#20a162)} +.dsh-nx-settings-msg.err{color:var(--dsw-alias-state-error-primary,#d54941)} +.dsh-nx-settings-empty{padding:24px;text-align:center;color:var(--dsw-alias-label-tertiary,#8f959e);font-size:13px} +.dsh-nx-btn{appearance:none;display:inline-flex;align-items:center;justify-content:center;border:1px solid var(--dsw-alias-border-l2,#dfe1e5);border-radius:4px;padding:8px 12px;font:inherit;font-size:13px;line-height:20px;cursor:pointer;background:var(--dsw-alias-bg-module-platform,#f4f5f7);color:var(--dsw-alias-label-primary,#1f2329)} +.dsh-nx-btn:hover:not(:disabled){background:var(--dsw-alias-interactive-bg-hover,#f7f8fa)} .dsh-nx-btn:disabled{opacity:.4;cursor:default} +.dsh-nx-btn-primary{background:var(--dsw-alias-state-business-primary,#3370ff);color:#fff;border-color:transparent} +.dsh-nx-btn-primary:hover:not(:disabled){opacity:.9;background:var(--dsw-alias-state-business-primary,#3370ff)} +.dsh-nx-btn-ghost{background:transparent} ` export function ensureStyles(): void { diff --git a/src/index.ts b/src/index.ts index e679fc6..d7bcf3a 100644 --- a/src/index.ts +++ b/src/index.ts @@ -434,6 +434,7 @@ export function apply(ctx: Context, config: Config = Config({})): void { value: { available: false, options: [], + reasonCode: 'im_catalog_unavailable', hint: 'dsh-im-ops missing or outdated — install ≥ops.24 for delivery picker', }, } diff --git a/src/netx/alarm-push.ts b/src/netx/alarm-push.ts index 8fe6e4b..be7022a 100644 --- a/src/netx/alarm-push.ts +++ b/src/netx/alarm-push.ts @@ -7,6 +7,7 @@ import { publishAlarmPushStatus, type AlarmPushPhase, } from './alarm-push-status.ts' +import { alarmActionLabel, tHost } from './i18n.ts' export function alarmSubscribeUrl(apiUrl: string): string { const trimmed = apiUrl.trim().replace(/\/$/, '') @@ -42,16 +43,6 @@ export interface KeyAlarmPayload { /** Human-readable prompt for a sticky Netx Ops DSH session. */ export function formatAlarmPrompt(payload: KeyAlarmPayload, lang = 'zh'): string { const action = String(payload.action ?? '').trim().toLowerCase() - const actionZh: Record = { - inserted: '告警产生', - updated: '告警更新', - deleted: '告警清除', - } - const actionEn: Record = { - inserted: 'Alarm Raised', - updated: 'Alarm Updated', - deleted: 'Alarm Cleared', - } const ne = payload.ne && typeof payload.ne === 'object' ? payload.ne : {} const host = String(ne.host_name ?? '').trim() const ip = String(ne.ip_address ?? '').trim() @@ -59,32 +50,22 @@ export function formatAlarmPrompt(payload: KeyAlarmPayload, lang = 'zh'): string let device = host || neName || String(payload.ne_id ?? '').trim() || '-' if (ip) device = device === '-' ? ip : `${device} (${ip})` - const label = String(payload.rule_label ?? payload.native_probable_cause ?? '关键告警').trim() - if (lang.startsWith('en')) { - return [ - `[NMS ${actionEn[action] ?? (action || 'Alarm')}] ${label}`, - `Device: ${device}`, - `Object: ${String(payload.object_name ?? '-').trim()}`, - `Severity: ${String(payload.perceived_severity ?? '-').trim()}`, - `Cause: ${String(payload.native_probable_cause ?? '-').trim()}`, - `Time: ${String(payload.time_created ?? '-').trim()}`, - `notificationId: ${String(payload.notification_id ?? '-').trim()}`, - `alarm_key: ${String(payload.alarm_key ?? '-').trim()}`, - '', - 'Please analyze this key alarm and suggest next ops steps.', - ].join('\n') - } + const label = String( + payload.rule_label + ?? payload.native_probable_cause + ?? tHost('alarm.defaultLabel', lang), + ).trim() return [ - `[NMS ${actionZh[action] ?? (action || '告警')}] ${label}`, - `设备: ${device}`, - `对象: ${String(payload.object_name ?? '-').trim()}`, - `级别: ${String(payload.perceived_severity ?? '-').trim()}`, - `原因: ${String(payload.native_probable_cause ?? '-').trim()}`, - `时间: ${String(payload.time_created ?? '-').trim()}`, + `[NMS ${alarmActionLabel(action, lang)}] ${label}`, + `${tHost('alarm.field.device', lang)}: ${device}`, + `${tHost('alarm.field.object', lang)}: ${String(payload.object_name ?? '-').trim()}`, + `${tHost('alarm.field.severity', lang)}: ${String(payload.perceived_severity ?? '-').trim()}`, + `${tHost('alarm.field.cause', lang)}: ${String(payload.native_probable_cause ?? '-').trim()}`, + `${tHost('alarm.field.time', lang)}: ${String(payload.time_created ?? '-').trim()}`, `notificationId: ${String(payload.notification_id ?? '-').trim()}`, `alarm_key: ${String(payload.alarm_key ?? '-').trim()}`, '', - '请分析这条关键告警并给出下一步运维建议。', + tHost('alarm.analyzeHint', lang), ].join('\n') } diff --git a/src/netx/alarm-session.ts b/src/netx/alarm-session.ts index e0aa06f..af42250 100644 --- a/src/netx/alarm-session.ts +++ b/src/netx/alarm-session.ts @@ -10,10 +10,10 @@ import { homedir } from 'node:os' import { join } from 'node:path' import type { Context } from '@deepseek-ai/cordis' import { formatAlarmPrompt, type KeyAlarmPayload } from './alarm-push.ts' +import { tHost } from './i18n.ts' const PRESET_ID = 'netxops' const PERMISSION_PRESET = 'default' -const TITLE = 'Netx 关键告警' interface StickyHandle { sessionId: string @@ -75,7 +75,7 @@ export async function deliverAlarmToSession( } } - await createStickySession(ctx, prompt) + await createStickySession(ctx, prompt, lang) } const next = deliveryChain.then(run, run) @@ -121,7 +121,7 @@ async function followup(ctx: Context, agent: { followup: (msg: unknown) => unkno } } -async function createStickySession(ctx: Context, prompt: string): Promise { +async function createStickySession(ctx: Context, prompt: string, lang = 'zh'): Promise { // eslint-disable-next-line @typescript-eslint/no-explicit-any const c = ctx as any const agents = c.agents @@ -179,7 +179,7 @@ async function createStickySession(ctx: Context, prompt: string): Promise permissionPresets.set(handle.agent.session, PERMISSION_PRESET) } if (sessionTitle && typeof sessionTitle.rename === 'function') { - sessionTitle.rename(handle.agent.session, TITLE) + sessionTitle.rename(handle.agent.session, tHost('alarm.sessionTitle', lang)) } await followup(ctx, handle.agent, prompt) sticky = { diff --git a/src/netx/i18n.ts b/src/netx/i18n.ts new file mode 100644 index 0000000..5252db8 --- /dev/null +++ b/src/netx/i18n.ts @@ -0,0 +1,90 @@ +/** + * Host-plane Netx Ops i18n (alarm prompts / session titles). + * UI copy lives in `src/client/locales.ts` via dsh-client-locale. + */ + +export type NetxLocale = 'zh' | 'en' + +export type NetxHostMessageKey = + | 'alarm.sessionTitle' + | 'alarm.defaultLabel' + | 'alarm.action.inserted' + | 'alarm.action.updated' + | 'alarm.action.deleted' + | 'alarm.action.fallback' + | 'alarm.field.device' + | 'alarm.field.object' + | 'alarm.field.severity' + | 'alarm.field.cause' + | 'alarm.field.time' + | 'alarm.analyzeHint' + +const ZH: Record = { + 'alarm.sessionTitle': 'Netx 关键告警', + 'alarm.defaultLabel': '关键告警', + 'alarm.action.inserted': '告警产生', + 'alarm.action.updated': '告警更新', + 'alarm.action.deleted': '告警清除', + 'alarm.action.fallback': '告警', + 'alarm.field.device': '设备', + 'alarm.field.object': '对象', + 'alarm.field.severity': '级别', + 'alarm.field.cause': '原因', + 'alarm.field.time': '时间', + 'alarm.analyzeHint': '请分析这条关键告警并给出下一步运维建议。', +} + +const EN: Record = { + 'alarm.sessionTitle': 'Netx key alarm', + 'alarm.defaultLabel': 'Key alarm', + 'alarm.action.inserted': 'Alarm Raised', + 'alarm.action.updated': 'Alarm Updated', + 'alarm.action.deleted': 'Alarm Cleared', + 'alarm.action.fallback': 'Alarm', + 'alarm.field.device': 'Device', + 'alarm.field.object': 'Object', + 'alarm.field.severity': 'Severity', + 'alarm.field.cause': 'Cause', + 'alarm.field.time': 'Time', + 'alarm.analyzeHint': 'Please analyze this key alarm and suggest next ops steps.', +} + +const TABLES: Record> = { + zh: ZH, + en: EN, +} + +/** Map plugin `lang` / Accept-Language-ish values to zh|en (default zh). */ +export function normalizeNetxLocale(lang: string | undefined | null): NetxLocale { + const normalized = String(lang ?? '').trim().toLowerCase() + if (normalized === 'english' || /^en(?:[-_].*)?$/u.test(normalized)) return 'en' + return 'zh' +} + +/** + * Translate a host-plane message key. + * @param key - stable message id. + * @param lang - plugin lang or locale tag. + * @param vars - `{name}` replacements. + */ +export function tHost( + key: NetxHostMessageKey, + lang: string | undefined | null = 'zh', + vars?: Record, +): string { + const locale = normalizeNetxLocale(lang) + const template = TABLES[locale][key] ?? TABLES.zh[key] ?? key + if (vars === undefined) return template + return template.replace(/\{(\w+)\}/g, (match, name: string) => ( + Object.prototype.hasOwnProperty.call(vars, name) ? String(vars[name]) : match + )) +} + +/** Alarm action verb for prompt headers. */ +export function alarmActionLabel(action: string, lang: string | undefined | null): string { + const normalized = String(action ?? '').trim().toLowerCase() + if (normalized === 'inserted') return tHost('alarm.action.inserted', lang) + if (normalized === 'updated') return tHost('alarm.action.updated', lang) + if (normalized === 'deleted') return tHost('alarm.action.deleted', lang) + return normalized || tHost('alarm.action.fallback', lang) +} diff --git a/src/netx/session-export.ts b/src/netx/session-export.ts index 25b2e73..cf0fd8c 100644 --- a/src/netx/session-export.ts +++ b/src/netx/session-export.ts @@ -132,6 +132,7 @@ export async function getSessionsExportStatus( available: false, sessionCount: 0, supportsRawArtifacts: false, + reasonCode: 'no_persistence', reason: 'sessionPersistence unavailable — mount a JSONL session backend (web profile default)', } } @@ -140,6 +141,7 @@ export async function getSessionsExportStatus( available: false, sessionCount: 0, supportsRawArtifacts: false, + reasonCode: 'no_raw_artifacts', reason: 'persistence backend does not expose per-session raw artifacts (SQLite export unsupported)', } } @@ -156,6 +158,7 @@ export async function getSessionsExportStatus( available: false, sessionCount: 0, supportsRawArtifacts: true, + reasonCode: 'list_failed', reason: error instanceof Error ? error.message : String(error), } } diff --git a/src/session-export-shared.ts b/src/session-export-shared.ts index be19e17..20be608 100644 --- a/src/session-export-shared.ts +++ b/src/session-export-shared.ts @@ -5,10 +5,26 @@ /** Authenticated browser download path for the all-sessions archive. */ export const NETXOPS_SESSIONS_EXPORT_PATH = '/api/netxops.sessions.export' +/** + * Stable export-status / download failure codes for client locale mapping. + * Prefer these over free-form `reason` / Error.message in the Plugins card. + */ +export type SessionsExportReasonCode = + | 'rpc_unavailable' + | 'rpc_failed' + | 'no_persistence' + | 'no_raw_artifacts' + | 'list_failed' + | 'http_failed' + | 'empty_body' + /** Preflight / RPC snapshot for the Plugins card. */ export interface SessionsExportStatus { readonly available: boolean readonly sessionCount: number readonly supportsRawArtifacts: boolean + /** Stable code for UI i18n (preferred). */ + readonly reasonCode?: SessionsExportReasonCode + /** Optional English diagnostic for logs / fallback when no locale key. */ readonly reason?: string } diff --git a/test/alarm-push.test.mjs b/test/alarm-push.test.mjs index 2f7ae8e..5a37050 100644 --- a/test/alarm-push.test.mjs +++ b/test/alarm-push.test.mjs @@ -35,3 +35,21 @@ test('formatAlarmPrompt keeps human fields without raw sockets', () => { assert.match(text, /PE1 \(10\.0\.0\.1\)/) assert.match(text, /请分析这条关键告警/) }) + +test('formatAlarmPrompt switches to English when lang starts with en', () => { + const text = formatAlarmPrompt({ + action: 'deleted', + 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' }, + }, 'en-US') + assert.match(text, /Alarm Cleared/) + assert.match(text, /Device: PE1 \(10\.0\.0\.1\)/) + assert.match(text, /Please analyze this key alarm/) + assert.doesNotMatch(text, /告警/) +}) diff --git a/test/i18n.test.mjs b/test/i18n.test.mjs new file mode 100644 index 0000000..edf20d1 --- /dev/null +++ b/test/i18n.test.mjs @@ -0,0 +1,39 @@ +import assert from 'node:assert/strict' +import { test } from 'node:test' + +import { + alarmActionLabel, + normalizeNetxLocale, + tHost, +} from '../src/netx/i18n.ts' +import { getSessionsExportStatus } from '../src/netx/session-export.ts' + +test('normalizeNetxLocale defaults to zh and accepts en tags', () => { + assert.equal(normalizeNetxLocale(undefined), 'zh') + assert.equal(normalizeNetxLocale(''), 'zh') + assert.equal(normalizeNetxLocale('zh-CN'), 'zh') + assert.equal(normalizeNetxLocale('en'), 'en') + assert.equal(normalizeNetxLocale('en-US'), 'en') + assert.equal(normalizeNetxLocale('english'), 'en') +}) + +test('tHost localizes alarm session titles', () => { + assert.equal(tHost('alarm.sessionTitle', 'zh'), 'Netx 关键告警') + assert.equal(tHost('alarm.sessionTitle', 'en'), 'Netx key alarm') + assert.equal(alarmActionLabel('inserted', 'en'), 'Alarm Raised') +}) + +test('getSessionsExportStatus returns reasonCode for UI i18n', async () => { + const missing = await getSessionsExportStatus({ get: () => undefined }) + assert.equal(missing.available, false) + assert.equal(missing.reasonCode, 'no_persistence') + + const noRaw = await getSessionsExportStatus({ + get: () => ({ + supportsRawArtifacts: false, + list: async () => [], + readRaw: async () => undefined, + }), + }) + assert.equal(noRaw.reasonCode, 'no_raw_artifacts') +})