mirror of
https://github.com/hansjone/netxops.git
synced 2026-10-11 22:00:45 +08:00
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 <cursoragent@cursor.com>
This commit is contained in:
parent
12f7018103
commit
e8b7d7331f
14 changed files with 783 additions and 55 deletions
|
|
@ -16,7 +16,7 @@ dsh web
|
||||||
Then:
|
Then:
|
||||||
|
|
||||||
1. Settings → **Plugins** → **Netx Ops** → API URL / token
|
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)
|
3. Settings → **Agent presets** → Custom → **Netx Ops** (installed automatically on first host activate)
|
||||||
4. New session → choose **Netx Ops**
|
4. New session → choose **Netx Ops**
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -30,7 +30,7 @@ dsh web # or: pnpm dsh web
|
||||||
|
|
||||||
1. **Settings → Plugins → Netx Ops** → API URL (+ token if the field is enabled).
|
1. **Settings → Plugins → Netx Ops** → API URL (+ token if the field is enabled).
|
||||||
Token fallback: `scripts/set-netx-token.ps1` / `.sh`.
|
Token fallback: `scripts/set-netx-token.ps1` / `.sh`.
|
||||||
Optional: enable **关键告警推送** so this DSH dials `ws(s)://<apiUrl>/v1/integrations/dsh-alarm/ws` and follows a sticky session when netx matches a key alert.
|
Optional: enable **关键告警推送** so this DSH dials `ws(s)://<apiUrl>/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.
|
2. Restart or open Settings → **Agent presets** → Custom → **Netx Ops** should appear after the host plugin has activated once.
|
||||||
3. **New session → preset Netx Ops** → ask e.g. Critical Top / single-host alarms.
|
3. **New session → preset Netx Ops** → ask e.g. Critical Top / single-host alarms.
|
||||||
|
|
||||||
|
|
@ -38,13 +38,15 @@ dsh web # or: pnpm dsh web
|
||||||
|
|
||||||
- **netx** (fixed IP) hosts the subscribe hub and fans out matched key alerts.
|
- **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.
|
- **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.
|
- WhatsApp / `dsh-im` is **not** required for this path.
|
||||||
|
|
||||||
## Verify
|
## Verify
|
||||||
|
|
||||||
1. Plugins card **Netx Ops** visible.
|
1. Plugins card **Netx Ops** visible.
|
||||||
2. Agent presets → Custom → **Netx Ops**.
|
2. With key-alarm push on: card badge shows **Connected** (or reconnecting / auth failed with detail).
|
||||||
3. Tools include `netx__queryUmeAlarms`.
|
3. Agent presets → Custom → **Netx Ops**.
|
||||||
|
4. Tools include `netx__queryUmeAlarms`.
|
||||||
|
|
||||||
See [TOOL_MAP.md](TOOL_MAP.md).
|
See [TOOL_MAP.md](TOOL_MAP.md).
|
||||||
|
|
||||||
|
|
|
||||||
227
lib/client.js
227
lib/client.js
|
|
@ -53,6 +53,65 @@ module.exports = __toCommonJS(exports_client);
|
||||||
// src/client/NetxopsCard.tsx
|
// src/client/NetxopsCard.tsx
|
||||||
var import_react = require("react");
|
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
|
// src/client/styles.ts
|
||||||
var STYLE_ID = "dsh-netxops-card-css";
|
var STYLE_ID = "dsh-netxops-card-css";
|
||||||
var 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-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-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-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-chevron{flex:none;color:var(--dsw-alias-label-tertiary);transition:transform .16s}
|
||||||
.dsh-nx-chevronOpen{transform:rotate(180deg)}
|
.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-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
|
// src/client/NetxopsCard.tsx
|
||||||
var jsx_runtime = require("react/jsx-runtime");
|
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) {
|
function ValueField(props) {
|
||||||
return /* @__PURE__ */ jsx_runtime.jsxs("div", {
|
return /* @__PURE__ */ jsx_runtime.jsxs("div", {
|
||||||
className: "dsh-nx-field",
|
className: "dsh-nx-field",
|
||||||
|
|
@ -176,6 +269,8 @@ function NetxopsCard(props) {
|
||||||
const title = t("title");
|
const title = t("title");
|
||||||
const disabled = !state.writable;
|
const disabled = !state.writable;
|
||||||
const blocked = !state.dirty || state.invalid || state.saving;
|
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", {
|
return /* @__PURE__ */ jsx_runtime.jsxs("li", {
|
||||||
className: open ? "dsh-nx-card dsh-nx-cardOpen" : "dsh-nx-card",
|
className: open ? "dsh-nx-card dsh-nx-cardOpen" : "dsh-nx-card",
|
||||||
children: [
|
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", {
|
state.dirty ? /* @__PURE__ */ jsx_runtime.jsx("span", {
|
||||||
className: "dsh-nx-pending",
|
className: "dsh-nx-pending",
|
||||||
children: t("unsaved")
|
children: t("unsaved")
|
||||||
|
|
@ -300,24 +399,32 @@ function NetxopsCard(props) {
|
||||||
htmlFor: "netxops-alarm-push",
|
htmlFor: "netxops-alarm-push",
|
||||||
children: t("alarmPushEnabled")
|
children: t("alarmPushEnabled")
|
||||||
}),
|
}),
|
||||||
state.alarmPushEnabled.overridden ? /* @__PURE__ */ jsx_runtime.jsxs("span", {
|
/* @__PURE__ */ jsx_runtime.jsxs("span", {
|
||||||
className: "dsh-nx-badges",
|
className: "dsh-nx-badges",
|
||||||
children: [
|
children: [
|
||||||
/* @__PURE__ */ jsx_runtime.jsx("span", {
|
pushStatus ? /* @__PURE__ */ jsx_runtime.jsx(StatusBadge, {
|
||||||
className: "dsh-nx-badge",
|
phase: pushStatus.phase,
|
||||||
children: t("overridden")
|
label: `${t("alarmPushStatus")}: ${t(phaseLocaleKey(pushStatus.phase))}`
|
||||||
}),
|
}) : null,
|
||||||
/* @__PURE__ */ jsx_runtime.jsx("button", {
|
state.alarmPushEnabled.overridden ? /* @__PURE__ */ jsx_runtime.jsxs(jsx_runtime.Fragment, {
|
||||||
type: "button",
|
children: [
|
||||||
className: "dsh-nx-reset",
|
/* @__PURE__ */ jsx_runtime.jsx("span", {
|
||||||
disabled,
|
className: "dsh-nx-badge",
|
||||||
onClick: () => {
|
children: t("overridden")
|
||||||
props.resetField("alarmPushEnabled");
|
}),
|
||||||
},
|
/* @__PURE__ */ jsx_runtime.jsx("button", {
|
||||||
children: t("reset")
|
type: "button",
|
||||||
})
|
className: "dsh-nx-reset",
|
||||||
|
disabled,
|
||||||
|
onClick: () => {
|
||||||
|
props.resetField("alarmPushEnabled");
|
||||||
|
},
|
||||||
|
children: t("reset")
|
||||||
|
})
|
||||||
|
]
|
||||||
|
}) : null
|
||||||
]
|
]
|
||||||
}) : null
|
})
|
||||||
]
|
]
|
||||||
}),
|
}),
|
||||||
/* @__PURE__ */ jsx_runtime.jsxs("label", {
|
/* @__PURE__ */ jsx_runtime.jsxs("label", {
|
||||||
|
|
@ -337,7 +444,16 @@ function NetxopsCard(props) {
|
||||||
children: t("alarmPushEnabledHint")
|
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", {
|
/* @__PURE__ */ jsx_runtime.jsxs("div", {
|
||||||
|
|
@ -580,6 +696,7 @@ class CardForm {
|
||||||
var NETXOPS_NS = "netxops";
|
var NETXOPS_NS = "netxops";
|
||||||
var DEFAULT_TOKEN_REF = "NETX_API_TOKEN";
|
var DEFAULT_TOKEN_REF = "NETX_API_TOKEN";
|
||||||
var API_TOKEN_FIELD = "apiToken";
|
var API_TOKEN_FIELD = "apiToken";
|
||||||
|
var STATUS_POLL_MS = 2000;
|
||||||
|
|
||||||
class NetxopsCardController {
|
class NetxopsCardController {
|
||||||
scope;
|
scope;
|
||||||
|
|
@ -592,6 +709,10 @@ class NetxopsCardController {
|
||||||
writable: false,
|
writable: false,
|
||||||
remoteReady: false
|
remoteReady: false
|
||||||
};
|
};
|
||||||
|
rpcCall;
|
||||||
|
alarmPushStatus = null;
|
||||||
|
pollTimer;
|
||||||
|
pollInFlight = false;
|
||||||
constructor(scope, ctx) {
|
constructor(scope, ctx) {
|
||||||
this.scope = scope;
|
this.scope = scope;
|
||||||
this.ctx = ctx;
|
this.ctx = ctx;
|
||||||
|
|
@ -614,6 +735,48 @@ class NetxopsCardController {
|
||||||
if (ready)
|
if (ready)
|
||||||
this.readCredential();
|
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() {
|
projection() {
|
||||||
return {
|
return {
|
||||||
...this.form.shell(),
|
...this.form.shell(),
|
||||||
|
|
@ -623,7 +786,8 @@ class NetxopsCardController {
|
||||||
apiToken: this.form.field(API_TOKEN_FIELD),
|
apiToken: this.form.field(API_TOKEN_FIELD),
|
||||||
apiTokenConfigured: this.credential.configured,
|
apiTokenConfigured: this.credential.configured,
|
||||||
apiTokenWritable: this.credential.remoteReady && this.credential.writable,
|
apiTokenWritable: this.credential.remoteReady && this.credential.writable,
|
||||||
apiTokenRemoteReady: this.credential.remoteReady
|
apiTokenRemoteReady: this.credential.remoteReady,
|
||||||
|
alarmPushStatus: this.alarmPushStatus
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
credentials() {
|
credentials() {
|
||||||
|
|
@ -695,6 +859,15 @@ var en = {
|
||||||
langHint: "Response language hint (zh / en).",
|
langHint: "Response language hint (zh / en).",
|
||||||
alarmPushEnabled: "Key-alarm push",
|
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.",
|
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",
|
apiToken: "API token",
|
||||||
apiTokenHint: "Stored as credential NETX_API_TOKEN (never written into settings). Leave blank to keep the current 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.",
|
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)。",
|
langHint: "响应语言提示(zh / en)。",
|
||||||
alarmPushEnabled: "关键告警推送",
|
alarmPushEnabled: "关键告警推送",
|
||||||
alarmPushEnabledHint: "主动连接 netx;匹配到关键告警后在本机打开/续写一条 DSH 会话。不必安装 WhatsApp / im。",
|
alarmPushEnabledHint: "主动连接 netx;匹配到关键告警后在本机打开/续写一条 DSH 会话。不必安装 WhatsApp / im。",
|
||||||
|
alarmPushStatus: "推送链路",
|
||||||
|
alarmPushPhaseDisabled: "未开启",
|
||||||
|
alarmPushPhaseIdle: "空闲",
|
||||||
|
alarmPushPhaseConnecting: "连接中…",
|
||||||
|
alarmPushPhaseAuthenticating: "鉴权中…",
|
||||||
|
alarmPushPhaseConnected: "已连接",
|
||||||
|
alarmPushPhaseReconnecting: "重连中…",
|
||||||
|
alarmPushPhaseAuthFailed: "鉴权失败",
|
||||||
|
alarmPushPhaseError: "异常",
|
||||||
apiToken: "API Token",
|
apiToken: "API Token",
|
||||||
apiTokenHint: "写入凭据 NETX_API_TOKEN(不会进 settings)。留空表示保留已有 token。",
|
apiTokenHint: "写入凭据 NETX_API_TOKEN(不会进 settings)。留空表示保留已有 token。",
|
||||||
apiTokenUnavailable: "当前 DSH 未提供 remote.credentials。请用 scripts/set-netx-token.ps1(或 .sh)写入 token;若 harness 在监视凭据文件则无需重启。",
|
apiTokenUnavailable: "当前 DSH 未提供 remote.credentials。请用 scripts/set-netx-token.ps1(或 .sh)写入 token;若 harness 在监视凭据文件则无需重启。",
|
||||||
|
|
@ -762,6 +944,17 @@ function apply(ctx) {
|
||||||
};
|
};
|
||||||
}, "netxops: credential invalidations");
|
}, "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({
|
ctx.slots.inject("settings.plugin.item", () => ctx.slots.register({
|
||||||
name: "settings.plugin.item",
|
name: "settings.plugin.item",
|
||||||
key: NETXOPS_NS,
|
key: NETXOPS_NS,
|
||||||
|
|
|
||||||
125
lib/index.js
125
lib/index.js
|
|
@ -10,6 +10,46 @@ import z from "@deepseek-ai/schemastery";
|
||||||
import { credentialRef } from "@deepseek-ai/dsh-credentials";
|
import { credentialRef } from "@deepseek-ai/dsh-credentials";
|
||||||
import * as DshSettings from "@deepseek-ai/dsh-settings";
|
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
|
// src/netx/alarm-push.ts
|
||||||
function alarmSubscribeUrl(apiUrl) {
|
function alarmSubscribeUrl(apiUrl) {
|
||||||
const trimmed = apiUrl.trim().replace(/\/$/, "");
|
const trimmed = apiUrl.trim().replace(/\/$/, "");
|
||||||
|
|
@ -76,17 +116,35 @@ function formatAlarmPrompt(payload, lang = "zh") {
|
||||||
].join(`
|
].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) {
|
function startAlarmPushClient(options) {
|
||||||
const log = options.logger ?? console;
|
const log = options.logger ?? console;
|
||||||
const wsUrl = alarmSubscribeUrl(options.apiUrl);
|
const wsUrl = alarmSubscribeUrl(options.apiUrl);
|
||||||
const token = options.token.trim();
|
const token = options.token.trim();
|
||||||
if (!wsUrl || !token) {
|
if (!wsUrl || !token) {
|
||||||
log.warn?.("netxops alarm-push: missing apiUrl or token — not connecting");
|
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 () => {};
|
return () => {};
|
||||||
}
|
}
|
||||||
const WS = options.WebSocketImpl ?? globalThis.WebSocket;
|
const WS = options.WebSocketImpl ?? globalThis.WebSocket;
|
||||||
if (typeof WS !== "function") {
|
if (typeof WS !== "function") {
|
||||||
log.error?.("netxops alarm-push: WebSocket is unavailable in this runtime");
|
log.error?.("netxops alarm-push: WebSocket is unavailable in this runtime");
|
||||||
|
setPhase("error", wsUrl, {
|
||||||
|
detail: "websocket_unavailable",
|
||||||
|
lastError: "WebSocket unavailable"
|
||||||
|
});
|
||||||
return () => {};
|
return () => {};
|
||||||
}
|
}
|
||||||
let closed = false;
|
let closed = false;
|
||||||
|
|
@ -100,12 +158,16 @@ function startAlarmPushClient(options) {
|
||||||
reconnectTimer = undefined;
|
reconnectTimer = undefined;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
const scheduleReconnect = () => {
|
const scheduleReconnect = (reason) => {
|
||||||
if (closed)
|
if (closed)
|
||||||
return;
|
return;
|
||||||
clearReconnect();
|
clearReconnect();
|
||||||
const delay = Math.min(60000, baseDelay * 2 ** Math.min(attempt, 5));
|
const delay = Math.min(60000, baseDelay * 2 ** Math.min(attempt, 5));
|
||||||
attempt += 1;
|
attempt += 1;
|
||||||
|
setPhase("reconnecting", wsUrl, {
|
||||||
|
detail: `retry_in_${delay}ms`,
|
||||||
|
lastError: reason
|
||||||
|
});
|
||||||
reconnectTimer = setTimeout(() => {
|
reconnectTimer = setTimeout(() => {
|
||||||
connect();
|
connect();
|
||||||
}, delay);
|
}, delay);
|
||||||
|
|
@ -114,15 +176,17 @@ function startAlarmPushClient(options) {
|
||||||
if (closed)
|
if (closed)
|
||||||
return;
|
return;
|
||||||
clearReconnect();
|
clearReconnect();
|
||||||
|
setPhase(attempt > 0 ? "reconnecting" : "connecting", wsUrl, { detail: "dialing" });
|
||||||
try {
|
try {
|
||||||
socket = new WS(wsUrl);
|
socket = new WS(wsUrl);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : String(error);
|
||||||
log.warn?.("netxops alarm-push: connect failed:", error);
|
log.warn?.("netxops alarm-push: connect failed:", error);
|
||||||
scheduleReconnect();
|
scheduleReconnect(message);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
socket.addEventListener("open", () => {
|
socket.addEventListener("open", () => {
|
||||||
attempt = 0;
|
setPhase("authenticating", wsUrl, { detail: "auth" });
|
||||||
socket?.send(JSON.stringify({ type: "auth", token }));
|
socket?.send(JSON.stringify({ type: "auth", token }));
|
||||||
});
|
});
|
||||||
socket.addEventListener("message", (event) => {
|
socket.addEventListener("message", (event) => {
|
||||||
|
|
@ -134,11 +198,20 @@ function startAlarmPushClient(options) {
|
||||||
}
|
}
|
||||||
const type = String(msg.type ?? "").toLowerCase();
|
const type = String(msg.type ?? "").toLowerCase();
|
||||||
if (type === "auth-ok") {
|
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);
|
log.info?.("netxops alarm-push: subscribed to %s", wsUrl);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (type === "auth-fail") {
|
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();
|
socket?.close();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
@ -154,7 +227,7 @@ function startAlarmPushClient(options) {
|
||||||
socket.addEventListener("close", () => {
|
socket.addEventListener("close", () => {
|
||||||
socket = null;
|
socket = null;
|
||||||
if (!closed)
|
if (!closed)
|
||||||
scheduleReconnect();
|
scheduleReconnect("socket_closed");
|
||||||
});
|
});
|
||||||
socket.addEventListener("error", () => {});
|
socket.addEventListener("error", () => {});
|
||||||
};
|
};
|
||||||
|
|
@ -297,18 +370,18 @@ function resetAlarmSession() {
|
||||||
}
|
}
|
||||||
|
|
||||||
// src/netx/runtime.ts
|
// src/netx/runtime.ts
|
||||||
var STORE_KEY = Symbol.for("dsh-netxops.connection-store");
|
var STORE_KEY2 = Symbol.for("dsh-netxops.connection-store");
|
||||||
function store() {
|
function store2() {
|
||||||
const root = globalThis;
|
const root = globalThis;
|
||||||
let current = root[STORE_KEY];
|
let current = root[STORE_KEY2];
|
||||||
if (current === undefined) {
|
if (current === undefined) {
|
||||||
current = { connection: undefined, listeners: new Set };
|
current = { connection: undefined, listeners: new Set };
|
||||||
root[STORE_KEY] = current;
|
root[STORE_KEY2] = current;
|
||||||
}
|
}
|
||||||
return current;
|
return current;
|
||||||
}
|
}
|
||||||
function publishNetxConnection(next) {
|
function publishNetxConnection(next) {
|
||||||
const state = store();
|
const state = store2();
|
||||||
state.connection = next;
|
state.connection = next;
|
||||||
for (const listener of state.listeners)
|
for (const listener of state.listeners)
|
||||||
listener();
|
listener();
|
||||||
|
|
@ -317,6 +390,7 @@ function publishNetxConnection(next) {
|
||||||
// src/index.ts
|
// src/index.ts
|
||||||
var name = "netxops";
|
var name = "netxops";
|
||||||
var inject = ["credentials"];
|
var inject = ["credentials"];
|
||||||
|
var NETXOPS_RPC_CHANNEL = "/netxops";
|
||||||
var NETXOPS_SETTINGS_NAMESPACE = "netxops";
|
var NETXOPS_SETTINGS_NAMESPACE = "netxops";
|
||||||
var NETXOPS_PRESET_ID = "netxops";
|
var NETXOPS_PRESET_ID = "netxops";
|
||||||
var DEFAULT_TOKEN_REF = "NETX_API_TOKEN";
|
var DEFAULT_TOKEN_REF = "NETX_API_TOKEN";
|
||||||
|
|
@ -385,10 +459,19 @@ function apply(ctx, config = Config({})) {
|
||||||
stopAlarmPush?.();
|
stopAlarmPush?.();
|
||||||
stopAlarmPush = undefined;
|
stopAlarmPush = undefined;
|
||||||
resetAlarmSession();
|
resetAlarmSession();
|
||||||
if (!enabled)
|
if (!enabled) {
|
||||||
|
resetAlarmPushStatus();
|
||||||
return;
|
return;
|
||||||
|
}
|
||||||
if (!token.trim()) {
|
if (!token.trim()) {
|
||||||
ctx.logger.warn("netxops alarm-push: enabled but token is empty — not connecting");
|
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;
|
return;
|
||||||
}
|
}
|
||||||
stopAlarmPush = startAlarmPushClient({
|
stopAlarmPush = startAlarmPushClient({
|
||||||
|
|
@ -436,11 +519,30 @@ function apply(ctx, config = Config({})) {
|
||||||
if (String(ref) === source().tokenCredentialRef)
|
if (String(ref) === source().tokenCredentialRef)
|
||||||
publish();
|
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(() => () => {
|
ctx.effect(() => () => {
|
||||||
generation += 1;
|
generation += 1;
|
||||||
stopAlarmPush?.();
|
stopAlarmPush?.();
|
||||||
stopAlarmPush = undefined;
|
stopAlarmPush = undefined;
|
||||||
resetAlarmSession();
|
resetAlarmSession();
|
||||||
|
resetAlarmPushStatus();
|
||||||
}, "netxops: dispose host bridge");
|
}, "netxops: dispose host bridge");
|
||||||
}
|
}
|
||||||
export {
|
export {
|
||||||
|
|
@ -449,6 +551,7 @@ export {
|
||||||
ensureAgentPresetInstalled,
|
ensureAgentPresetInstalled,
|
||||||
apply,
|
apply,
|
||||||
NETXOPS_SETTINGS_NAMESPACE,
|
NETXOPS_SETTINGS_NAMESPACE,
|
||||||
|
NETXOPS_RPC_CHANNEL,
|
||||||
NETXOPS_PRESET_ID,
|
NETXOPS_PRESET_ID,
|
||||||
DEFAULT_TOKEN_REF,
|
DEFAULT_TOKEN_REF,
|
||||||
Config
|
Config
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"name": "dsh-netxops",
|
"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",
|
"description": "DeepSeek Harness Netx Ops: Ops-scoped netx__* REST tools + key-alarm push to DSH sessions + Plugins settings card + agent preset",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
|
|
|
||||||
|
|
@ -7,9 +7,39 @@ import type { InjectFace, PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-cli
|
||||||
import type { NetxopsCardFace, NetxopsCardState } from './controller.ts'
|
import type { NetxopsCardFace, NetxopsCardState } from './controller.ts'
|
||||||
import type { NetxopsLocaleKey } from './locales.ts'
|
import type { NetxopsLocaleKey } from './locales.ts'
|
||||||
import type { CardFieldState } from './card-form.ts'
|
import type { CardFieldState } from './card-form.ts'
|
||||||
|
import { alarmPushTone, type AlarmPushPhase } from './alarm-push-status-view.ts'
|
||||||
import { ensureStyles } from './styles.ts'
|
import { ensureStyles } from './styles.ts'
|
||||||
import type {} from '@deepseek-ai/dsh-client-ui-settings-plugins/client'
|
import type {} from '@deepseek-ai/dsh-client-ui-settings-plugins/client'
|
||||||
|
|
||||||
|
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 <span className={className}>{props.label}</span>
|
||||||
|
}
|
||||||
|
|
||||||
export type NetxopsCardProps =
|
export type NetxopsCardProps =
|
||||||
PropsRuntime<'settings.plugin.item'>
|
PropsRuntime<'settings.plugin.item'>
|
||||||
& PropsLocale<'settings.netxops'>
|
& PropsLocale<'settings.netxops'>
|
||||||
|
|
@ -80,6 +110,9 @@ export function NetxopsCard(props: NetxopsCardProps) {
|
||||||
const title = t('title' satisfies NetxopsLocaleKey)
|
const title = t('title' satisfies NetxopsLocaleKey)
|
||||||
const disabled = !state.writable
|
const disabled = !state.writable
|
||||||
const blocked = !state.dirty || state.invalid || state.saving
|
const blocked = !state.dirty || state.invalid || state.saving
|
||||||
|
const pushStatus = state.alarmPushStatus
|
||||||
|
const showHeaderStatus = pushStatus !== null
|
||||||
|
&& (pushStatus.enabled || pushStatus.phase !== 'disabled')
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<li className={open ? 'dsh-nx-card dsh-nx-cardOpen' : 'dsh-nx-card'}>
|
<li className={open ? 'dsh-nx-card dsh-nx-cardOpen' : 'dsh-nx-card'}>
|
||||||
|
|
@ -94,6 +127,14 @@ export function NetxopsCard(props: NetxopsCardProps) {
|
||||||
<span className="dsh-nx-name">{title}</span>
|
<span className="dsh-nx-name">{title}</span>
|
||||||
<span className="dsh-nx-desc">{t('description')}</span>
|
<span className="dsh-nx-desc">{t('description')}</span>
|
||||||
</span>
|
</span>
|
||||||
|
{showHeaderStatus && pushStatus
|
||||||
|
? (
|
||||||
|
<StatusBadge
|
||||||
|
phase={pushStatus.phase}
|
||||||
|
label={t(phaseLocaleKey(pushStatus.phase))}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
: null}
|
||||||
{state.dirty ? <span className="dsh-nx-pending">{t('unsaved')}</span> : null}
|
{state.dirty ? <span className="dsh-nx-pending">{t('unsaved')}</span> : null}
|
||||||
<span className={open ? 'dsh-nx-chevron dsh-nx-chevronOpen' : 'dsh-nx-chevron'} aria-hidden>▾</span>
|
<span className={open ? 'dsh-nx-chevron dsh-nx-chevronOpen' : 'dsh-nx-chevron'} aria-hidden>▾</span>
|
||||||
</button>
|
</button>
|
||||||
|
|
@ -150,21 +191,31 @@ export function NetxopsCard(props: NetxopsCardProps) {
|
||||||
<div className="dsh-nx-field">
|
<div className="dsh-nx-field">
|
||||||
<div className="dsh-nx-fieldHead">
|
<div className="dsh-nx-fieldHead">
|
||||||
<label className="dsh-nx-label" htmlFor="netxops-alarm-push">{t('alarmPushEnabled')}</label>
|
<label className="dsh-nx-label" htmlFor="netxops-alarm-push">{t('alarmPushEnabled')}</label>
|
||||||
{state.alarmPushEnabled.overridden
|
<span className="dsh-nx-badges">
|
||||||
? (
|
{pushStatus
|
||||||
<span className="dsh-nx-badges">
|
? (
|
||||||
<span className="dsh-nx-badge">{t('overridden')}</span>
|
<StatusBadge
|
||||||
<button
|
phase={pushStatus.phase}
|
||||||
type="button"
|
label={`${t('alarmPushStatus')}: ${t(phaseLocaleKey(pushStatus.phase))}`}
|
||||||
className="dsh-nx-reset"
|
/>
|
||||||
disabled={disabled}
|
)
|
||||||
onClick={() => { props.resetField('alarmPushEnabled') }}
|
: null}
|
||||||
>
|
{state.alarmPushEnabled.overridden
|
||||||
{t('reset')}
|
? (
|
||||||
</button>
|
<>
|
||||||
</span>
|
<span className="dsh-nx-badge">{t('overridden')}</span>
|
||||||
)
|
<button
|
||||||
: null}
|
type="button"
|
||||||
|
className="dsh-nx-reset"
|
||||||
|
disabled={disabled}
|
||||||
|
onClick={() => { props.resetField('alarmPushEnabled') }}
|
||||||
|
>
|
||||||
|
{t('reset')}
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
: null}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<label className="dsh-nx-checkRow" htmlFor="netxops-alarm-push">
|
<label className="dsh-nx-checkRow" htmlFor="netxops-alarm-push">
|
||||||
<input
|
<input
|
||||||
|
|
@ -178,6 +229,12 @@ export function NetxopsCard(props: NetxopsCardProps) {
|
||||||
/>
|
/>
|
||||||
<span>{t('alarmPushEnabledHint')}</span>
|
<span>{t('alarmPushEnabledHint')}</span>
|
||||||
</label>
|
</label>
|
||||||
|
{pushStatus?.wsUrl
|
||||||
|
? <p className="dsh-nx-hint">{pushStatus.wsUrl}</p>
|
||||||
|
: null}
|
||||||
|
{pushStatus?.lastError
|
||||||
|
? <p className="dsh-nx-invalid" role="status">{pushStatus.lastError}</p>
|
||||||
|
: null}
|
||||||
</div>
|
</div>
|
||||||
<div className="dsh-nx-footer">
|
<div className="dsh-nx-footer">
|
||||||
{state.failed ? <p className="dsh-nx-failed" role="status">{t('saveFailed')}</p> : null}
|
{state.failed ? <p className="dsh-nx-failed" role="status">{t('saveFailed')}</p> : null}
|
||||||
|
|
|
||||||
90
src/client/alarm-push-status-view.ts
Normal file
90
src/client/alarm-push-status-view.ts
Normal file
|
|
@ -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<unknown>
|
||||||
|
|
||||||
|
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<string, unknown>
|
||||||
|
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<AlarmPushStatus> {
|
||||||
|
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'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -10,10 +10,16 @@ import {
|
||||||
CardForm, textField, booleanField,
|
CardForm, textField, booleanField,
|
||||||
type CardActions, type CardFieldState, type CardShell,
|
type CardActions, type CardFieldState, type CardShell,
|
||||||
} from './card-form.ts'
|
} from './card-form.ts'
|
||||||
|
import {
|
||||||
|
fetchAlarmPushStatus,
|
||||||
|
type AlarmPushRpcCall,
|
||||||
|
type AlarmPushStatus,
|
||||||
|
} from './alarm-push-status-view.ts'
|
||||||
|
|
||||||
export const NETXOPS_NS = 'netxops'
|
export const NETXOPS_NS = 'netxops'
|
||||||
const DEFAULT_TOKEN_REF = 'NETX_API_TOKEN'
|
const DEFAULT_TOKEN_REF = 'NETX_API_TOKEN'
|
||||||
const API_TOKEN_FIELD = 'apiToken'
|
const API_TOKEN_FIELD = 'apiToken'
|
||||||
|
const STATUS_POLL_MS = 2_000
|
||||||
|
|
||||||
export interface NetxopsSettings {
|
export interface NetxopsSettings {
|
||||||
apiUrl?: string
|
apiUrl?: string
|
||||||
|
|
@ -37,6 +43,8 @@ export interface NetxopsCardState extends CardShell {
|
||||||
apiTokenConfigured: boolean
|
apiTokenConfigured: boolean
|
||||||
apiTokenWritable: boolean
|
apiTokenWritable: boolean
|
||||||
apiTokenRemoteReady: boolean
|
apiTokenRemoteReady: boolean
|
||||||
|
/** Host WSS status when Connection RPC is available; otherwise null. */
|
||||||
|
alarmPushStatus: AlarmPushStatus | null
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface NetxopsCardFace extends CardActions {
|
export interface NetxopsCardFace extends CardActions {
|
||||||
|
|
@ -59,6 +67,10 @@ export class NetxopsCardController {
|
||||||
writable: false,
|
writable: false,
|
||||||
remoteReady: false,
|
remoteReady: false,
|
||||||
}
|
}
|
||||||
|
private rpcCall: AlarmPushRpcCall | undefined
|
||||||
|
private alarmPushStatus: AlarmPushStatus | null = null
|
||||||
|
private pollTimer: ReturnType<typeof setInterval> | undefined
|
||||||
|
private pollInFlight = false
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private readonly scope: SettingsScope<NetxopsSettings>,
|
private readonly scope: SettingsScope<NetxopsSettings>,
|
||||||
|
|
@ -86,6 +98,60 @@ export class NetxopsCardController {
|
||||||
if (ready) void this.readCredential()
|
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<void> {
|
||||||
|
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 {
|
private projection(): NetxopsCardState {
|
||||||
return {
|
return {
|
||||||
...this.form.shell(),
|
...this.form.shell(),
|
||||||
|
|
@ -96,6 +162,7 @@ export class NetxopsCardController {
|
||||||
apiTokenConfigured: this.credential.configured,
|
apiTokenConfigured: this.credential.configured,
|
||||||
apiTokenWritable: this.credential.remoteReady && this.credential.writable,
|
apiTokenWritable: this.credential.remoteReady && this.credential.writable,
|
||||||
apiTokenRemoteReady: this.credential.remoteReady,
|
apiTokenRemoteReady: this.credential.remoteReady,
|
||||||
|
alarmPushStatus: this.alarmPushStatus,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -54,6 +54,19 @@ export function apply(ctx: ClientContext): void {
|
||||||
}, 'netxops: credential invalidations')
|
}, '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({
|
ctx.slots.inject('settings.plugin.item', () => ctx.slots.register({
|
||||||
name: 'settings.plugin.item',
|
name: 'settings.plugin.item',
|
||||||
key: NETXOPS_NS,
|
key: NETXOPS_NS,
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,15 @@ export type NetxopsLocaleKey =
|
||||||
| 'langHint'
|
| 'langHint'
|
||||||
| 'alarmPushEnabled'
|
| 'alarmPushEnabled'
|
||||||
| 'alarmPushEnabledHint'
|
| 'alarmPushEnabledHint'
|
||||||
|
| 'alarmPushStatus'
|
||||||
|
| 'alarmPushPhaseDisabled'
|
||||||
|
| 'alarmPushPhaseIdle'
|
||||||
|
| 'alarmPushPhaseConnecting'
|
||||||
|
| 'alarmPushPhaseAuthenticating'
|
||||||
|
| 'alarmPushPhaseConnected'
|
||||||
|
| 'alarmPushPhaseReconnecting'
|
||||||
|
| 'alarmPushPhaseAuthFailed'
|
||||||
|
| 'alarmPushPhaseError'
|
||||||
| 'apiToken'
|
| 'apiToken'
|
||||||
| 'apiTokenHint'
|
| 'apiTokenHint'
|
||||||
| 'apiTokenUnavailable'
|
| 'apiTokenUnavailable'
|
||||||
|
|
@ -35,6 +44,15 @@ export const en: Record<NetxopsLocaleKey, string> = {
|
||||||
langHint: 'Response language hint (zh / en).',
|
langHint: 'Response language hint (zh / en).',
|
||||||
alarmPushEnabled: 'Key-alarm push',
|
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.',
|
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',
|
apiToken: 'API token',
|
||||||
apiTokenHint: 'Stored as credential NETX_API_TOKEN (never written into settings). Leave blank to keep the current 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.',
|
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<NetxopsLocaleKey, string> = {
|
||||||
langHint: '响应语言提示(zh / en)。',
|
langHint: '响应语言提示(zh / en)。',
|
||||||
alarmPushEnabled: '关键告警推送',
|
alarmPushEnabled: '关键告警推送',
|
||||||
alarmPushEnabledHint: '主动连接 netx;匹配到关键告警后在本机打开/续写一条 DSH 会话。不必安装 WhatsApp / im。',
|
alarmPushEnabledHint: '主动连接 netx;匹配到关键告警后在本机打开/续写一条 DSH 会话。不必安装 WhatsApp / im。',
|
||||||
|
alarmPushStatus: '推送链路',
|
||||||
|
alarmPushPhaseDisabled: '未开启',
|
||||||
|
alarmPushPhaseIdle: '空闲',
|
||||||
|
alarmPushPhaseConnecting: '连接中…',
|
||||||
|
alarmPushPhaseAuthenticating: '鉴权中…',
|
||||||
|
alarmPushPhaseConnected: '已连接',
|
||||||
|
alarmPushPhaseReconnecting: '重连中…',
|
||||||
|
alarmPushPhaseAuthFailed: '鉴权失败',
|
||||||
|
alarmPushPhaseError: '异常',
|
||||||
apiToken: 'API Token',
|
apiToken: 'API Token',
|
||||||
apiTokenHint: '写入凭据 NETX_API_TOKEN(不会进 settings)。留空表示保留已有 token。',
|
apiTokenHint: '写入凭据 NETX_API_TOKEN(不会进 settings)。留空表示保留已有 token。',
|
||||||
apiTokenUnavailable: '当前 DSH 未提供 remote.credentials。请用 scripts/set-netx-token.ps1(或 .sh)写入 token;若 harness 在监视凭据文件则无需重启。',
|
apiTokenUnavailable: '当前 DSH 未提供 remote.credentials。请用 scripts/set-netx-token.ps1(或 .sh)写入 token;若 harness 在监视凭据文件则无需重启。',
|
||||||
|
|
|
||||||
|
|
@ -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-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-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-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-chevron{flex:none;color:var(--dsw-alias-label-tertiary);transition:transform .16s}
|
||||||
.dsh-nx-chevronOpen{transform:rotate(180deg)}
|
.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-body{border-top:.5px solid var(--dsw-alias-border-l2);margin:0 16px;padding-bottom:8px}
|
||||||
|
|
|
||||||
42
src/index.ts
42
src/index.ts
|
|
@ -20,6 +20,11 @@ import type {} from '@deepseek-ai/dsh-credentials'
|
||||||
import * as DshSettings from '@deepseek-ai/dsh-settings'
|
import * as DshSettings from '@deepseek-ai/dsh-settings'
|
||||||
import type {} from '@deepseek-ai/dsh-settings'
|
import type {} from '@deepseek-ai/dsh-settings'
|
||||||
import { startAlarmPushClient } from './netx/alarm-push.ts'
|
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 { deliverAlarmToSession, resetAlarmSession } from './netx/alarm-session.ts'
|
||||||
import { publishNetxConnection } from './netx/runtime.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. */
|
/** Wait for the credentials store before publishing a Bearer snapshot. */
|
||||||
export const inject = ['credentials']
|
export const inject = ['credentials']
|
||||||
|
|
||||||
|
/** Connection RPC channel for browser status reads. */
|
||||||
|
export const NETXOPS_RPC_CHANNEL = '/netxops'
|
||||||
|
|
||||||
/** Settings / composition namespace (Plugins page join key). */
|
/** Settings / composition namespace (Plugins page join key). */
|
||||||
export const NETXOPS_SETTINGS_NAMESPACE = 'netxops'
|
export const NETXOPS_SETTINGS_NAMESPACE = 'netxops'
|
||||||
|
|
||||||
|
|
@ -167,9 +175,19 @@ export function apply(ctx: Context, config: Config = Config({})): void {
|
||||||
stopAlarmPush?.()
|
stopAlarmPush?.()
|
||||||
stopAlarmPush = undefined
|
stopAlarmPush = undefined
|
||||||
resetAlarmSession()
|
resetAlarmSession()
|
||||||
if (!enabled) return
|
if (!enabled) {
|
||||||
|
resetAlarmPushStatus()
|
||||||
|
return
|
||||||
|
}
|
||||||
if (!token.trim()) {
|
if (!token.trim()) {
|
||||||
ctx.logger.warn('netxops alarm-push: enabled but token is empty — not connecting')
|
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
|
return
|
||||||
}
|
}
|
||||||
stopAlarmPush = startAlarmPushClient({
|
stopAlarmPush = startAlarmPushClient({
|
||||||
|
|
@ -229,10 +247,32 @@ export function apply(ctx: Context, config: Config = Config({})): void {
|
||||||
if (String(ref) === source().tokenCredentialRef) publish()
|
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(() => () => {
|
ctx.effect(() => () => {
|
||||||
generation += 1
|
generation += 1
|
||||||
stopAlarmPush?.()
|
stopAlarmPush?.()
|
||||||
stopAlarmPush = undefined
|
stopAlarmPush = undefined
|
||||||
resetAlarmSession()
|
resetAlarmSession()
|
||||||
|
resetAlarmPushStatus()
|
||||||
}, 'netxops: dispose host bridge')
|
}, 'netxops: dispose host bridge')
|
||||||
}
|
}
|
||||||
|
|
|
||||||
89
src/netx/alarm-push-status.ts
Normal file
89
src/netx/alarm-push-status.ts
Normal file
|
|
@ -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<Listener>
|
||||||
|
}
|
||||||
|
|
||||||
|
const STORE_KEY = Symbol.for('dsh-netxops.alarm-push-status')
|
||||||
|
|
||||||
|
function emptyStatus(partial: Partial<AlarmPushStatus> = {}): 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<AlarmPushStatus> & Pick<AlarmPushStatus, 'phase'>): 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 }))
|
||||||
|
}
|
||||||
|
|
@ -2,6 +2,12 @@
|
||||||
* Convert a netx REST apiUrl into the DSH alarm-subscribe WebSocket URL.
|
* Convert a netx REST apiUrl into the DSH alarm-subscribe WebSocket URL.
|
||||||
* @param apiUrl - e.g. http://192.168.1.10:8890
|
* @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 {
|
export function alarmSubscribeUrl(apiUrl: string): string {
|
||||||
const trimmed = apiUrl.trim().replace(/\/$/, '')
|
const trimmed = apiUrl.trim().replace(/\/$/, '')
|
||||||
if (!trimmed) return ''
|
if (!trimmed) return ''
|
||||||
|
|
@ -99,6 +105,21 @@ export interface AlarmPushClientOptions {
|
||||||
WebSocketImpl?: typeof WebSocket
|
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.
|
* Dial out to netx's fixed-IP alarm hub and forward `netx.alarm` events.
|
||||||
* @returns disposer that closes the socket and cancels reconnect.
|
* @returns disposer that closes the socket and cancels reconnect.
|
||||||
|
|
@ -109,12 +130,20 @@ export function startAlarmPushClient(options: AlarmPushClientOptions): () => voi
|
||||||
const token = options.token.trim()
|
const token = options.token.trim()
|
||||||
if (!wsUrl || !token) {
|
if (!wsUrl || !token) {
|
||||||
log.warn?.('netxops alarm-push: missing apiUrl or token — not connecting')
|
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 () => {}
|
return () => {}
|
||||||
}
|
}
|
||||||
|
|
||||||
const WS = options.WebSocketImpl ?? globalThis.WebSocket
|
const WS = options.WebSocketImpl ?? globalThis.WebSocket
|
||||||
if (typeof WS !== 'function') {
|
if (typeof WS !== 'function') {
|
||||||
log.error?.('netxops alarm-push: WebSocket is unavailable in this runtime')
|
log.error?.('netxops alarm-push: WebSocket is unavailable in this runtime')
|
||||||
|
setPhase('error', wsUrl, {
|
||||||
|
detail: 'websocket_unavailable',
|
||||||
|
lastError: 'WebSocket unavailable',
|
||||||
|
})
|
||||||
return () => {}
|
return () => {}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -131,27 +160,33 @@ export function startAlarmPushClient(options: AlarmPushClientOptions): () => voi
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const scheduleReconnect = (): void => {
|
const scheduleReconnect = (reason: string): void => {
|
||||||
if (closed) return
|
if (closed) return
|
||||||
clearReconnect()
|
clearReconnect()
|
||||||
const delay = Math.min(60_000, baseDelay * (2 ** Math.min(attempt, 5)))
|
const delay = Math.min(60_000, baseDelay * (2 ** Math.min(attempt, 5)))
|
||||||
attempt += 1
|
attempt += 1
|
||||||
|
setPhase('reconnecting', wsUrl, {
|
||||||
|
detail: `retry_in_${delay}ms`,
|
||||||
|
lastError: reason,
|
||||||
|
})
|
||||||
reconnectTimer = setTimeout(() => { connect() }, delay)
|
reconnectTimer = setTimeout(() => { connect() }, delay)
|
||||||
}
|
}
|
||||||
|
|
||||||
const connect = (): void => {
|
const connect = (): void => {
|
||||||
if (closed) return
|
if (closed) return
|
||||||
clearReconnect()
|
clearReconnect()
|
||||||
|
setPhase(attempt > 0 ? 'reconnecting' : 'connecting', wsUrl, { detail: 'dialing' })
|
||||||
try {
|
try {
|
||||||
socket = new WS(wsUrl)
|
socket = new WS(wsUrl)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : String(error)
|
||||||
log.warn?.('netxops alarm-push: connect failed:', error)
|
log.warn?.('netxops alarm-push: connect failed:', error)
|
||||||
scheduleReconnect()
|
scheduleReconnect(message)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
socket.addEventListener('open', () => {
|
socket.addEventListener('open', () => {
|
||||||
attempt = 0
|
setPhase('authenticating', wsUrl, { detail: 'auth' })
|
||||||
socket?.send(JSON.stringify({ type: 'auth', token }))
|
socket?.send(JSON.stringify({ type: 'auth', token }))
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
@ -164,11 +199,20 @@ export function startAlarmPushClient(options: AlarmPushClientOptions): () => voi
|
||||||
}
|
}
|
||||||
const type = String(msg.type ?? '').toLowerCase()
|
const type = String(msg.type ?? '').toLowerCase()
|
||||||
if (type === 'auth-ok') {
|
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)
|
log.info?.('netxops alarm-push: subscribed to %s', wsUrl)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (type === 'auth-fail') {
|
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()
|
socket?.close()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
@ -185,7 +229,7 @@ export function startAlarmPushClient(options: AlarmPushClientOptions): () => voi
|
||||||
|
|
||||||
socket.addEventListener('close', () => {
|
socket.addEventListener('close', () => {
|
||||||
socket = null
|
socket = null
|
||||||
if (!closed) scheduleReconnect()
|
if (!closed) scheduleReconnect('socket_closed')
|
||||||
})
|
})
|
||||||
|
|
||||||
socket.addEventListener('error', () => {
|
socket.addEventListener('error', () => {
|
||||||
|
|
@ -193,7 +237,6 @@ export function startAlarmPushClient(options: AlarmPushClientOptions): () => voi
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// Keepalive ping while connected.
|
|
||||||
const pingTimer = setInterval(() => {
|
const pingTimer = setInterval(() => {
|
||||||
if (socket && socket.readyState === WS.OPEN) {
|
if (socket && socket.readyState === WS.OPEN) {
|
||||||
try {
|
try {
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue