mirror of
https://github.com/hansjone/netxops.git
synced 2026-10-10 01:43:16 +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
|
|
@ -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}
|
||||
|
|
|
|||
90
src/client/alarm-push-status-view.ts
Normal file
90
src/client/alarm-push-status-view.ts
Normal 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'
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 在监视凭据文件则无需重启。',
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue