mirror of
https://github.com/hansjone/netxops.git
synced 2026-10-10 09:53:21 +08:00
Ship capability groups (nms/common/topology) with shared netx skills.
One group maps to one skill and tools-*; rename NMS tools to *Nms*; sync playbooks from netx/skills. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
83c3796fbb
commit
3c9a383071
47 changed files with 11790 additions and 680 deletions
|
|
@ -62,7 +62,7 @@ export function formatAlarmPrompt(payload: KeyAlarmPayload, lang = 'zh'): string
|
|||
const label = String(payload.rule_label ?? payload.native_probable_cause ?? '关键告警').trim()
|
||||
if (lang.startsWith('en')) {
|
||||
return [
|
||||
`[UME ${actionEn[action] ?? (action || 'Alarm')}] ${label}`,
|
||||
`[NMS ${actionEn[action] ?? (action || 'Alarm')}] ${label}`,
|
||||
`Device: ${device}`,
|
||||
`Object: ${String(payload.object_name ?? '-').trim()}`,
|
||||
`Severity: ${String(payload.perceived_severity ?? '-').trim()}`,
|
||||
|
|
@ -75,7 +75,7 @@ export function formatAlarmPrompt(payload: KeyAlarmPayload, lang = 'zh'): string
|
|||
].join('\n')
|
||||
}
|
||||
return [
|
||||
`[UME ${actionZh[action] ?? (action || '告警')}] ${label}`,
|
||||
`[NMS ${actionZh[action] ?? (action || '告警')}] ${label}`,
|
||||
`设备: ${device}`,
|
||||
`对象: ${String(payload.object_name ?? '-').trim()}`,
|
||||
`级别: ${String(payload.perceived_severity ?? '-').trim()}`,
|
||||
|
|
|
|||
176
src/netx/capability-groups.ts
Normal file
176
src/netx/capability-groups.ts
Normal file
|
|
@ -0,0 +1,176 @@
|
|||
/**
|
||||
* Netx Ops capability groups: selective tool/skill exposure.
|
||||
*
|
||||
* Rule: **one group ↔ one skill** (dirs under `netx/skills/<group>/`).
|
||||
*
|
||||
* - `nms` → skill `netx-nms` — vendor NMS adapter (zte-ume behind generic names)
|
||||
* - `common` → skill `netx-common` — managed CLI + findTopologyPaths
|
||||
* - `topology` → skill `netx-topology` — canvas / fabric / dual_unit / layout recipes
|
||||
*/
|
||||
|
||||
/** Stable group ids used in settings, skill dirs, and registration filters. */
|
||||
export type NetxCapabilityGroupId = 'nms' | 'common' | 'topology'
|
||||
|
||||
/** Per-group exposure knobs. */
|
||||
export interface NetxGroupExposure {
|
||||
/** Mount into the Netx Ops agent preset scope. */
|
||||
inPreset: boolean
|
||||
/** Mount on the host tool/skill layer so other presets can see the group. */
|
||||
public: boolean
|
||||
}
|
||||
|
||||
/** Full capability-group policy published with the connection snapshot. */
|
||||
export type NetxCapabilityGroups = Record<NetxCapabilityGroupId, NetxGroupExposure>
|
||||
|
||||
/** Flat settings fields (Plugins card + schemastery Config). */
|
||||
export interface NetxCapabilityGroupSettingsFields {
|
||||
groupNmsInPreset: boolean
|
||||
groupNmsPublic: boolean
|
||||
groupCommonInPreset: boolean
|
||||
groupCommonPublic: boolean
|
||||
groupTopologyInPreset: boolean
|
||||
groupTopologyPublic: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Default: nms + common in Ops preset; topology and all public off.
|
||||
*/
|
||||
export const DEFAULT_CAPABILITY_GROUPS: NetxCapabilityGroups = Object.freeze({
|
||||
nms: Object.freeze({ inPreset: true, public: false }),
|
||||
common: Object.freeze({ inPreset: true, public: false }),
|
||||
topology: Object.freeze({ inPreset: false, public: false }),
|
||||
})
|
||||
|
||||
/**
|
||||
* Model-facing tool names (`netx__*`) owned by each group.
|
||||
* nms tools use generic `Nms` names; HTTP still hits the configured NMS provider adapter.
|
||||
*/
|
||||
export const TOOLS_BY_GROUP: Readonly<Record<NetxCapabilityGroupId, readonly string[]>> = Object.freeze({
|
||||
nms: Object.freeze([
|
||||
'netx__queryNmsAlarms',
|
||||
'netx__aggregateNmsAlarms',
|
||||
'netx__runNmsDiagnostics',
|
||||
'netx__queryNmsNeInventory',
|
||||
'netx__getNmsNe',
|
||||
'netx__queryNmsAlarmsRaw',
|
||||
'netx__aggregateNmsAlarmsRaw',
|
||||
'netx__listNmsAlarmFields',
|
||||
'netx__sqlQueryNms',
|
||||
]),
|
||||
common: Object.freeze([
|
||||
'netx__listManagedNe',
|
||||
'netx__getManagedNe',
|
||||
'netx__execManagedNe',
|
||||
'netx__listCliTargets',
|
||||
'netx__findTopologyPaths',
|
||||
]),
|
||||
topology: Object.freeze([
|
||||
'netx__getTopologyTree',
|
||||
'netx__getTopologyView',
|
||||
'netx__createTopologyFolder',
|
||||
'netx__addTopologyViewNodes',
|
||||
'netx__removeTopologyViewNodes',
|
||||
'netx__copyTopologyViewNodes',
|
||||
'netx__updateTopologyViewPositions',
|
||||
'netx__projectTopologyNeighbors',
|
||||
'netx__queryTopologyFabricNodes',
|
||||
'netx__classifyTopologyFabricNodes',
|
||||
'netx__queryTopologyNeighborhood',
|
||||
'netx__queryTopologyEdges',
|
||||
'netx__layoutTopologyView',
|
||||
'netx__suggestSinkHubs',
|
||||
'netx__analyzeTopologyViewLayout',
|
||||
'netx__sinkTopologyDualUnits',
|
||||
]),
|
||||
})
|
||||
|
||||
/** Skill directory name under skills root / `presets/netxops/skills/<group>/`. */
|
||||
export const SKILL_DIR_BY_GROUP: Readonly<Record<NetxCapabilityGroupId, string>> = Object.freeze({
|
||||
nms: 'nms',
|
||||
common: 'common',
|
||||
topology: 'topology',
|
||||
})
|
||||
|
||||
export const CAPABILITY_GROUP_IDS: readonly NetxCapabilityGroupId[] = Object.freeze([
|
||||
'nms',
|
||||
'common',
|
||||
'topology',
|
||||
])
|
||||
|
||||
/**
|
||||
* Build group policy from flat settings / Config fields.
|
||||
* Accepts legacy `groupManagedNe*` as aliases of `groupCommon*`.
|
||||
* Legacy `groupTopologyLayout*` ORs into `topology` (layout tools now live in topology).
|
||||
*/
|
||||
export function capabilityGroupsFromSettings(
|
||||
fields: Partial<NetxCapabilityGroupSettingsFields & {
|
||||
groupManagedNeInPreset?: boolean
|
||||
groupManagedNePublic?: boolean
|
||||
groupTopologyLayoutInPreset?: boolean
|
||||
groupTopologyLayoutPublic?: boolean
|
||||
}> | null | undefined,
|
||||
): NetxCapabilityGroups {
|
||||
const src = fields ?? {}
|
||||
const commonInPreset = src.groupCommonInPreset !== undefined
|
||||
? src.groupCommonInPreset !== false
|
||||
: src.groupManagedNeInPreset !== false
|
||||
const commonPublic = src.groupCommonPublic === true
|
||||
|| src.groupManagedNePublic === true
|
||||
return {
|
||||
nms: {
|
||||
inPreset: src.groupNmsInPreset !== false,
|
||||
public: src.groupNmsPublic === true,
|
||||
},
|
||||
common: {
|
||||
inPreset: commonInPreset,
|
||||
public: commonPublic,
|
||||
},
|
||||
topology: {
|
||||
inPreset: src.groupTopologyInPreset === true
|
||||
|| src.groupTopologyLayoutInPreset === true,
|
||||
public: src.groupTopologyPublic === true
|
||||
|| src.groupTopologyLayoutPublic === true,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Groups enabled for one registration plane.
|
||||
* @param groups - published policy.
|
||||
* @param plane - preset scope vs host public layer.
|
||||
* @param only - when set, intersect with this allow-list (per-export tools packages).
|
||||
*/
|
||||
export function groupsForPlane(
|
||||
groups: NetxCapabilityGroups | undefined,
|
||||
plane: 'preset' | 'public',
|
||||
only?: readonly NetxCapabilityGroupId[],
|
||||
): NetxCapabilityGroupId[] {
|
||||
const policy = groups ?? DEFAULT_CAPABILITY_GROUPS
|
||||
const enabled = CAPABILITY_GROUP_IDS.filter((id) => (
|
||||
plane === 'preset' ? policy[id].inPreset : policy[id].public
|
||||
))
|
||||
if (!only || only.length === 0) return enabled
|
||||
const allow = new Set(only)
|
||||
return enabled.filter((id) => allow.has(id))
|
||||
}
|
||||
|
||||
/**
|
||||
* Force-enable the listed groups (for `dsh-netxops/tools-<group>` mounts that
|
||||
* intentionally ignore Ops-preset inPreset flags).
|
||||
*/
|
||||
export function groupsForced(
|
||||
only: readonly NetxCapabilityGroupId[],
|
||||
): NetxCapabilityGroupId[] {
|
||||
return CAPABILITY_GROUP_IDS.filter((id) => only.includes(id))
|
||||
}
|
||||
|
||||
/**
|
||||
* Tool name allow-list for the given groups.
|
||||
*/
|
||||
export function toolNamesForGroups(groupIds: readonly NetxCapabilityGroupId[]): Set<string> {
|
||||
const names = new Set<string>()
|
||||
for (const id of groupIds) {
|
||||
for (const tool of TOOLS_BY_GROUP[id]) names.add(tool)
|
||||
}
|
||||
return names
|
||||
}
|
||||
196
src/netx/group-skills.ts
Normal file
196
src/netx/group-skills.ts
Normal file
|
|
@ -0,0 +1,196 @@
|
|||
/**
|
||||
* Register Ops playbook skills for enabled capability groups from disk.
|
||||
*/
|
||||
|
||||
import { existsSync } from 'node:fs'
|
||||
import { readdir, readFile, stat } from 'node:fs/promises'
|
||||
import { dirname, join } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import type { Context } from '@deepseek-ai/cordis'
|
||||
import {
|
||||
CAPABILITY_GROUP_IDS,
|
||||
SKILL_DIR_BY_GROUP,
|
||||
type NetxCapabilityGroupId,
|
||||
} from './capability-groups.ts'
|
||||
|
||||
interface ParsedSkill {
|
||||
name: string
|
||||
description: string
|
||||
content: string
|
||||
path: string
|
||||
directory: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Package `presets/netxops/skills` root, or a shared netx skills checkout.
|
||||
*
|
||||
* Resolution order:
|
||||
* 1. `NETX_SKILLS_ROOT` (canonical `netx/skills`)
|
||||
* 2. Sibling `../netx/skills` when developing next to the netx repo
|
||||
* 3. Bundled `presets/netxops/skills` inside this package
|
||||
*/
|
||||
export function opsSkillsRoot(): string {
|
||||
const envRoot = process.env.NETX_SKILLS_ROOT?.trim()
|
||||
if (envRoot && existsSync(envRoot)) return envRoot
|
||||
|
||||
const here = dirname(fileURLToPath(import.meta.url))
|
||||
const siblingCandidates = [
|
||||
// src/netx → ../../../netx/skills ; lib → ../../netx/skills
|
||||
join(here, '..', '..', '..', 'netx', 'skills'),
|
||||
join(here, '..', '..', 'netx', 'skills'),
|
||||
]
|
||||
for (const candidate of siblingCandidates) {
|
||||
if (existsSync(candidate)) return candidate
|
||||
}
|
||||
|
||||
const packaged = [
|
||||
join(here, '..', 'presets', 'netxops', 'skills'),
|
||||
join(here, '..', '..', 'presets', 'netxops', 'skills'),
|
||||
]
|
||||
for (const candidate of packaged) {
|
||||
if (existsSync(candidate)) return candidate
|
||||
}
|
||||
return packaged[0]
|
||||
}
|
||||
|
||||
function parseFrontmatter(raw: string): { data: Record<string, unknown>; body: string } | undefined {
|
||||
if (!raw.startsWith('---')) return undefined
|
||||
const end = raw.indexOf('\n---', 3)
|
||||
if (end < 0) return undefined
|
||||
const yaml = raw.slice(3, end).replace(/^\r?\n/, '')
|
||||
const body = raw.slice(end + 4).replace(/^\r?\n/, '')
|
||||
const data: Record<string, unknown> = {}
|
||||
const lines = yaml.split(/\r?\n/)
|
||||
for (let i = 0; i < lines.length; i += 1) {
|
||||
const line = lines[i]
|
||||
const nameMatch = /^name\s*:\s*(.+)\s*$/.exec(line)
|
||||
if (nameMatch) {
|
||||
data.name = stripQuotes(nameMatch[1].trim())
|
||||
continue
|
||||
}
|
||||
const descMatch = /^description\s*:\s*(.*)$/.exec(line)
|
||||
if (!descMatch) continue
|
||||
const head = descMatch[1].trim()
|
||||
if (head === '>' || head === '>-' || head === '|' || head === '|-') {
|
||||
const parts: string[] = []
|
||||
while (i + 1 < lines.length && /^[ \t]+/.test(lines[i + 1])) {
|
||||
i += 1
|
||||
parts.push(lines[i].trim())
|
||||
}
|
||||
data.description = parts.filter(Boolean).join(' ')
|
||||
continue
|
||||
}
|
||||
data.description = stripQuotes(head)
|
||||
}
|
||||
return { data, body }
|
||||
}
|
||||
|
||||
function stripQuotes(value: string): string {
|
||||
if (
|
||||
(value.startsWith('"') && value.endsWith('"'))
|
||||
|| (value.startsWith("'") && value.endsWith("'"))
|
||||
) {
|
||||
return value.slice(1, -1)
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
async function loadSkillBundle(dir: string): Promise<ParsedSkill | null> {
|
||||
const skillPath = join(dir, 'SKILL.md')
|
||||
let raw: string
|
||||
try {
|
||||
raw = await readFile(skillPath, 'utf8')
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
const parsed = parseFrontmatter(raw)
|
||||
if (!parsed) return null
|
||||
const name = typeof parsed.data.name === 'string' ? parsed.data.name.trim() : ''
|
||||
const description = typeof parsed.data.description === 'string'
|
||||
? parsed.data.description.trim()
|
||||
: ''
|
||||
if (!name || !description) return null
|
||||
return {
|
||||
name,
|
||||
description,
|
||||
content: parsed.body.trimStart(),
|
||||
path: skillPath,
|
||||
directory: dir,
|
||||
}
|
||||
}
|
||||
|
||||
async function loadGroupSkills(
|
||||
skillsRoot: string,
|
||||
groupId: NetxCapabilityGroupId,
|
||||
): Promise<ParsedSkill[]> {
|
||||
const groupDir = join(skillsRoot, SKILL_DIR_BY_GROUP[groupId])
|
||||
let entries: string[]
|
||||
try {
|
||||
entries = await readdir(groupDir)
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
const skills: ParsedSkill[] = []
|
||||
for (const entry of entries) {
|
||||
const full = join(groupDir, entry)
|
||||
let isDir = false
|
||||
try {
|
||||
isDir = (await stat(full)).isDirectory()
|
||||
} catch {
|
||||
continue
|
||||
}
|
||||
if (!isDir) continue
|
||||
const skill = await loadSkillBundle(full)
|
||||
if (skill) skills.push(skill)
|
||||
}
|
||||
return skills
|
||||
}
|
||||
|
||||
/**
|
||||
* Register playbook skills for the given capability groups into `ctx.skills`.
|
||||
* @param ctx - context with a skills registry (preset or host layer).
|
||||
* @param groupIds - enabled groups for this plane.
|
||||
* @param providerLabel - `provider` stamp on each registration.
|
||||
* @returns disposer that unregisters every skill.
|
||||
*/
|
||||
export async function registerGroupSkills(
|
||||
ctx: Context,
|
||||
groupIds: readonly NetxCapabilityGroupId[],
|
||||
providerLabel: string,
|
||||
): Promise<() => void> {
|
||||
const skillsApi = (ctx as { skills?: { register: (skill: {
|
||||
name: string
|
||||
description: string
|
||||
content: string
|
||||
path?: string
|
||||
resourceBase?: { kind: 'directory'; path: string }
|
||||
provider?: string
|
||||
source: string
|
||||
}) => () => void } }).skills
|
||||
if (!skillsApi || typeof skillsApi.register !== 'function') {
|
||||
return () => {}
|
||||
}
|
||||
|
||||
const root = opsSkillsRoot()
|
||||
const disposers: Array<() => void> = []
|
||||
const enabled = new Set(groupIds)
|
||||
for (const groupId of CAPABILITY_GROUP_IDS) {
|
||||
if (!enabled.has(groupId)) continue
|
||||
const skills = await loadGroupSkills(root, groupId)
|
||||
for (const skill of skills) {
|
||||
disposers.push(skillsApi.register({
|
||||
name: skill.name,
|
||||
description: skill.description,
|
||||
content: skill.content,
|
||||
path: skill.path,
|
||||
resourceBase: { kind: 'directory', path: skill.directory },
|
||||
provider: providerLabel,
|
||||
source: 'custom',
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
return () => {
|
||||
for (const dispose of disposers) dispose()
|
||||
}
|
||||
}
|
||||
107
src/netx/group-tools-plugin.ts
Normal file
107
src/netx/group-tools-plugin.ts
Normal file
|
|
@ -0,0 +1,107 @@
|
|||
/**
|
||||
* Shared agent-plane mount: tools + skills for one or more capability groups.
|
||||
*/
|
||||
|
||||
import type { Context } from '@deepseek-ai/cordis'
|
||||
import type {} from '@deepseek-ai/dsh-tools'
|
||||
import {
|
||||
groupsForced,
|
||||
groupsForPlane,
|
||||
type NetxCapabilityGroupId,
|
||||
} from './capability-groups.ts'
|
||||
import { registerGroupSkills } from './group-skills.ts'
|
||||
import { getNetxConnection, watchNetxConnection } from './runtime.ts'
|
||||
import { registerNetxTools } from './tools.ts'
|
||||
|
||||
export interface GroupToolsPluginOptions {
|
||||
/** Cordis plugin name. */
|
||||
name: string
|
||||
/**
|
||||
* `settings` — honor inPreset/public from connection (Ops aggregate mount).
|
||||
* `forced` — always register `only` groups when connection exists (per-group export).
|
||||
*/
|
||||
mode: 'settings' | 'forced'
|
||||
/** Group allow-list; omit in settings mode to use all groups. */
|
||||
only?: readonly NetxCapabilityGroupId[]
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply a group-scoped tools/skills plugin on the calling (usually preset) context.
|
||||
*/
|
||||
export function applyGroupToolsPlugin(ctx: Context, options: GroupToolsPluginOptions): void {
|
||||
let unregisterTools: (() => void) | undefined
|
||||
let unregisterSkills: (() => void) | undefined
|
||||
let skillGeneration = 0
|
||||
|
||||
const resolveGroups = (): NetxCapabilityGroupId[] => {
|
||||
const connection = getNetxConnection()
|
||||
if (options.mode === 'forced') {
|
||||
return groupsForced(options.only ?? [])
|
||||
}
|
||||
return groupsForPlane(connection?.groups, 'preset', options.only)
|
||||
}
|
||||
|
||||
const remountTools = (): void => {
|
||||
unregisterTools?.()
|
||||
unregisterTools = undefined
|
||||
const connection = getNetxConnection()
|
||||
if (connection === undefined) {
|
||||
ctx.logger.warn('%s: no connection yet — waiting for host settings bridge', options.name)
|
||||
return
|
||||
}
|
||||
const enabled = resolveGroups()
|
||||
if (enabled.length === 0) {
|
||||
ctx.logger.info('%s: no groups enabled', options.name)
|
||||
return
|
||||
}
|
||||
unregisterTools = registerNetxTools(ctx, connection, {
|
||||
plane: 'preset',
|
||||
only: options.mode === 'forced' ? enabled : options.only,
|
||||
forceGroups: options.mode === 'forced' ? enabled : undefined,
|
||||
})
|
||||
ctx.logger.info(
|
||||
'%s: groups=[%s] → %s tokenConfigured=%s',
|
||||
options.name,
|
||||
enabled.join(',') || '(none)',
|
||||
connection.apiUrl,
|
||||
connection.token.trim().length > 0,
|
||||
)
|
||||
}
|
||||
|
||||
remountTools()
|
||||
const stopToolWatch = watchNetxConnection(() => { remountTools() })
|
||||
|
||||
ctx.inject(['skills'], (skillsCtx) => {
|
||||
const remountSkills = (): void => {
|
||||
const gen = ++skillGeneration
|
||||
unregisterSkills?.()
|
||||
unregisterSkills = undefined
|
||||
if (getNetxConnection() === undefined) return
|
||||
const enabled = resolveGroups()
|
||||
void registerGroupSkills(skillsCtx, enabled, options.name).then((dispose) => {
|
||||
if (gen !== skillGeneration) {
|
||||
dispose()
|
||||
return
|
||||
}
|
||||
unregisterSkills = dispose
|
||||
}).catch((error) => {
|
||||
skillsCtx.logger.warn('%s: skill register failed: %s', options.name, error)
|
||||
})
|
||||
}
|
||||
|
||||
remountSkills()
|
||||
const stopSkillWatch = watchNetxConnection(() => { remountSkills() })
|
||||
skillsCtx.effect(() => () => {
|
||||
skillGeneration += 1
|
||||
stopSkillWatch()
|
||||
unregisterSkills?.()
|
||||
unregisterSkills = undefined
|
||||
}, `${options.name}: dispose skills`)
|
||||
})
|
||||
|
||||
ctx.effect(() => () => {
|
||||
stopToolWatch()
|
||||
unregisterTools?.()
|
||||
unregisterTools = undefined
|
||||
}, `${options.name}: dispose tools`)
|
||||
}
|
||||
|
|
@ -38,6 +38,16 @@ function str(args: NetxJson, key: string, fallback = ''): string {
|
|||
return fallback
|
||||
}
|
||||
|
||||
/** Prefer generic `nms_*` aliases; fall back to historical `ume_*` wire names. */
|
||||
function nmsOrUme(args: NetxJson, nmsKey: string, umeKey: string): string {
|
||||
return str(args, nmsKey).trim() || str(args, umeKey).trim()
|
||||
}
|
||||
|
||||
function nmsOrUmeList(args: NetxJson, nmsKey: string, umeKey: string): string[] {
|
||||
const primary = strList(args, nmsKey)
|
||||
return primary.length > 0 ? primary : strList(args, umeKey)
|
||||
}
|
||||
|
||||
function num(args: NetxJson, key: string): number | undefined {
|
||||
const v = args[key]
|
||||
return typeof v === 'number' && Number.isFinite(v) ? v : undefined
|
||||
|
|
@ -189,7 +199,7 @@ export async function getManagedNe(client: NetxClient, args: NetxJson, signal?:
|
|||
ok: false,
|
||||
error: 'ne_id_required',
|
||||
error_code: 'ne_id_required',
|
||||
hint: 'Pass managed NE id from listManagedNe/listCliTargets (source=managed). For UME inventory UUIDs use execManagedNe(ume_ne_id=...) or getUmeNe, not getManagedNe.',
|
||||
hint: 'Pass managed NE id from listManagedNe/listCliTargets (source=managed). For NMS inventory UUIDs use execManagedNe(nms_ne_id=...) or getNmsNe, not getManagedNe.',
|
||||
example: { ne_id: '<managed-ne-uuid-from-listManagedNe>' },
|
||||
}
|
||||
}
|
||||
|
|
@ -199,7 +209,7 @@ export async function getManagedNe(client: NetxClient, args: NetxJson, signal?:
|
|||
if (detail.includes('404') || detail.includes('not_found') || detail.includes('not found') || out.error === 'netx_http_404') {
|
||||
return {
|
||||
...out,
|
||||
hint: 'Managed NE not found for this ne_id. Call listManagedNe(keyword=...) or listCliTargets(source=managed) first. If this is a UME ne_id, use execManagedNe(ume_ne_id=...) / getUmeNe instead of getManagedNe.',
|
||||
hint: 'Managed NE not found for this ne_id. Call listManagedNe(keyword=...) or listCliTargets(source=managed) first. If this is an NMS inventory id, use execManagedNe(nms_ne_id=...) / getNmsNe instead of getManagedNe.',
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -209,7 +219,7 @@ export async function getManagedNe(client: NetxClient, args: NetxJson, signal?:
|
|||
export async function execManagedNe(client: NetxClient, args: NetxJson, signal?: AbortSignal): Promise<NetxJson> {
|
||||
const targetsRaw = args.targets
|
||||
const neIds = strList(args, 'ne_ids')
|
||||
const umeNeIds = strList(args, 'ume_ne_ids')
|
||||
const umeNeIds = nmsOrUmeList(args, 'nms_ne_ids', 'ume_ne_ids')
|
||||
const sharedCommands = strList(args, 'commands')
|
||||
const multi = (Array.isArray(targetsRaw) && targetsRaw.length > 0)
|
||||
|| neIds.length > 0
|
||||
|
|
@ -224,7 +234,7 @@ export async function execManagedNe(client: NetxClient, args: NetxJson, signal?:
|
|||
const row = t as NetxJson
|
||||
const item: NetxJson = {}
|
||||
const neId = str(row, 'ne_id').trim()
|
||||
const umeNeId = str(row, 'ume_ne_id').trim()
|
||||
const umeNeId = nmsOrUme(row, 'nms_ne_id', 'ume_ne_id')
|
||||
if (neId) item.ne_id = neId
|
||||
if (umeNeId) item.ume_ne_id = umeNeId
|
||||
const cmds = Array.isArray(row.commands)
|
||||
|
|
@ -256,13 +266,13 @@ export async function execManagedNe(client: NetxClient, args: NetxJson, signal?:
|
|||
}
|
||||
|
||||
const neId = str(args, 'ne_id').trim()
|
||||
const umeNeId = str(args, 'ume_ne_id').trim()
|
||||
const umeNeId = nmsOrUme(args, 'nms_ne_id', 'ume_ne_id')
|
||||
if (Boolean(neId) === Boolean(umeNeId)) {
|
||||
return {
|
||||
ok: false,
|
||||
error: 'exactly_one_of_ne_id_or_ume_ne_id_required',
|
||||
error_code: 'exactly_one_of_ne_id_or_ume_ne_id_required',
|
||||
hint: 'For one NE pass ne_id OR ume_ne_id. For many NEs pass ne_ids / ume_ne_ids with shared commands, or targets[] with per-NE commands — one call, concurrent on server.',
|
||||
error: 'exactly_one_of_ne_id_or_nms_ne_id_required',
|
||||
error_code: 'exactly_one_of_ne_id_or_nms_ne_id_required',
|
||||
hint: 'For one NE pass ne_id OR nms_ne_id (alias ume_ne_id). For many NEs pass ne_ids / nms_ne_ids with shared commands, or targets[] — one call, concurrent on server.',
|
||||
}
|
||||
}
|
||||
if (sharedCommands.length === 0) {
|
||||
|
|
@ -296,15 +306,15 @@ export async function listCliTargets(client: NetxClient, args: NetxJson, signal?
|
|||
}
|
||||
|
||||
export async function findTopologyPaths(client: NetxClient, args: NetxJson, signal?: AbortSignal): Promise<NetxJson> {
|
||||
const fromUid = str(args, 'from_ume_ne_id').trim()
|
||||
const fromUid = nmsOrUme(args, 'from_nms_ne_id', 'from_ume_ne_id')
|
||||
const fromMid = str(args, 'from_managed_ne_id').trim()
|
||||
const toUid = str(args, 'to_ume_ne_id').trim()
|
||||
const toUid = nmsOrUme(args, 'to_nms_ne_id', 'to_ume_ne_id')
|
||||
const toMid = str(args, 'to_managed_ne_id').trim()
|
||||
if (Boolean(fromUid) === Boolean(fromMid)) {
|
||||
return { ok: false, error: 'exactly_one_of_from_ume_ne_id_or_from_managed_ne_id_required' }
|
||||
return { ok: false, error: 'exactly_one_of_from_nms_ne_id_or_from_managed_ne_id_required' }
|
||||
}
|
||||
if (Boolean(toUid) === Boolean(toMid)) {
|
||||
return { ok: false, error: 'exactly_one_of_to_ume_ne_id_or_to_managed_ne_id_required' }
|
||||
return { ok: false, error: 'exactly_one_of_to_nms_ne_id_or_to_managed_ne_id_required' }
|
||||
}
|
||||
let detail = str(args, 'detail', 'summary').trim().toLowerCase() || 'summary'
|
||||
if (detail !== 'summary' && detail !== 'full') detail = 'summary'
|
||||
|
|
|
|||
|
|
@ -121,6 +121,9 @@ export function createNetxClient(connection: NetxConnection) {
|
|||
post(path: string, body: NetxJson, signal?: AbortSignal, timeoutMs?: number) {
|
||||
return request('POST', path, { body, signal, timeoutMs })
|
||||
},
|
||||
patch(path: string, body: NetxJson, signal?: AbortSignal, timeoutMs?: number) {
|
||||
return request('PATCH', path, { body, signal, timeoutMs })
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -4,15 +4,24 @@
|
|||
|
||||
import type { Context } from '@deepseek-ai/cordis'
|
||||
import { defineTool } from '@deepseek-ai/dsh-tools'
|
||||
import {
|
||||
groupsForPlane,
|
||||
toolNamesForGroups,
|
||||
type NetxCapabilityGroupId,
|
||||
type NetxCapabilityGroups,
|
||||
} from './capability-groups.ts'
|
||||
import { createNetxClient, type NetxClient, type NetxJson } from './http.ts'
|
||||
import { getNetxConnection } from './runtime.ts'
|
||||
import * as H from './handlers.ts'
|
||||
import * as T from './topology-handlers.ts'
|
||||
|
||||
export interface NetxToolConnection {
|
||||
apiUrl: string
|
||||
token: string
|
||||
lang: string
|
||||
toolCallTimeoutMs: number
|
||||
/** Capability-group exposure; omitted → package defaults. */
|
||||
groups?: NetxCapabilityGroups
|
||||
}
|
||||
|
||||
type Handler = (client: NetxClient, args: NetxJson, signal?: AbortSignal) => Promise<NetxJson>
|
||||
|
|
@ -60,14 +69,36 @@ function tool(
|
|||
})
|
||||
}
|
||||
|
||||
export interface RegisterNetxToolsOptions {
|
||||
/**
|
||||
* `preset` — groups with `inPreset` (Netx Ops preset scope).
|
||||
* `public` — groups with `public` (host layer for other presets).
|
||||
*/
|
||||
plane: 'preset' | 'public'
|
||||
/** Optional intersect with settings-enabled groups. */
|
||||
only?: readonly NetxCapabilityGroupId[]
|
||||
/** When set, register exactly these groups (ignore plane flags). */
|
||||
forceGroups?: readonly NetxCapabilityGroupId[]
|
||||
}
|
||||
|
||||
/**
|
||||
* Register all Netx Ops tools against the current connection snapshot.
|
||||
* Register Netx Ops tools for one exposure plane against the connection snapshot.
|
||||
* Bearer is read from the live process store on each request (not frozen here).
|
||||
* @param ctx - host context with `tools`.
|
||||
* @param connection - apiUrl / token / lang / timeout (apiUrl/lang/timeout used for client base).
|
||||
* @param ctx - context with `tools`.
|
||||
* @param connection - apiUrl / token / lang / timeout / groups.
|
||||
* @param options - which plane's group flags to honor.
|
||||
* @returns disposer that unregisters every tool.
|
||||
*/
|
||||
export function registerNetxTools(ctx: Context, connection: NetxToolConnection): () => void {
|
||||
export function registerNetxTools(
|
||||
ctx: Context,
|
||||
connection: NetxToolConnection,
|
||||
options: RegisterNetxToolsOptions,
|
||||
): () => void {
|
||||
const enabledGroups = options.forceGroups
|
||||
?? groupsForPlane(connection.groups, options.plane, options.only)
|
||||
const allow = toolNamesForGroups(enabledGroups)
|
||||
if (allow.size === 0) return () => {}
|
||||
|
||||
const client = createNetxClient({
|
||||
apiUrl: connection.apiUrl,
|
||||
lang: connection.lang,
|
||||
|
|
@ -77,10 +108,10 @@ export function registerNetxTools(ctx: Context, connection: NetxToolConnection):
|
|||
const getClient = () => client
|
||||
const t = connection.toolCallTimeoutMs
|
||||
|
||||
const disposers = [
|
||||
ctx.tools.register(tool(
|
||||
'netx__queryUmeAlarms',
|
||||
'Query UME current alarms (each row includes host_name). Supports severity/ne_id/host_name/keyword, last_seen time_from/time_to, pagination. Prefer host_name for display; ne_id is for filters only.',
|
||||
const catalog = [
|
||||
tool(
|
||||
'netx__queryNmsAlarms',
|
||||
'Query NMS current alarms (each row includes host_name). Supports severity/ne_id/host_name/keyword, last_seen time_from/time_to, pagination. Prefer host_name for display; ne_id is for filters only.',
|
||||
{
|
||||
severity: str(),
|
||||
ne_id: str('Filter only; do not show UUID to users'),
|
||||
|
|
@ -93,10 +124,10 @@ export function registerNetxTools(ctx: Context, connection: NetxToolConnection):
|
|||
page_size: num(),
|
||||
},
|
||||
H.queryUmeAlarms, getClient, t,
|
||||
)),
|
||||
ctx.tools.register(tool(
|
||||
'netx__aggregateUmeAlarms',
|
||||
'Aggregate UME current alarms (by_severity + top by_ne). If group_by is set, routes to aggregateUmeAlarmsRaw. Always filter severity/keyword/time before paging.',
|
||||
),
|
||||
tool(
|
||||
'netx__aggregateNmsAlarms',
|
||||
'Aggregate NMS current alarms (by_severity + top by_ne). If group_by is set, routes to aggregateUmeAlarmsRaw. Always filter severity/keyword/time before paging.',
|
||||
{
|
||||
severity: str('Optional perceived_severity filter (critical/major/minor/warning).'),
|
||||
top_ne: num('Max NE buckets (default 50). Ignored when group_by is set.'),
|
||||
|
|
@ -112,34 +143,34 @@ export function registerNetxTools(ctx: Context, connection: NetxToolConnection):
|
|||
limit: num(),
|
||||
},
|
||||
H.aggregateUmeAlarms, getClient, t,
|
||||
)),
|
||||
ctx.tools.register(tool(
|
||||
'netx__runUmeDiagnostics',
|
||||
'UME alarm diagnostics: severity, top_event_types, top_alarm_codes, top_ne, protocol buckets, freshness meta.',
|
||||
),
|
||||
tool(
|
||||
'netx__runNmsDiagnostics',
|
||||
'NMS alarm diagnostics: severity, top_event_types, top_alarm_codes, top_ne, protocol buckets, freshness meta.',
|
||||
{},
|
||||
H.runUmeDiagnostics, getClient, t,
|
||||
)),
|
||||
ctx.tools.register(tool(
|
||||
'netx__queryUmeNeInventory',
|
||||
'Paged UME NE inventory synced in netx (keyword matches ne_id/ne_name/user_label/ip/host_name).',
|
||||
),
|
||||
tool(
|
||||
'netx__queryNmsNeInventory',
|
||||
'Paged NMS NE inventory synced in netx (keyword matches ne_id/ne_name/user_label/ip/host_name).',
|
||||
{
|
||||
keyword: str(),
|
||||
page: num(),
|
||||
page_size: num(),
|
||||
},
|
||||
H.queryUmeNeInventory, getClient, t,
|
||||
)),
|
||||
ctx.tools.register(tool(
|
||||
'netx__getUmeNe',
|
||||
'Get single UME NE detail by ne_id (UUID).',
|
||||
),
|
||||
tool(
|
||||
'netx__getNmsNe',
|
||||
'Get single NMS NE detail by ne_id (UUID).',
|
||||
{
|
||||
ne_id: { type: 'string' as const, required: true as const, description: 'UME inventory ne_id (UUID).' },
|
||||
ne_id: { type: 'string' as const, required: true as const, description: 'NMS inventory ne_id (UUID).' },
|
||||
},
|
||||
H.getUmeNe, getClient, t,
|
||||
)),
|
||||
ctx.tools.register(tool(
|
||||
'netx__queryUmeAlarmsRaw',
|
||||
'Power query UME current alarms with full alarm_* + ne_* fields; optional field_preset or select_fields. Use field_preset=evidence for citations.',
|
||||
),
|
||||
tool(
|
||||
'netx__queryNmsAlarmsRaw',
|
||||
'Power query NMS current alarms with full alarm_* + ne_* fields; optional field_preset or select_fields. Use field_preset=evidence for citations.',
|
||||
{
|
||||
severity: str(),
|
||||
is_cleared: str(),
|
||||
|
|
@ -156,10 +187,10 @@ export function registerNetxTools(ctx: Context, connection: NetxToolConnection):
|
|||
page_size: num(),
|
||||
},
|
||||
H.queryUmeAlarmsRaw, getClient, t,
|
||||
)),
|
||||
ctx.tools.register(tool(
|
||||
'netx__aggregateUmeAlarmsRaw',
|
||||
'Dynamic aggregation on UME raw fields (group_by/group_by2); prefer alarm_host_name.',
|
||||
),
|
||||
tool(
|
||||
'netx__aggregateNmsAlarmsRaw',
|
||||
'Dynamic aggregation on NMS raw fields (group_by/group_by2); prefer alarm_host_name.',
|
||||
{
|
||||
group_by: { type: 'string' as const, required: true as const },
|
||||
group_by2: str(),
|
||||
|
|
@ -174,24 +205,24 @@ export function registerNetxTools(ctx: Context, connection: NetxToolConnection):
|
|||
limit: num(),
|
||||
},
|
||||
H.aggregateUmeAlarmsRaw, getClient, t,
|
||||
)),
|
||||
ctx.tools.register(tool(
|
||||
'netx__listUmeAlarmFields',
|
||||
'List available fields for UME raw alarm queries.',
|
||||
),
|
||||
tool(
|
||||
'netx__listNmsAlarmFields',
|
||||
'List available fields for NMS raw alarm queries.',
|
||||
{},
|
||||
H.listUmeAlarmFields, getClient, t,
|
||||
)),
|
||||
ctx.tools.register(tool(
|
||||
'netx__sqlQueryUme',
|
||||
'Read-only SELECT on UME tables (ume_alarms_current/ume_inventory_ne); server enforces limits. Requires sql:query scope.',
|
||||
),
|
||||
tool(
|
||||
'netx__sqlQueryNms',
|
||||
'Read-only SELECT on NMS tables (ume_alarms_current/ume_inventory_ne); server enforces limits. Requires sql:query scope.',
|
||||
{
|
||||
sql: { type: 'string' as const, required: true as const },
|
||||
limit: num(),
|
||||
statement_timeout_ms: num(),
|
||||
},
|
||||
H.sqlQueryUme, getClient, t,
|
||||
)),
|
||||
ctx.tools.register(tool(
|
||||
),
|
||||
tool(
|
||||
'netx__listManagedNe',
|
||||
'List filtered netx managed NEs (keyword/vendor/connect_status required); use before execManagedNe.',
|
||||
{
|
||||
|
|
@ -202,25 +233,27 @@ export function registerNetxTools(ctx: Context, connection: NetxToolConnection):
|
|||
page_size: num(),
|
||||
},
|
||||
H.listManagedNe, getClient, t,
|
||||
)),
|
||||
ctx.tools.register(tool(
|
||||
),
|
||||
tool(
|
||||
'netx__getManagedNe',
|
||||
'Get one managed NE by managed ne_id (from listManagedNe / listCliTargets source=managed). Do NOT pass UME inventory UUID here.',
|
||||
'Get one managed NE by managed ne_id (from listManagedNe / listCliTargets source=managed). Do NOT pass NMS inventory UUID here.',
|
||||
{
|
||||
ne_id: str('Managed NE id'),
|
||||
managed_ne_id: str('Alias for ne_id'),
|
||||
id: str('Alias for ne_id'),
|
||||
},
|
||||
H.getManagedNe, getClient, t,
|
||||
)),
|
||||
ctx.tools.register(tool(
|
||||
),
|
||||
tool(
|
||||
'netx__execManagedNe',
|
||||
'Run read-only CLI via netx (show/display/ping/traceroute). Single NE: ne_id OR ume_ne_id + commands. Many NEs: ne_ids[]/ume_ne_ids[] + shared commands, or targets[{ume_ne_id|ne_id, commands}]. Do NOT loop one-NE calls for multi-NE work.',
|
||||
{
|
||||
ne_id: str(),
|
||||
ume_ne_id: str(),
|
||||
nms_ne_id: str('NMS inventory id; alias ume_ne_id'),
|
||||
ume_ne_id: str('Legacy alias of nms_ne_id'),
|
||||
ne_ids: strArr('Managed NE ids for concurrent batch (shared commands).'),
|
||||
ume_ne_ids: strArr('UME inventory ne_ids for concurrent batch (shared commands).'),
|
||||
nms_ne_ids: strArr('NMS inventory ids; alias ume_ne_ids'),
|
||||
ume_ne_ids: strArr('Legacy alias of nms_ne_ids'),
|
||||
targets: {
|
||||
type: 'array' as const,
|
||||
description: 'Per-NE command sets: each item is one NE (ne_id OR ume_ne_id) with commands[].',
|
||||
|
|
@ -240,25 +273,27 @@ export function registerNetxTools(ctx: Context, connection: NetxToolConnection):
|
|||
async: bool('oclaw-only async hint; ignored by native REST client.'),
|
||||
},
|
||||
H.execManagedNe, getClient, Math.max(t, 300_000),
|
||||
)),
|
||||
ctx.tools.register(tool(
|
||||
),
|
||||
tool(
|
||||
'netx__listCliTargets',
|
||||
'List CLI-capable targets (managed NE and/or UME inventory). Call once per session with keyword/source, cache ids, then execManagedNe.',
|
||||
'List CLI-capable targets (managed NE and/or NMS inventory). Call once per session with keyword/source, cache ids, then execManagedNe.',
|
||||
{
|
||||
source: str('managed | ume | all'),
|
||||
source: str('managed | nms | ume | all'),
|
||||
keyword: str(),
|
||||
page: num(),
|
||||
page_size: num(),
|
||||
},
|
||||
H.listCliTargets, getClient, t,
|
||||
)),
|
||||
ctx.tools.register(tool(
|
||||
),
|
||||
tool(
|
||||
'netx__findTopologyPaths',
|
||||
'Find up to max_paths simple paths between two fabric nodes. For each endpoint provide exactly one of ume_ne_id or managed_ne_id.',
|
||||
'Find up to max_paths simple paths between two fabric nodes (common group — native netx, not NMS-bound). For each endpoint provide exactly one of ume_ne_id or managed_ne_id.',
|
||||
{
|
||||
from_ume_ne_id: str(),
|
||||
from_nms_ne_id: str('NMS inventory id'),
|
||||
from_ume_ne_id: str('Legacy alias of from_nms_ne_id'),
|
||||
from_managed_ne_id: str(),
|
||||
to_ume_ne_id: str(),
|
||||
to_nms_ne_id: str('NMS inventory id'),
|
||||
to_ume_ne_id: str('Legacy alias of to_nms_ne_id'),
|
||||
to_managed_ne_id: str(),
|
||||
max_paths: num(),
|
||||
max_hops: num(),
|
||||
|
|
@ -266,9 +301,249 @@ export function registerNetxTools(ctx: Context, connection: NetxToolConnection):
|
|||
detail: str('summary | full'),
|
||||
},
|
||||
H.findTopologyPaths, getClient, t,
|
||||
)),
|
||||
),
|
||||
// ── topology group (netx-topology canvas / fabric) ─────────────────────
|
||||
tool(
|
||||
'netx__getTopologyTree',
|
||||
'Get topology folder tree (nav roots + Root map canvases). Start here before createTopologyFolder.',
|
||||
{ compact: bool(), max_depth: num() },
|
||||
T.getTopologyTree, getClient, t,
|
||||
),
|
||||
tool(
|
||||
'netx__getTopologyView',
|
||||
'Get a topology view by view_id. Default detail=summary with sample_nodes + links[].',
|
||||
{
|
||||
view_id: { type: 'string' as const, required: true as const },
|
||||
detail: str('summary | full'),
|
||||
sample: num(),
|
||||
},
|
||||
T.getTopologyView, getClient, t,
|
||||
),
|
||||
tool(
|
||||
'netx__createTopologyFolder',
|
||||
'Create topology folders / region canvases. Only way to create canvases (ne:write).',
|
||||
{
|
||||
name: { type: 'string' as const, required: true as const },
|
||||
parent_id: str(),
|
||||
locale: str('zh | en'),
|
||||
sort_order: num(),
|
||||
},
|
||||
T.createTopologyFolder, getClient, t,
|
||||
),
|
||||
tool(
|
||||
'netx__addTopologyViewNodes',
|
||||
'Bulk-place fabric nodes on a view via filters or fabric_node_ids (never managed/UME ids).',
|
||||
{
|
||||
view_id: { type: 'string' as const, required: true as const },
|
||||
max_nodes: num(),
|
||||
keyword: str(),
|
||||
role: str(),
|
||||
vendor: str(),
|
||||
link_status: str(),
|
||||
limit: num(),
|
||||
offset: num(),
|
||||
fabric_node_ids: strArr(),
|
||||
layout: str('grid | keep'),
|
||||
},
|
||||
T.addTopologyViewNodes, getClient, t,
|
||||
),
|
||||
tool(
|
||||
'netx__removeTopologyViewNodes',
|
||||
'Remove placements from a view (not fabric) by filters or fabric_node_ids.',
|
||||
{
|
||||
view_id: { type: 'string' as const, required: true as const },
|
||||
keyword: str(),
|
||||
role: str(),
|
||||
vendor: str(),
|
||||
link_status: str(),
|
||||
fabric_node_ids: strArr(),
|
||||
},
|
||||
T.removeTopologyViewNodes, getClient, t,
|
||||
),
|
||||
tool(
|
||||
'netx__copyTopologyViewNodes',
|
||||
'Clone fabric placements from source_view_id onto target_view_id (optional clear_target / copy_positions).',
|
||||
{
|
||||
source_view_id: { type: 'string' as const, required: true as const },
|
||||
target_view_id: { type: 'string' as const, required: true as const },
|
||||
copy_positions: bool(),
|
||||
clear_target: bool(),
|
||||
offset_x: num(),
|
||||
offset_y: num(),
|
||||
limit: num(),
|
||||
dry_run: bool(),
|
||||
},
|
||||
T.copyTopologyViewNodes, getClient, t,
|
||||
),
|
||||
tool(
|
||||
'netx__updateTopologyViewPositions',
|
||||
'Move nodes on a view via positions[] or layout=grid|offset|stack + filters.',
|
||||
{
|
||||
view_id: { type: 'string' as const, required: true as const },
|
||||
positions: { type: 'array' as const, items: { type: 'object' as const } },
|
||||
layout: str('grid | offset | stack'),
|
||||
keyword: str(),
|
||||
role: str(),
|
||||
vendor: str(),
|
||||
link_status: str(),
|
||||
fabric_node_ids: strArr(),
|
||||
offset_x: num(),
|
||||
offset_y: num(),
|
||||
},
|
||||
T.updateTopologyViewPositions, getClient, t,
|
||||
),
|
||||
tool(
|
||||
'netx__projectTopologyNeighbors',
|
||||
'Project existing fabric neighbors of on-view nodes onto the canvas.',
|
||||
{
|
||||
view_id: { type: 'string' as const, required: true as const },
|
||||
seed_fabric_node_ids: strArr(),
|
||||
managed_ne_ids: strArr(),
|
||||
region_folder_id: str(),
|
||||
detail: str('summary | full'),
|
||||
sample: num(),
|
||||
},
|
||||
T.projectTopologyNeighbors, getClient, t,
|
||||
),
|
||||
tool(
|
||||
'netx__queryTopologyFabricNodes',
|
||||
'Fabric inventory: mode=summary|list|search (keyword/role/level/region/link_status).',
|
||||
{
|
||||
mode: str('summary | list | search'),
|
||||
q: str(),
|
||||
keyword: str(),
|
||||
role: str(),
|
||||
level: str(),
|
||||
level_major: str(),
|
||||
region_folder_id: str(),
|
||||
link_status: str(),
|
||||
page: num(),
|
||||
page_size: num(),
|
||||
limit: num(),
|
||||
summary: bool(),
|
||||
},
|
||||
T.queryTopologyFabricNodes, getClient, t,
|
||||
),
|
||||
tool(
|
||||
'netx__classifyTopologyFabricNodes',
|
||||
'Classify fabric nodes: action=match|tag|patch|unmatched|preview_rules|apply_rules|list_rules.',
|
||||
{
|
||||
action: { type: 'string' as const, required: true as const },
|
||||
pattern: str(),
|
||||
q: str(),
|
||||
match_field: str(),
|
||||
sample_limit: num(),
|
||||
fabric_node_ids: strArr(),
|
||||
fabric_node_id: str(),
|
||||
node_id: str(),
|
||||
level: str(),
|
||||
role: str(),
|
||||
region_folder_id: str(),
|
||||
clear_region: bool(),
|
||||
dry_run: bool(),
|
||||
kind: str(),
|
||||
page: num(),
|
||||
page_size: num(),
|
||||
skip_manual: bool(),
|
||||
overwrite_manual: bool(),
|
||||
fill_empty_only: bool(),
|
||||
},
|
||||
T.classifyTopologyFabricNodes, getClient, t,
|
||||
),
|
||||
tool(
|
||||
'netx__queryTopologyNeighborhood',
|
||||
'Neighborhood around a fabric node (depth 1–3) with compact links[].',
|
||||
{
|
||||
node_id: { type: 'string' as const, required: true as const },
|
||||
depth: num(),
|
||||
layer: str(),
|
||||
},
|
||||
T.queryTopologyNeighborhood, getClient, t,
|
||||
),
|
||||
tool(
|
||||
'netx__queryTopologyEdges',
|
||||
'Fabric adjacency links[] (detail=adjacency) or port rows (detail=ports).',
|
||||
{
|
||||
node_id: str(),
|
||||
keyword: str(),
|
||||
layer: str(),
|
||||
status: str(),
|
||||
source: str('lldp | ume | manual'),
|
||||
detail: str('adjacency | ports'),
|
||||
page: num(),
|
||||
page_size: num(),
|
||||
},
|
||||
T.queryTopologyEdges, getClient, t,
|
||||
),
|
||||
tool(
|
||||
'netx__suggestSinkHubs',
|
||||
'Rank hub territories on a source view for non-dual move_nodes(park) batches (degree stand-in in DSH).',
|
||||
{
|
||||
source_view_id: str(),
|
||||
view_id: str(),
|
||||
pick: num(),
|
||||
exclude_portal_ids: strArr(),
|
||||
exclude_fabric_node_ids: strArr(),
|
||||
},
|
||||
T.suggestSinkHubs, getClient, t,
|
||||
),
|
||||
tool(
|
||||
'netx__analyzeTopologyViewLayout',
|
||||
'Basic layout QA for a view (bbox/hubs). Full dual_unit/crossing scores need netx-topology MCP.',
|
||||
{
|
||||
view_id: str(),
|
||||
folder_id: str(),
|
||||
detail: str('summary | structure | hotspots | blocks | both'),
|
||||
score_profile: str(),
|
||||
sight_limit: num(),
|
||||
max_views: num(),
|
||||
min_nodes: num(),
|
||||
max_nodes: num(),
|
||||
with_meta: bool(),
|
||||
},
|
||||
T.analyzeTopologyViewLayout, getClient, t,
|
||||
),
|
||||
tool(
|
||||
'netx__sinkTopologyDualUnits',
|
||||
'Drain dual_unit eyes from source to sink (requires netx-topology MCP layout engine in DSH).',
|
||||
{
|
||||
source_view_id: str(),
|
||||
sink_view_id: str(),
|
||||
max_units: num(),
|
||||
min_nodes: num(),
|
||||
max_nodes: num(),
|
||||
max_batch_nodes: num(),
|
||||
layout_batch: bool(),
|
||||
until_empty: bool(),
|
||||
dry_run: bool(),
|
||||
},
|
||||
T.sinkTopologyDualUnits, getClient, t,
|
||||
),
|
||||
tool(
|
||||
'netx__layoutTopologyView',
|
||||
'Layout / polish a canvas. DSH supports catalog + move_nodes|sink_nodes over HTTP; dual_unit/orbit/polish recipes need netx-topology MCP.',
|
||||
{
|
||||
view_id: str(),
|
||||
action: str(),
|
||||
source_view_id: str(),
|
||||
recipe: str(),
|
||||
preset: str(),
|
||||
mode: str('preview | apply'),
|
||||
tune: bool(),
|
||||
params: { type: 'object' as const },
|
||||
catalog: bool(),
|
||||
fabric_node_ids: strArr(),
|
||||
park: bool(),
|
||||
copy_positions: bool(),
|
||||
},
|
||||
T.layoutTopologyView, getClient, Math.max(t, 180_000),
|
||||
),
|
||||
]
|
||||
|
||||
const disposers = catalog
|
||||
.filter((entry) => allow.has(entry.name))
|
||||
.map((entry) => ctx.tools.register(entry))
|
||||
|
||||
return () => {
|
||||
for (const dispose of disposers) dispose()
|
||||
}
|
||||
|
|
|
|||
668
src/netx/topology-handlers.ts
Normal file
668
src/netx/topology-handlers.ts
Normal file
|
|
@ -0,0 +1,668 @@
|
|||
/**
|
||||
* Topology canvas / fabric handlers — HTTP port of netx-topology-mcp tools.
|
||||
* Layout recipes (dual_unit / orbit / polish) stay MCP-local; DSH exposes HTTP
|
||||
* CRUD + fabric query + move_nodes, and returns a clear hint for recipe actions.
|
||||
*/
|
||||
|
||||
import { type NetxClient, type NetxJson } from './http.ts'
|
||||
|
||||
function asRecord(value: unknown): NetxJson {
|
||||
return typeof value === 'object' && value !== null && !Array.isArray(value)
|
||||
? value as NetxJson
|
||||
: {}
|
||||
}
|
||||
|
||||
function str(args: NetxJson, key: string, fallback = ''): string {
|
||||
const v = args[key]
|
||||
if (typeof v === 'string') return v
|
||||
if (typeof v === 'number' || typeof v === 'boolean') return String(v)
|
||||
return fallback
|
||||
}
|
||||
|
||||
function num(args: NetxJson, key: string): number | undefined {
|
||||
const v = args[key]
|
||||
return typeof v === 'number' && Number.isFinite(v) ? v : undefined
|
||||
}
|
||||
|
||||
function bool(args: NetxJson, key: string): boolean | undefined {
|
||||
const v = args[key]
|
||||
return typeof v === 'boolean' ? v : undefined
|
||||
}
|
||||
|
||||
function strList(args: NetxJson, key: string): string[] {
|
||||
const v = args[key]
|
||||
if (!Array.isArray(v)) return []
|
||||
return v.map((x) => String(x).trim()).filter((x) => x.length > 0)
|
||||
}
|
||||
|
||||
function clampInt(value: number | undefined, fallback: number, min: number, max: number): number {
|
||||
const n = value === undefined ? fallback : Math.trunc(value)
|
||||
return Math.max(min, Math.min(max, n))
|
||||
}
|
||||
|
||||
function unwrap(out: NetxJson): NetxJson {
|
||||
if (out.ok !== true) return out
|
||||
const data = asRecord(out.data)
|
||||
if (Object.keys(data).length === 0) return { ok: true, data: out.data }
|
||||
return { ok: true, ...data, data }
|
||||
}
|
||||
|
||||
function filterFields(args: NetxJson): NetxJson {
|
||||
const out: NetxJson = {}
|
||||
for (const key of ['keyword', 'role', 'vendor', 'link_status'] as const) {
|
||||
const v = str(args, key).trim()
|
||||
if (v) out[key] = v
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
function edgeEndpoints(edge: NetxJson): [string, string] {
|
||||
const a = str(edge, 'a_node_id') || str(edge, 'a')
|
||||
const b = str(edge, 'b_node_id') || str(edge, 'b')
|
||||
return [a.trim(), b.trim()]
|
||||
}
|
||||
|
||||
function collapseEdgesToLinks(edges: unknown[], includeNames = false): NetxJson[] {
|
||||
const buckets = new Map<string, NetxJson>()
|
||||
for (const raw of edges) {
|
||||
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) continue
|
||||
const e = raw as NetxJson
|
||||
const [a, b] = edgeEndpoints(e)
|
||||
if (!a || !b || a === b) continue
|
||||
const lo = a < b ? a : b
|
||||
const hi = a < b ? b : a
|
||||
const key = `${lo}\0${hi}`
|
||||
const row = buckets.get(key) ?? {
|
||||
a_node_id: lo,
|
||||
b_node_id: hi,
|
||||
link_count: 0,
|
||||
}
|
||||
row.link_count = Number(row.link_count || 0) + 1
|
||||
if (includeNames && !row.a_name) {
|
||||
if (a === lo) {
|
||||
row.a_name = str(e, 'a_name')
|
||||
row.b_name = str(e, 'b_name')
|
||||
} else {
|
||||
row.a_name = str(e, 'b_name')
|
||||
row.b_name = str(e, 'a_name')
|
||||
}
|
||||
}
|
||||
buckets.set(key, row)
|
||||
}
|
||||
return [...buckets.values()]
|
||||
}
|
||||
|
||||
function summarizeViewGraph(graph: NetxJson, sample = 20): NetxJson {
|
||||
const nodes = Array.isArray(graph.nodes) ? graph.nodes : []
|
||||
const edges = Array.isArray(graph.edges) ? graph.edges : (Array.isArray(graph.links) ? graph.links : [])
|
||||
const sampleNodes = nodes.slice(0, Math.max(0, sample)).map((n) => {
|
||||
const row = asRecord(n)
|
||||
return {
|
||||
id: str(row, 'id') || str(row, 'fabric_node_id'),
|
||||
name: str(row, 'name'),
|
||||
x: row.x,
|
||||
y: row.y,
|
||||
level: row.level,
|
||||
role: row.role,
|
||||
}
|
||||
})
|
||||
return {
|
||||
ok: true,
|
||||
view_id: str(graph, 'id') || str(graph, 'view_id'),
|
||||
node_count: nodes.length,
|
||||
edge_count: edges.length,
|
||||
link_count: collapseEdgesToLinks(edges).length,
|
||||
links: collapseEdgesToLinks(edges),
|
||||
sample_nodes: sampleNodes,
|
||||
detail: 'summary',
|
||||
}
|
||||
}
|
||||
|
||||
function layoutRecipeUnavailable(action: string): NetxJson {
|
||||
return {
|
||||
ok: false,
|
||||
error: 'layout_recipe_requires_netx_topology_mcp',
|
||||
action,
|
||||
hint: 'Canvas CRUD/query and move_nodes work in dsh-netxops. Dual-unit / orbit / polish recipes still need the netx-topology MCP layout engine (or enable that MCP alongside).',
|
||||
}
|
||||
}
|
||||
|
||||
/** GET /v1/topology/tree */
|
||||
export async function getTopologyTree(client: NetxClient, args: NetxJson, signal?: AbortSignal): Promise<NetxJson> {
|
||||
const out = unwrap(await client.get('/v1/topology/tree', undefined, signal))
|
||||
if (out.ok !== true) return out
|
||||
const compact = bool(args, 'compact') !== false
|
||||
const maxDepth = num(args, 'max_depth')
|
||||
if (!compact && maxDepth === undefined) return out
|
||||
// Tree is already usable; pass through with flags for the model.
|
||||
return { ...out, compact, max_depth: maxDepth ?? null }
|
||||
}
|
||||
|
||||
/** GET /v1/topology/views/{id} */
|
||||
export async function getTopologyView(client: NetxClient, args: NetxJson, signal?: AbortSignal): Promise<NetxJson> {
|
||||
const viewId = str(args, 'view_id').trim()
|
||||
if (!viewId) return { ok: false, error: 'view_id_required' }
|
||||
const out = unwrap(await client.get(`/v1/topology/views/${encodeURIComponent(viewId)}`, undefined, signal, 120_000))
|
||||
if (out.ok !== true) return out
|
||||
const detail = str(args, 'detail', 'summary').trim().toLowerCase() || 'summary'
|
||||
if (detail === 'full') return { ...out, detail: 'full' }
|
||||
return summarizeViewGraph(out, clampInt(num(args, 'sample'), 20, 0, 200))
|
||||
}
|
||||
|
||||
/** POST /v1/topology/folders */
|
||||
export async function createTopologyFolder(client: NetxClient, args: NetxJson, signal?: AbortSignal): Promise<NetxJson> {
|
||||
const name = str(args, 'name').trim()
|
||||
if (!name) return { ok: false, error: 'name_required' }
|
||||
const body: NetxJson = { name }
|
||||
const parentId = str(args, 'parent_id').trim()
|
||||
if (parentId) body.parent_id = parentId
|
||||
const locale = str(args, 'locale').trim()
|
||||
if (locale) body.locale = locale
|
||||
if (num(args, 'sort_order') !== undefined) body.sort_order = Math.trunc(num(args, 'sort_order')!)
|
||||
return unwrap(await client.post('/v1/topology/folders', body, signal))
|
||||
}
|
||||
|
||||
/** POST /v1/topology/views/{id}/nodes */
|
||||
export async function addTopologyViewNodes(client: NetxClient, args: NetxJson, signal?: AbortSignal): Promise<NetxJson> {
|
||||
const viewId = str(args, 'view_id').trim()
|
||||
if (!viewId) return { ok: false, error: 'view_id_required' }
|
||||
if (strList(args, 'managed_ne_ids').length || strList(args, 'ume_ne_ids').length) {
|
||||
return {
|
||||
ok: false,
|
||||
error: 'fabric_nodes_only',
|
||||
detail: 'Use keyword/role/vendor/link_status or fabric_node_ids; never managed/UME ids.',
|
||||
}
|
||||
}
|
||||
const filters = filterFields(args)
|
||||
const fabricIds = strList(args, 'fabric_node_ids')
|
||||
if (Object.keys(filters).length === 0 && fabricIds.length === 0) {
|
||||
return {
|
||||
ok: false,
|
||||
error: 'filter_or_fabric_node_ids_required',
|
||||
detail: 'Pass keyword/role/vendor/link_status (preferred) or fabric_node_ids.',
|
||||
}
|
||||
}
|
||||
if (num(args, 'max_nodes') !== undefined) {
|
||||
const got = unwrap(await client.get(`/v1/topology/views/${encodeURIComponent(viewId)}`, undefined, signal))
|
||||
if (got.ok === true) {
|
||||
const data = asRecord(got.data)
|
||||
const filt = asRecord(got.filter ?? data.filter)
|
||||
const membership = asRecord(filt.membership ?? {})
|
||||
membership.max_nodes = clampInt(num(args, 'max_nodes'), 2000, 1, 2000)
|
||||
filt.membership = membership
|
||||
await client.patch(`/v1/topology/views/${encodeURIComponent(viewId)}`, { filter: filt }, signal)
|
||||
}
|
||||
}
|
||||
const layout = str(args, 'layout', 'grid').trim() || 'grid'
|
||||
const body: NetxJson = {
|
||||
managed_ne_ids: [],
|
||||
layout,
|
||||
...filters,
|
||||
}
|
||||
if (fabricIds.length) body.fabric_node_ids = fabricIds
|
||||
if (num(args, 'limit') !== undefined) body.limit = clampInt(num(args, 'limit'), 500, 1, 2000)
|
||||
if (num(args, 'offset') !== undefined) body.offset = clampInt(num(args, 'offset'), 0, 0, Number.MAX_SAFE_INTEGER)
|
||||
return unwrap(await client.post(`/v1/topology/views/${encodeURIComponent(viewId)}/nodes`, body, signal, 180_000))
|
||||
}
|
||||
|
||||
/** POST /v1/topology/views/{id}/nodes/remove */
|
||||
export async function removeTopologyViewNodes(client: NetxClient, args: NetxJson, signal?: AbortSignal): Promise<NetxJson> {
|
||||
const viewId = str(args, 'view_id').trim()
|
||||
if (!viewId) return { ok: false, error: 'view_id_required' }
|
||||
const body: NetxJson = { ...filterFields(args) }
|
||||
const fabricIds = strList(args, 'fabric_node_ids')
|
||||
if (fabricIds.length) body.fabric_node_ids = fabricIds
|
||||
return unwrap(await client.post(
|
||||
`/v1/topology/views/${encodeURIComponent(viewId)}/nodes/remove`,
|
||||
body,
|
||||
signal,
|
||||
))
|
||||
}
|
||||
|
||||
/** Clone placements from source view onto target view. */
|
||||
export async function copyTopologyViewNodes(client: NetxClient, args: NetxJson, signal?: AbortSignal): Promise<NetxJson> {
|
||||
const sourceId = str(args, 'source_view_id').trim()
|
||||
const targetId = str(args, 'target_view_id').trim()
|
||||
if (!sourceId || !targetId) {
|
||||
return { ok: false, error: 'source_view_id_and_target_view_id_required' }
|
||||
}
|
||||
const source = unwrap(await client.get(`/v1/topology/views/${encodeURIComponent(sourceId)}`, undefined, signal, 120_000))
|
||||
if (source.ok !== true) return source
|
||||
const nodes = Array.isArray(source.nodes) ? source.nodes : []
|
||||
const limit = num(args, 'limit')
|
||||
const selected = typeof limit === 'number' ? nodes.slice(0, clampInt(limit, nodes.length, 1, 2000)) : nodes
|
||||
const fabricIds = selected.map((n) => {
|
||||
const row = asRecord(n)
|
||||
return str(row, 'id') || str(row, 'fabric_node_id')
|
||||
}).filter(Boolean)
|
||||
if (bool(args, 'dry_run') === true) {
|
||||
return { ok: true, dry_run: true, would_copy: fabricIds.length, source_view_id: sourceId, target_view_id: targetId }
|
||||
}
|
||||
if (bool(args, 'clear_target') === true) {
|
||||
await client.post(`/v1/topology/views/${encodeURIComponent(targetId)}/nodes/remove`, {
|
||||
fabric_node_ids: 'ALL',
|
||||
clear_all: true,
|
||||
}, signal)
|
||||
}
|
||||
if (fabricIds.length === 0) {
|
||||
return { ok: true, added: 0, source_view_id: sourceId, target_view_id: targetId }
|
||||
}
|
||||
const add = unwrap(await client.post(
|
||||
`/v1/topology/views/${encodeURIComponent(targetId)}/nodes`,
|
||||
{ fabric_node_ids: fabricIds, layout: 'keep', managed_ne_ids: [] },
|
||||
signal,
|
||||
180_000,
|
||||
))
|
||||
if (add.ok !== true) return add
|
||||
if (bool(args, 'copy_positions') !== false) {
|
||||
const ox = num(args, 'offset_x') ?? 0
|
||||
const oy = num(args, 'offset_y') ?? 0
|
||||
const positions = selected.map((n) => {
|
||||
const row = asRecord(n)
|
||||
const id = str(row, 'id') || str(row, 'fabric_node_id')
|
||||
return {
|
||||
fabric_node_id: id,
|
||||
x: Number(row.x || 0) + ox,
|
||||
y: Number(row.y || 0) + oy,
|
||||
}
|
||||
}).filter((p) => p.fabric_node_id)
|
||||
if (positions.length) {
|
||||
await client.patch(
|
||||
`/v1/topology/views/${encodeURIComponent(targetId)}/positions`,
|
||||
{ positions },
|
||||
signal,
|
||||
120_000,
|
||||
)
|
||||
}
|
||||
}
|
||||
return { ok: true, added: fabricIds.length, source_view_id: sourceId, target_view_id: targetId, ...asRecord(add) }
|
||||
}
|
||||
|
||||
/** PATCH /v1/topology/views/{id}/positions */
|
||||
export async function updateTopologyViewPositions(client: NetxClient, args: NetxJson, signal?: AbortSignal): Promise<NetxJson> {
|
||||
const viewId = str(args, 'view_id').trim()
|
||||
if (!viewId) return { ok: false, error: 'view_id_required' }
|
||||
const body: NetxJson = {}
|
||||
if (Array.isArray(args.positions)) body.positions = args.positions
|
||||
const layout = str(args, 'layout').trim()
|
||||
if (layout) body.layout = layout
|
||||
Object.assign(body, filterFields(args))
|
||||
for (const key of ['offset_x', 'offset_y', 'cols', 'gap_x', 'gap_y'] as const) {
|
||||
if (num(args, key) !== undefined) body[key] = num(args, key)
|
||||
}
|
||||
const fabricIds = strList(args, 'fabric_node_ids')
|
||||
if (fabricIds.length) body.fabric_node_ids = fabricIds
|
||||
return unwrap(await client.patch(
|
||||
`/v1/topology/views/${encodeURIComponent(viewId)}/positions`,
|
||||
body,
|
||||
signal,
|
||||
120_000,
|
||||
))
|
||||
}
|
||||
|
||||
/** POST .../project-neighbors */
|
||||
export async function projectTopologyNeighbors(client: NetxClient, args: NetxJson, signal?: AbortSignal): Promise<NetxJson> {
|
||||
const viewId = str(args, 'view_id').trim()
|
||||
if (!viewId) return { ok: false, error: 'view_id_required' }
|
||||
const body: NetxJson = {}
|
||||
const seeds = strList(args, 'seed_fabric_node_ids').length
|
||||
? strList(args, 'seed_fabric_node_ids')
|
||||
: strList(args, 'fabric_node_ids')
|
||||
if (seeds.length) body.seed_fabric_node_ids = seeds
|
||||
const mids = strList(args, 'managed_ne_ids')
|
||||
if (mids.length) body.managed_ne_ids = mids
|
||||
const region = str(args, 'region_folder_id').trim()
|
||||
if (region) body.region_folder_id = region
|
||||
const out = unwrap(await client.post(
|
||||
`/v1/topology/views/${encodeURIComponent(viewId)}/project-neighbors`,
|
||||
body,
|
||||
signal,
|
||||
180_000,
|
||||
))
|
||||
if (out.ok !== true) return out
|
||||
const detail = str(args, 'detail', 'summary').trim().toLowerCase() || 'summary'
|
||||
if (detail === 'full' || detail === 'raw' || detail === 'graph') {
|
||||
return { ...out, detail: 'full', projected: true }
|
||||
}
|
||||
return { ...summarizeViewGraph(out, clampInt(num(args, 'sample'), 20, 0, 100)), projected: true, view_id: viewId }
|
||||
}
|
||||
|
||||
/** Fabric inventory summary|list|search */
|
||||
export async function queryTopologyFabricNodes(client: NetxClient, args: NetxJson, signal?: AbortSignal): Promise<NetxJson> {
|
||||
let mode = str(args, 'mode').trim().toLowerCase()
|
||||
const q = str(args, 'q').trim()
|
||||
const keyword = str(args, 'keyword').trim()
|
||||
if (!mode) {
|
||||
if (bool(args, 'summary') === true) mode = 'summary'
|
||||
else if (q) mode = 'search'
|
||||
else mode = 'list'
|
||||
}
|
||||
if (mode === 'summary' || mode === 'stats' || mode === 'count') {
|
||||
const out = unwrap(await client.get('/v1/topology/fabric/summary', undefined, signal))
|
||||
return out.ok === true ? { ...out, mode: 'summary' } : out
|
||||
}
|
||||
if (mode === 'search' || mode === 'find') {
|
||||
const needle = q || keyword
|
||||
if (!needle) return { ok: false, error: 'q_required', hint: 'mode=search needs q (or keyword).' }
|
||||
return unwrap(await client.get('/v1/topology/fabric/nodes/search', {
|
||||
q: needle,
|
||||
page: clampInt(num(args, 'page'), 1, 1, Number.MAX_SAFE_INTEGER),
|
||||
page_size: clampInt(num(args, 'page_size') ?? num(args, 'limit'), 50, 1, 200),
|
||||
}, signal))
|
||||
}
|
||||
const params: Record<string, string | number | boolean> = {
|
||||
page: clampInt(num(args, 'page'), 1, 1, Number.MAX_SAFE_INTEGER),
|
||||
page_size: clampInt(num(args, 'page_size') ?? num(args, 'limit'), 50, 1, 500),
|
||||
}
|
||||
for (const key of ['keyword', 'role', 'level', 'level_major', 'region_folder_id', 'link_status'] as const) {
|
||||
const v = str(args, key).trim() || (key === 'keyword' ? q : '')
|
||||
if (v) params[key] = v
|
||||
}
|
||||
const out = unwrap(await client.get('/v1/topology/fabric/nodes', params, signal))
|
||||
return out.ok === true ? { ...out, mode: 'list' } : out
|
||||
}
|
||||
|
||||
/** Classify / tag fabric nodes */
|
||||
export async function classifyTopologyFabricNodes(client: NetxClient, args: NetxJson, signal?: AbortSignal): Promise<NetxJson> {
|
||||
const action = str(args, 'action').trim().toLowerCase()
|
||||
if (!action) return { ok: false, error: 'action_required' }
|
||||
if (action === 'match') {
|
||||
const pattern = str(args, 'pattern').trim() || str(args, 'q').trim()
|
||||
if (!pattern) return { ok: false, error: 'pattern_required' }
|
||||
return unwrap(await client.post('/v1/topology/fabric/nodes/match', {
|
||||
pattern,
|
||||
match_field: str(args, 'match_field', 'name').trim() || 'name',
|
||||
sample_limit: clampInt(num(args, 'sample_limit'), 50, 1, 200),
|
||||
}, signal))
|
||||
}
|
||||
if (action === 'tag') {
|
||||
const body: NetxJson = {
|
||||
fabric_node_ids: strList(args, 'fabric_node_ids'),
|
||||
dry_run: bool(args, 'dry_run') === true,
|
||||
}
|
||||
const pattern = str(args, 'pattern').trim() || str(args, 'q').trim()
|
||||
if (pattern) body.pattern = pattern
|
||||
if (args.level !== undefined) body.level = args.level
|
||||
const role = str(args, 'role').trim()
|
||||
if (role) body.role = role
|
||||
const region = str(args, 'region_folder_id').trim()
|
||||
if (region) body.region_folder_id = region
|
||||
if (bool(args, 'clear_region') === true) body.clear_region = true
|
||||
return unwrap(await client.post('/v1/topology/fabric/nodes/tags/bulk', body, signal))
|
||||
}
|
||||
if (action === 'patch') {
|
||||
const nodeId = str(args, 'fabric_node_id').trim() || str(args, 'node_id').trim()
|
||||
if (!nodeId) return { ok: false, error: 'fabric_node_id_required' }
|
||||
const body: NetxJson = {}
|
||||
if (args.level !== undefined) body.level = args.level
|
||||
const role = str(args, 'role').trim()
|
||||
if (role) body.role = role
|
||||
const region = str(args, 'region_folder_id').trim()
|
||||
if (region) body.region_folder_id = region
|
||||
if (bool(args, 'clear_region') === true) body.clear_region = true
|
||||
return unwrap(await client.patch(
|
||||
`/v1/topology/fabric/nodes/${encodeURIComponent(nodeId)}/tags`,
|
||||
body,
|
||||
signal,
|
||||
))
|
||||
}
|
||||
if (action === 'unmatched') {
|
||||
return unwrap(await client.get('/v1/topology/classify/unmatched', {
|
||||
kind: str(args, 'kind', 'any').trim() || 'any',
|
||||
page: clampInt(num(args, 'page'), 1, 1, Number.MAX_SAFE_INTEGER),
|
||||
page_size: clampInt(num(args, 'page_size') ?? num(args, 'limit'), 50, 1, 500),
|
||||
}, signal))
|
||||
}
|
||||
if (action === 'preview_rules') {
|
||||
return unwrap(await client.post('/v1/topology/classify/preview', {}, signal))
|
||||
}
|
||||
if (action === 'apply_rules') {
|
||||
const params: Record<string, string | number | boolean> = {}
|
||||
if (bool(args, 'overwrite_manual') === true) params.skip_manual = false
|
||||
else if (bool(args, 'skip_manual') !== undefined) params.skip_manual = bool(args, 'skip_manual') !== false
|
||||
if (bool(args, 'fill_empty_only') === true) params.fill_empty_only = true
|
||||
return unwrap(await client.post('/v1/topology/classify/apply', {}, signal))
|
||||
}
|
||||
if (action === 'list_rules') {
|
||||
return unwrap(await client.get('/v1/topology/classify/rules', undefined, signal))
|
||||
}
|
||||
return { ok: false, error: 'unknown_action', action }
|
||||
}
|
||||
|
||||
/** GET /v1/topology/fabric/neighborhood */
|
||||
export async function queryTopologyNeighborhood(client: NetxClient, args: NetxJson, signal?: AbortSignal): Promise<NetxJson> {
|
||||
const nodeId = str(args, 'node_id').trim()
|
||||
if (!nodeId) return { ok: false, error: 'node_id_required' }
|
||||
const out = unwrap(await client.get('/v1/topology/fabric/neighborhood', {
|
||||
node_id: nodeId,
|
||||
depth: clampInt(num(args, 'depth'), 1, 1, 3),
|
||||
layer: str(args, 'layer', 'physical').trim() || 'physical',
|
||||
}, signal))
|
||||
if (out.ok !== true) return out
|
||||
const edges = Array.isArray(out.edges) ? out.edges : []
|
||||
return { ...out, links: collapseEdgesToLinks(edges) }
|
||||
}
|
||||
|
||||
/** GET /v1/topology/fabric/edges */
|
||||
export async function queryTopologyEdges(client: NetxClient, args: NetxJson, signal?: AbortSignal): Promise<NetxJson> {
|
||||
const params: Record<string, string | number | boolean> = {
|
||||
page: clampInt(num(args, 'page'), 1, 1, Number.MAX_SAFE_INTEGER),
|
||||
page_size: clampInt(num(args, 'page_size'), 100, 1, 500),
|
||||
layer: str(args, 'layer', 'physical').trim() || 'physical',
|
||||
}
|
||||
for (const key of ['node_id', 'keyword', 'status', 'source'] as const) {
|
||||
const v = str(args, key).trim()
|
||||
if (v) params[key] = v
|
||||
}
|
||||
const out = unwrap(await client.get('/v1/topology/fabric/edges', params, signal))
|
||||
if (out.ok !== true) return out
|
||||
const detail = str(args, 'detail', 'adjacency').trim().toLowerCase() || 'adjacency'
|
||||
const items = Array.isArray(out.items) ? out.items : (Array.isArray(out.edges) ? out.edges : [])
|
||||
if (detail === 'ports') return { ...out, detail: 'ports', items }
|
||||
const links = collapseEdgesToLinks(items, true)
|
||||
return { ...out, detail: 'adjacency', links, items: undefined }
|
||||
}
|
||||
|
||||
/** Simple degree-ranked hub suggestions (HTTP-only stand-in). */
|
||||
export async function suggestSinkHubs(client: NetxClient, args: NetxJson, signal?: AbortSignal): Promise<NetxJson> {
|
||||
const viewId = str(args, 'source_view_id').trim() || str(args, 'view_id').trim()
|
||||
if (!viewId) return { ok: false, error: 'source_view_id_required' }
|
||||
const out = unwrap(await client.get(`/v1/topology/views/${encodeURIComponent(viewId)}`, undefined, signal, 120_000))
|
||||
if (out.ok !== true) return out
|
||||
const nodes = Array.isArray(out.nodes) ? out.nodes : []
|
||||
const edges = Array.isArray(out.edges) ? out.edges : []
|
||||
const degree = new Map<string, number>()
|
||||
for (const raw of edges) {
|
||||
const [a, b] = edgeEndpoints(asRecord(raw))
|
||||
if (!a || !b) continue
|
||||
degree.set(a, (degree.get(a) || 0) + 1)
|
||||
degree.set(b, (degree.get(b) || 0) + 1)
|
||||
}
|
||||
const exclude = new Set([
|
||||
...strList(args, 'exclude_portal_ids'),
|
||||
...strList(args, 'exclude_fabric_node_ids'),
|
||||
])
|
||||
const pick = clampInt(num(args, 'pick'), 5, 1, 50)
|
||||
const hubs = [...degree.entries()]
|
||||
.filter(([id]) => !exclude.has(id))
|
||||
.sort((a, b) => b[1] - a[1])
|
||||
.slice(0, pick)
|
||||
.map(([fabric_node_id, deg]) => {
|
||||
const node = nodes.find((n) => {
|
||||
const row = asRecord(n)
|
||||
return (str(row, 'id') || str(row, 'fabric_node_id')) === fabric_node_id
|
||||
})
|
||||
const row = asRecord(node)
|
||||
return {
|
||||
fabric_node_id,
|
||||
degree: deg,
|
||||
name: str(row, 'name'),
|
||||
level: row.level,
|
||||
role: row.role,
|
||||
}
|
||||
})
|
||||
return {
|
||||
ok: true,
|
||||
source_view_id: viewId,
|
||||
hubs,
|
||||
hint: 'Degree ranking only — full dual_unit-aware suggestSinkHubs lives in netx-topology MCP.',
|
||||
}
|
||||
}
|
||||
|
||||
/** Basic layout QA from view graph (no Python layout_stats). */
|
||||
export async function analyzeTopologyViewLayout(client: NetxClient, args: NetxJson, signal?: AbortSignal): Promise<NetxJson> {
|
||||
const viewId = str(args, 'view_id').trim()
|
||||
if (!viewId) {
|
||||
return {
|
||||
ok: false,
|
||||
error: 'view_id_required',
|
||||
hint: 'Pass view_id. folder_id sampling without view_id needs netx-topology MCP structure analysis.',
|
||||
}
|
||||
}
|
||||
const out = unwrap(await client.get(`/v1/topology/views/${encodeURIComponent(viewId)}`, undefined, signal, 120_000))
|
||||
if (out.ok !== true) return out
|
||||
const nodes = Array.isArray(out.nodes) ? out.nodes : []
|
||||
const edges = Array.isArray(out.edges) ? out.edges : []
|
||||
let minX = Infinity
|
||||
let minY = Infinity
|
||||
let maxX = -Infinity
|
||||
let maxY = -Infinity
|
||||
let missingxy = 0
|
||||
for (const raw of nodes) {
|
||||
const row = asRecord(raw)
|
||||
const x = Number(row.x)
|
||||
const y = Number(row.y)
|
||||
if (!Number.isFinite(x) || !Number.isFinite(y)) {
|
||||
missingxy += 1
|
||||
continue
|
||||
}
|
||||
minX = Math.min(minX, x)
|
||||
minY = Math.min(minY, y)
|
||||
maxX = Math.max(maxX, x)
|
||||
maxY = Math.max(maxY, y)
|
||||
}
|
||||
const degree = new Map<string, number>()
|
||||
for (const raw of edges) {
|
||||
const [a, b] = edgeEndpoints(asRecord(raw))
|
||||
if (!a || !b) continue
|
||||
degree.set(a, (degree.get(a) || 0) + 1)
|
||||
degree.set(b, (degree.get(b) || 0) + 1)
|
||||
}
|
||||
const hubs = [...degree.entries()].sort((a, b) => b[1] - a[1]).slice(0, 10)
|
||||
.map(([id, deg]) => ({ fabric_node_id: id, degree: deg }))
|
||||
return {
|
||||
ok: true,
|
||||
view_id: viewId,
|
||||
detail: str(args, 'detail', 'summary') || 'summary',
|
||||
node_count: nodes.length,
|
||||
edge_count: edges.length,
|
||||
link_count: collapseEdgesToLinks(edges).length,
|
||||
missing_xy: missingxy,
|
||||
bbox: Number.isFinite(minX) ? { minX, minY, maxX, maxY, width: maxX - minX, height: maxY - minY } : null,
|
||||
hubs,
|
||||
hint: 'Basic graph stats only. Crossing/dual_unit/score.total analysis needs netx-topology MCP.',
|
||||
}
|
||||
}
|
||||
|
||||
/** Dual-unit sink requires MCP layout engine. */
|
||||
export async function sinkTopologyDualUnits(_client: NetxClient, args: NetxJson): Promise<NetxJson> {
|
||||
return {
|
||||
ok: false,
|
||||
error: 'sink_dual_units_requires_netx_topology_mcp',
|
||||
source_view_id: str(args, 'source_view_id'),
|
||||
sink_view_id: str(args, 'sink_view_id'),
|
||||
hint: 'sinkTopologyDualUnits needs the dual_unit layout engine from netx-topology MCP. Use layoutTopologyView(action=move_nodes, park=true) for HTTP-only parking, or enable the topology MCP.',
|
||||
}
|
||||
}
|
||||
|
||||
/** Layout tool: catalog + move_nodes over HTTP; recipes deferred to MCP. */
|
||||
export async function layoutTopologyView(client: NetxClient, args: NetxJson, signal?: AbortSignal): Promise<NetxJson> {
|
||||
if (bool(args, 'catalog') === true) {
|
||||
return {
|
||||
ok: true,
|
||||
catalog: true,
|
||||
actions: [
|
||||
'layout', 'layout_dual_unit', 'move_nodes', 'sink_nodes', 'orbit_sweep', 'level_bands',
|
||||
'polish_crossings', 'clear_edge_hits', 'compact_bbox', 'pull_far_chains', 'align_reference',
|
||||
'fix_overlaps', 'resolve_overlaps', 'untangle', 'straighten_channels', 'job_status', 'job_cancel',
|
||||
],
|
||||
dsh_supported: ['move_nodes', 'sink_nodes', 'catalog'],
|
||||
recipes: ['rings', 'corridor', 'compact', 'unstick'],
|
||||
hint: 'Only move_nodes/sink_nodes/catalog run in dsh-netxops; other actions need netx-topology MCP.',
|
||||
}
|
||||
}
|
||||
const action = str(args, 'action', 'layout').trim().toLowerCase() || 'layout'
|
||||
if (action === 'job_status' || action === 'job_cancel') {
|
||||
return {
|
||||
ok: false,
|
||||
error: 'layout_jobs_not_in_dsh_host',
|
||||
hint: 'Background layout jobs are owned by netx-topology MCP.',
|
||||
}
|
||||
}
|
||||
if (action === 'move_nodes' || action === 'sink_nodes') {
|
||||
const toId = str(args, 'view_id').trim()
|
||||
const fromId = str(args, 'source_view_id').trim()
|
||||
const ids = strList(args, 'fabric_node_ids')
|
||||
const params = asRecord(args.params)
|
||||
const fabricIds = ids.length ? ids : strList(params, 'fabric_node_ids')
|
||||
if (!toId || !fromId) {
|
||||
return { ok: false, error: 'view_id_and_source_view_id_required' }
|
||||
}
|
||||
if (fabricIds.length === 0) {
|
||||
return { ok: false, error: 'fabric_node_ids_required' }
|
||||
}
|
||||
const copyPositions = bool(params, 'copy_positions') !== false
|
||||
&& bool(args, 'copy_positions') !== false
|
||||
const removeFromSource = bool(params, 'remove_from_source') !== false
|
||||
const source = unwrap(await client.get(`/v1/topology/views/${encodeURIComponent(fromId)}`, undefined, signal, 120_000))
|
||||
if (source.ok !== true) return source
|
||||
const nodes = Array.isArray(source.nodes) ? source.nodes : []
|
||||
const wanted = new Set(fabricIds)
|
||||
const selected = nodes.filter((n) => {
|
||||
const row = asRecord(n)
|
||||
const id = str(row, 'id') || str(row, 'fabric_node_id')
|
||||
return wanted.has(id)
|
||||
})
|
||||
const add = unwrap(await client.post(
|
||||
`/v1/topology/views/${encodeURIComponent(toId)}/nodes`,
|
||||
{ fabric_node_ids: fabricIds, layout: copyPositions ? 'keep' : 'grid', managed_ne_ids: [] },
|
||||
signal,
|
||||
180_000,
|
||||
))
|
||||
if (add.ok !== true) return add
|
||||
if (copyPositions && selected.length) {
|
||||
const ox = Number(params.offset_x || 0)
|
||||
const oy = Number(params.offset_y || 0)
|
||||
const positions = selected.map((n) => {
|
||||
const row = asRecord(n)
|
||||
return {
|
||||
fabric_node_id: str(row, 'id') || str(row, 'fabric_node_id'),
|
||||
x: Number(row.x || 0) + ox,
|
||||
y: Number(row.y || 0) + oy,
|
||||
}
|
||||
}).filter((p) => p.fabric_node_id)
|
||||
await client.patch(
|
||||
`/v1/topology/views/${encodeURIComponent(toId)}/positions`,
|
||||
{ positions },
|
||||
signal,
|
||||
120_000,
|
||||
)
|
||||
}
|
||||
if (removeFromSource) {
|
||||
await client.post(
|
||||
`/v1/topology/views/${encodeURIComponent(fromId)}/nodes/remove`,
|
||||
{ fabric_node_ids: fabricIds },
|
||||
signal,
|
||||
)
|
||||
}
|
||||
return {
|
||||
ok: true,
|
||||
action,
|
||||
moved: fabricIds.length,
|
||||
source_view_id: fromId,
|
||||
view_id: toId,
|
||||
park: bool(params, 'park') === true || bool(args, 'park') === true,
|
||||
}
|
||||
}
|
||||
return layoutRecipeUnavailable(action)
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue