mirror of
https://github.com/hansjone/netxops.git
synced 2026-10-09 06:50:46 +08:00
Send client=netxops@host on hub auth so NetX can distinguish multiple DSH subscribers. Co-authored-by: Cursor <cursoragent@cursor.com>
273 lines
8.5 KiB
TypeScript
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
|
|
}
|
|
}
|