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:
oliver 2026-09-05 21:08:14 +08:00
parent 12f7018103
commit e8b7d7331f
14 changed files with 783 additions and 55 deletions

View file

@ -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 <span className={className}>{props.label}</span>
}
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 (
<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-desc">{t('description')}</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}
<span className={open ? 'dsh-nx-chevron dsh-nx-chevronOpen' : 'dsh-nx-chevron'} aria-hidden>▾</span>
</button>
@ -150,21 +191,31 @@ export function NetxopsCard(props: NetxopsCardProps) {
<div className="dsh-nx-field">
<div className="dsh-nx-fieldHead">
<label className="dsh-nx-label" htmlFor="netxops-alarm-push">{t('alarmPushEnabled')}</label>
{state.alarmPushEnabled.overridden
? (
<span className="dsh-nx-badges">
<span className="dsh-nx-badge">{t('overridden')}</span>
<button
type="button"
className="dsh-nx-reset"
disabled={disabled}
onClick={() => { props.resetField('alarmPushEnabled') }}
>
{t('reset')}
</button>
</span>
)
: null}
<span className="dsh-nx-badges">
{pushStatus
? (
<StatusBadge
phase={pushStatus.phase}
label={`${t('alarmPushStatus')}: ${t(phaseLocaleKey(pushStatus.phase))}`}
/>
)
: null}
{state.alarmPushEnabled.overridden
? (
<>
<span className="dsh-nx-badge">{t('overridden')}</span>
<button
type="button"
className="dsh-nx-reset"
disabled={disabled}
onClick={() => { props.resetField('alarmPushEnabled') }}
>
{t('reset')}
</button>
</>
)
: null}
</span>
</div>
<label className="dsh-nx-checkRow" htmlFor="netxops-alarm-push">
<input
@ -178,6 +229,12 @@ export function NetxopsCard(props: NetxopsCardProps) {
/>
<span>{t('alarmPushEnabledHint')}</span>
</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 className="dsh-nx-footer">
{state.failed ? <p className="dsh-nx-failed" role="status">{t('saveFailed')}</p> : null}

View 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'
}
}

View file

@ -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<typeof setInterval> | undefined
private pollInFlight = false
constructor(
private readonly scope: SettingsScope<NetxopsSettings>,
@ -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<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 {
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,
}
}

View file

@ -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,

View file

@ -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<NetxopsLocaleKey, string> = {
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<NetxopsLocaleKey, string> = {
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 在监视凭据文件则无需重启。',

View file

@ -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}

View file

@ -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')
}

View 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 }))
}

View file

@ -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 {