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

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