netxops/src/netx/alarm-push.ts
oliver e6f9d062ed Identify alarm-push clients to NetX with hostname labels.
Send client=netxops@host on hub auth so NetX can distinguish multiple DSH subscribers.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-10 16:57:42 +08:00

273 lines
8.5 KiB
TypeScript

/**
* 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'
import { alarmActionLabel, tHost } from './i18n.ts'
export function alarmSubscribeUrl(apiUrl: string): string {
const trimmed = apiUrl.trim().replace(/\/$/, '')
if (!trimmed) return ''
let url: URL
try {
url = new URL(trimmed)
} catch {
return ''
}
url.protocol = url.protocol === 'https:' ? 'wss:' : 'ws:'
url.pathname = '/v1/integrations/dsh-alarm/ws'
url.search = ''
url.hash = ''
return url.toString()
}
export interface KeyAlarmPayload {
action?: string
alarm_key?: string
notification_id?: string
rule_label?: string
rule_key?: string
native_probable_cause?: string
perceived_severity?: string
time_created?: string
object_name?: string
ne_id?: string
ne?: Record<string, string>
[key: string]: unknown
}
/** Human-readable prompt for a sticky Netx Ops DSH session. */
export function formatAlarmPrompt(payload: KeyAlarmPayload, lang = 'zh'): string {
const action = String(payload.action ?? '').trim().toLowerCase()
const ne = payload.ne && typeof payload.ne === 'object' ? payload.ne : {}
const host = String(ne.host_name ?? '').trim()
const ip = String(ne.ip_address ?? '').trim()
const neName = String(ne.ne_name ?? ne.user_label ?? '').trim()
let device = host || neName || String(payload.ne_id ?? '').trim() || '-'
if (ip) device = device === '-' ? ip : `${device} (${ip})`
const label = String(
payload.rule_label
?? payload.native_probable_cause
?? tHost('alarm.defaultLabel', lang),
).trim()
return [
`[NMS ${alarmActionLabel(action, lang)}] ${label}`,
`${tHost('alarm.field.device', lang)}: ${device}`,
`${tHost('alarm.field.object', lang)}: ${String(payload.object_name ?? '-').trim()}`,
`${tHost('alarm.field.severity', lang)}: ${String(payload.perceived_severity ?? '-').trim()}`,
`${tHost('alarm.field.cause', lang)}: ${String(payload.native_probable_cause ?? '-').trim()}`,
`${tHost('alarm.field.time', lang)}: ${String(payload.time_created ?? '-').trim()}`,
`notificationId: ${String(payload.notification_id ?? '-').trim()}`,
`alarm_key: ${String(payload.alarm_key ?? '-').trim()}`,
'',
tHost('alarm.analyzeHint', lang),
].join('\n')
}
export type AlarmHandler = (payload: KeyAlarmPayload) => void | Promise<void>
export interface AlarmPushClientOptions {
apiUrl: string
token: string
logger?: {
info?: (...args: unknown[]) => void
warn?: (...args: unknown[]) => void
error?: (...args: unknown[]) => void
}
onAlarm: AlarmHandler
/** Reconnect delay base in ms (default 2000). */
reconnectMs?: number
/** WebSocket constructor override (tests). */
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.
*/
export function startAlarmPushClient(options: AlarmPushClientOptions): () => void {
const log = options.logger ?? console
const wsUrl = alarmSubscribeUrl(options.apiUrl)
const token = options.token.trim()
if (!wsUrl || !token) {
log.warn?.('netxops alarm-push: missing apiUrl or token — not connecting')
setPhase('error', wsUrl, {
detail: 'missing_url_or_token',
lastError: 'missing apiUrl or token',
})
return () => {}
}
const WS = options.WebSocketImpl ?? globalThis.WebSocket
if (typeof WS !== 'function') {
log.error?.('netxops alarm-push: WebSocket is unavailable in this runtime')
setPhase('error', wsUrl, {
detail: 'websocket_unavailable',
lastError: 'WebSocket unavailable',
})
return () => {}
}
let closed = false
/** After auth-fail, do not reconnect until the client is disposed/restarted. */
let haltReconnect = false
/** Only forward alarms after a successful auth-ok on the current socket. */
let subscribed = false
let socket: WebSocket | null = null
let reconnectTimer: ReturnType<typeof setTimeout> | undefined
let attempt = 0
const baseDelay = Math.max(500, options.reconnectMs ?? 2_000)
const clearReconnect = (): void => {
if (reconnectTimer !== undefined) {
clearTimeout(reconnectTimer)
reconnectTimer = undefined
}
}
const scheduleReconnect = (reason: string): void => {
if (closed || haltReconnect) 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 || haltReconnect) return
clearReconnect()
subscribed = false
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(message)
return
}
socket.addEventListener('open', () => {
setPhase('authenticating', wsUrl, { detail: 'auth' })
let clientLabel = 'netxops'
try {
// Node / Bun hosts: include hostname so NetX hub can list multi-DSH links.
const env = typeof process !== 'undefined' ? process.env : undefined
const hostname = env?.HOSTNAME || env?.COMPUTERNAME
if (typeof hostname === 'string' && hostname.trim()) {
clientLabel = `netxops@${hostname.trim()}`
}
} catch {
// browser / restricted runtime — keep generic label
}
socket?.send(JSON.stringify({ type: 'auth', token, client: clientLabel }))
})
socket.addEventListener('message', (event) => {
let msg: Record<string, unknown>
try {
msg = JSON.parse(String(event.data)) as Record<string, unknown>
} catch {
return
}
const type = String(msg.type ?? '').toLowerCase()
if (type === 'auth-ok') {
attempt = 0
subscribed = true
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') {
const err = String(msg.error ?? 'auth_failed')
log.error?.('netxops alarm-push: auth failed (%s) — not reconnecting until settings/token change', err)
subscribed = false
haltReconnect = true
clearReconnect()
setPhase('auth_failed', wsUrl, { detail: err, lastError: err })
try {
socket?.close()
} catch {
// ignore
}
return
}
if (type === 'pong') return
if (type === 'event' && String(msg.event ?? '') === 'netx.alarm') {
if (!subscribed) {
log.warn?.('netxops alarm-push: ignoring alarm before auth-ok')
return
}
const payload = msg.payload && typeof msg.payload === 'object'
? msg.payload as KeyAlarmPayload
: {}
void Promise.resolve(options.onAlarm(payload)).catch((error) => {
log.warn?.('netxops alarm-push: handler failed:', error)
})
}
})
socket.addEventListener('close', () => {
socket = null
subscribed = false
if (!closed && !haltReconnect) scheduleReconnect('socket_closed')
})
socket.addEventListener('error', () => {
// close handler schedules reconnect
})
}
const pingTimer = setInterval(() => {
if (socket && socket.readyState === WS.OPEN) {
try {
socket.send(JSON.stringify({ type: 'ping', ts: new Date().toISOString() }))
} catch {
// ignore
}
}
}, 25_000)
connect()
return () => {
closed = true
clearReconnect()
clearInterval(pingTimer)
try {
socket?.close()
} catch {
// ignore
}
socket = null
}
}