mirror of
https://github.com/hansjone/netxops.git
synced 2026-10-09 15:10:47 +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
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.
|
||||
* @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 {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue