From e8b7d7331fe298c56abf7c52b55d07435b6033f2 Mon Sep 17 00:00:00 2001 From: oliver Date: Sat, 5 Sep 2026 21:08:14 +0800 Subject: [PATCH] Show key-alarm WSS status on the Netx Ops Plugins card (0.1.16). Host publishes connection phases over Connection RPC; the card polls and badges Connected / Reconnecting / Auth failed so operators need not curl logs. Co-authored-by: Cursor --- README.md | 2 +- docs/INSTALL.md | 8 +- lib/client.js | 227 +++++++++++++++++++++++++-- lib/index.js | 125 +++++++++++++-- package.json | 2 +- src/client/NetxopsCard.tsx | 87 ++++++++-- src/client/alarm-push-status-view.ts | 90 +++++++++++ src/client/controller.ts | 67 ++++++++ src/client/index.ts | 13 ++ src/client/locales.ts | 27 ++++ src/client/styles.ts | 4 + src/index.ts | 42 ++++- src/netx/alarm-push-status.ts | 89 +++++++++++ src/netx/alarm-push.ts | 55 ++++++- 14 files changed, 783 insertions(+), 55 deletions(-) create mode 100644 src/client/alarm-push-status-view.ts create mode 100644 src/netx/alarm-push-status.ts diff --git a/README.md b/README.md index d886189..38da63b 100644 --- a/README.md +++ b/README.md @@ -16,7 +16,7 @@ dsh web Then: 1. Settings → **Plugins** → **Netx Ops** → API URL / token -2. Optional: enable **关键告警推送** — DSH dials out to netx and opens a sticky session on matched key alarms (WhatsApp / im not required) +2. Optional: enable **关键告警推送** — DSH dials out to netx and opens a sticky session on matched key alarms (WhatsApp / im not required). The card shows live WSS status (Connected / Reconnecting / Auth failed). 3. Settings → **Agent presets** → Custom → **Netx Ops** (installed automatically on first host activate) 4. New session → choose **Netx Ops** diff --git a/docs/INSTALL.md b/docs/INSTALL.md index 7f77588..055307b 100644 --- a/docs/INSTALL.md +++ b/docs/INSTALL.md @@ -30,7 +30,7 @@ dsh web # or: pnpm dsh web 1. **Settings → Plugins → Netx Ops** → API URL (+ token if the field is enabled). Token fallback: `scripts/set-netx-token.ps1` / `.sh`. - Optional: enable **关键告警推送** so this DSH dials `ws(s):///v1/integrations/dsh-alarm/ws` and follows a sticky session when netx matches a key alert. + Optional: enable **关键告警推送** so this DSH dials `ws(s):///v1/integrations/dsh-alarm/ws` and follows a sticky session when netx matches a key alert. The card header shows live WSS status (`Connected` / `Reconnecting` / `Auth failed`). 2. Restart or open Settings → **Agent presets** → Custom → **Netx Ops** should appear after the host plugin has activated once. 3. **New session → preset Netx Ops** → ask e.g. Critical Top / single-host alarms. @@ -38,13 +38,15 @@ dsh web # or: pnpm dsh web - **netx** (fixed IP) hosts the subscribe hub and fans out matched key alerts. - **netxops** (each DSH) reuses the same URL/token, connects outbound, and delivers into a DSH session. +- Live status is exposed on the Plugins card via Connection RPC (`/netxops` → `alarm-push.status`); no curl required for day-to-day checks. - WhatsApp / `dsh-im` is **not** required for this path. ## Verify 1. Plugins card **Netx Ops** visible. -2. Agent presets → Custom → **Netx Ops**. -3. Tools include `netx__queryUmeAlarms`. +2. With key-alarm push on: card badge shows **Connected** (or reconnecting / auth failed with detail). +3. Agent presets → Custom → **Netx Ops**. +4. Tools include `netx__queryUmeAlarms`. See [TOOL_MAP.md](TOOL_MAP.md). diff --git a/lib/client.js b/lib/client.js index 408cef3..869326d 100644 --- a/lib/client.js +++ b/lib/client.js @@ -53,6 +53,65 @@ module.exports = __toCommonJS(exports_client); // src/client/NetxopsCard.tsx var import_react = require("react"); +// src/client/alarm-push-status-view.ts +var NETXOPS_RPC_CHANNEL = "/netxops"; +var ALARM_PUSH_STATUS_ENDPOINT = "alarm-push.status"; +var EMPTY = { + phase: "disabled", + enabled: false, + wsUrl: "", + detail: "", + updatedAt: 0, + lastConnectedAt: null, + lastError: null +}; +function asAlarmPushStatus(value) { + if (value === null || typeof value !== "object" || Array.isArray(value)) + return { ...EMPTY }; + const row = value; + const phase = typeof row.phase === "string" ? row.phase : "disabled"; + return { + phase, + enabled: row.enabled === true, + wsUrl: typeof row.wsUrl === "string" ? row.wsUrl : "", + detail: typeof row.detail === "string" ? row.detail : "", + updatedAt: typeof row.updatedAt === "number" ? row.updatedAt : 0, + lastConnectedAt: typeof row.lastConnectedAt === "number" ? row.lastConnectedAt : null, + lastError: typeof row.lastError === "string" ? row.lastError : null + }; +} +async function fetchAlarmPushStatus(call, signal) { + const result = await call(NETXOPS_RPC_CHANNEL, ALARM_PUSH_STATUS_ENDPOINT, {}, signal); + if (result !== null && typeof result === "object" && result.ok === true) { + return asAlarmPushStatus(result.value); + } + if (result !== null && typeof result === "object" && result.ok === false) { + return { + ...EMPTY, + phase: "error", + enabled: true, + detail: "rpc_error", + lastError: String(result.error?.message ?? "rpc failed") + }; + } + return asAlarmPushStatus(result); +} +function alarmPushTone(phase) { + switch (phase) { + case "connected": + return "ok"; + case "connecting": + case "authenticating": + case "reconnecting": + return "warn"; + case "auth_failed": + case "error": + return "err"; + default: + return "mute"; + } +} + // src/client/styles.ts var STYLE_ID = "dsh-netxops-card-css"; var CSS = ` @@ -65,6 +124,10 @@ var CSS = ` .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} @@ -106,6 +169,36 @@ function ensureStyles() { // src/client/NetxopsCard.tsx var jsx_runtime = require("react/jsx-runtime"); +function phaseLocaleKey(phase) { + switch (phase) { + case "disabled": + return "alarmPushPhaseDisabled"; + case "idle": + return "alarmPushPhaseIdle"; + case "connecting": + return "alarmPushPhaseConnecting"; + case "authenticating": + return "alarmPushPhaseAuthenticating"; + case "connected": + return "alarmPushPhaseConnected"; + case "reconnecting": + return "alarmPushPhaseReconnecting"; + case "auth_failed": + return "alarmPushPhaseAuthFailed"; + case "error": + return "alarmPushPhaseError"; + default: + return "alarmPushPhaseDisabled"; + } +} +function StatusBadge(props) { + const tone = alarmPushTone(props.phase); + const className = tone === "ok" ? "dsh-nx-status dsh-nx-statusOk" : tone === "warn" ? "dsh-nx-status dsh-nx-statusWarn" : tone === "err" ? "dsh-nx-status dsh-nx-statusErr" : "dsh-nx-status"; + return /* @__PURE__ */ jsx_runtime.jsx("span", { + className, + children: props.label + }); +} function ValueField(props) { return /* @__PURE__ */ jsx_runtime.jsxs("div", { className: "dsh-nx-field", @@ -176,6 +269,8 @@ function NetxopsCard(props) { const title = t("title"); 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", children: [ @@ -201,6 +296,10 @@ function NetxopsCard(props) { }) ] }), + 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") @@ -300,24 +399,32 @@ function NetxopsCard(props) { htmlFor: "netxops-alarm-push", children: t("alarmPushEnabled") }), - state.alarmPushEnabled.overridden ? /* @__PURE__ */ jsx_runtime.jsxs("span", { + /* @__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("alarmPushEnabled"); - }, - children: t("reset") - }) + 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 ] - }) : null + }) ] }), /* @__PURE__ */ jsx_runtime.jsxs("label", { @@ -337,7 +444,16 @@ function NetxopsCard(props) { children: t("alarmPushEnabledHint") }) ] - }) + }), + 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.jsxs("div", { @@ -580,6 +696,7 @@ class CardForm { var NETXOPS_NS = "netxops"; var DEFAULT_TOKEN_REF = "NETX_API_TOKEN"; var API_TOKEN_FIELD = "apiToken"; +var STATUS_POLL_MS = 2000; class NetxopsCardController { scope; @@ -592,6 +709,10 @@ class NetxopsCardController { writable: false, remoteReady: false }; + rpcCall; + alarmPushStatus = null; + pollTimer; + pollInFlight = false; constructor(scope, ctx) { this.scope = scope; this.ctx = ctx; @@ -614,6 +735,48 @@ class NetxopsCardController { if (ready) this.readCredential(); } + setAlarmPushRpc(call) { + this.rpcCall = call; + if (call === undefined) { + this.stopStatusPoll(); + if (this.alarmPushStatus !== null) { + this.alarmPushStatus = null; + this.store.set(this.projection()); + } + return; + } + this.startStatusPoll(); + this.refreshAlarmPushStatus(); + } + startStatusPoll() { + if (this.pollTimer !== undefined) + return; + this.pollTimer = setInterval(() => { + this.refreshAlarmPushStatus(); + }, STATUS_POLL_MS); + } + stopStatusPoll() { + if (this.pollTimer === undefined) + return; + clearInterval(this.pollTimer); + this.pollTimer = undefined; + } + async refreshAlarmPushStatus() { + const call = this.rpcCall; + if (call === undefined || this.pollInFlight) + return; + this.pollInFlight = true; + try { + const next = await fetchAlarmPushStatus(call); + const prev = this.alarmPushStatus; + if (prev && prev.phase === next.phase && prev.enabled === next.enabled && prev.wsUrl === next.wsUrl && prev.detail === next.detail && prev.lastError === next.lastError && prev.lastConnectedAt === next.lastConnectedAt) + return; + this.alarmPushStatus = next; + this.store.set(this.projection()); + } catch {} finally { + this.pollInFlight = false; + } + } projection() { return { ...this.form.shell(), @@ -623,7 +786,8 @@ class NetxopsCardController { apiToken: this.form.field(API_TOKEN_FIELD), apiTokenConfigured: this.credential.configured, apiTokenWritable: this.credential.remoteReady && this.credential.writable, - apiTokenRemoteReady: this.credential.remoteReady + apiTokenRemoteReady: this.credential.remoteReady, + alarmPushStatus: this.alarmPushStatus }; } credentials() { @@ -695,6 +859,15 @@ var en = { langHint: "Response language hint (zh / en).", alarmPushEnabled: "Key-alarm push", alarmPushEnabledHint: "Dial out to netx and open/follow a DSH session when a matched key alarm arrives. WhatsApp/im is optional and not required.", + alarmPushStatus: "Push link", + alarmPushPhaseDisabled: "Off", + alarmPushPhaseIdle: "Idle", + alarmPushPhaseConnecting: "Connecting…", + alarmPushPhaseAuthenticating: "Authenticating…", + alarmPushPhaseConnected: "Connected", + alarmPushPhaseReconnecting: "Reconnecting…", + alarmPushPhaseAuthFailed: "Auth failed", + alarmPushPhaseError: "Error", apiToken: "API token", apiTokenHint: "Stored as credential NETX_API_TOKEN (never written into settings). Leave blank to keep the current token.", apiTokenUnavailable: "This DSH build does not expose remote.credentials. Set the token with scripts/set-netx-token.ps1 (or .sh), then restart is not required if credentials are watched.", @@ -721,6 +894,15 @@ var zh = { langHint: "响应语言提示(zh / en)。", alarmPushEnabled: "关键告警推送", alarmPushEnabledHint: "主动连接 netx;匹配到关键告警后在本机打开/续写一条 DSH 会话。不必安装 WhatsApp / im。", + alarmPushStatus: "推送链路", + alarmPushPhaseDisabled: "未开启", + alarmPushPhaseIdle: "空闲", + alarmPushPhaseConnecting: "连接中…", + alarmPushPhaseAuthenticating: "鉴权中…", + alarmPushPhaseConnected: "已连接", + alarmPushPhaseReconnecting: "重连中…", + alarmPushPhaseAuthFailed: "鉴权失败", + alarmPushPhaseError: "异常", apiToken: "API Token", apiTokenHint: "写入凭据 NETX_API_TOKEN(不会进 settings)。留空表示保留已有 token。", apiTokenUnavailable: "当前 DSH 未提供 remote.credentials。请用 scripts/set-netx-token.ps1(或 .sh)写入 token;若 harness 在监视凭据文件则无需重启。", @@ -762,6 +944,17 @@ function apply(ctx) { }; }, "netxops: credential invalidations"); }); + ctx.inject(["connection"], (connCtx) => { + const call = connCtx.connection?.rpc?.call?.bind(connCtx.connection.rpc); + if (typeof call !== "function") { + connCtx.logger.warn("netxops: connection.rpc.call unavailable — alarm status UI disabled"); + return; + } + card.setAlarmPushRpc(call); + connCtx.effect(() => () => { + card.setAlarmPushRpc(undefined); + }, "netxops: clear alarm-push rpc"); + }); ctx.slots.inject("settings.plugin.item", () => ctx.slots.register({ name: "settings.plugin.item", key: NETXOPS_NS, diff --git a/lib/index.js b/lib/index.js index 4dad28e..8fb98dc 100644 --- a/lib/index.js +++ b/lib/index.js @@ -10,6 +10,46 @@ import z from "@deepseek-ai/schemastery"; import { credentialRef } from "@deepseek-ai/dsh-credentials"; import * as DshSettings from "@deepseek-ai/dsh-settings"; +// src/netx/alarm-push-status.ts +var STORE_KEY = Symbol.for("dsh-netxops.alarm-push-status"); +function emptyStatus(partial = {}) { + return { + phase: "disabled", + enabled: false, + wsUrl: "", + detail: "", + updatedAt: Date.now(), + lastConnectedAt: null, + lastError: null, + ...partial + }; +} +function store() { + const root = globalThis; + let current = root[STORE_KEY]; + if (current === undefined) { + current = { status: emptyStatus(), listeners: new Set }; + root[STORE_KEY] = current; + } + return current; +} +function getAlarmPushStatus() { + return { ...store().status }; +} +function publishAlarmPushStatus(next) { + const state = store(); + state.status = { + ...state.status, + ...next, + updatedAt: Date.now() + }; + for (const listener of state.listeners) + listener(); +} +function resetAlarmPushStatus() { + publishAlarmPushStatus(emptyStatus({ phase: "disabled", enabled: false })); +} + // src/netx/alarm-push.ts function alarmSubscribeUrl(apiUrl) { const trimmed = apiUrl.trim().replace(/\/$/, ""); @@ -76,17 +116,35 @@ function formatAlarmPrompt(payload, lang = "zh") { ].join(` `); } +function setPhase(phase, wsUrl, extra = {}) { + publishAlarmPushStatus({ + phase, + enabled: true, + wsUrl, + detail: extra.detail ?? "", + lastError: extra.lastError === undefined ? null : extra.lastError, + ...extra.lastConnectedAt !== undefined ? { lastConnectedAt: extra.lastConnectedAt } : {} + }); +} function startAlarmPushClient(options) { const log = options.logger ?? console; const wsUrl = alarmSubscribeUrl(options.apiUrl); const token = options.token.trim(); if (!wsUrl || !token) { log.warn?.("netxops alarm-push: missing apiUrl or token — not connecting"); + setPhase("error", wsUrl, { + detail: "missing_url_or_token", + lastError: "missing apiUrl or token" + }); return () => {}; } const WS = options.WebSocketImpl ?? globalThis.WebSocket; if (typeof WS !== "function") { log.error?.("netxops alarm-push: WebSocket is unavailable in this runtime"); + setPhase("error", wsUrl, { + detail: "websocket_unavailable", + lastError: "WebSocket unavailable" + }); return () => {}; } let closed = false; @@ -100,12 +158,16 @@ function startAlarmPushClient(options) { reconnectTimer = undefined; } }; - const scheduleReconnect = () => { + const scheduleReconnect = (reason) => { if (closed) return; clearReconnect(); const delay = Math.min(60000, baseDelay * 2 ** Math.min(attempt, 5)); attempt += 1; + setPhase("reconnecting", wsUrl, { + detail: `retry_in_${delay}ms`, + lastError: reason + }); reconnectTimer = setTimeout(() => { connect(); }, delay); @@ -114,15 +176,17 @@ function startAlarmPushClient(options) { if (closed) return; clearReconnect(); + setPhase(attempt > 0 ? "reconnecting" : "connecting", wsUrl, { detail: "dialing" }); try { socket = new WS(wsUrl); } catch (error) { + const message = error instanceof Error ? error.message : String(error); log.warn?.("netxops alarm-push: connect failed:", error); - scheduleReconnect(); + scheduleReconnect(message); return; } socket.addEventListener("open", () => { - attempt = 0; + setPhase("authenticating", wsUrl, { detail: "auth" }); socket?.send(JSON.stringify({ type: "auth", token })); }); socket.addEventListener("message", (event) => { @@ -134,11 +198,20 @@ function startAlarmPushClient(options) { } const type = String(msg.type ?? "").toLowerCase(); if (type === "auth-ok") { + attempt = 0; + const now = Date.now(); + setPhase("connected", wsUrl, { + detail: String(msg.user ?? "ok"), + lastError: null, + lastConnectedAt: now + }); log.info?.("netxops alarm-push: subscribed to %s", wsUrl); return; } if (type === "auth-fail") { - log.error?.("netxops alarm-push: auth failed (%s)", msg.error); + const err = String(msg.error ?? "auth_failed"); + log.error?.("netxops alarm-push: auth failed (%s)", err); + setPhase("auth_failed", wsUrl, { detail: err, lastError: err }); socket?.close(); return; } @@ -154,7 +227,7 @@ function startAlarmPushClient(options) { socket.addEventListener("close", () => { socket = null; if (!closed) - scheduleReconnect(); + scheduleReconnect("socket_closed"); }); socket.addEventListener("error", () => {}); }; @@ -297,18 +370,18 @@ function resetAlarmSession() { } // src/netx/runtime.ts -var STORE_KEY = Symbol.for("dsh-netxops.connection-store"); -function store() { +var STORE_KEY2 = Symbol.for("dsh-netxops.connection-store"); +function store2() { const root = globalThis; - let current = root[STORE_KEY]; + let current = root[STORE_KEY2]; if (current === undefined) { current = { connection: undefined, listeners: new Set }; - root[STORE_KEY] = current; + root[STORE_KEY2] = current; } return current; } function publishNetxConnection(next) { - const state = store(); + const state = store2(); state.connection = next; for (const listener of state.listeners) listener(); @@ -317,6 +390,7 @@ function publishNetxConnection(next) { // src/index.ts var name = "netxops"; var inject = ["credentials"]; +var NETXOPS_RPC_CHANNEL = "/netxops"; var NETXOPS_SETTINGS_NAMESPACE = "netxops"; var NETXOPS_PRESET_ID = "netxops"; var DEFAULT_TOKEN_REF = "NETX_API_TOKEN"; @@ -385,10 +459,19 @@ function apply(ctx, config = Config({})) { stopAlarmPush?.(); stopAlarmPush = undefined; resetAlarmSession(); - if (!enabled) + if (!enabled) { + resetAlarmPushStatus(); return; + } if (!token.trim()) { ctx.logger.warn("netxops alarm-push: enabled but token is empty — not connecting"); + publishAlarmPushStatus({ + phase: "error", + enabled: true, + wsUrl: "", + detail: "missing_token", + lastError: "token empty" + }); return; } stopAlarmPush = startAlarmPushClient({ @@ -436,11 +519,30 @@ function apply(ctx, config = Config({})) { if (String(ref) === source().tokenCredentialRef) publish(); }); + ctx.inject(["connection"], (connCtx) => { + const rpc = connCtx.connection?.rpc; + if (!rpc || typeof rpc.handle !== "function") { + connCtx.logger.warn("netxops: connection.rpc.handle unavailable — alarm status UI disabled"); + return; + } + connCtx.effect(() => { + const dispose = rpc.handle(NETXOPS_RPC_CHANNEL, async (endpoint) => { + if (endpoint !== "alarm-push.status") { + return { ok: false, error: { code: "bad-request", message: "Unknown endpoint." } }; + } + return { ok: true, value: getAlarmPushStatus() }; + }); + return () => { + dispose(); + }; + }, "netxops: alarm-push status rpc"); + }); ctx.effect(() => () => { generation += 1; stopAlarmPush?.(); stopAlarmPush = undefined; resetAlarmSession(); + resetAlarmPushStatus(); }, "netxops: dispose host bridge"); } export { @@ -449,6 +551,7 @@ export { ensureAgentPresetInstalled, apply, NETXOPS_SETTINGS_NAMESPACE, + NETXOPS_RPC_CHANNEL, NETXOPS_PRESET_ID, DEFAULT_TOKEN_REF, Config diff --git a/package.json b/package.json index bd94729..3e3e13d 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "dsh-netxops", - "version": "0.1.15", + "version": "0.1.16", "description": "DeepSeek Harness Netx Ops: Ops-scoped netx__* REST tools + key-alarm push to DSH sessions + Plugins settings card + agent preset", "license": "MIT", "type": "module", diff --git a/src/client/NetxopsCard.tsx b/src/client/NetxopsCard.tsx index 4b645e6..99668e3 100644 --- a/src/client/NetxopsCard.tsx +++ b/src/client/NetxopsCard.tsx @@ -7,9 +7,39 @@ import type { InjectFace, PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-cli import type { NetxopsCardFace, NetxopsCardState } from './controller.ts' import type { NetxopsLocaleKey } from './locales.ts' import type { CardFieldState } from './card-form.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' +function phaseLocaleKey(phase: AlarmPushPhase): NetxopsLocaleKey { + switch (phase) { + case 'disabled': return 'alarmPushPhaseDisabled' + case 'idle': return 'alarmPushPhaseIdle' + case 'connecting': return 'alarmPushPhaseConnecting' + case 'authenticating': return 'alarmPushPhaseAuthenticating' + case 'connected': return 'alarmPushPhaseConnected' + case 'reconnecting': return 'alarmPushPhaseReconnecting' + case 'auth_failed': return 'alarmPushPhaseAuthFailed' + case 'error': return 'alarmPushPhaseError' + default: return 'alarmPushPhaseDisabled' + } +} + +function StatusBadge(props: { + phase: AlarmPushPhase + label: string +}) { + const tone = alarmPushTone(props.phase) + const className = tone === 'ok' + ? 'dsh-nx-status dsh-nx-statusOk' + : tone === 'warn' + ? 'dsh-nx-status dsh-nx-statusWarn' + : tone === 'err' + ? 'dsh-nx-status dsh-nx-statusErr' + : 'dsh-nx-status' + return {props.label} +} + export type NetxopsCardProps = PropsRuntime<'settings.plugin.item'> & PropsLocale<'settings.netxops'> @@ -80,6 +110,9 @@ export function NetxopsCard(props: NetxopsCardProps) { 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 (
  • @@ -94,6 +127,14 @@ export function NetxopsCard(props: NetxopsCardProps) { {title} {t('description')} + {showHeaderStatus && pushStatus + ? ( + + ) + : null} {state.dirty ? {t('unsaved')} : null} ▾ @@ -150,21 +191,31 @@ export function NetxopsCard(props: NetxopsCardProps) {
    - {state.alarmPushEnabled.overridden - ? ( - - {t('overridden')} - - - ) - : null} + + {pushStatus + ? ( + + ) + : null} + {state.alarmPushEnabled.overridden + ? ( + <> + {t('overridden')} + + + ) + : null} +
    + {pushStatus?.wsUrl + ?

    {pushStatus.wsUrl}

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

    {pushStatus.lastError}

    + : null}
    {state.failed ?

    {t('saveFailed')}

    : null} diff --git a/src/client/alarm-push-status-view.ts b/src/client/alarm-push-status-view.ts new file mode 100644 index 0000000..68908e1 --- /dev/null +++ b/src/client/alarm-push-status-view.ts @@ -0,0 +1,90 @@ +/** + * Browser-side alarm-push status labels + Connection RPC poll helper. + */ + +import type { AlarmPushPhase, AlarmPushStatus } from '../netx/alarm-push-status.ts' + +/** Same channel as host `NETXOPS_RPC_CHANNEL`. */ +export const NETXOPS_RPC_CHANNEL = '/netxops' + +export const ALARM_PUSH_STATUS_ENDPOINT = 'alarm-push.status' + +export type { AlarmPushPhase, AlarmPushStatus } + +export type AlarmPushRpcCall = ( + channel: string, + endpoint: string, + payload?: unknown, + signal?: AbortSignal, +) => Promise + +const EMPTY: AlarmPushStatus = { + phase: 'disabled', + enabled: false, + wsUrl: '', + detail: '', + updatedAt: 0, + lastConnectedAt: null, + lastError: null, +} + +/** + * Normalize a host status snapshot for the card. + * @param value - RPC value or unknown. + */ +export function asAlarmPushStatus(value: unknown): AlarmPushStatus { + if (value === null || typeof value !== 'object' || Array.isArray(value)) return { ...EMPTY } + const row = value as Record + const phase = typeof row.phase === 'string' ? row.phase as AlarmPushPhase : 'disabled' + return { + phase, + enabled: row.enabled === true, + wsUrl: typeof row.wsUrl === 'string' ? row.wsUrl : '', + detail: typeof row.detail === 'string' ? row.detail : '', + updatedAt: typeof row.updatedAt === 'number' ? row.updatedAt : 0, + lastConnectedAt: typeof row.lastConnectedAt === 'number' ? row.lastConnectedAt : null, + lastError: typeof row.lastError === 'string' ? row.lastError : null, + } +} + +/** + * Fetch the latest host-side alarm-push status. + * @param call - `ctx.connection.rpc.call`. + * @param signal - optional abort. + */ +export async function fetchAlarmPushStatus( + call: AlarmPushRpcCall, + signal?: AbortSignal, +): Promise { + const result = await call(NETXOPS_RPC_CHANNEL, ALARM_PUSH_STATUS_ENDPOINT, {}, signal) + if (result !== null && typeof result === 'object' && (result as { ok?: boolean }).ok === true) { + return asAlarmPushStatus((result as { value?: unknown }).value) + } + if (result !== null && typeof result === 'object' && (result as { ok?: boolean }).ok === false) { + return { + ...EMPTY, + phase: 'error', + enabled: true, + detail: 'rpc_error', + lastError: String((result as { error?: { message?: string } }).error?.message ?? 'rpc failed'), + } + } + return asAlarmPushStatus(result) +} + +/** CSS modifier for a phase badge. */ +export function alarmPushTone(phase: AlarmPushPhase): 'ok' | 'warn' | 'err' | 'mute' { + switch (phase) { + case 'connected': + return 'ok' + case 'connecting': + case 'authenticating': + case 'reconnecting': + return 'warn' + case 'auth_failed': + case 'error': + return 'err' + default: + return 'mute' + } +} diff --git a/src/client/controller.ts b/src/client/controller.ts index 061e309..3bf0a48 100644 --- a/src/client/controller.ts +++ b/src/client/controller.ts @@ -10,10 +10,16 @@ import { CardForm, textField, booleanField, type CardActions, type CardFieldState, type CardShell, } from './card-form.ts' +import { + fetchAlarmPushStatus, + type AlarmPushRpcCall, + type AlarmPushStatus, +} from './alarm-push-status-view.ts' export const NETXOPS_NS = 'netxops' const DEFAULT_TOKEN_REF = 'NETX_API_TOKEN' const API_TOKEN_FIELD = 'apiToken' +const STATUS_POLL_MS = 2_000 export interface NetxopsSettings { apiUrl?: string @@ -37,6 +43,8 @@ export interface NetxopsCardState extends CardShell { apiTokenConfigured: boolean apiTokenWritable: boolean apiTokenRemoteReady: boolean + /** Host WSS status when Connection RPC is available; otherwise null. */ + alarmPushStatus: AlarmPushStatus | null } export interface NetxopsCardFace extends CardActions { @@ -59,6 +67,10 @@ export class NetxopsCardController { writable: false, remoteReady: false, } + private rpcCall: AlarmPushRpcCall | undefined + private alarmPushStatus: AlarmPushStatus | null = null + private pollTimer: ReturnType | undefined + private pollInFlight = false constructor( private readonly scope: SettingsScope, @@ -86,6 +98,60 @@ export class NetxopsCardController { if (ready) void this.readCredential() } + /** + * Soft-inject Host Connection RPC used to read alarm-push WSS status. + * @param call - `ctx.connection.rpc.call`, or undefined when connection leaves. + */ + setAlarmPushRpc(call: AlarmPushRpcCall | undefined): void { + this.rpcCall = call + if (call === undefined) { + this.stopStatusPoll() + if (this.alarmPushStatus !== null) { + this.alarmPushStatus = null + this.store.set(this.projection()) + } + return + } + this.startStatusPoll() + void this.refreshAlarmPushStatus() + } + + private startStatusPoll(): void { + if (this.pollTimer !== undefined) return + this.pollTimer = setInterval(() => { void this.refreshAlarmPushStatus() }, STATUS_POLL_MS) + } + + private stopStatusPoll(): void { + if (this.pollTimer === undefined) return + clearInterval(this.pollTimer) + this.pollTimer = undefined + } + + private async refreshAlarmPushStatus(): Promise { + const call = this.rpcCall + if (call === undefined || this.pollInFlight) return + this.pollInFlight = true + try { + const next = await fetchAlarmPushStatus(call) + const prev = this.alarmPushStatus + if ( + prev + && prev.phase === next.phase + && prev.enabled === next.enabled + && prev.wsUrl === next.wsUrl + && prev.detail === next.detail + && prev.lastError === next.lastError + && prev.lastConnectedAt === next.lastConnectedAt + ) return + this.alarmPushStatus = next + this.store.set(this.projection()) + } catch { + // Keep last good snapshot; next poll retries. + } finally { + this.pollInFlight = false + } + } + private projection(): NetxopsCardState { return { ...this.form.shell(), @@ -96,6 +162,7 @@ export class NetxopsCardController { apiTokenConfigured: this.credential.configured, apiTokenWritable: this.credential.remoteReady && this.credential.writable, apiTokenRemoteReady: this.credential.remoteReady, + alarmPushStatus: this.alarmPushStatus, } } diff --git a/src/client/index.ts b/src/client/index.ts index 993da9d..a84b039 100644 --- a/src/client/index.ts +++ b/src/client/index.ts @@ -54,6 +54,19 @@ export function apply(ctx: ClientContext): void { }, 'netxops: credential invalidations') }) + // Soft-inject Connection so the card can poll host WSS status. + ctx.inject(['connection'], (connCtx) => { + const call = connCtx.connection?.rpc?.call?.bind(connCtx.connection.rpc) + if (typeof call !== 'function') { + connCtx.logger.warn('netxops: connection.rpc.call unavailable — alarm status UI disabled') + return + } + card.setAlarmPushRpc(call) + connCtx.effect(() => () => { + card.setAlarmPushRpc(undefined) + }, 'netxops: clear alarm-push rpc') + }) + ctx.slots.inject('settings.plugin.item', () => ctx.slots.register({ name: 'settings.plugin.item', key: NETXOPS_NS, diff --git a/src/client/locales.ts b/src/client/locales.ts index f8c9aea..6b536e7 100644 --- a/src/client/locales.ts +++ b/src/client/locales.ts @@ -9,6 +9,15 @@ export type NetxopsLocaleKey = | 'langHint' | 'alarmPushEnabled' | 'alarmPushEnabledHint' + | 'alarmPushStatus' + | 'alarmPushPhaseDisabled' + | 'alarmPushPhaseIdle' + | 'alarmPushPhaseConnecting' + | 'alarmPushPhaseAuthenticating' + | 'alarmPushPhaseConnected' + | 'alarmPushPhaseReconnecting' + | 'alarmPushPhaseAuthFailed' + | 'alarmPushPhaseError' | 'apiToken' | 'apiTokenHint' | 'apiTokenUnavailable' @@ -35,6 +44,15 @@ export const en: Record = { langHint: 'Response language hint (zh / en).', alarmPushEnabled: 'Key-alarm push', alarmPushEnabledHint: 'Dial out to netx and open/follow a DSH session when a matched key alarm arrives. WhatsApp/im is optional and not required.', + alarmPushStatus: 'Push link', + alarmPushPhaseDisabled: 'Off', + alarmPushPhaseIdle: 'Idle', + alarmPushPhaseConnecting: 'Connecting…', + alarmPushPhaseAuthenticating: 'Authenticating…', + alarmPushPhaseConnected: 'Connected', + alarmPushPhaseReconnecting: 'Reconnecting…', + alarmPushPhaseAuthFailed: 'Auth failed', + alarmPushPhaseError: 'Error', apiToken: 'API token', apiTokenHint: 'Stored as credential NETX_API_TOKEN (never written into settings). Leave blank to keep the current token.', apiTokenUnavailable: 'This DSH build does not expose remote.credentials. Set the token with scripts/set-netx-token.ps1 (or .sh), then restart is not required if credentials are watched.', @@ -62,6 +80,15 @@ export const zh: Record = { langHint: '响应语言提示(zh / en)。', alarmPushEnabled: '关键告警推送', alarmPushEnabledHint: '主动连接 netx;匹配到关键告警后在本机打开/续写一条 DSH 会话。不必安装 WhatsApp / im。', + alarmPushStatus: '推送链路', + alarmPushPhaseDisabled: '未开启', + alarmPushPhaseIdle: '空闲', + alarmPushPhaseConnecting: '连接中…', + alarmPushPhaseAuthenticating: '鉴权中…', + alarmPushPhaseConnected: '已连接', + alarmPushPhaseReconnecting: '重连中…', + alarmPushPhaseAuthFailed: '鉴权失败', + alarmPushPhaseError: '异常', apiToken: 'API Token', apiTokenHint: '写入凭据 NETX_API_TOKEN(不会进 settings)。留空表示保留已有 token。', apiTokenUnavailable: '当前 DSH 未提供 remote.credentials。请用 scripts/set-netx-token.ps1(或 .sh)写入 token;若 harness 在监视凭据文件则无需重启。', diff --git a/src/client/styles.ts b/src/client/styles.ts index eab2874..ae0d10c 100644 --- a/src/client/styles.ts +++ b/src/client/styles.ts @@ -12,6 +12,10 @@ const CSS = ` .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} diff --git a/src/index.ts b/src/index.ts index f7de4f6..ee05acf 100644 --- a/src/index.ts +++ b/src/index.ts @@ -20,6 +20,11 @@ import type {} from '@deepseek-ai/dsh-credentials' import * as DshSettings from '@deepseek-ai/dsh-settings' import type {} from '@deepseek-ai/dsh-settings' import { startAlarmPushClient } from './netx/alarm-push.ts' +import { + getAlarmPushStatus, + publishAlarmPushStatus, + resetAlarmPushStatus, +} from './netx/alarm-push-status.ts' import { deliverAlarmToSession, resetAlarmSession } from './netx/alarm-session.ts' import { publishNetxConnection } from './netx/runtime.ts' @@ -29,6 +34,9 @@ export const name = 'netxops' /** Wait for the credentials store before publishing a Bearer snapshot. */ export const inject = ['credentials'] +/** Connection RPC channel for browser status reads. */ +export const NETXOPS_RPC_CHANNEL = '/netxops' + /** Settings / composition namespace (Plugins page join key). */ export const NETXOPS_SETTINGS_NAMESPACE = 'netxops' @@ -167,9 +175,19 @@ export function apply(ctx: Context, config: Config = Config({})): void { stopAlarmPush?.() stopAlarmPush = undefined resetAlarmSession() - if (!enabled) return + if (!enabled) { + resetAlarmPushStatus() + return + } if (!token.trim()) { ctx.logger.warn('netxops alarm-push: enabled but token is empty — not connecting') + publishAlarmPushStatus({ + phase: 'error', + enabled: true, + wsUrl: '', + detail: 'missing_token', + lastError: 'token empty', + }) return } stopAlarmPush = startAlarmPushClient({ @@ -229,10 +247,32 @@ export function apply(ctx: Context, config: Config = Config({})): void { if (String(ref) === source().tokenCredentialRef) publish() }) + // Browser card polls alarm-push WSS status through Connection RPC. + ctx.inject(['connection'], (connCtx) => { + const rpc = connCtx.connection?.rpc + if (!rpc || typeof rpc.handle !== 'function') { + connCtx.logger.warn('netxops: connection.rpc.handle unavailable — alarm status UI disabled') + return + } + connCtx.effect(() => { + const dispose = rpc.handle( + NETXOPS_RPC_CHANNEL, + async (endpoint: string) => { + if (endpoint !== 'alarm-push.status') { + return { ok: false, error: { code: 'bad-request', message: 'Unknown endpoint.' } } + } + return { ok: true, value: getAlarmPushStatus() } + }, + ) + return () => { void dispose() } + }, 'netxops: alarm-push status rpc') + }) + ctx.effect(() => () => { generation += 1 stopAlarmPush?.() stopAlarmPush = undefined resetAlarmSession() + resetAlarmPushStatus() }, 'netxops: dispose host bridge') } diff --git a/src/netx/alarm-push-status.ts b/src/netx/alarm-push-status.ts new file mode 100644 index 0000000..f920914 --- /dev/null +++ b/src/netx/alarm-push-status.ts @@ -0,0 +1,89 @@ +/** + * Process-local alarm-push connection status (host → browser via RPC). + */ + +export type AlarmPushPhase = + | 'disabled' + | 'idle' + | 'connecting' + | 'authenticating' + | 'connected' + | 'reconnecting' + | 'auth_failed' + | 'error' + +export interface AlarmPushStatus { + phase: AlarmPushPhase + enabled: boolean + wsUrl: string + detail: string + updatedAt: number + lastConnectedAt: number | null + lastError: string | null +} + +type Listener = () => void + +interface Store { + status: AlarmPushStatus + listeners: Set +} + +const STORE_KEY = Symbol.for('dsh-netxops.alarm-push-status') + +function emptyStatus(partial: Partial = {}): AlarmPushStatus { + return { + phase: 'disabled', + enabled: false, + wsUrl: '', + detail: '', + updatedAt: Date.now(), + lastConnectedAt: null, + lastError: null, + ...partial, + } +} + +function store(): Store { + const root = globalThis as typeof globalThis & { [STORE_KEY]?: Store } + let current = root[STORE_KEY] + if (current === undefined) { + current = { status: emptyStatus(), listeners: new Set() } + root[STORE_KEY] = current + } + return current +} + +/** @returns the latest alarm-push status snapshot. */ +export function getAlarmPushStatus(): AlarmPushStatus { + return { ...store().status } +} + +/** + * Replace the published status and notify listeners. + * @param next - full or partial status fields. + */ +export function publishAlarmPushStatus(next: Partial & Pick): void { + const state = store() + state.status = { + ...state.status, + ...next, + updatedAt: Date.now(), + } + for (const listener of state.listeners) listener() +} + +/** + * Subscribe to status publishes. + * @param listener - called after each publish. + */ +export function watchAlarmPushStatus(listener: Listener): () => void { + const state = store() + state.listeners.add(listener) + return () => { state.listeners.delete(listener) } +} + +/** Reset to disabled (plugin dispose / toggle off). */ +export function resetAlarmPushStatus(): void { + publishAlarmPushStatus(emptyStatus({ phase: 'disabled', enabled: false })) +} diff --git a/src/netx/alarm-push.ts b/src/netx/alarm-push.ts index ef287df..d0fef7b 100644 --- a/src/netx/alarm-push.ts +++ b/src/netx/alarm-push.ts @@ -2,6 +2,12 @@ * Convert a netx REST apiUrl into the DSH alarm-subscribe WebSocket URL. * @param apiUrl - e.g. http://192.168.1.10:8890 */ + +import { + publishAlarmPushStatus, + type AlarmPushPhase, +} from './alarm-push-status.ts' + export function alarmSubscribeUrl(apiUrl: string): string { const trimmed = apiUrl.trim().replace(/\/$/, '') if (!trimmed) return '' @@ -99,6 +105,21 @@ export interface AlarmPushClientOptions { WebSocketImpl?: typeof WebSocket } +function setPhase( + phase: AlarmPushPhase, + wsUrl: string, + extra: { detail?: string, lastError?: string | null, lastConnectedAt?: number | null } = {}, +): void { + publishAlarmPushStatus({ + phase, + enabled: true, + wsUrl, + detail: extra.detail ?? '', + lastError: extra.lastError === undefined ? null : extra.lastError, + ...(extra.lastConnectedAt !== undefined ? { lastConnectedAt: extra.lastConnectedAt } : {}), + }) +} + /** * Dial out to netx's fixed-IP alarm hub and forward `netx.alarm` events. * @returns disposer that closes the socket and cancels reconnect. @@ -109,12 +130,20 @@ export function startAlarmPushClient(options: AlarmPushClientOptions): () => voi const token = options.token.trim() if (!wsUrl || !token) { log.warn?.('netxops alarm-push: missing apiUrl or token — not connecting') + setPhase('error', wsUrl, { + detail: 'missing_url_or_token', + lastError: 'missing apiUrl or token', + }) return () => {} } const WS = options.WebSocketImpl ?? globalThis.WebSocket if (typeof WS !== 'function') { log.error?.('netxops alarm-push: WebSocket is unavailable in this runtime') + setPhase('error', wsUrl, { + detail: 'websocket_unavailable', + lastError: 'WebSocket unavailable', + }) return () => {} } @@ -131,27 +160,33 @@ export function startAlarmPushClient(options: AlarmPushClientOptions): () => voi } } - const scheduleReconnect = (): void => { + const scheduleReconnect = (reason: string): void => { if (closed) return clearReconnect() const delay = Math.min(60_000, baseDelay * (2 ** Math.min(attempt, 5))) attempt += 1 + setPhase('reconnecting', wsUrl, { + detail: `retry_in_${delay}ms`, + lastError: reason, + }) reconnectTimer = setTimeout(() => { connect() }, delay) } const connect = (): void => { if (closed) return clearReconnect() + setPhase(attempt > 0 ? 'reconnecting' : 'connecting', wsUrl, { detail: 'dialing' }) try { socket = new WS(wsUrl) } catch (error) { + const message = error instanceof Error ? error.message : String(error) log.warn?.('netxops alarm-push: connect failed:', error) - scheduleReconnect() + scheduleReconnect(message) return } socket.addEventListener('open', () => { - attempt = 0 + setPhase('authenticating', wsUrl, { detail: 'auth' }) socket?.send(JSON.stringify({ type: 'auth', token })) }) @@ -164,11 +199,20 @@ export function startAlarmPushClient(options: AlarmPushClientOptions): () => voi } const type = String(msg.type ?? '').toLowerCase() if (type === 'auth-ok') { + attempt = 0 + const now = Date.now() + setPhase('connected', wsUrl, { + detail: String(msg.user ?? 'ok'), + lastError: null, + lastConnectedAt: now, + }) log.info?.('netxops alarm-push: subscribed to %s', wsUrl) return } if (type === 'auth-fail') { - log.error?.('netxops alarm-push: auth failed (%s)', msg.error) + const err = String(msg.error ?? 'auth_failed') + log.error?.('netxops alarm-push: auth failed (%s)', err) + setPhase('auth_failed', wsUrl, { detail: err, lastError: err }) socket?.close() return } @@ -185,7 +229,7 @@ export function startAlarmPushClient(options: AlarmPushClientOptions): () => voi socket.addEventListener('close', () => { socket = null - if (!closed) scheduleReconnect() + if (!closed) scheduleReconnect('socket_closed') }) socket.addEventListener('error', () => { @@ -193,7 +237,6 @@ export function startAlarmPushClient(options: AlarmPushClientOptions): () => voi }) } - // Keepalive ping while connected. const pingTimer = setInterval(() => { if (socket && socket.readyState === WS.OPEN) { try {