mirror of
https://github.com/hansjone/netxops.git
synced 2026-10-09 05:10:45 +08:00
Add Netx Ops i18n and promote settings to a dedicated section.
Localize alarm prompts and UI reason codes for zh/en, and restyle the settings page like uds-auth as its own settings.section. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
909b5d61a7
commit
a4993b2407
18 changed files with 1329 additions and 840 deletions
899
lib/client.js
899
lib/client.js
File diff suppressed because it is too large
Load diff
111
lib/index.js
111
lib/index.js
|
|
@ -50,6 +50,63 @@ function resetAlarmPushStatus() {
|
|||
publishAlarmPushStatus(emptyStatus({ phase: "disabled", enabled: false }));
|
||||
}
|
||||
|
||||
// src/netx/i18n.ts
|
||||
var ZH = {
|
||||
"alarm.sessionTitle": "Netx 关键告警",
|
||||
"alarm.defaultLabel": "关键告警",
|
||||
"alarm.action.inserted": "告警产生",
|
||||
"alarm.action.updated": "告警更新",
|
||||
"alarm.action.deleted": "告警清除",
|
||||
"alarm.action.fallback": "告警",
|
||||
"alarm.field.device": "设备",
|
||||
"alarm.field.object": "对象",
|
||||
"alarm.field.severity": "级别",
|
||||
"alarm.field.cause": "原因",
|
||||
"alarm.field.time": "时间",
|
||||
"alarm.analyzeHint": "请分析这条关键告警并给出下一步运维建议。"
|
||||
};
|
||||
var EN = {
|
||||
"alarm.sessionTitle": "Netx key alarm",
|
||||
"alarm.defaultLabel": "Key alarm",
|
||||
"alarm.action.inserted": "Alarm Raised",
|
||||
"alarm.action.updated": "Alarm Updated",
|
||||
"alarm.action.deleted": "Alarm Cleared",
|
||||
"alarm.action.fallback": "Alarm",
|
||||
"alarm.field.device": "Device",
|
||||
"alarm.field.object": "Object",
|
||||
"alarm.field.severity": "Severity",
|
||||
"alarm.field.cause": "Cause",
|
||||
"alarm.field.time": "Time",
|
||||
"alarm.analyzeHint": "Please analyze this key alarm and suggest next ops steps."
|
||||
};
|
||||
var TABLES = {
|
||||
zh: ZH,
|
||||
en: EN
|
||||
};
|
||||
function normalizeNetxLocale(lang) {
|
||||
const normalized = String(lang ?? "").trim().toLowerCase();
|
||||
if (normalized === "english" || /^en(?:[-_].*)?$/u.test(normalized))
|
||||
return "en";
|
||||
return "zh";
|
||||
}
|
||||
function tHost(key, lang = "zh", vars) {
|
||||
const locale = normalizeNetxLocale(lang);
|
||||
const template = TABLES[locale][key] ?? TABLES.zh[key] ?? key;
|
||||
if (vars === undefined)
|
||||
return template;
|
||||
return template.replace(/\{(\w+)\}/g, (match, name) => Object.prototype.hasOwnProperty.call(vars, name) ? String(vars[name]) : match);
|
||||
}
|
||||
function alarmActionLabel(action, lang) {
|
||||
const normalized = String(action ?? "").trim().toLowerCase();
|
||||
if (normalized === "inserted")
|
||||
return tHost("alarm.action.inserted", lang);
|
||||
if (normalized === "updated")
|
||||
return tHost("alarm.action.updated", lang);
|
||||
if (normalized === "deleted")
|
||||
return tHost("alarm.action.deleted", lang);
|
||||
return normalized || tHost("alarm.action.fallback", lang);
|
||||
}
|
||||
|
||||
// src/netx/alarm-push.ts
|
||||
function alarmSubscribeUrl(apiUrl) {
|
||||
const trimmed = apiUrl.trim().replace(/\/$/, "");
|
||||
|
|
@ -69,16 +126,6 @@ function alarmSubscribeUrl(apiUrl) {
|
|||
}
|
||||
function formatAlarmPrompt(payload, lang = "zh") {
|
||||
const action = String(payload.action ?? "").trim().toLowerCase();
|
||||
const actionZh = {
|
||||
inserted: "告警产生",
|
||||
updated: "告警更新",
|
||||
deleted: "告警清除"
|
||||
};
|
||||
const actionEn = {
|
||||
inserted: "Alarm Raised",
|
||||
updated: "Alarm Updated",
|
||||
deleted: "Alarm Cleared"
|
||||
};
|
||||
const ne = payload.ne && typeof payload.ne === "object" ? payload.ne : {};
|
||||
const host = String(ne.host_name ?? "").trim();
|
||||
const ip = String(ne.ip_address ?? "").trim();
|
||||
|
|
@ -86,33 +133,18 @@ function formatAlarmPrompt(payload, lang = "zh") {
|
|||
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 ?? "关键告警").trim();
|
||||
if (lang.startsWith("en")) {
|
||||
return [
|
||||
`[NMS ${actionEn[action] ?? (action || "Alarm")}] ${label}`,
|
||||
`Device: ${device}`,
|
||||
`Object: ${String(payload.object_name ?? "-").trim()}`,
|
||||
`Severity: ${String(payload.perceived_severity ?? "-").trim()}`,
|
||||
`Cause: ${String(payload.native_probable_cause ?? "-").trim()}`,
|
||||
`Time: ${String(payload.time_created ?? "-").trim()}`,
|
||||
`notificationId: ${String(payload.notification_id ?? "-").trim()}`,
|
||||
`alarm_key: ${String(payload.alarm_key ?? "-").trim()}`,
|
||||
"",
|
||||
"Please analyze this key alarm and suggest next ops steps."
|
||||
].join(`
|
||||
`);
|
||||
}
|
||||
const label = String(payload.rule_label ?? payload.native_probable_cause ?? tHost("alarm.defaultLabel", lang)).trim();
|
||||
return [
|
||||
`[NMS ${actionZh[action] ?? (action || "告警")}] ${label}`,
|
||||
`设备: ${device}`,
|
||||
`对象: ${String(payload.object_name ?? "-").trim()}`,
|
||||
`级别: ${String(payload.perceived_severity ?? "-").trim()}`,
|
||||
`原因: ${String(payload.native_probable_cause ?? "-").trim()}`,
|
||||
`时间: ${String(payload.time_created ?? "-").trim()}`,
|
||||
`[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(`
|
||||
`);
|
||||
}
|
||||
|
|
@ -368,7 +400,6 @@ import { homedir } from "node:os";
|
|||
import { join } from "node:path";
|
||||
var PRESET_ID = "netxops";
|
||||
var PERMISSION_PRESET = "default";
|
||||
var TITLE = "Netx 关键告警";
|
||||
var sticky = null;
|
||||
var deliveryChain = Promise.resolve();
|
||||
function resolveWorkspacePath() {
|
||||
|
|
@ -402,7 +433,7 @@ async function deliverAlarmToSession(ctx, payload, lang = "zh") {
|
|||
await disposeSticky(previous);
|
||||
}
|
||||
}
|
||||
await createStickySession(ctx, prompt);
|
||||
await createStickySession(ctx, prompt, lang);
|
||||
};
|
||||
const next = deliveryChain.then(run, run);
|
||||
deliveryChain = next.then(() => {
|
||||
|
|
@ -440,7 +471,7 @@ async function followup(ctx, agent, prompt) {
|
|||
throw error;
|
||||
}
|
||||
}
|
||||
async function createStickySession(ctx, prompt) {
|
||||
async function createStickySession(ctx, prompt, lang = "zh") {
|
||||
const c = ctx;
|
||||
const agents = c.agents;
|
||||
const agentPresets = c.agentPresets;
|
||||
|
|
@ -485,7 +516,7 @@ async function createStickySession(ctx, prompt) {
|
|||
permissionPresets.set(handle.agent.session, PERMISSION_PRESET);
|
||||
}
|
||||
if (sessionTitle && typeof sessionTitle.rename === "function") {
|
||||
sessionTitle.rename(handle.agent.session, TITLE);
|
||||
sessionTitle.rename(handle.agent.session, tHost("alarm.sessionTitle", lang));
|
||||
}
|
||||
await followup(ctx, handle.agent, prompt);
|
||||
sticky = {
|
||||
|
|
@ -777,7 +808,7 @@ function watchNetxConnection(listener) {
|
|||
// src/netx/session-export.ts
|
||||
import { homedir as homedir2, hostname as osHostname } from "node:os";
|
||||
|
||||
// node_modules/fflate/esm/index.mjs
|
||||
// node_modules/.pnpm/fflate@0.8.3/node_modules/fflate/esm/index.mjs
|
||||
import { createRequire as createRequire2 } from "module";
|
||||
var require2 = createRequire2("/");
|
||||
var _a;
|
||||
|
|
@ -1629,6 +1660,7 @@ async function getSessionsExportStatus(ctx, signal) {
|
|||
available: false,
|
||||
sessionCount: 0,
|
||||
supportsRawArtifacts: false,
|
||||
reasonCode: "no_persistence",
|
||||
reason: "sessionPersistence unavailable — mount a JSONL session backend (web profile default)"
|
||||
};
|
||||
}
|
||||
|
|
@ -1637,6 +1669,7 @@ async function getSessionsExportStatus(ctx, signal) {
|
|||
available: false,
|
||||
sessionCount: 0,
|
||||
supportsRawArtifacts: false,
|
||||
reasonCode: "no_raw_artifacts",
|
||||
reason: "persistence backend does not expose per-session raw artifacts (SQLite export unsupported)"
|
||||
};
|
||||
}
|
||||
|
|
@ -1653,6 +1686,7 @@ async function getSessionsExportStatus(ctx, signal) {
|
|||
available: false,
|
||||
sessionCount: 0,
|
||||
supportsRawArtifacts: true,
|
||||
reasonCode: "list_failed",
|
||||
reason: error instanceof Error ? error.message : String(error)
|
||||
};
|
||||
}
|
||||
|
|
@ -3571,6 +3605,7 @@ function apply(ctx, config = Config({})) {
|
|||
value: {
|
||||
available: false,
|
||||
options: [],
|
||||
reasonCode: "im_catalog_unavailable",
|
||||
hint: "dsh-im-ops missing or outdated — install ≥ops.24 for delivery picker"
|
||||
}
|
||||
};
|
||||
|
|
|
|||
|
|
@ -64,7 +64,6 @@
|
|||
"inject": [
|
||||
"@deepseek-ai/dsh-client-locale",
|
||||
"@deepseek-ai/dsh-client-ui-settings",
|
||||
"@deepseek-ai/dsh-client-ui-settings-plugins",
|
||||
"@deepseek-ai/dsh-api-remotes"
|
||||
]
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,8 +1,8 @@
|
|||
/**
|
||||
* Netx Ops Plugins settings card — apiUrl / lang + credential token.
|
||||
* Netx Ops settings section — uds-auth visual language (page header + cards).
|
||||
*/
|
||||
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { useEffect, useRef, useState, type ReactNode } from 'react'
|
||||
import type { InjectFace, PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import type { NetxopsCardFace, NetxopsCardState } from './controller.ts'
|
||||
import type { NetxopsLocaleKey } from './locales.ts'
|
||||
|
|
@ -20,7 +20,8 @@ import {
|
|||
} from '../netx/im-targets.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'
|
||||
import { sessionsExportReasonLocaleKey } from './sessions-export-view.ts'
|
||||
import type {} from '@deepseek-ai/dsh-client-ui-settings/client'
|
||||
|
||||
function phaseLocaleKey(phase: AlarmPushPhase): NetxopsLocaleKey {
|
||||
switch (phase) {
|
||||
|
|
@ -51,8 +52,43 @@ function StatusBadge(props: {
|
|||
return <span className={className}>{props.label}</span>
|
||||
}
|
||||
|
||||
function fillTemplate(template: string, vars: Record<string, string>): string {
|
||||
return template.replace(/\{(\w+)\}/g, (match, name: string) => (
|
||||
Object.prototype.hasOwnProperty.call(vars, name) ? vars[name]! : match
|
||||
))
|
||||
}
|
||||
|
||||
function exportStatusMessage(
|
||||
t: (key: NetxopsLocaleKey) => string,
|
||||
status: NonNullable<NetxopsCardState['sessionsExportStatus']>,
|
||||
): string {
|
||||
const key = sessionsExportReasonLocaleKey(status.reasonCode)
|
||||
const template = t(key)
|
||||
if (key === 'sessionsExportListFailed') {
|
||||
return fillTemplate(template, { detail: status.reason || '' })
|
||||
}
|
||||
return status.reasonCode ? template : (status.reason || template)
|
||||
}
|
||||
|
||||
function exportErrorMessage(
|
||||
t: (key: NetxopsLocaleKey) => string,
|
||||
error: NonNullable<NetxopsCardState['sessionsExportError']>,
|
||||
): string {
|
||||
const key = sessionsExportReasonLocaleKey(error.code)
|
||||
const template = t(key)
|
||||
if (error.code === 'http_failed') {
|
||||
const detail = error.detail.trim() === '' ? '' : ` ${error.detail.trim()}`
|
||||
return fillTemplate(template, {
|
||||
status: error.status !== undefined ? String(error.status) : '',
|
||||
detail,
|
||||
})
|
||||
}
|
||||
if (error.code === 'empty_body') return template
|
||||
return error.fallback || template
|
||||
}
|
||||
|
||||
export type NetxopsCardProps =
|
||||
PropsRuntime<'settings.plugin.item'>
|
||||
PropsRuntime<'settings.section'>
|
||||
& PropsLocale<'settings.netxops'>
|
||||
& InjectFace<NetxopsCardFace>
|
||||
|
||||
|
|
@ -69,9 +105,9 @@ function ValueField(props: {
|
|||
onReset: () => void
|
||||
}) {
|
||||
return (
|
||||
<div className="dsh-nx-field">
|
||||
<div className="dsh-nx-fieldHead">
|
||||
<label className="dsh-nx-label" htmlFor={props.id}>{props.label}</label>
|
||||
<div className="dsh-nx-settings-field">
|
||||
<div className="dsh-nx-field-head">
|
||||
<label htmlFor={props.id}>{props.label}</label>
|
||||
{props.field.overridden
|
||||
? (
|
||||
<span className="dsh-nx-badges">
|
||||
|
|
@ -85,7 +121,7 @@ function ValueField(props: {
|
|||
</div>
|
||||
<input
|
||||
id={props.id}
|
||||
className={props.field.invalid ? 'dsh-nx-input dsh-nx-inputInvalid' : 'dsh-nx-input'}
|
||||
className={props.field.invalid ? 'dsh-nx-inputInvalid' : undefined}
|
||||
type="text"
|
||||
value={props.field.text}
|
||||
disabled={props.disabled}
|
||||
|
|
@ -120,8 +156,6 @@ function ImDeliveryPicker(props: {
|
|||
imTargetId: props.legacyTargetId,
|
||||
})
|
||||
const selectedKeys = new Set(selected.map((row) => imTargetKey(row.botId, row.targetId)))
|
||||
|
||||
// Orphan legacy / JSON targets not present in the live catalog still show as checked rows.
|
||||
const orphanSelected = selected.filter(
|
||||
(row) => !options.some(
|
||||
(opt) => imCatalogOptionKey(opt.botId, opt.targetId) === imTargetKey(row.botId, row.targetId),
|
||||
|
|
@ -129,9 +163,9 @@ function ImDeliveryPicker(props: {
|
|||
)
|
||||
|
||||
return (
|
||||
<div className="dsh-nx-field">
|
||||
<div className="dsh-nx-fieldHead">
|
||||
<span className="dsh-nx-label">{props.labels.target}</span>
|
||||
<div className="dsh-nx-settings-field">
|
||||
<div className="dsh-nx-field-head">
|
||||
<span className="dsh-nx-field-label">{props.labels.target}</span>
|
||||
{selected.length > 0
|
||||
? (
|
||||
<span className="dsh-nx-badgeMuted">
|
||||
|
|
@ -144,7 +178,9 @@ function ImDeliveryPicker(props: {
|
|||
? (
|
||||
<p className="dsh-nx-hint">
|
||||
{!props.catalog.available
|
||||
? (props.catalog.hint || props.labels.unavailable)
|
||||
? (props.catalog.reasonCode
|
||||
? props.labels.unavailable
|
||||
: (props.catalog.hint || props.labels.unavailable))
|
||||
: props.labels.none}
|
||||
</p>
|
||||
)
|
||||
|
|
@ -190,7 +226,13 @@ function ImDeliveryPicker(props: {
|
|||
</div>
|
||||
)}
|
||||
{!props.catalog.available && options.length > 0
|
||||
? <p className="dsh-nx-hint">{props.catalog.hint || props.labels.unavailable}</p>
|
||||
? (
|
||||
<p className="dsh-nx-hint">
|
||||
{props.catalog.reasonCode
|
||||
? props.labels.unavailable
|
||||
: (props.catalog.hint || props.labels.unavailable)}
|
||||
</p>
|
||||
)
|
||||
: null}
|
||||
</div>
|
||||
)
|
||||
|
|
@ -233,312 +275,334 @@ function CapabilityGroupBlock(props: {
|
|||
)
|
||||
}
|
||||
|
||||
function ToggleField(props: {
|
||||
id: string
|
||||
label: string
|
||||
checked: boolean
|
||||
disabled: boolean
|
||||
overridden: boolean
|
||||
overriddenLabel: string
|
||||
resetLabel: string
|
||||
onToggle: (checked: boolean) => void
|
||||
onReset: () => void
|
||||
trailing?: ReactNode
|
||||
hint?: ReactNode
|
||||
error?: ReactNode
|
||||
}) {
|
||||
return (
|
||||
<div className="dsh-nx-settings-field">
|
||||
<div className="dsh-nx-field-head">
|
||||
<label htmlFor={props.id}>{props.label}</label>
|
||||
<span className="dsh-nx-badges">
|
||||
{props.trailing}
|
||||
{props.overridden
|
||||
? (
|
||||
<>
|
||||
<span className="dsh-nx-badge">{props.overriddenLabel}</span>
|
||||
<button type="button" className="dsh-nx-reset" disabled={props.disabled} onClick={props.onReset}>
|
||||
{props.resetLabel}
|
||||
</button>
|
||||
</>
|
||||
)
|
||||
: null}
|
||||
</span>
|
||||
</div>
|
||||
<label className="dsh-nx-checkRow" htmlFor={props.id}>
|
||||
<input
|
||||
id={props.id}
|
||||
type="checkbox"
|
||||
checked={props.checked}
|
||||
disabled={props.disabled}
|
||||
aria-label={props.label}
|
||||
onChange={(event) => { props.onToggle(event.target.checked) }}
|
||||
/>
|
||||
</label>
|
||||
{props.hint}
|
||||
{props.error}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function NetxopsCard(props: NetxopsCardProps) {
|
||||
ensureStyles()
|
||||
const { t } = props
|
||||
const state: NetxopsCardState = props.useNetxopsCard(snapshot => snapshot)
|
||||
const [open, setOpen] = useState(false)
|
||||
const [flash, setFlash] = useState<'ok' | 'err' | null>(null)
|
||||
const saveStarted = useRef(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (state.saving) {
|
||||
saveStarted.current = true
|
||||
setFlash(null)
|
||||
return
|
||||
}
|
||||
if (!saveStarted.current) return
|
||||
saveStarted.current = false
|
||||
if (!state.dirty && !state.failed) setOpen(false)
|
||||
if (state.failed) {
|
||||
setFlash('err')
|
||||
return
|
||||
}
|
||||
if (!state.dirty) setFlash('ok')
|
||||
}, [state.dirty, state.failed, state.saving])
|
||||
|
||||
if (!state.available) return null
|
||||
if (!state.available) {
|
||||
return (
|
||||
<section className="dsh-nx-settings" aria-label={t('title')}>
|
||||
<div className="dsh-nx-settings-empty">{t('sessionsExportUnavailable')}</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
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'}>
|
||||
<button
|
||||
type="button"
|
||||
className="dsh-nx-header"
|
||||
aria-expanded={open}
|
||||
aria-label={`${t(open ? 'collapse' : 'expand')}: ${title}`}
|
||||
onClick={() => { setOpen(!open) }}
|
||||
>
|
||||
<span className="dsh-nx-headText">
|
||||
<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>
|
||||
{open
|
||||
? (
|
||||
<div className="dsh-nx-body">
|
||||
{!state.writable ? <p className="dsh-nx-readOnly" role="status">{t('readOnly')}</p> : null}
|
||||
<div className="dsh-nx-field">
|
||||
<div className="dsh-nx-fieldHead">
|
||||
<label className="dsh-nx-label" htmlFor="netxops-api-token">{t('apiToken')}</label>
|
||||
<span className="dsh-nx-badges">
|
||||
<span className={state.apiTokenConfigured ? 'dsh-nx-badge' : 'dsh-nx-badgeMuted'}>
|
||||
{state.apiTokenConfigured ? t('apiTokenSet') : t('apiTokenUnset')}
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
<input
|
||||
id="netxops-api-token"
|
||||
className="dsh-nx-input"
|
||||
type="password"
|
||||
autoComplete="off"
|
||||
value={state.apiToken.text}
|
||||
disabled={!state.apiTokenWritable}
|
||||
onChange={(event) => { props.edit('apiToken', event.target.value) }}
|
||||
/>
|
||||
<p className="dsh-nx-hint">
|
||||
{state.apiTokenRemoteReady ? t('apiTokenHint') : t('apiTokenUnavailable')}
|
||||
</p>
|
||||
</div>
|
||||
<ValueField
|
||||
id="netxops-api-url"
|
||||
label={t('apiUrl')}
|
||||
hint={t('apiUrlHint')}
|
||||
field={state.apiUrl}
|
||||
overriddenLabel={t('overridden')}
|
||||
resetLabel={t('reset')}
|
||||
invalidLabel={t('invalid')}
|
||||
disabled={disabled}
|
||||
onEdit={(text) => { props.edit('apiUrl', text) }}
|
||||
onReset={() => { props.resetField('apiUrl') }}
|
||||
/>
|
||||
<ValueField
|
||||
id="netxops-lang"
|
||||
label={t('lang')}
|
||||
hint={t('langHint')}
|
||||
field={state.lang}
|
||||
overriddenLabel={t('overridden')}
|
||||
resetLabel={t('reset')}
|
||||
invalidLabel={t('invalid')}
|
||||
disabled={disabled}
|
||||
onEdit={(text) => { props.edit('lang', text) }}
|
||||
onReset={() => { props.resetField('lang') }}
|
||||
/>
|
||||
<ValueField
|
||||
id="netxops-nms-provider"
|
||||
label={t('nmsProvider')}
|
||||
hint={t('nmsProviderHint')}
|
||||
field={state.nmsProvider}
|
||||
overriddenLabel={t('overridden')}
|
||||
resetLabel={t('reset')}
|
||||
invalidLabel={t('invalid')}
|
||||
disabled={disabled}
|
||||
onEdit={(text) => { props.edit('nmsProvider', text) }}
|
||||
onReset={() => { props.resetField('nmsProvider') }}
|
||||
/>
|
||||
<div className="dsh-nx-field">
|
||||
<div className="dsh-nx-fieldHead">
|
||||
<span className="dsh-nx-label">{t('capabilityGroups')}</span>
|
||||
</div>
|
||||
<CapabilityGroupBlock
|
||||
title={t('groupOps')}
|
||||
inPresetLabel={t('groupInPreset')}
|
||||
publicLabel={t('groupPublic')}
|
||||
inPreset={state.groupOpsInPreset}
|
||||
published={state.groupOpsPublic}
|
||||
disabled={disabled}
|
||||
onEditInPreset={(checked) => {
|
||||
props.edit('groupOpsInPreset', checked ? 'true' : 'false')
|
||||
}}
|
||||
onEditPublic={(checked) => {
|
||||
props.edit('groupOpsPublic', checked ? 'true' : 'false')
|
||||
}}
|
||||
/>
|
||||
<CapabilityGroupBlock
|
||||
title={t('groupTopology')}
|
||||
inPresetLabel={t('groupInPreset')}
|
||||
publicLabel={t('groupPublic')}
|
||||
inPreset={state.groupTopologyInPreset}
|
||||
published={state.groupTopologyPublic}
|
||||
disabled={disabled}
|
||||
onEditInPreset={(checked) => {
|
||||
props.edit('groupTopologyInPreset', checked ? 'true' : 'false')
|
||||
}}
|
||||
onEditPublic={(checked) => {
|
||||
props.edit('groupTopologyPublic', checked ? 'true' : 'false')
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="dsh-nx-field">
|
||||
<div className="dsh-nx-fieldHead">
|
||||
<label className="dsh-nx-label" htmlFor="netxops-alarm-push">{t('alarmPushEnabled')}</label>
|
||||
<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
|
||||
id="netxops-alarm-push"
|
||||
type="checkbox"
|
||||
checked={state.alarmPushEnabled.text === 'true'}
|
||||
disabled={disabled}
|
||||
aria-label={t('alarmPushEnabled')}
|
||||
onChange={(event) => {
|
||||
props.edit('alarmPushEnabled', event.target.checked ? 'true' : 'false')
|
||||
}}
|
||||
/>
|
||||
</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-field">
|
||||
<div className="dsh-nx-fieldHead">
|
||||
<label className="dsh-nx-label" htmlFor="netxops-alarm-dsh">{t('alarmDeliverDsh')}</label>
|
||||
{state.alarmDeliverDsh.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('alarmDeliverDsh') }}>
|
||||
{t('reset')}
|
||||
</button>
|
||||
</span>
|
||||
)
|
||||
: null}
|
||||
</div>
|
||||
<label className="dsh-nx-checkRow" htmlFor="netxops-alarm-dsh">
|
||||
<input
|
||||
id="netxops-alarm-dsh"
|
||||
type="checkbox"
|
||||
checked={state.alarmDeliverDsh.text === 'true'}
|
||||
disabled={disabled}
|
||||
aria-label={t('alarmDeliverDsh')}
|
||||
onChange={(event) => {
|
||||
props.edit('alarmDeliverDsh', event.target.checked ? 'true' : 'false')
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
<ImDeliveryPicker
|
||||
catalog={state.imDeliveryCatalog}
|
||||
targetsJson={state.imTargets.text}
|
||||
legacyBotId={state.imBotId.text}
|
||||
legacyTargetId={state.imTargetId.text}
|
||||
disabled={disabled}
|
||||
labels={{
|
||||
target: t('alarmDeliverIm'),
|
||||
none: t('imTargetNone'),
|
||||
selectedCount: t('imTargetSelectedCount'),
|
||||
unavailable: t('imCatalogUnavailable'),
|
||||
}}
|
||||
onChange={(targets) => {
|
||||
props.edit('imTargets', formatImTargetsJson(targets))
|
||||
const first = targets[0]
|
||||
props.edit('imBotId', first?.botId ?? '')
|
||||
props.edit('imTargetId', first?.targetId ?? '')
|
||||
props.edit('alarmDeliverIm', targets.length > 0 ? 'true' : 'false')
|
||||
}}
|
||||
/>
|
||||
<div className="dsh-nx-field">
|
||||
<div className="dsh-nx-fieldHead">
|
||||
<span className="dsh-nx-label">{t('sessionsExport')}</span>
|
||||
</div>
|
||||
{state.sessionsExportStatus === null
|
||||
? <p className="dsh-nx-hint">{t('sessionsExportUnavailable')}</p>
|
||||
: state.sessionsExportStatus.available
|
||||
? (
|
||||
<p className="dsh-nx-hint">
|
||||
{t('sessionsExportCount').replace(
|
||||
'{count}',
|
||||
String(state.sessionsExportStatus.sessionCount),
|
||||
)}
|
||||
</p>
|
||||
)
|
||||
: (
|
||||
<p className="dsh-nx-invalid" role="status">
|
||||
{state.sessionsExportStatus.reason || t('sessionsExportUnavailable')}
|
||||
</p>
|
||||
)}
|
||||
{state.sessionsExportError
|
||||
? <p className="dsh-nx-invalid" role="status">{state.sessionsExportError}</p>
|
||||
: null}
|
||||
{state.sessionsExportLastFile && !state.sessionsExportBusy && !state.sessionsExportError
|
||||
? (
|
||||
<p className="dsh-nx-hint" role="status">
|
||||
{t('sessionsExportDone').replace('{file}', state.sessionsExportLastFile)}
|
||||
</p>
|
||||
)
|
||||
: null}
|
||||
<div className="dsh-nx-exportRow">
|
||||
<button
|
||||
type="button"
|
||||
className="dsh-nx-btn dsh-nx-export"
|
||||
disabled={
|
||||
state.sessionsExportBusy
|
||||
|| state.sessionsExportStatus?.available !== true
|
||||
}
|
||||
onClick={() => { props.exportAllSessions() }}
|
||||
>
|
||||
{t(state.sessionsExportBusy ? 'sessionsExportBusy' : 'sessionsExportButton')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="dsh-nx-footer">
|
||||
{state.failed ? <p className="dsh-nx-failed" role="status">{t('saveFailed')}</p> : null}
|
||||
<button
|
||||
type="button"
|
||||
className="dsh-nx-btn dsh-nx-discard"
|
||||
disabled={!state.dirty || state.saving}
|
||||
onClick={props.discard}
|
||||
>
|
||||
{t('discard')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="dsh-nx-btn dsh-nx-save"
|
||||
disabled={blocked}
|
||||
onClick={props.save}
|
||||
>
|
||||
{t(state.saving ? 'saving' : 'save')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
<section className="dsh-nx-settings" aria-label={t('title')}>
|
||||
<header>
|
||||
<h2>{t('title')}</h2>
|
||||
<p className="dsh-nx-settings-intro">{t('description')}</p>
|
||||
</header>
|
||||
|
||||
{!state.writable
|
||||
? <p className="dsh-nx-hint" role="status">{t('readOnly')}</p>
|
||||
: null}
|
||||
</li>
|
||||
|
||||
<div className="dsh-nx-settings-card">
|
||||
<h3>{t('sectionConnection')}</h3>
|
||||
<div className="dsh-nx-settings-field">
|
||||
<div className="dsh-nx-field-head">
|
||||
<label htmlFor="netxops-api-token">{t('apiToken')}</label>
|
||||
<span className="dsh-nx-badges">
|
||||
<span className={state.apiTokenConfigured ? 'dsh-nx-badge' : 'dsh-nx-badgeMuted'}>
|
||||
{state.apiTokenConfigured ? t('apiTokenSet') : t('apiTokenUnset')}
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
<input
|
||||
id="netxops-api-token"
|
||||
type="password"
|
||||
autoComplete="off"
|
||||
value={state.apiToken.text}
|
||||
disabled={!state.apiTokenWritable}
|
||||
onChange={(event) => { props.edit('apiToken', event.target.value) }}
|
||||
/>
|
||||
<p className="dsh-nx-hint">
|
||||
{state.apiTokenRemoteReady ? t('apiTokenHint') : t('apiTokenUnavailable')}
|
||||
</p>
|
||||
</div>
|
||||
<ValueField
|
||||
id="netxops-api-url"
|
||||
label={t('apiUrl')}
|
||||
hint={t('apiUrlHint')}
|
||||
field={state.apiUrl}
|
||||
overriddenLabel={t('overridden')}
|
||||
resetLabel={t('reset')}
|
||||
invalidLabel={t('invalid')}
|
||||
disabled={disabled}
|
||||
onEdit={(text) => { props.edit('apiUrl', text) }}
|
||||
onReset={() => { props.resetField('apiUrl') }}
|
||||
/>
|
||||
<ValueField
|
||||
id="netxops-lang"
|
||||
label={t('lang')}
|
||||
hint={t('langHint')}
|
||||
field={state.lang}
|
||||
overriddenLabel={t('overridden')}
|
||||
resetLabel={t('reset')}
|
||||
invalidLabel={t('invalid')}
|
||||
disabled={disabled}
|
||||
onEdit={(text) => { props.edit('lang', text) }}
|
||||
onReset={() => { props.resetField('lang') }}
|
||||
/>
|
||||
<ValueField
|
||||
id="netxops-nms-provider"
|
||||
label={t('nmsProvider')}
|
||||
hint={t('nmsProviderHint')}
|
||||
field={state.nmsProvider}
|
||||
overriddenLabel={t('overridden')}
|
||||
resetLabel={t('reset')}
|
||||
invalidLabel={t('invalid')}
|
||||
disabled={disabled}
|
||||
onEdit={(text) => { props.edit('nmsProvider', text) }}
|
||||
onReset={() => { props.resetField('nmsProvider') }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="dsh-nx-settings-card">
|
||||
<h3>{t('sectionCapabilities')}</h3>
|
||||
<CapabilityGroupBlock
|
||||
title={t('groupOps')}
|
||||
inPresetLabel={t('groupInPreset')}
|
||||
publicLabel={t('groupPublic')}
|
||||
inPreset={state.groupOpsInPreset}
|
||||
published={state.groupOpsPublic}
|
||||
disabled={disabled}
|
||||
onEditInPreset={(checked) => {
|
||||
props.edit('groupOpsInPreset', checked ? 'true' : 'false')
|
||||
}}
|
||||
onEditPublic={(checked) => {
|
||||
props.edit('groupOpsPublic', checked ? 'true' : 'false')
|
||||
}}
|
||||
/>
|
||||
<CapabilityGroupBlock
|
||||
title={t('groupTopology')}
|
||||
inPresetLabel={t('groupInPreset')}
|
||||
publicLabel={t('groupPublic')}
|
||||
inPreset={state.groupTopologyInPreset}
|
||||
published={state.groupTopologyPublic}
|
||||
disabled={disabled}
|
||||
onEditInPreset={(checked) => {
|
||||
props.edit('groupTopologyInPreset', checked ? 'true' : 'false')
|
||||
}}
|
||||
onEditPublic={(checked) => {
|
||||
props.edit('groupTopologyPublic', checked ? 'true' : 'false')
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="dsh-nx-settings-card">
|
||||
<h3>{t('sectionAlarms')}</h3>
|
||||
<ToggleField
|
||||
id="netxops-alarm-push"
|
||||
label={t('alarmPushEnabled')}
|
||||
checked={state.alarmPushEnabled.text === 'true'}
|
||||
disabled={disabled}
|
||||
overridden={state.alarmPushEnabled.overridden}
|
||||
overriddenLabel={t('overridden')}
|
||||
resetLabel={t('reset')}
|
||||
onToggle={(checked) => {
|
||||
props.edit('alarmPushEnabled', checked ? 'true' : 'false')
|
||||
}}
|
||||
onReset={() => { props.resetField('alarmPushEnabled') }}
|
||||
trailing={pushStatus
|
||||
? (
|
||||
<StatusBadge
|
||||
phase={pushStatus.phase}
|
||||
label={`${t('alarmPushStatus')}: ${t(phaseLocaleKey(pushStatus.phase))}`}
|
||||
/>
|
||||
)
|
||||
: null}
|
||||
hint={pushStatus?.wsUrl ? <p className="dsh-nx-hint">{pushStatus.wsUrl}</p> : null}
|
||||
error={pushStatus?.lastError
|
||||
? <p className="dsh-nx-invalid" role="status">{pushStatus.lastError}</p>
|
||||
: null}
|
||||
/>
|
||||
<ToggleField
|
||||
id="netxops-alarm-dsh"
|
||||
label={t('alarmDeliverDsh')}
|
||||
checked={state.alarmDeliverDsh.text === 'true'}
|
||||
disabled={disabled}
|
||||
overridden={state.alarmDeliverDsh.overridden}
|
||||
overriddenLabel={t('overridden')}
|
||||
resetLabel={t('reset')}
|
||||
onToggle={(checked) => {
|
||||
props.edit('alarmDeliverDsh', checked ? 'true' : 'false')
|
||||
}}
|
||||
onReset={() => { props.resetField('alarmDeliverDsh') }}
|
||||
/>
|
||||
<ImDeliveryPicker
|
||||
catalog={state.imDeliveryCatalog}
|
||||
targetsJson={state.imTargets.text}
|
||||
legacyBotId={state.imBotId.text}
|
||||
legacyTargetId={state.imTargetId.text}
|
||||
disabled={disabled}
|
||||
labels={{
|
||||
target: t('alarmDeliverIm'),
|
||||
none: t('imTargetNone'),
|
||||
selectedCount: t('imTargetSelectedCount'),
|
||||
unavailable: t('imCatalogUnavailable'),
|
||||
}}
|
||||
onChange={(targets) => {
|
||||
props.edit('imTargets', formatImTargetsJson(targets))
|
||||
const first = targets[0]
|
||||
props.edit('imBotId', first?.botId ?? '')
|
||||
props.edit('imTargetId', first?.targetId ?? '')
|
||||
props.edit('alarmDeliverIm', targets.length > 0 ? 'true' : 'false')
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="dsh-nx-settings-card">
|
||||
<h3>{t('sectionExport')}</h3>
|
||||
<div className="dsh-nx-settings-field">
|
||||
<span className="dsh-nx-field-label">{t('sessionsExport')}</span>
|
||||
{state.sessionsExportStatus === null
|
||||
? <p className="dsh-nx-hint">{t('sessionsExportUnavailable')}</p>
|
||||
: state.sessionsExportStatus.available
|
||||
? (
|
||||
<p className="dsh-nx-hint">
|
||||
{t('sessionsExportCount').replace(
|
||||
'{count}',
|
||||
String(state.sessionsExportStatus.sessionCount),
|
||||
)}
|
||||
</p>
|
||||
)
|
||||
: (
|
||||
<p className="dsh-nx-hint">
|
||||
{exportStatusMessage(t, state.sessionsExportStatus)}
|
||||
</p>
|
||||
)}
|
||||
{state.sessionsExportError
|
||||
? (
|
||||
<p className="dsh-nx-invalid" role="status">
|
||||
{exportErrorMessage(t, state.sessionsExportError)}
|
||||
</p>
|
||||
)
|
||||
: null}
|
||||
{state.sessionsExportLastFile && !state.sessionsExportBusy && !state.sessionsExportError
|
||||
? (
|
||||
<p className="dsh-nx-hint" role="status">
|
||||
{t('sessionsExportDone').replace('{file}', state.sessionsExportLastFile)}
|
||||
</p>
|
||||
)
|
||||
: null}
|
||||
<div className="dsh-nx-settings-actions">
|
||||
<button
|
||||
type="button"
|
||||
className="dsh-nx-btn"
|
||||
disabled={
|
||||
state.sessionsExportBusy
|
||||
|| state.sessionsExportStatus?.available !== true
|
||||
}
|
||||
onClick={() => { props.exportAllSessions() }}
|
||||
>
|
||||
{t(state.sessionsExportBusy ? 'sessionsExportBusy' : 'sessionsExportButton')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="dsh-nx-settings-actions">
|
||||
<button
|
||||
type="button"
|
||||
className="dsh-nx-btn dsh-nx-btn-primary"
|
||||
disabled={blocked}
|
||||
onClick={props.save}
|
||||
>
|
||||
{t(state.saving ? 'saving' : 'save')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="dsh-nx-btn dsh-nx-btn-ghost"
|
||||
disabled={!state.dirty || state.saving}
|
||||
onClick={() => {
|
||||
props.discard()
|
||||
setFlash(null)
|
||||
}}
|
||||
>
|
||||
{t('discard')}
|
||||
</button>
|
||||
{state.dirty && !state.saving
|
||||
? <span className="dsh-nx-settings-msg">{t('unsaved')}</span>
|
||||
: null}
|
||||
{flash === 'ok' && !state.dirty
|
||||
? <span className="dsh-nx-settings-msg ok">{t('configSaved')}</span>
|
||||
: null}
|
||||
{(flash === 'err' || state.failed)
|
||||
? <span className="dsh-nx-settings-msg err" role="status">{t('saveFailed')}</span>
|
||||
: null}
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -23,8 +23,10 @@ import {
|
|||
import {
|
||||
downloadAllSessionsExport,
|
||||
fetchSessionsExportStatus,
|
||||
SessionsExportDownloadError,
|
||||
type SessionsExportStatus,
|
||||
} from './sessions-export-view.ts'
|
||||
import type { SessionsExportReasonCode } from '../session-export-shared.ts'
|
||||
|
||||
export const NETXOPS_NS = 'netxops'
|
||||
const DEFAULT_TOKEN_REF = 'NETX_API_TOKEN'
|
||||
|
|
@ -82,8 +84,13 @@ export interface NetxopsCardState extends CardShell {
|
|||
sessionsExportStatus: SessionsExportStatus | null
|
||||
/** In-flight bulk export download. */
|
||||
sessionsExportBusy: boolean
|
||||
/** Last bulk export error message, if any. */
|
||||
sessionsExportError: string | null
|
||||
/** Last bulk export error (stable code + optional detail for locale templates). */
|
||||
sessionsExportError: {
|
||||
code: SessionsExportReasonCode
|
||||
status?: number
|
||||
detail: string
|
||||
fallback: string
|
||||
} | null
|
||||
/** Last successful download filename. */
|
||||
sessionsExportLastFile: string | null
|
||||
}
|
||||
|
|
@ -115,7 +122,7 @@ export class NetxopsCardController {
|
|||
private imDeliveryCatalog: ImDeliveryCatalog = { ...EMPTY_IM_DELIVERY_CATALOG }
|
||||
private sessionsExportStatus: SessionsExportStatus | null = null
|
||||
private sessionsExportBusy = false
|
||||
private sessionsExportError: string | null = null
|
||||
private sessionsExportError: NetxopsCardState['sessionsExportError'] = null
|
||||
private sessionsExportLastFile: string | null = null
|
||||
private pollTimer: ReturnType<typeof setInterval> | undefined
|
||||
private pollInFlight = false
|
||||
|
|
@ -301,7 +308,20 @@ export class NetxopsCardController {
|
|||
this.sessionsExportError = null
|
||||
})
|
||||
.catch((error: unknown) => {
|
||||
this.sessionsExportError = error instanceof Error ? error.message : String(error)
|
||||
if (error instanceof SessionsExportDownloadError) {
|
||||
this.sessionsExportError = {
|
||||
code: error.code,
|
||||
status: error.status,
|
||||
detail: error.detail,
|
||||
fallback: error.message,
|
||||
}
|
||||
return
|
||||
}
|
||||
this.sessionsExportError = {
|
||||
code: 'http_failed',
|
||||
detail: '',
|
||||
fallback: error instanceof Error ? error.message : String(error),
|
||||
}
|
||||
})
|
||||
.finally(() => {
|
||||
this.sessionsExportBusy = false
|
||||
|
|
|
|||
|
|
@ -21,6 +21,8 @@ export interface ImDeliveryCatalog {
|
|||
available: boolean
|
||||
options: ImDeliveryCatalogOption[]
|
||||
hint: string
|
||||
/** Stable code so the card can use locale keys instead of English hints. */
|
||||
reasonCode?: 'im_catalog_unavailable' | 'rpc_failed'
|
||||
loading: boolean
|
||||
}
|
||||
|
||||
|
|
@ -59,6 +61,9 @@ export function asImDeliveryCatalog(value: unknown): ImDeliveryCatalog {
|
|||
available: row.available !== false,
|
||||
options,
|
||||
hint: typeof row.hint === 'string' ? row.hint : '',
|
||||
reasonCode: row.reasonCode === 'im_catalog_unavailable' || row.reasonCode === 'rpc_failed'
|
||||
? row.reasonCode
|
||||
: undefined,
|
||||
loading: false,
|
||||
}
|
||||
}
|
||||
|
|
@ -79,6 +84,7 @@ export async function fetchImDeliveryCatalog(
|
|||
return {
|
||||
...EMPTY_IM_DELIVERY_CATALOG,
|
||||
available: false,
|
||||
reasonCode: 'rpc_failed',
|
||||
hint: String((result as { error?: { message?: string } }).error?.message ?? 'rpc failed'),
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
/**
|
||||
* Browser half — Settings → Plugins → Netx Ops card.
|
||||
* Browser half — Settings → Netx Ops section (uds-auth-style page).
|
||||
*
|
||||
* Do not hard-inject `remote.credentials`: shipped `@deepseek-ai/dsh` 0.1.1-rc.2
|
||||
* remotes assembly does not mount that namespace (Models/Plugins cards only
|
||||
|
|
@ -11,7 +11,6 @@ import type {} from '@deepseek-ai/dsh-client-locale/client'
|
|||
import type {} from '@deepseek-ai/dsh-client-ui-settings/client'
|
||||
import type {} from '@deepseek-ai/dsh-client-ui-renderer/client'
|
||||
import type {} from '@deepseek-ai/dsh-api-remotes/client'
|
||||
import type {} from '@deepseek-ai/dsh-client-ui-settings-plugins/client'
|
||||
import { NetxopsCard } from './NetxopsCard.tsx'
|
||||
import { NETXOPS_NS, NetxopsCardController } from './controller.ts'
|
||||
import { en, zh, type NetxopsLocaleKey } from './locales.ts'
|
||||
|
|
@ -24,7 +23,7 @@ declare module '@deepseek-ai/dsh-client-ui-slots' {
|
|||
|
||||
const LOCALE_NS = 'settings.netxops'
|
||||
|
||||
/** Match shipped ui-settings-plugins inject (no remote.credentials). */
|
||||
/** Match shipped settings shell inject (no remote.credentials). */
|
||||
export const inject = [
|
||||
'slots',
|
||||
'locale',
|
||||
|
|
@ -33,12 +32,24 @@ export const inject = [
|
|||
]
|
||||
|
||||
export function apply(ctx: ClientContext): void {
|
||||
ctx.effect(() => ctx.locale.register(LOCALE_NS, { zh, en }), 'netxops: locales')
|
||||
ctx.effect(() => {
|
||||
try {
|
||||
return ctx.locale.register(LOCALE_NS, { zh, en })
|
||||
} catch {
|
||||
const offZh = ctx.locale.register(LOCALE_NS, 'zh', zh)
|
||||
const offEn = ctx.locale.register(LOCALE_NS, 'en', en)
|
||||
return () => {
|
||||
offZh?.()
|
||||
offEn?.()
|
||||
}
|
||||
}
|
||||
}, 'netxops: locales')
|
||||
|
||||
const card = new NetxopsCardController(
|
||||
ctx.settingsScope.bind({ namespace: NETXOPS_NS }),
|
||||
ctx,
|
||||
)
|
||||
const t = ctx.locale.bind(LOCALE_NS) as (key: NetxopsLocaleKey) => string
|
||||
|
||||
// Optional: newer remotes that mount credentials unlock the token field.
|
||||
ctx.inject(['remote.credentials'], (credCtx) => {
|
||||
|
|
@ -67,9 +78,11 @@ export function apply(ctx: ClientContext): void {
|
|||
}, 'netxops: clear alarm-push rpc')
|
||||
})
|
||||
|
||||
ctx.slots.inject('settings.plugin.item', () => ctx.slots.register({
|
||||
name: 'settings.plugin.item',
|
||||
key: NETXOPS_NS,
|
||||
ctx.slots.inject('settings.section', () => ctx.slots.register({
|
||||
name: 'settings.section',
|
||||
id: NETXOPS_NS,
|
||||
order: 24,
|
||||
label: () => t('title'),
|
||||
locale: LOCALE_NS,
|
||||
inject: () => card.inject(),
|
||||
}, NetxopsCard))
|
||||
|
|
|
|||
|
|
@ -1,8 +1,12 @@
|
|||
/** Locale keys for the Netx Ops Plugins settings card. */
|
||||
/** Locale keys for the Netx Ops settings section. */
|
||||
|
||||
export type NetxopsLocaleKey =
|
||||
| 'title'
|
||||
| 'description'
|
||||
| 'sectionConnection'
|
||||
| 'sectionCapabilities'
|
||||
| 'sectionAlarms'
|
||||
| 'sectionExport'
|
||||
| 'apiUrl'
|
||||
| 'apiUrlHint'
|
||||
| 'lang'
|
||||
|
|
@ -37,24 +41,34 @@ export type NetxopsLocaleKey =
|
|||
| 'sessionsExport'
|
||||
| 'sessionsExportCount'
|
||||
| 'sessionsExportUnavailable'
|
||||
| 'sessionsExportNoPersistence'
|
||||
| 'sessionsExportNoRawArtifacts'
|
||||
| 'sessionsExportListFailed'
|
||||
| 'sessionsExportRpcUnavailable'
|
||||
| 'sessionsExportRpcFailed'
|
||||
| 'sessionsExportHttpFailed'
|
||||
| 'sessionsExportEmpty'
|
||||
| 'sessionsExportButton'
|
||||
| 'sessionsExportBusy'
|
||||
| 'sessionsExportDone'
|
||||
| 'overridden'
|
||||
| 'reset'
|
||||
| 'invalid'
|
||||
| 'expand'
|
||||
| 'collapse'
|
||||
| 'unsaved'
|
||||
| 'readOnly'
|
||||
| 'saveFailed'
|
||||
| 'discard'
|
||||
| 'save'
|
||||
| 'saving'
|
||||
| 'configSaved'
|
||||
|
||||
export const en: Record<NetxopsLocaleKey, string> = {
|
||||
title: 'Netx Ops',
|
||||
description: 'API, token, capabilities, and alarm delivery.',
|
||||
sectionConnection: 'Connection',
|
||||
sectionCapabilities: 'Capability groups',
|
||||
sectionAlarms: 'Key-alarm delivery',
|
||||
sectionExport: 'Session export',
|
||||
apiUrl: 'API URL',
|
||||
apiUrlHint: 'e.g. http://127.0.0.1:8890',
|
||||
lang: 'Language',
|
||||
|
|
@ -89,31 +103,41 @@ export const en: Record<NetxopsLocaleKey, string> = {
|
|||
sessionsExport: 'Export all sessions',
|
||||
sessionsExportCount: '{count} sessions',
|
||||
sessionsExportUnavailable: 'Export unavailable',
|
||||
sessionsExportNoPersistence: 'Session store unavailable (JSONL backend required).',
|
||||
sessionsExportNoRawArtifacts: 'This persistence backend cannot export raw session files.',
|
||||
sessionsExportListFailed: 'Failed to list sessions: {detail}',
|
||||
sessionsExportRpcUnavailable: 'Cannot reach Host RPC for export status.',
|
||||
sessionsExportRpcFailed: 'Export status RPC failed.',
|
||||
sessionsExportHttpFailed: 'Export failed: HTTP {status}{detail}',
|
||||
sessionsExportEmpty: 'Export failed: empty ZIP body.',
|
||||
sessionsExportButton: 'Download ZIP',
|
||||
sessionsExportBusy: 'Preparing…',
|
||||
sessionsExportDone: 'Downloaded: {file}',
|
||||
overridden: 'Overridden',
|
||||
reset: 'Reset',
|
||||
invalid: 'Invalid',
|
||||
expand: 'Expand',
|
||||
collapse: 'Collapse',
|
||||
unsaved: 'Unsaved',
|
||||
unsaved: 'Unsaved changes',
|
||||
readOnly: 'Read-only',
|
||||
saveFailed: 'Save failed',
|
||||
discard: 'Discard',
|
||||
save: 'Save',
|
||||
saving: 'Saving…',
|
||||
configSaved: 'Saved',
|
||||
}
|
||||
|
||||
export const zh: Record<NetxopsLocaleKey, string> = {
|
||||
title: 'Netx Ops',
|
||||
description: 'API、Token、能力组与告警投递。',
|
||||
sectionConnection: '连接',
|
||||
sectionCapabilities: '能力组',
|
||||
sectionAlarms: '关键告警投递',
|
||||
sectionExport: '会话导出',
|
||||
apiUrl: 'API 地址',
|
||||
apiUrlHint: '例如 http://127.0.0.1:8890',
|
||||
lang: '语言',
|
||||
langHint: 'zh / en',
|
||||
capabilityGroups: '能力组',
|
||||
nmsProvider: 'NMS provider',
|
||||
nmsProvider: 'NMS 提供方',
|
||||
nmsProviderHint: 'zte-ume',
|
||||
groupOps: 'ops',
|
||||
groupTopology: 'topology',
|
||||
|
|
@ -142,18 +166,24 @@ export const zh: Record<NetxopsLocaleKey, string> = {
|
|||
sessionsExport: '导出全部会话',
|
||||
sessionsExportCount: '{count} 个会话',
|
||||
sessionsExportUnavailable: '无法导出',
|
||||
sessionsExportNoPersistence: '会话持久化不可用(需要 JSONL 后端)。',
|
||||
sessionsExportNoRawArtifacts: '当前持久化后端不支持导出原始会话文件。',
|
||||
sessionsExportListFailed: '列出会话失败:{detail}',
|
||||
sessionsExportRpcUnavailable: '无法通过 Host RPC 查询导出状态。',
|
||||
sessionsExportRpcFailed: '导出状态 RPC 失败。',
|
||||
sessionsExportHttpFailed: '导出失败:HTTP {status}{detail}',
|
||||
sessionsExportEmpty: '导出失败:ZIP 为空。',
|
||||
sessionsExportButton: '下载 ZIP',
|
||||
sessionsExportBusy: '准备中…',
|
||||
sessionsExportDone: '已下载:{file}',
|
||||
overridden: '已覆盖',
|
||||
reset: '重置',
|
||||
invalid: '无效',
|
||||
expand: '展开',
|
||||
collapse: '收起',
|
||||
unsaved: '未保存',
|
||||
unsaved: '有未保存更改',
|
||||
readOnly: '只读',
|
||||
saveFailed: '保存失败',
|
||||
discard: '丢弃',
|
||||
save: '保存',
|
||||
saving: '保存中…',
|
||||
configSaved: '已保存',
|
||||
}
|
||||
|
|
|
|||
|
|
@ -8,10 +8,12 @@ import {
|
|||
} from './alarm-push-status-view.ts'
|
||||
import {
|
||||
NETXOPS_SESSIONS_EXPORT_PATH,
|
||||
type SessionsExportReasonCode,
|
||||
type SessionsExportStatus,
|
||||
} from '../session-export-shared.ts'
|
||||
import type { NetxopsLocaleKey } from './locales.ts'
|
||||
|
||||
export type { SessionsExportStatus }
|
||||
export type { SessionsExportStatus, SessionsExportReasonCode }
|
||||
export { NETXOPS_SESSIONS_EXPORT_PATH }
|
||||
|
||||
export const SESSIONS_EXPORT_STATUS_ENDPOINT = 'sessions.export.status'
|
||||
|
|
@ -20,9 +22,42 @@ const EMPTY_STATUS: SessionsExportStatus = {
|
|||
available: false,
|
||||
sessionCount: 0,
|
||||
supportsRawArtifacts: false,
|
||||
reasonCode: 'rpc_unavailable',
|
||||
reason: 'RPC unavailable',
|
||||
}
|
||||
|
||||
const REASON_CODES = new Set<SessionsExportReasonCode>([
|
||||
'rpc_unavailable',
|
||||
'rpc_failed',
|
||||
'no_persistence',
|
||||
'no_raw_artifacts',
|
||||
'list_failed',
|
||||
'http_failed',
|
||||
'empty_body',
|
||||
])
|
||||
|
||||
function asReasonCode(value: unknown): SessionsExportReasonCode | undefined {
|
||||
return typeof value === 'string' && REASON_CODES.has(value as SessionsExportReasonCode)
|
||||
? value as SessionsExportReasonCode
|
||||
: undefined
|
||||
}
|
||||
|
||||
/** Map a status reasonCode to a client locale key. */
|
||||
export function sessionsExportReasonLocaleKey(
|
||||
code: SessionsExportReasonCode | undefined,
|
||||
): NetxopsLocaleKey {
|
||||
switch (code) {
|
||||
case 'no_persistence': return 'sessionsExportNoPersistence'
|
||||
case 'no_raw_artifacts': return 'sessionsExportNoRawArtifacts'
|
||||
case 'list_failed': return 'sessionsExportListFailed'
|
||||
case 'rpc_unavailable': return 'sessionsExportRpcUnavailable'
|
||||
case 'rpc_failed': return 'sessionsExportRpcFailed'
|
||||
case 'http_failed': return 'sessionsExportHttpFailed'
|
||||
case 'empty_body': return 'sessionsExportEmpty'
|
||||
default: return 'sessionsExportUnavailable'
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize a host export-status snapshot for the card.
|
||||
* @param value - RPC value or unknown.
|
||||
|
|
@ -36,6 +71,7 @@ export function asSessionsExportStatus(value: unknown): SessionsExportStatus {
|
|||
available: row.available === true,
|
||||
sessionCount: typeof row.sessionCount === 'number' ? row.sessionCount : 0,
|
||||
supportsRawArtifacts: row.supportsRawArtifacts === true,
|
||||
reasonCode: asReasonCode(row.reasonCode),
|
||||
reason: typeof row.reason === 'string' ? row.reason : undefined,
|
||||
}
|
||||
}
|
||||
|
|
@ -56,6 +92,7 @@ export async function fetchSessionsExportStatus(
|
|||
if (result !== null && typeof result === 'object' && (result as { ok?: boolean }).ok === false) {
|
||||
return {
|
||||
...EMPTY_STATUS,
|
||||
reasonCode: 'rpc_failed',
|
||||
reason: String((result as { error?: { message?: string } }).error?.message ?? 'rpc failed'),
|
||||
}
|
||||
}
|
||||
|
|
@ -93,6 +130,25 @@ export function saveBlobDownload(blob: Blob, filename: string): void {
|
|||
}
|
||||
}
|
||||
|
||||
/** Browser download failure with a stable code for UI i18n. */
|
||||
export class SessionsExportDownloadError extends Error {
|
||||
readonly code: Extract<SessionsExportReasonCode, 'http_failed' | 'empty_body'>
|
||||
readonly status?: number
|
||||
readonly detail: string
|
||||
|
||||
constructor(
|
||||
code: Extract<SessionsExportReasonCode, 'http_failed' | 'empty_body'>,
|
||||
message: string,
|
||||
extra: { status?: number, detail?: string } = {},
|
||||
) {
|
||||
super(message)
|
||||
this.name = 'SessionsExportDownloadError'
|
||||
this.code = code
|
||||
this.status = extra.status
|
||||
this.detail = extra.detail ?? ''
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch the bulk sessions ZIP and hand it to the browser download manager.
|
||||
* Uses a credentialed GET + blob URL (not a bare `<a href=/api/...>` click after
|
||||
|
|
@ -112,7 +168,11 @@ export async function downloadAllSessionsExport(
|
|||
if (!response.ok) {
|
||||
const fromHeader = response.headers.get('x-netxops-export-error') ?? ''
|
||||
const detail = fromHeader || (await response.text().catch(() => ''))
|
||||
throw new Error(`Export failed: HTTP ${response.status}${detail === '' ? '' : ` ${detail}`}`)
|
||||
throw new SessionsExportDownloadError(
|
||||
'http_failed',
|
||||
`Export failed: HTTP ${response.status}${detail === '' ? '' : ` ${detail}`}`,
|
||||
{ status: response.status, detail },
|
||||
)
|
||||
}
|
||||
const disposition = response.headers.get('content-disposition') ?? ''
|
||||
const matched = /filename="([^"]+)"/i.exec(disposition)
|
||||
|
|
@ -125,7 +185,7 @@ export async function downloadAllSessionsExport(
|
|||
: 0
|
||||
const blob = await response.blob()
|
||||
if (blob.size <= 0) {
|
||||
throw new Error('Export failed: empty ZIP body')
|
||||
throw new SessionsExportDownloadError('empty_body', 'Export failed: empty ZIP body')
|
||||
}
|
||||
save(blob, filename)
|
||||
return {
|
||||
|
|
|
|||
|
|
@ -1,56 +1,57 @@
|
|||
/** Inject shared Netx Ops card styles once. */
|
||||
/** Inject shared Netx Ops settings-section styles once (uds-auth visual language). */
|
||||
|
||||
const STYLE_ID = 'dsh-netxops-card-css'
|
||||
const STYLE_ID = 'dsh-netxops-settings-css'
|
||||
|
||||
const CSS = `
|
||||
.dsh-nx-card{list-style:none;border:.5px solid var(--dsw-alias-border-l4);border-radius:16px;background:var(--dsw-alias-bg-layer-3);transition:border-color .16s,background .16s}
|
||||
.dsh-nx-card:hover{border-color:var(--dsw-alias-label-dimmed)}
|
||||
.dsh-nx-cardOpen{background:var(--dsw-alias-bg-layer-2);border-color:var(--dsw-alias-label-dimmed)}
|
||||
.dsh-nx-header{width:100%;appearance:none;border:0;background:none;font:inherit;color:inherit;text-align:left;cursor:pointer;display:flex;align-items:center;gap:12px;padding:14px 16px;border-radius:12px}
|
||||
.dsh-nx-header:focus-visible{outline:2px solid var(--dsw-alias-brand-primary);outline-offset:-2px}
|
||||
.dsh-nx-headText{flex:1;min-width:0;display:flex;flex-direction:column;gap:4px}
|
||||
.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}
|
||||
.dsh-nx-readOnly{margin:12px 0 0;font-size:12px;line-height:1.5;color:var(--dsw-alias-label-tertiary)}
|
||||
.dsh-nx-field{display:flex;flex-direction:column;gap:6px;padding:12px 0}
|
||||
.dsh-nx-field+.dsh-nx-field{border-top:.5px solid var(--dsw-alias-border-l2)}
|
||||
.dsh-nx-fieldHead{display:flex;align-items:center;gap:8px}
|
||||
.dsh-nx-label{flex:1;min-width:0;font-size:13px;font-weight:500;line-height:1.5;color:var(--dsw-alias-label-primary)}
|
||||
.dsh-nx-badges{display:inline-flex;align-items:center;gap:8px}
|
||||
.dsh-nx-badge{border-radius:999px;padding:1px 8px;font-size:11px;line-height:17px;white-space:nowrap;font-weight:500;background:var(--dsw-alias-bg-module-platform);color:var(--dsw-alias-label-secondary)}
|
||||
.dsh-nx-badgeMuted{border-radius:999px;padding:1px 8px;font-size:11px;line-height:17px;white-space:nowrap;color:var(--dsw-alias-label-tertiary)}
|
||||
.dsh-nx-reset{border:none;background:none;padding:0;font:inherit;font-size:12px;line-height:1.5;color:var(--dsw-alias-label-secondary);cursor:pointer}
|
||||
.dsh-nx-reset:hover:not(:disabled){color:var(--dsw-alias-label-primary)}
|
||||
.dsh-nx-input{height:34px;padding:0 12px;border:.5px solid var(--dsw-alias-border-l4);border-radius:8px;background:var(--dsw-alias-bg-layer-3);font:inherit;font-size:13px;line-height:1.5;color:var(--dsw-alias-label-primary)}
|
||||
.dsh-nx-select{appearance:auto;cursor:pointer}
|
||||
.dsh-nx-input:focus-visible{outline:none;border-color:var(--dsw-alias-brand-primary)}
|
||||
.dsh-nx-inputInvalid{border-color:var(--dsw-alias-label-error)}
|
||||
.dsh-nx-hint{margin:0;font-size:12px;line-height:1.5;color:var(--dsw-alias-label-tertiary)}
|
||||
.dsh-nx-checkRow{display:flex;align-items:flex-start;gap:10px;font-size:12px;line-height:1.5;color:var(--dsw-alias-label-secondary);cursor:pointer}
|
||||
.dsh-nx-checkRow input{margin-top:2px;flex:none}
|
||||
.dsh-nx-groupBlock{display:flex;flex-direction:column;gap:8px;padding:8px 0 4px}
|
||||
.dsh-nx-groupTitle{font-size:12px;font-weight:600;line-height:1.5;color:var(--dsw-alias-label-primary)}
|
||||
.dsh-nx-groupChecks{display:flex;flex-direction:column;gap:6px;padding-left:2px}
|
||||
.dsh-nx-settings{box-sizing:border-box;display:flex;flex-direction:column;gap:20px;min-height:0;padding:4px 4px 24px;color:var(--dsw-alias-label-primary,#1f2329)}
|
||||
.dsh-nx-settings>header{display:flex;flex-direction:column;gap:4px}
|
||||
.dsh-nx-settings h2{margin:0;font-size:20px;font-weight:600;line-height:28px}
|
||||
.dsh-nx-settings-intro{margin:0;font-size:13px;line-height:1.5;color:var(--dsw-alias-label-secondary,#646a73)}
|
||||
.dsh-nx-settings-card{border:1px solid var(--dsw-alias-border-l2,#dee0e3);border-radius:12px;padding:16px;background:var(--dsw-alias-bg-layer-1,transparent)}
|
||||
.dsh-nx-settings-card h3{margin:0 0 12px;font-size:15px;font-weight:600;line-height:22px}
|
||||
.dsh-nx-settings-field{display:flex;flex-direction:column;gap:4px;margin-bottom:12px}
|
||||
.dsh-nx-settings-field:last-child{margin-bottom:0}
|
||||
.dsh-nx-settings-field>label,.dsh-nx-field-label{font-size:12px;line-height:18px;color:var(--dsw-alias-label-secondary,#646a73)}
|
||||
.dsh-nx-field-head{display:flex;align-items:center;justify-content:space-between;gap:8px}
|
||||
.dsh-nx-field-head>label,.dsh-nx-field-head>.dsh-nx-field-label{flex:1;min-width:0}
|
||||
.dsh-nx-settings-field input[type=text],
|
||||
.dsh-nx-settings-field input[type=password],
|
||||
.dsh-nx-settings-field input:not([type]){
|
||||
box-sizing:border-box;width:100%;padding:8px 10px;border:1px solid var(--dsw-alias-border-l2,#dfe1e5);border-radius:6px;font:inherit;font-size:13px;line-height:20px;background:var(--dsw-alias-bg-module-platform,#f4f5f7);color:inherit
|
||||
}
|
||||
.dsh-nx-settings-field input:focus-visible{outline:2px solid var(--dsw-alias-state-business-primary,#3370ff);outline-offset:1px}
|
||||
.dsh-nx-settings-field input:disabled{opacity:.55;cursor:default}
|
||||
.dsh-nx-inputInvalid{border-color:var(--dsw-alias-state-error-primary,#d54941)!important}
|
||||
.dsh-nx-hint{margin:0;font-size:12px;line-height:1.5;color:var(--dsw-alias-label-tertiary,#8f959e)}
|
||||
.dsh-nx-invalid{margin:0;font-size:12px;line-height:1.5;color:var(--dsw-alias-state-error-primary,#d54941)}
|
||||
.dsh-nx-badges{display:inline-flex;align-items:center;gap:8px;flex:none}
|
||||
.dsh-nx-badge{border-radius:999px;padding:1px 8px;font-size:11px;line-height:17px;white-space:nowrap;font-weight:500;background:var(--dsw-alias-bg-module-platform,#f4f5f7);color:var(--dsw-alias-label-secondary,#646a73)}
|
||||
.dsh-nx-badgeMuted{border-radius:999px;padding:1px 8px;font-size:11px;line-height:17px;white-space:nowrap;color:var(--dsw-alias-label-tertiary,#8f959e)}
|
||||
.dsh-nx-status{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,#f4f5f7);color:var(--dsw-alias-label-tertiary,#8f959e)}
|
||||
.dsh-nx-statusOk{color:var(--dsw-alias-state-success-primary,#20a162);background:rgba(32,161,98,.12)}
|
||||
.dsh-nx-statusWarn{color:var(--dsw-alias-state-warn-primary,#d97706);background:rgba(217,119,6,.12)}
|
||||
.dsh-nx-statusErr{color:var(--dsw-alias-state-error-primary,#d54941);background:rgba(213,73,65,.12)}
|
||||
.dsh-nx-reset{border:none;background:none;padding:0;font:inherit;font-size:12px;line-height:1.5;color:var(--dsw-alias-state-business-primary,#3370ff);cursor:pointer}
|
||||
.dsh-nx-reset:hover:not(:disabled){opacity:.85}
|
||||
.dsh-nx-reset:disabled{opacity:.4;cursor:default}
|
||||
.dsh-nx-checkRow{display:flex;align-items:flex-start;gap:10px;margin:0;font-size:13px;line-height:1.5;color:var(--dsw-alias-label-primary,#1f2329);cursor:pointer}
|
||||
.dsh-nx-checkRow input{margin-top:3px;flex:none}
|
||||
.dsh-nx-groupBlock{display:flex;flex-direction:column;gap:8px;padding:4px 0 8px}
|
||||
.dsh-nx-groupBlock+.dsh-nx-groupBlock{border-top:1px solid var(--dsw-alias-border-l1,#eef0f3);padding-top:12px}
|
||||
.dsh-nx-groupTitle{font-size:13px;font-weight:600;line-height:1.5;color:var(--dsw-alias-label-primary,#1f2329)}
|
||||
.dsh-nx-groupChecks{display:flex;flex-direction:column;gap:8px;padding-left:2px}
|
||||
.dsh-nx-imTargetList{display:flex;flex-direction:column;gap:8px;padding:4px 0 2px}
|
||||
.dsh-nx-invalid{margin:0;font-size:12px;line-height:1.5;color:var(--dsw-alias-label-error)}
|
||||
.dsh-nx-exportRow{display:flex;align-items:center;gap:8px;padding-top:4px}
|
||||
.dsh-nx-export{border-color:var(--dsw-alias-border-l2);background:none;color:var(--dsw-alias-label-primary)}
|
||||
.dsh-nx-export:hover:not(:disabled){border-color:var(--dsw-alias-label-dimmed)}
|
||||
.dsh-nx-footer{display:flex;align-items:center;justify-content:flex-end;gap:8px;padding:12px 0 4px;border-top:.5px solid var(--dsw-alias-border-l2)}
|
||||
.dsh-nx-failed{flex:1;min-width:0;margin:0;font-size:12px;line-height:1.5;color:var(--dsw-alias-label-error)}
|
||||
.dsh-nx-btn{appearance:none;border:1px solid transparent;border-radius:8px;padding:5px 14px;font:inherit;font-size:13px;line-height:1.5;cursor:pointer}
|
||||
.dsh-nx-discard{border-color:var(--dsw-alias-border-l2);background:none;color:var(--dsw-alias-label-secondary)}
|
||||
.dsh-nx-discard:hover:not(:disabled){color:var(--dsw-alias-label-primary);border-color:var(--dsw-alias-label-dimmed)}
|
||||
.dsh-nx-save{background:var(--dsw-alias-label-primary);color:var(--dsw-alias-bg-layer-3)}
|
||||
.dsh-nx-settings-actions{display:flex;gap:8px;align-items:center;flex-wrap:wrap;margin-top:4px}
|
||||
.dsh-nx-settings-msg{font-size:12px;color:var(--dsw-alias-label-secondary,#646a73)}
|
||||
.dsh-nx-settings-msg.ok{color:var(--dsw-alias-state-success-primary,#20a162)}
|
||||
.dsh-nx-settings-msg.err{color:var(--dsw-alias-state-error-primary,#d54941)}
|
||||
.dsh-nx-settings-empty{padding:24px;text-align:center;color:var(--dsw-alias-label-tertiary,#8f959e);font-size:13px}
|
||||
.dsh-nx-btn{appearance:none;display:inline-flex;align-items:center;justify-content:center;border:1px solid var(--dsw-alias-border-l2,#dfe1e5);border-radius:4px;padding:8px 12px;font:inherit;font-size:13px;line-height:20px;cursor:pointer;background:var(--dsw-alias-bg-module-platform,#f4f5f7);color:var(--dsw-alias-label-primary,#1f2329)}
|
||||
.dsh-nx-btn:hover:not(:disabled){background:var(--dsw-alias-interactive-bg-hover,#f7f8fa)}
|
||||
.dsh-nx-btn:disabled{opacity:.4;cursor:default}
|
||||
.dsh-nx-btn-primary{background:var(--dsw-alias-state-business-primary,#3370ff);color:#fff;border-color:transparent}
|
||||
.dsh-nx-btn-primary:hover:not(:disabled){opacity:.9;background:var(--dsw-alias-state-business-primary,#3370ff)}
|
||||
.dsh-nx-btn-ghost{background:transparent}
|
||||
`
|
||||
|
||||
export function ensureStyles(): void {
|
||||
|
|
|
|||
|
|
@ -434,6 +434,7 @@ export function apply(ctx: Context, config: Config = Config({})): void {
|
|||
value: {
|
||||
available: false,
|
||||
options: [],
|
||||
reasonCode: 'im_catalog_unavailable',
|
||||
hint: 'dsh-im-ops missing or outdated — install ≥ops.24 for delivery picker',
|
||||
},
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ 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(/\/$/, '')
|
||||
|
|
@ -42,16 +43,6 @@ export interface KeyAlarmPayload {
|
|||
/** 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 actionZh: Record<string, string> = {
|
||||
inserted: '告警产生',
|
||||
updated: '告警更新',
|
||||
deleted: '告警清除',
|
||||
}
|
||||
const actionEn: Record<string, string> = {
|
||||
inserted: 'Alarm Raised',
|
||||
updated: 'Alarm Updated',
|
||||
deleted: 'Alarm Cleared',
|
||||
}
|
||||
const ne = payload.ne && typeof payload.ne === 'object' ? payload.ne : {}
|
||||
const host = String(ne.host_name ?? '').trim()
|
||||
const ip = String(ne.ip_address ?? '').trim()
|
||||
|
|
@ -59,32 +50,22 @@ export function formatAlarmPrompt(payload: KeyAlarmPayload, lang = 'zh'): string
|
|||
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 ?? '关键告警').trim()
|
||||
if (lang.startsWith('en')) {
|
||||
return [
|
||||
`[NMS ${actionEn[action] ?? (action || 'Alarm')}] ${label}`,
|
||||
`Device: ${device}`,
|
||||
`Object: ${String(payload.object_name ?? '-').trim()}`,
|
||||
`Severity: ${String(payload.perceived_severity ?? '-').trim()}`,
|
||||
`Cause: ${String(payload.native_probable_cause ?? '-').trim()}`,
|
||||
`Time: ${String(payload.time_created ?? '-').trim()}`,
|
||||
`notificationId: ${String(payload.notification_id ?? '-').trim()}`,
|
||||
`alarm_key: ${String(payload.alarm_key ?? '-').trim()}`,
|
||||
'',
|
||||
'Please analyze this key alarm and suggest next ops steps.',
|
||||
].join('\n')
|
||||
}
|
||||
const label = String(
|
||||
payload.rule_label
|
||||
?? payload.native_probable_cause
|
||||
?? tHost('alarm.defaultLabel', lang),
|
||||
).trim()
|
||||
return [
|
||||
`[NMS ${actionZh[action] ?? (action || '告警')}] ${label}`,
|
||||
`设备: ${device}`,
|
||||
`对象: ${String(payload.object_name ?? '-').trim()}`,
|
||||
`级别: ${String(payload.perceived_severity ?? '-').trim()}`,
|
||||
`原因: ${String(payload.native_probable_cause ?? '-').trim()}`,
|
||||
`时间: ${String(payload.time_created ?? '-').trim()}`,
|
||||
`[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')
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -10,10 +10,10 @@ import { homedir } from 'node:os'
|
|||
import { join } from 'node:path'
|
||||
import type { Context } from '@deepseek-ai/cordis'
|
||||
import { formatAlarmPrompt, type KeyAlarmPayload } from './alarm-push.ts'
|
||||
import { tHost } from './i18n.ts'
|
||||
|
||||
const PRESET_ID = 'netxops'
|
||||
const PERMISSION_PRESET = 'default'
|
||||
const TITLE = 'Netx 关键告警'
|
||||
|
||||
interface StickyHandle {
|
||||
sessionId: string
|
||||
|
|
@ -75,7 +75,7 @@ export async function deliverAlarmToSession(
|
|||
}
|
||||
}
|
||||
|
||||
await createStickySession(ctx, prompt)
|
||||
await createStickySession(ctx, prompt, lang)
|
||||
}
|
||||
|
||||
const next = deliveryChain.then(run, run)
|
||||
|
|
@ -121,7 +121,7 @@ async function followup(ctx: Context, agent: { followup: (msg: unknown) => unkno
|
|||
}
|
||||
}
|
||||
|
||||
async function createStickySession(ctx: Context, prompt: string): Promise<void> {
|
||||
async function createStickySession(ctx: Context, prompt: string, lang = 'zh'): Promise<void> {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const c = ctx as any
|
||||
const agents = c.agents
|
||||
|
|
@ -179,7 +179,7 @@ async function createStickySession(ctx: Context, prompt: string): Promise<void>
|
|||
permissionPresets.set(handle.agent.session, PERMISSION_PRESET)
|
||||
}
|
||||
if (sessionTitle && typeof sessionTitle.rename === 'function') {
|
||||
sessionTitle.rename(handle.agent.session, TITLE)
|
||||
sessionTitle.rename(handle.agent.session, tHost('alarm.sessionTitle', lang))
|
||||
}
|
||||
await followup(ctx, handle.agent, prompt)
|
||||
sticky = {
|
||||
|
|
|
|||
90
src/netx/i18n.ts
Normal file
90
src/netx/i18n.ts
Normal file
|
|
@ -0,0 +1,90 @@
|
|||
/**
|
||||
* Host-plane Netx Ops i18n (alarm prompts / session titles).
|
||||
* UI copy lives in `src/client/locales.ts` via dsh-client-locale.
|
||||
*/
|
||||
|
||||
export type NetxLocale = 'zh' | 'en'
|
||||
|
||||
export type NetxHostMessageKey =
|
||||
| 'alarm.sessionTitle'
|
||||
| 'alarm.defaultLabel'
|
||||
| 'alarm.action.inserted'
|
||||
| 'alarm.action.updated'
|
||||
| 'alarm.action.deleted'
|
||||
| 'alarm.action.fallback'
|
||||
| 'alarm.field.device'
|
||||
| 'alarm.field.object'
|
||||
| 'alarm.field.severity'
|
||||
| 'alarm.field.cause'
|
||||
| 'alarm.field.time'
|
||||
| 'alarm.analyzeHint'
|
||||
|
||||
const ZH: Record<NetxHostMessageKey, string> = {
|
||||
'alarm.sessionTitle': 'Netx 关键告警',
|
||||
'alarm.defaultLabel': '关键告警',
|
||||
'alarm.action.inserted': '告警产生',
|
||||
'alarm.action.updated': '告警更新',
|
||||
'alarm.action.deleted': '告警清除',
|
||||
'alarm.action.fallback': '告警',
|
||||
'alarm.field.device': '设备',
|
||||
'alarm.field.object': '对象',
|
||||
'alarm.field.severity': '级别',
|
||||
'alarm.field.cause': '原因',
|
||||
'alarm.field.time': '时间',
|
||||
'alarm.analyzeHint': '请分析这条关键告警并给出下一步运维建议。',
|
||||
}
|
||||
|
||||
const EN: Record<NetxHostMessageKey, string> = {
|
||||
'alarm.sessionTitle': 'Netx key alarm',
|
||||
'alarm.defaultLabel': 'Key alarm',
|
||||
'alarm.action.inserted': 'Alarm Raised',
|
||||
'alarm.action.updated': 'Alarm Updated',
|
||||
'alarm.action.deleted': 'Alarm Cleared',
|
||||
'alarm.action.fallback': 'Alarm',
|
||||
'alarm.field.device': 'Device',
|
||||
'alarm.field.object': 'Object',
|
||||
'alarm.field.severity': 'Severity',
|
||||
'alarm.field.cause': 'Cause',
|
||||
'alarm.field.time': 'Time',
|
||||
'alarm.analyzeHint': 'Please analyze this key alarm and suggest next ops steps.',
|
||||
}
|
||||
|
||||
const TABLES: Record<NetxLocale, Record<NetxHostMessageKey, string>> = {
|
||||
zh: ZH,
|
||||
en: EN,
|
||||
}
|
||||
|
||||
/** Map plugin `lang` / Accept-Language-ish values to zh|en (default zh). */
|
||||
export function normalizeNetxLocale(lang: string | undefined | null): NetxLocale {
|
||||
const normalized = String(lang ?? '').trim().toLowerCase()
|
||||
if (normalized === 'english' || /^en(?:[-_].*)?$/u.test(normalized)) return 'en'
|
||||
return 'zh'
|
||||
}
|
||||
|
||||
/**
|
||||
* Translate a host-plane message key.
|
||||
* @param key - stable message id.
|
||||
* @param lang - plugin lang or locale tag.
|
||||
* @param vars - `{name}` replacements.
|
||||
*/
|
||||
export function tHost(
|
||||
key: NetxHostMessageKey,
|
||||
lang: string | undefined | null = 'zh',
|
||||
vars?: Record<string, string | number>,
|
||||
): string {
|
||||
const locale = normalizeNetxLocale(lang)
|
||||
const template = TABLES[locale][key] ?? TABLES.zh[key] ?? key
|
||||
if (vars === undefined) return template
|
||||
return template.replace(/\{(\w+)\}/g, (match, name: string) => (
|
||||
Object.prototype.hasOwnProperty.call(vars, name) ? String(vars[name]) : match
|
||||
))
|
||||
}
|
||||
|
||||
/** Alarm action verb for prompt headers. */
|
||||
export function alarmActionLabel(action: string, lang: string | undefined | null): string {
|
||||
const normalized = String(action ?? '').trim().toLowerCase()
|
||||
if (normalized === 'inserted') return tHost('alarm.action.inserted', lang)
|
||||
if (normalized === 'updated') return tHost('alarm.action.updated', lang)
|
||||
if (normalized === 'deleted') return tHost('alarm.action.deleted', lang)
|
||||
return normalized || tHost('alarm.action.fallback', lang)
|
||||
}
|
||||
|
|
@ -132,6 +132,7 @@ export async function getSessionsExportStatus(
|
|||
available: false,
|
||||
sessionCount: 0,
|
||||
supportsRawArtifacts: false,
|
||||
reasonCode: 'no_persistence',
|
||||
reason: 'sessionPersistence unavailable — mount a JSONL session backend (web profile default)',
|
||||
}
|
||||
}
|
||||
|
|
@ -140,6 +141,7 @@ export async function getSessionsExportStatus(
|
|||
available: false,
|
||||
sessionCount: 0,
|
||||
supportsRawArtifacts: false,
|
||||
reasonCode: 'no_raw_artifacts',
|
||||
reason: 'persistence backend does not expose per-session raw artifacts (SQLite export unsupported)',
|
||||
}
|
||||
}
|
||||
|
|
@ -156,6 +158,7 @@ export async function getSessionsExportStatus(
|
|||
available: false,
|
||||
sessionCount: 0,
|
||||
supportsRawArtifacts: true,
|
||||
reasonCode: 'list_failed',
|
||||
reason: error instanceof Error ? error.message : String(error),
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,10 +5,26 @@
|
|||
/** Authenticated browser download path for the all-sessions archive. */
|
||||
export const NETXOPS_SESSIONS_EXPORT_PATH = '/api/netxops.sessions.export'
|
||||
|
||||
/**
|
||||
* Stable export-status / download failure codes for client locale mapping.
|
||||
* Prefer these over free-form `reason` / Error.message in the Plugins card.
|
||||
*/
|
||||
export type SessionsExportReasonCode =
|
||||
| 'rpc_unavailable'
|
||||
| 'rpc_failed'
|
||||
| 'no_persistence'
|
||||
| 'no_raw_artifacts'
|
||||
| 'list_failed'
|
||||
| 'http_failed'
|
||||
| 'empty_body'
|
||||
|
||||
/** Preflight / RPC snapshot for the Plugins card. */
|
||||
export interface SessionsExportStatus {
|
||||
readonly available: boolean
|
||||
readonly sessionCount: number
|
||||
readonly supportsRawArtifacts: boolean
|
||||
/** Stable code for UI i18n (preferred). */
|
||||
readonly reasonCode?: SessionsExportReasonCode
|
||||
/** Optional English diagnostic for logs / fallback when no locale key. */
|
||||
readonly reason?: string
|
||||
}
|
||||
|
|
|
|||
|
|
@ -35,3 +35,21 @@ test('formatAlarmPrompt keeps human fields without raw sockets', () => {
|
|||
assert.match(text, /PE1 \(10\.0\.0\.1\)/)
|
||||
assert.match(text, /请分析这条关键告警/)
|
||||
})
|
||||
|
||||
test('formatAlarmPrompt switches to English when lang starts with en', () => {
|
||||
const text = formatAlarmPrompt({
|
||||
action: 'deleted',
|
||||
rule_label: 'Power Down',
|
||||
object_name: 'PORT-1',
|
||||
perceived_severity: 'critical',
|
||||
native_probable_cause: 'LOS',
|
||||
time_created: '2026-01-01T00:00:00Z',
|
||||
notification_id: 'n1',
|
||||
alarm_key: 'k1',
|
||||
ne: { host_name: 'PE1', ip_address: '10.0.0.1' },
|
||||
}, 'en-US')
|
||||
assert.match(text, /Alarm Cleared/)
|
||||
assert.match(text, /Device: PE1 \(10\.0\.0\.1\)/)
|
||||
assert.match(text, /Please analyze this key alarm/)
|
||||
assert.doesNotMatch(text, /告警/)
|
||||
})
|
||||
|
|
|
|||
39
test/i18n.test.mjs
Normal file
39
test/i18n.test.mjs
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
import assert from 'node:assert/strict'
|
||||
import { test } from 'node:test'
|
||||
|
||||
import {
|
||||
alarmActionLabel,
|
||||
normalizeNetxLocale,
|
||||
tHost,
|
||||
} from '../src/netx/i18n.ts'
|
||||
import { getSessionsExportStatus } from '../src/netx/session-export.ts'
|
||||
|
||||
test('normalizeNetxLocale defaults to zh and accepts en tags', () => {
|
||||
assert.equal(normalizeNetxLocale(undefined), 'zh')
|
||||
assert.equal(normalizeNetxLocale(''), 'zh')
|
||||
assert.equal(normalizeNetxLocale('zh-CN'), 'zh')
|
||||
assert.equal(normalizeNetxLocale('en'), 'en')
|
||||
assert.equal(normalizeNetxLocale('en-US'), 'en')
|
||||
assert.equal(normalizeNetxLocale('english'), 'en')
|
||||
})
|
||||
|
||||
test('tHost localizes alarm session titles', () => {
|
||||
assert.equal(tHost('alarm.sessionTitle', 'zh'), 'Netx 关键告警')
|
||||
assert.equal(tHost('alarm.sessionTitle', 'en'), 'Netx key alarm')
|
||||
assert.equal(alarmActionLabel('inserted', 'en'), 'Alarm Raised')
|
||||
})
|
||||
|
||||
test('getSessionsExportStatus returns reasonCode for UI i18n', async () => {
|
||||
const missing = await getSessionsExportStatus({ get: () => undefined })
|
||||
assert.equal(missing.available, false)
|
||||
assert.equal(missing.reasonCode, 'no_persistence')
|
||||
|
||||
const noRaw = await getSessionsExportStatus({
|
||||
get: () => ({
|
||||
supportsRawArtifacts: false,
|
||||
list: async () => [],
|
||||
readRaw: async () => undefined,
|
||||
}),
|
||||
})
|
||||
assert.equal(noRaw.reasonCode, 'no_raw_artifacts')
|
||||
})
|
||||
Loading…
Add table
Add a link
Reference in a new issue