mirror of
https://github.com/hansjone/netxops.git
synced 2026-10-11 07:00:45 +08:00
Add operator-subset knowledge base wiring for Netx Ops P1.
Locate and validate MANIFEST under kbRoot, show anti-mixup status in settings, and inject KB_* env plus kb-context so sessions degrade to pure netx when unset. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
e6f9d062ed
commit
de7c6a0c4b
23 changed files with 2694 additions and 68 deletions
|
|
@ -43,6 +43,7 @@ One command, three **direct** profile bundles. Do **not** nest IM/cron under net
|
||||||
Optional: enable **关键告警推送** so this DSH dials `ws(s)://<apiUrl>/v1/integrations/dsh-alarm/ws`. Choose **投递到 DSH 会话** and/or **投递到 WhatsApp / IM**. For IM, install `dsh-im-ops`, create a delivery target, then paste `imBotId` / `imTargetId`. The card header shows live WSS status.
|
Optional: enable **关键告警推送** so this DSH dials `ws(s)://<apiUrl>/v1/integrations/dsh-alarm/ws`. Choose **投递到 DSH 会话** and/or **投递到 WhatsApp / IM**. For IM, install `dsh-im-ops`, create a delivery target, then paste `imBotId` / `imTargetId`. The card header shows live WSS status.
|
||||||
Optional: **导出全部会话** downloads `dsh-sessions-<host>-<utc>.zip` via `GET /api/netxops.sessions.export` (browser download; works for cloud Hosts).
|
Optional: **导出全部会话** downloads `dsh-sessions-<host>-<utc>.zip` via `GET /api/netxops.sessions.export` (browser download; works for cloud Hosts).
|
||||||
Capability groups: leave default for **ops**, or enable **topology** / **对其他预设公开** (new sessions after save).
|
Capability groups: leave default for **ops**, or enable **topology** / **对其他预设公开** (new sessions after save).
|
||||||
|
Optional: **知识库** — set package root (folder with `MANIFEST.json` operator-subset v1.0). Empty = pure netx. See [KNOWLEDGE_BASE.md](KNOWLEDGE_BASE.md).
|
||||||
2. Restart or open Settings → **Agent presets** → Custom → **Netx Ops** should appear after the host plugin has activated once.
|
2. Restart or open Settings → **Agent presets** → Custom → **Netx Ops** should appear after the host plugin has activated once.
|
||||||
3. **New session → preset Netx Ops** → ask e.g. Critical Top / single-host alarms / 「能否登录」.
|
3. **New session → preset Netx Ops** → ask e.g. Critical Top / single-host alarms / 「能否登录」.
|
||||||
|
|
||||||
|
|
@ -67,8 +68,9 @@ One command, three **direct** profile bundles. Do **not** nest IM/cron under net
|
||||||
3. Agent presets → Custom → **Netx Ops**.
|
3. Agent presets → Custom → **Netx Ops**.
|
||||||
4. Tools include `netx__queryNmsAlarms` / `netx__execManagedNe` / `netx__findTopologyPaths` (**ops** → skill `netx-ops`). Canvas / dual_unit need **topology** (`netx-topology`).
|
4. Tools include `netx__queryNmsAlarms` / `netx__execManagedNe` / `netx__findTopologyPaths` (**ops** → skill `netx-ops`). Canvas / dual_unit need **topology** (`netx-topology`).
|
||||||
5. Export section shows a session count; **下载 ZIP** starts a browser download.
|
5. Export section shows a session count; **下载 ZIP** starts a browser download.
|
||||||
|
6. Optional knowledge-base badge shows operator/country/version when `kbRoot` + MANIFEST are valid.
|
||||||
|
|
||||||
See [TOOL_MAP.md](TOOL_MAP.md).
|
See [TOOL_MAP.md](TOOL_MAP.md) and [KNOWLEDGE_BASE.md](KNOWLEDGE_BASE.md).
|
||||||
|
|
||||||
## Path / local checkout (developers)
|
## Path / local checkout (developers)
|
||||||
|
|
||||||
|
|
|
||||||
44
docs/KNOWLEDGE_BASE.md
Normal file
44
docs/KNOWLEDGE_BASE.md
Normal file
|
|
@ -0,0 +1,44 @@
|
||||||
|
# Knowledge base (P1)
|
||||||
|
|
||||||
|
Operator-subset knowledge packages are wired through **netxops settings**, not into the netx API or `netx-ops` skill text.
|
||||||
|
|
||||||
|
## Setup
|
||||||
|
|
||||||
|
1. Settings → Plugins → **Netx Ops** → **知识库 / Knowledge base**
|
||||||
|
2. Set **知识包根目录** to a folder that contains (or uniquely nests ≤3 levels) a `MANIFEST.json`
|
||||||
|
3. Save — Host injects env + a `kb-context` skill for new sessions
|
||||||
|
|
||||||
|
Browse uses Host `remote.directoryPicker` when available; otherwise paste an absolute path.
|
||||||
|
|
||||||
|
## MANIFEST v1.0 (plugin checks)
|
||||||
|
|
||||||
|
| Field | Rule |
|
||||||
|
|-------|------|
|
||||||
|
| `schemaVersion` | must be `"1.0"` |
|
||||||
|
| `packageType` | must be `"operator-subset"` |
|
||||||
|
| `operator.name` / `operator.country` | required non-empty strings |
|
||||||
|
| `version` | required non-empty string |
|
||||||
|
| `content.*` | object of booleans; missing keys → `false` (`regions` / `theory` / `packet` / `skills` known) |
|
||||||
|
|
||||||
|
Location: `${kbRoot}/MANIFEST.json`, else recurse ≤3 levels and **exactly one** hit. Zero or multiple → error.
|
||||||
|
|
||||||
|
Authoritative packaging prose lives in the workspace contract notes (`插件` / `契约`); this doc only describes what the plugin enforces.
|
||||||
|
|
||||||
|
## Injected context
|
||||||
|
|
||||||
|
| Channel | Fields |
|
||||||
|
|---------|--------|
|
||||||
|
| `process.env` | `KB_ROOT`, `KB_OPERATOR`, `KB_COUNTRY`, `KB_VERSION`, `KB_CONTENT` (JSON), `KB_STATUS` |
|
||||||
|
| Skill `kb-context` | Same identity for the model (markdown table) |
|
||||||
|
|
||||||
|
RPC (channel `/netxops`): `kb.status` (saved snapshot), `kb.resolve` with `{ path }` (preview unsaved paths).
|
||||||
|
|
||||||
|
## Degradation matrix
|
||||||
|
|
||||||
|
| Condition | `KB_STATUS` | Behavior |
|
||||||
|
|-----------|-------------|----------|
|
||||||
|
| Empty `kbRoot` | `unconfigured` | Pure netx; skill says do not invent an operator |
|
||||||
|
| Missing / ambiguous / invalid MANIFEST | `error` | Pure netx; badge shows error detail |
|
||||||
|
| Valid MANIFEST | `configured` | Paths + operator identity available |
|
||||||
|
|
||||||
|
**Never** invent an operator when status is not `configured`. P2 knowledge-base skill packs (case search, etc.) are out of scope for this release.
|
||||||
|
|
@ -215,22 +215,120 @@ async function registerGroupSkills(ctx, groupIds, providerLabel) {
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// src/netx/runtime.ts
|
// src/netx/kb-context-skill.ts
|
||||||
var STORE_KEY = Symbol.for("dsh-netxops.connection-store");
|
var SKILL_NAME = "kb-context";
|
||||||
|
function skillBody(snapshot) {
|
||||||
|
if (snapshot.status === "configured") {
|
||||||
|
const flags = Object.entries(snapshot.content).filter(([, on]) => on).map(([key]) => key).join(", ") || "(none)";
|
||||||
|
return {
|
||||||
|
description: "Operator knowledge-base context for this Host (paths + identity from MANIFEST).",
|
||||||
|
content: [
|
||||||
|
"## Knowledge base (configured)",
|
||||||
|
"",
|
||||||
|
"When troubleshooting with operator playbooks or docs, **compose paths from this root**.",
|
||||||
|
"Do not invent another operator or country.",
|
||||||
|
"",
|
||||||
|
`| Field | Value |`,
|
||||||
|
`| --- | --- |`,
|
||||||
|
`| kbStatus | configured |`,
|
||||||
|
`| kbRoot | \`${snapshot.realRoot}\` |`,
|
||||||
|
`| kbOperator | ${snapshot.operatorName} |`,
|
||||||
|
`| kbCountry | ${snapshot.country} |`,
|
||||||
|
`| kbVersion | ${snapshot.version} |`,
|
||||||
|
`| kbContent (on) | ${flags} |`,
|
||||||
|
"",
|
||||||
|
"Environment mirrors: `KB_ROOT`, `KB_OPERATOR`, `KB_COUNTRY`, `KB_VERSION`, `KB_CONTENT`, `KB_STATUS`."
|
||||||
|
].join(`
|
||||||
|
`)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const reason = snapshot.status === "error" ? snapshot.errorMessage || "invalid knowledge base" : "kbRoot is empty";
|
||||||
|
return {
|
||||||
|
description: "Knowledge-base context is unavailable — stay on pure netx evidence.",
|
||||||
|
content: [
|
||||||
|
"## Knowledge base (unavailable)",
|
||||||
|
"",
|
||||||
|
`kbStatus=\`${snapshot.status}\`. ${reason}`,
|
||||||
|
"",
|
||||||
|
"**Do not invent an operator, country, or KB paths.**",
|
||||||
|
"Use only netx tools / live evidence (alarms, inventory, CLI, topology).",
|
||||||
|
"Operator-specific playbooks are out of scope until a valid MANIFEST is configured."
|
||||||
|
].join(`
|
||||||
|
`)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
function registerKbContextSkill(ctx, snapshot) {
|
||||||
|
const skillsApi = ctx.skills;
|
||||||
|
if (!skillsApi || typeof skillsApi.register !== "function") {
|
||||||
|
return () => {};
|
||||||
|
}
|
||||||
|
const body = skillBody(snapshot);
|
||||||
|
return skillsApi.register({
|
||||||
|
name: SKILL_NAME,
|
||||||
|
description: body.description,
|
||||||
|
content: body.content,
|
||||||
|
provider: "netxops-kb",
|
||||||
|
source: "custom"
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// src/netx/kb-manifest.ts
|
||||||
|
var EMPTY_CONTENT = {
|
||||||
|
regions: false,
|
||||||
|
theory: false,
|
||||||
|
packet: false,
|
||||||
|
skills: false
|
||||||
|
};
|
||||||
|
function unconfiguredKbSnapshot() {
|
||||||
|
return {
|
||||||
|
status: "unconfigured",
|
||||||
|
realRoot: "",
|
||||||
|
operatorName: "",
|
||||||
|
country: "",
|
||||||
|
version: "",
|
||||||
|
content: { ...EMPTY_CONTENT },
|
||||||
|
errorMessage: ""
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// src/netx/kb-runtime.ts
|
||||||
|
var STORE_KEY = Symbol.for("dsh-netxops.kb-store");
|
||||||
function store() {
|
function store() {
|
||||||
const root = globalThis;
|
const root = globalThis;
|
||||||
let current = root[STORE_KEY];
|
let current = root[STORE_KEY];
|
||||||
if (current === undefined) {
|
if (current === undefined) {
|
||||||
current = { connection: undefined, listeners: new Set };
|
current = { snapshot: unconfiguredKbSnapshot(), listeners: new Set };
|
||||||
root[STORE_KEY] = current;
|
root[STORE_KEY] = current;
|
||||||
}
|
}
|
||||||
return current;
|
return current;
|
||||||
}
|
}
|
||||||
|
function getKbContext() {
|
||||||
|
return { ...store().snapshot, content: { ...store().snapshot.content } };
|
||||||
|
}
|
||||||
|
function watchKbContext(listener) {
|
||||||
|
const state = store();
|
||||||
|
state.listeners.add(listener);
|
||||||
|
return () => {
|
||||||
|
state.listeners.delete(listener);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// src/netx/runtime.ts
|
||||||
|
var STORE_KEY2 = Symbol.for("dsh-netxops.connection-store");
|
||||||
|
function store2() {
|
||||||
|
const root = globalThis;
|
||||||
|
let current = root[STORE_KEY2];
|
||||||
|
if (current === undefined) {
|
||||||
|
current = { connection: undefined, listeners: new Set };
|
||||||
|
root[STORE_KEY2] = current;
|
||||||
|
}
|
||||||
|
return current;
|
||||||
|
}
|
||||||
function getNetxConnection() {
|
function getNetxConnection() {
|
||||||
return store().connection;
|
return store2().connection;
|
||||||
}
|
}
|
||||||
function watchNetxConnection(listener) {
|
function watchNetxConnection(listener) {
|
||||||
const state = store();
|
const state = store2();
|
||||||
state.listeners.add(listener);
|
state.listeners.add(listener);
|
||||||
return () => {
|
return () => {
|
||||||
state.listeners.delete(listener);
|
state.listeners.delete(listener);
|
||||||
|
|
@ -1749,6 +1847,7 @@ function applyGroupToolsPlugin(ctx, options) {
|
||||||
remountTools();
|
remountTools();
|
||||||
});
|
});
|
||||||
ctx.inject(["skills"], (skillsCtx) => {
|
ctx.inject(["skills"], (skillsCtx) => {
|
||||||
|
let unregisterKbSkill;
|
||||||
const remountSkills = () => {
|
const remountSkills = () => {
|
||||||
const gen = ++skillGeneration;
|
const gen = ++skillGeneration;
|
||||||
unregisterSkills?.();
|
unregisterSkills?.();
|
||||||
|
|
@ -1766,15 +1865,27 @@ function applyGroupToolsPlugin(ctx, options) {
|
||||||
skillsCtx.logger.warn("%s: skill register failed: %s", options.name, error);
|
skillsCtx.logger.warn("%s: skill register failed: %s", options.name, error);
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
const remountKbSkill = () => {
|
||||||
|
unregisterKbSkill?.();
|
||||||
|
unregisterKbSkill = undefined;
|
||||||
|
unregisterKbSkill = registerKbContextSkill(skillsCtx, getKbContext());
|
||||||
|
};
|
||||||
remountSkills();
|
remountSkills();
|
||||||
|
remountKbSkill();
|
||||||
const stopSkillWatch = watchNetxConnection(() => {
|
const stopSkillWatch = watchNetxConnection(() => {
|
||||||
remountSkills();
|
remountSkills();
|
||||||
});
|
});
|
||||||
|
const stopKbWatch = watchKbContext(() => {
|
||||||
|
remountKbSkill();
|
||||||
|
});
|
||||||
skillsCtx.effect(() => () => {
|
skillsCtx.effect(() => () => {
|
||||||
skillGeneration += 1;
|
skillGeneration += 1;
|
||||||
stopSkillWatch();
|
stopSkillWatch();
|
||||||
|
stopKbWatch();
|
||||||
unregisterSkills?.();
|
unregisterSkills?.();
|
||||||
unregisterSkills = undefined;
|
unregisterSkills = undefined;
|
||||||
|
unregisterKbSkill?.();
|
||||||
|
unregisterKbSkill = undefined;
|
||||||
}, `${options.name}: dispose skills`);
|
}, `${options.name}: dispose skills`);
|
||||||
});
|
});
|
||||||
ctx.effect(() => () => {
|
ctx.effect(() => () => {
|
||||||
|
|
|
||||||
|
|
@ -215,22 +215,120 @@ async function registerGroupSkills(ctx, groupIds, providerLabel) {
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// src/netx/runtime.ts
|
// src/netx/kb-context-skill.ts
|
||||||
var STORE_KEY = Symbol.for("dsh-netxops.connection-store");
|
var SKILL_NAME = "kb-context";
|
||||||
|
function skillBody(snapshot) {
|
||||||
|
if (snapshot.status === "configured") {
|
||||||
|
const flags = Object.entries(snapshot.content).filter(([, on]) => on).map(([key]) => key).join(", ") || "(none)";
|
||||||
|
return {
|
||||||
|
description: "Operator knowledge-base context for this Host (paths + identity from MANIFEST).",
|
||||||
|
content: [
|
||||||
|
"## Knowledge base (configured)",
|
||||||
|
"",
|
||||||
|
"When troubleshooting with operator playbooks or docs, **compose paths from this root**.",
|
||||||
|
"Do not invent another operator or country.",
|
||||||
|
"",
|
||||||
|
`| Field | Value |`,
|
||||||
|
`| --- | --- |`,
|
||||||
|
`| kbStatus | configured |`,
|
||||||
|
`| kbRoot | \`${snapshot.realRoot}\` |`,
|
||||||
|
`| kbOperator | ${snapshot.operatorName} |`,
|
||||||
|
`| kbCountry | ${snapshot.country} |`,
|
||||||
|
`| kbVersion | ${snapshot.version} |`,
|
||||||
|
`| kbContent (on) | ${flags} |`,
|
||||||
|
"",
|
||||||
|
"Environment mirrors: `KB_ROOT`, `KB_OPERATOR`, `KB_COUNTRY`, `KB_VERSION`, `KB_CONTENT`, `KB_STATUS`."
|
||||||
|
].join(`
|
||||||
|
`)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const reason = snapshot.status === "error" ? snapshot.errorMessage || "invalid knowledge base" : "kbRoot is empty";
|
||||||
|
return {
|
||||||
|
description: "Knowledge-base context is unavailable — stay on pure netx evidence.",
|
||||||
|
content: [
|
||||||
|
"## Knowledge base (unavailable)",
|
||||||
|
"",
|
||||||
|
`kbStatus=\`${snapshot.status}\`. ${reason}`,
|
||||||
|
"",
|
||||||
|
"**Do not invent an operator, country, or KB paths.**",
|
||||||
|
"Use only netx tools / live evidence (alarms, inventory, CLI, topology).",
|
||||||
|
"Operator-specific playbooks are out of scope until a valid MANIFEST is configured."
|
||||||
|
].join(`
|
||||||
|
`)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
function registerKbContextSkill(ctx, snapshot) {
|
||||||
|
const skillsApi = ctx.skills;
|
||||||
|
if (!skillsApi || typeof skillsApi.register !== "function") {
|
||||||
|
return () => {};
|
||||||
|
}
|
||||||
|
const body = skillBody(snapshot);
|
||||||
|
return skillsApi.register({
|
||||||
|
name: SKILL_NAME,
|
||||||
|
description: body.description,
|
||||||
|
content: body.content,
|
||||||
|
provider: "netxops-kb",
|
||||||
|
source: "custom"
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// src/netx/kb-manifest.ts
|
||||||
|
var EMPTY_CONTENT = {
|
||||||
|
regions: false,
|
||||||
|
theory: false,
|
||||||
|
packet: false,
|
||||||
|
skills: false
|
||||||
|
};
|
||||||
|
function unconfiguredKbSnapshot() {
|
||||||
|
return {
|
||||||
|
status: "unconfigured",
|
||||||
|
realRoot: "",
|
||||||
|
operatorName: "",
|
||||||
|
country: "",
|
||||||
|
version: "",
|
||||||
|
content: { ...EMPTY_CONTENT },
|
||||||
|
errorMessage: ""
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// src/netx/kb-runtime.ts
|
||||||
|
var STORE_KEY = Symbol.for("dsh-netxops.kb-store");
|
||||||
function store() {
|
function store() {
|
||||||
const root = globalThis;
|
const root = globalThis;
|
||||||
let current = root[STORE_KEY];
|
let current = root[STORE_KEY];
|
||||||
if (current === undefined) {
|
if (current === undefined) {
|
||||||
current = { connection: undefined, listeners: new Set };
|
current = { snapshot: unconfiguredKbSnapshot(), listeners: new Set };
|
||||||
root[STORE_KEY] = current;
|
root[STORE_KEY] = current;
|
||||||
}
|
}
|
||||||
return current;
|
return current;
|
||||||
}
|
}
|
||||||
|
function getKbContext() {
|
||||||
|
return { ...store().snapshot, content: { ...store().snapshot.content } };
|
||||||
|
}
|
||||||
|
function watchKbContext(listener) {
|
||||||
|
const state = store();
|
||||||
|
state.listeners.add(listener);
|
||||||
|
return () => {
|
||||||
|
state.listeners.delete(listener);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// src/netx/runtime.ts
|
||||||
|
var STORE_KEY2 = Symbol.for("dsh-netxops.connection-store");
|
||||||
|
function store2() {
|
||||||
|
const root = globalThis;
|
||||||
|
let current = root[STORE_KEY2];
|
||||||
|
if (current === undefined) {
|
||||||
|
current = { connection: undefined, listeners: new Set };
|
||||||
|
root[STORE_KEY2] = current;
|
||||||
|
}
|
||||||
|
return current;
|
||||||
|
}
|
||||||
function getNetxConnection() {
|
function getNetxConnection() {
|
||||||
return store().connection;
|
return store2().connection;
|
||||||
}
|
}
|
||||||
function watchNetxConnection(listener) {
|
function watchNetxConnection(listener) {
|
||||||
const state = store();
|
const state = store2();
|
||||||
state.listeners.add(listener);
|
state.listeners.add(listener);
|
||||||
return () => {
|
return () => {
|
||||||
state.listeners.delete(listener);
|
state.listeners.delete(listener);
|
||||||
|
|
@ -1749,6 +1847,7 @@ function applyGroupToolsPlugin(ctx, options) {
|
||||||
remountTools();
|
remountTools();
|
||||||
});
|
});
|
||||||
ctx.inject(["skills"], (skillsCtx) => {
|
ctx.inject(["skills"], (skillsCtx) => {
|
||||||
|
let unregisterKbSkill;
|
||||||
const remountSkills = () => {
|
const remountSkills = () => {
|
||||||
const gen = ++skillGeneration;
|
const gen = ++skillGeneration;
|
||||||
unregisterSkills?.();
|
unregisterSkills?.();
|
||||||
|
|
@ -1766,15 +1865,27 @@ function applyGroupToolsPlugin(ctx, options) {
|
||||||
skillsCtx.logger.warn("%s: skill register failed: %s", options.name, error);
|
skillsCtx.logger.warn("%s: skill register failed: %s", options.name, error);
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
const remountKbSkill = () => {
|
||||||
|
unregisterKbSkill?.();
|
||||||
|
unregisterKbSkill = undefined;
|
||||||
|
unregisterKbSkill = registerKbContextSkill(skillsCtx, getKbContext());
|
||||||
|
};
|
||||||
remountSkills();
|
remountSkills();
|
||||||
|
remountKbSkill();
|
||||||
const stopSkillWatch = watchNetxConnection(() => {
|
const stopSkillWatch = watchNetxConnection(() => {
|
||||||
remountSkills();
|
remountSkills();
|
||||||
});
|
});
|
||||||
|
const stopKbWatch = watchKbContext(() => {
|
||||||
|
remountKbSkill();
|
||||||
|
});
|
||||||
skillsCtx.effect(() => () => {
|
skillsCtx.effect(() => () => {
|
||||||
skillGeneration += 1;
|
skillGeneration += 1;
|
||||||
stopSkillWatch();
|
stopSkillWatch();
|
||||||
|
stopKbWatch();
|
||||||
unregisterSkills?.();
|
unregisterSkills?.();
|
||||||
unregisterSkills = undefined;
|
unregisterSkills = undefined;
|
||||||
|
unregisterKbSkill?.();
|
||||||
|
unregisterKbSkill = undefined;
|
||||||
}, `${options.name}: dispose skills`);
|
}, `${options.name}: dispose skills`);
|
||||||
});
|
});
|
||||||
ctx.effect(() => () => {
|
ctx.effect(() => () => {
|
||||||
|
|
|
||||||
|
|
@ -215,22 +215,120 @@ async function registerGroupSkills(ctx, groupIds, providerLabel) {
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// src/netx/runtime.ts
|
// src/netx/kb-context-skill.ts
|
||||||
var STORE_KEY = Symbol.for("dsh-netxops.connection-store");
|
var SKILL_NAME = "kb-context";
|
||||||
|
function skillBody(snapshot) {
|
||||||
|
if (snapshot.status === "configured") {
|
||||||
|
const flags = Object.entries(snapshot.content).filter(([, on]) => on).map(([key]) => key).join(", ") || "(none)";
|
||||||
|
return {
|
||||||
|
description: "Operator knowledge-base context for this Host (paths + identity from MANIFEST).",
|
||||||
|
content: [
|
||||||
|
"## Knowledge base (configured)",
|
||||||
|
"",
|
||||||
|
"When troubleshooting with operator playbooks or docs, **compose paths from this root**.",
|
||||||
|
"Do not invent another operator or country.",
|
||||||
|
"",
|
||||||
|
`| Field | Value |`,
|
||||||
|
`| --- | --- |`,
|
||||||
|
`| kbStatus | configured |`,
|
||||||
|
`| kbRoot | \`${snapshot.realRoot}\` |`,
|
||||||
|
`| kbOperator | ${snapshot.operatorName} |`,
|
||||||
|
`| kbCountry | ${snapshot.country} |`,
|
||||||
|
`| kbVersion | ${snapshot.version} |`,
|
||||||
|
`| kbContent (on) | ${flags} |`,
|
||||||
|
"",
|
||||||
|
"Environment mirrors: `KB_ROOT`, `KB_OPERATOR`, `KB_COUNTRY`, `KB_VERSION`, `KB_CONTENT`, `KB_STATUS`."
|
||||||
|
].join(`
|
||||||
|
`)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const reason = snapshot.status === "error" ? snapshot.errorMessage || "invalid knowledge base" : "kbRoot is empty";
|
||||||
|
return {
|
||||||
|
description: "Knowledge-base context is unavailable — stay on pure netx evidence.",
|
||||||
|
content: [
|
||||||
|
"## Knowledge base (unavailable)",
|
||||||
|
"",
|
||||||
|
`kbStatus=\`${snapshot.status}\`. ${reason}`,
|
||||||
|
"",
|
||||||
|
"**Do not invent an operator, country, or KB paths.**",
|
||||||
|
"Use only netx tools / live evidence (alarms, inventory, CLI, topology).",
|
||||||
|
"Operator-specific playbooks are out of scope until a valid MANIFEST is configured."
|
||||||
|
].join(`
|
||||||
|
`)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
function registerKbContextSkill(ctx, snapshot) {
|
||||||
|
const skillsApi = ctx.skills;
|
||||||
|
if (!skillsApi || typeof skillsApi.register !== "function") {
|
||||||
|
return () => {};
|
||||||
|
}
|
||||||
|
const body = skillBody(snapshot);
|
||||||
|
return skillsApi.register({
|
||||||
|
name: SKILL_NAME,
|
||||||
|
description: body.description,
|
||||||
|
content: body.content,
|
||||||
|
provider: "netxops-kb",
|
||||||
|
source: "custom"
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// src/netx/kb-manifest.ts
|
||||||
|
var EMPTY_CONTENT = {
|
||||||
|
regions: false,
|
||||||
|
theory: false,
|
||||||
|
packet: false,
|
||||||
|
skills: false
|
||||||
|
};
|
||||||
|
function unconfiguredKbSnapshot() {
|
||||||
|
return {
|
||||||
|
status: "unconfigured",
|
||||||
|
realRoot: "",
|
||||||
|
operatorName: "",
|
||||||
|
country: "",
|
||||||
|
version: "",
|
||||||
|
content: { ...EMPTY_CONTENT },
|
||||||
|
errorMessage: ""
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// src/netx/kb-runtime.ts
|
||||||
|
var STORE_KEY = Symbol.for("dsh-netxops.kb-store");
|
||||||
function store() {
|
function store() {
|
||||||
const root = globalThis;
|
const root = globalThis;
|
||||||
let current = root[STORE_KEY];
|
let current = root[STORE_KEY];
|
||||||
if (current === undefined) {
|
if (current === undefined) {
|
||||||
current = { connection: undefined, listeners: new Set };
|
current = { snapshot: unconfiguredKbSnapshot(), listeners: new Set };
|
||||||
root[STORE_KEY] = current;
|
root[STORE_KEY] = current;
|
||||||
}
|
}
|
||||||
return current;
|
return current;
|
||||||
}
|
}
|
||||||
|
function getKbContext() {
|
||||||
|
return { ...store().snapshot, content: { ...store().snapshot.content } };
|
||||||
|
}
|
||||||
|
function watchKbContext(listener) {
|
||||||
|
const state = store();
|
||||||
|
state.listeners.add(listener);
|
||||||
|
return () => {
|
||||||
|
state.listeners.delete(listener);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// src/netx/runtime.ts
|
||||||
|
var STORE_KEY2 = Symbol.for("dsh-netxops.connection-store");
|
||||||
|
function store2() {
|
||||||
|
const root = globalThis;
|
||||||
|
let current = root[STORE_KEY2];
|
||||||
|
if (current === undefined) {
|
||||||
|
current = { connection: undefined, listeners: new Set };
|
||||||
|
root[STORE_KEY2] = current;
|
||||||
|
}
|
||||||
|
return current;
|
||||||
|
}
|
||||||
function getNetxConnection() {
|
function getNetxConnection() {
|
||||||
return store().connection;
|
return store2().connection;
|
||||||
}
|
}
|
||||||
function watchNetxConnection(listener) {
|
function watchNetxConnection(listener) {
|
||||||
const state = store();
|
const state = store2();
|
||||||
state.listeners.add(listener);
|
state.listeners.add(listener);
|
||||||
return () => {
|
return () => {
|
||||||
state.listeners.delete(listener);
|
state.listeners.delete(listener);
|
||||||
|
|
@ -1749,6 +1847,7 @@ function applyGroupToolsPlugin(ctx, options) {
|
||||||
remountTools();
|
remountTools();
|
||||||
});
|
});
|
||||||
ctx.inject(["skills"], (skillsCtx) => {
|
ctx.inject(["skills"], (skillsCtx) => {
|
||||||
|
let unregisterKbSkill;
|
||||||
const remountSkills = () => {
|
const remountSkills = () => {
|
||||||
const gen = ++skillGeneration;
|
const gen = ++skillGeneration;
|
||||||
unregisterSkills?.();
|
unregisterSkills?.();
|
||||||
|
|
@ -1766,15 +1865,27 @@ function applyGroupToolsPlugin(ctx, options) {
|
||||||
skillsCtx.logger.warn("%s: skill register failed: %s", options.name, error);
|
skillsCtx.logger.warn("%s: skill register failed: %s", options.name, error);
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
const remountKbSkill = () => {
|
||||||
|
unregisterKbSkill?.();
|
||||||
|
unregisterKbSkill = undefined;
|
||||||
|
unregisterKbSkill = registerKbContextSkill(skillsCtx, getKbContext());
|
||||||
|
};
|
||||||
remountSkills();
|
remountSkills();
|
||||||
|
remountKbSkill();
|
||||||
const stopSkillWatch = watchNetxConnection(() => {
|
const stopSkillWatch = watchNetxConnection(() => {
|
||||||
remountSkills();
|
remountSkills();
|
||||||
});
|
});
|
||||||
|
const stopKbWatch = watchKbContext(() => {
|
||||||
|
remountKbSkill();
|
||||||
|
});
|
||||||
skillsCtx.effect(() => () => {
|
skillsCtx.effect(() => () => {
|
||||||
skillGeneration += 1;
|
skillGeneration += 1;
|
||||||
stopSkillWatch();
|
stopSkillWatch();
|
||||||
|
stopKbWatch();
|
||||||
unregisterSkills?.();
|
unregisterSkills?.();
|
||||||
unregisterSkills = undefined;
|
unregisterSkills = undefined;
|
||||||
|
unregisterKbSkill?.();
|
||||||
|
unregisterKbSkill = undefined;
|
||||||
}, `${options.name}: dispose skills`);
|
}, `${options.name}: dispose skills`);
|
||||||
});
|
});
|
||||||
ctx.effect(() => () => {
|
ctx.effect(() => () => {
|
||||||
|
|
|
||||||
|
|
@ -215,22 +215,120 @@ async function registerGroupSkills(ctx, groupIds, providerLabel) {
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// src/netx/runtime.ts
|
// src/netx/kb-context-skill.ts
|
||||||
var STORE_KEY = Symbol.for("dsh-netxops.connection-store");
|
var SKILL_NAME = "kb-context";
|
||||||
|
function skillBody(snapshot) {
|
||||||
|
if (snapshot.status === "configured") {
|
||||||
|
const flags = Object.entries(snapshot.content).filter(([, on]) => on).map(([key]) => key).join(", ") || "(none)";
|
||||||
|
return {
|
||||||
|
description: "Operator knowledge-base context for this Host (paths + identity from MANIFEST).",
|
||||||
|
content: [
|
||||||
|
"## Knowledge base (configured)",
|
||||||
|
"",
|
||||||
|
"When troubleshooting with operator playbooks or docs, **compose paths from this root**.",
|
||||||
|
"Do not invent another operator or country.",
|
||||||
|
"",
|
||||||
|
`| Field | Value |`,
|
||||||
|
`| --- | --- |`,
|
||||||
|
`| kbStatus | configured |`,
|
||||||
|
`| kbRoot | \`${snapshot.realRoot}\` |`,
|
||||||
|
`| kbOperator | ${snapshot.operatorName} |`,
|
||||||
|
`| kbCountry | ${snapshot.country} |`,
|
||||||
|
`| kbVersion | ${snapshot.version} |`,
|
||||||
|
`| kbContent (on) | ${flags} |`,
|
||||||
|
"",
|
||||||
|
"Environment mirrors: `KB_ROOT`, `KB_OPERATOR`, `KB_COUNTRY`, `KB_VERSION`, `KB_CONTENT`, `KB_STATUS`."
|
||||||
|
].join(`
|
||||||
|
`)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const reason = snapshot.status === "error" ? snapshot.errorMessage || "invalid knowledge base" : "kbRoot is empty";
|
||||||
|
return {
|
||||||
|
description: "Knowledge-base context is unavailable — stay on pure netx evidence.",
|
||||||
|
content: [
|
||||||
|
"## Knowledge base (unavailable)",
|
||||||
|
"",
|
||||||
|
`kbStatus=\`${snapshot.status}\`. ${reason}`,
|
||||||
|
"",
|
||||||
|
"**Do not invent an operator, country, or KB paths.**",
|
||||||
|
"Use only netx tools / live evidence (alarms, inventory, CLI, topology).",
|
||||||
|
"Operator-specific playbooks are out of scope until a valid MANIFEST is configured."
|
||||||
|
].join(`
|
||||||
|
`)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
function registerKbContextSkill(ctx, snapshot) {
|
||||||
|
const skillsApi = ctx.skills;
|
||||||
|
if (!skillsApi || typeof skillsApi.register !== "function") {
|
||||||
|
return () => {};
|
||||||
|
}
|
||||||
|
const body = skillBody(snapshot);
|
||||||
|
return skillsApi.register({
|
||||||
|
name: SKILL_NAME,
|
||||||
|
description: body.description,
|
||||||
|
content: body.content,
|
||||||
|
provider: "netxops-kb",
|
||||||
|
source: "custom"
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// src/netx/kb-manifest.ts
|
||||||
|
var EMPTY_CONTENT = {
|
||||||
|
regions: false,
|
||||||
|
theory: false,
|
||||||
|
packet: false,
|
||||||
|
skills: false
|
||||||
|
};
|
||||||
|
function unconfiguredKbSnapshot() {
|
||||||
|
return {
|
||||||
|
status: "unconfigured",
|
||||||
|
realRoot: "",
|
||||||
|
operatorName: "",
|
||||||
|
country: "",
|
||||||
|
version: "",
|
||||||
|
content: { ...EMPTY_CONTENT },
|
||||||
|
errorMessage: ""
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// src/netx/kb-runtime.ts
|
||||||
|
var STORE_KEY = Symbol.for("dsh-netxops.kb-store");
|
||||||
function store() {
|
function store() {
|
||||||
const root = globalThis;
|
const root = globalThis;
|
||||||
let current = root[STORE_KEY];
|
let current = root[STORE_KEY];
|
||||||
if (current === undefined) {
|
if (current === undefined) {
|
||||||
current = { connection: undefined, listeners: new Set };
|
current = { snapshot: unconfiguredKbSnapshot(), listeners: new Set };
|
||||||
root[STORE_KEY] = current;
|
root[STORE_KEY] = current;
|
||||||
}
|
}
|
||||||
return current;
|
return current;
|
||||||
}
|
}
|
||||||
|
function getKbContext() {
|
||||||
|
return { ...store().snapshot, content: { ...store().snapshot.content } };
|
||||||
|
}
|
||||||
|
function watchKbContext(listener) {
|
||||||
|
const state = store();
|
||||||
|
state.listeners.add(listener);
|
||||||
|
return () => {
|
||||||
|
state.listeners.delete(listener);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// src/netx/runtime.ts
|
||||||
|
var STORE_KEY2 = Symbol.for("dsh-netxops.connection-store");
|
||||||
|
function store2() {
|
||||||
|
const root = globalThis;
|
||||||
|
let current = root[STORE_KEY2];
|
||||||
|
if (current === undefined) {
|
||||||
|
current = { connection: undefined, listeners: new Set };
|
||||||
|
root[STORE_KEY2] = current;
|
||||||
|
}
|
||||||
|
return current;
|
||||||
|
}
|
||||||
function getNetxConnection() {
|
function getNetxConnection() {
|
||||||
return store().connection;
|
return store2().connection;
|
||||||
}
|
}
|
||||||
function watchNetxConnection(listener) {
|
function watchNetxConnection(listener) {
|
||||||
const state = store();
|
const state = store2();
|
||||||
state.listeners.add(listener);
|
state.listeners.add(listener);
|
||||||
return () => {
|
return () => {
|
||||||
state.listeners.delete(listener);
|
state.listeners.delete(listener);
|
||||||
|
|
@ -1749,6 +1847,7 @@ function applyGroupToolsPlugin(ctx, options) {
|
||||||
remountTools();
|
remountTools();
|
||||||
});
|
});
|
||||||
ctx.inject(["skills"], (skillsCtx) => {
|
ctx.inject(["skills"], (skillsCtx) => {
|
||||||
|
let unregisterKbSkill;
|
||||||
const remountSkills = () => {
|
const remountSkills = () => {
|
||||||
const gen = ++skillGeneration;
|
const gen = ++skillGeneration;
|
||||||
unregisterSkills?.();
|
unregisterSkills?.();
|
||||||
|
|
@ -1766,15 +1865,27 @@ function applyGroupToolsPlugin(ctx, options) {
|
||||||
skillsCtx.logger.warn("%s: skill register failed: %s", options.name, error);
|
skillsCtx.logger.warn("%s: skill register failed: %s", options.name, error);
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
const remountKbSkill = () => {
|
||||||
|
unregisterKbSkill?.();
|
||||||
|
unregisterKbSkill = undefined;
|
||||||
|
unregisterKbSkill = registerKbContextSkill(skillsCtx, getKbContext());
|
||||||
|
};
|
||||||
remountSkills();
|
remountSkills();
|
||||||
|
remountKbSkill();
|
||||||
const stopSkillWatch = watchNetxConnection(() => {
|
const stopSkillWatch = watchNetxConnection(() => {
|
||||||
remountSkills();
|
remountSkills();
|
||||||
});
|
});
|
||||||
|
const stopKbWatch = watchKbContext(() => {
|
||||||
|
remountKbSkill();
|
||||||
|
});
|
||||||
skillsCtx.effect(() => () => {
|
skillsCtx.effect(() => () => {
|
||||||
skillGeneration += 1;
|
skillGeneration += 1;
|
||||||
stopSkillWatch();
|
stopSkillWatch();
|
||||||
|
stopKbWatch();
|
||||||
unregisterSkills?.();
|
unregisterSkills?.();
|
||||||
unregisterSkills = undefined;
|
unregisterSkills = undefined;
|
||||||
|
unregisterKbSkill?.();
|
||||||
|
unregisterKbSkill = undefined;
|
||||||
}, `${options.name}: dispose skills`);
|
}, `${options.name}: dispose skills`);
|
||||||
});
|
});
|
||||||
ctx.effect(() => () => {
|
ctx.effect(() => () => {
|
||||||
|
|
|
||||||
|
|
@ -215,22 +215,120 @@ async function registerGroupSkills(ctx, groupIds, providerLabel) {
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// src/netx/runtime.ts
|
// src/netx/kb-context-skill.ts
|
||||||
var STORE_KEY = Symbol.for("dsh-netxops.connection-store");
|
var SKILL_NAME = "kb-context";
|
||||||
|
function skillBody(snapshot) {
|
||||||
|
if (snapshot.status === "configured") {
|
||||||
|
const flags = Object.entries(snapshot.content).filter(([, on]) => on).map(([key]) => key).join(", ") || "(none)";
|
||||||
|
return {
|
||||||
|
description: "Operator knowledge-base context for this Host (paths + identity from MANIFEST).",
|
||||||
|
content: [
|
||||||
|
"## Knowledge base (configured)",
|
||||||
|
"",
|
||||||
|
"When troubleshooting with operator playbooks or docs, **compose paths from this root**.",
|
||||||
|
"Do not invent another operator or country.",
|
||||||
|
"",
|
||||||
|
`| Field | Value |`,
|
||||||
|
`| --- | --- |`,
|
||||||
|
`| kbStatus | configured |`,
|
||||||
|
`| kbRoot | \`${snapshot.realRoot}\` |`,
|
||||||
|
`| kbOperator | ${snapshot.operatorName} |`,
|
||||||
|
`| kbCountry | ${snapshot.country} |`,
|
||||||
|
`| kbVersion | ${snapshot.version} |`,
|
||||||
|
`| kbContent (on) | ${flags} |`,
|
||||||
|
"",
|
||||||
|
"Environment mirrors: `KB_ROOT`, `KB_OPERATOR`, `KB_COUNTRY`, `KB_VERSION`, `KB_CONTENT`, `KB_STATUS`."
|
||||||
|
].join(`
|
||||||
|
`)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const reason = snapshot.status === "error" ? snapshot.errorMessage || "invalid knowledge base" : "kbRoot is empty";
|
||||||
|
return {
|
||||||
|
description: "Knowledge-base context is unavailable — stay on pure netx evidence.",
|
||||||
|
content: [
|
||||||
|
"## Knowledge base (unavailable)",
|
||||||
|
"",
|
||||||
|
`kbStatus=\`${snapshot.status}\`. ${reason}`,
|
||||||
|
"",
|
||||||
|
"**Do not invent an operator, country, or KB paths.**",
|
||||||
|
"Use only netx tools / live evidence (alarms, inventory, CLI, topology).",
|
||||||
|
"Operator-specific playbooks are out of scope until a valid MANIFEST is configured."
|
||||||
|
].join(`
|
||||||
|
`)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
function registerKbContextSkill(ctx, snapshot) {
|
||||||
|
const skillsApi = ctx.skills;
|
||||||
|
if (!skillsApi || typeof skillsApi.register !== "function") {
|
||||||
|
return () => {};
|
||||||
|
}
|
||||||
|
const body = skillBody(snapshot);
|
||||||
|
return skillsApi.register({
|
||||||
|
name: SKILL_NAME,
|
||||||
|
description: body.description,
|
||||||
|
content: body.content,
|
||||||
|
provider: "netxops-kb",
|
||||||
|
source: "custom"
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// src/netx/kb-manifest.ts
|
||||||
|
var EMPTY_CONTENT = {
|
||||||
|
regions: false,
|
||||||
|
theory: false,
|
||||||
|
packet: false,
|
||||||
|
skills: false
|
||||||
|
};
|
||||||
|
function unconfiguredKbSnapshot() {
|
||||||
|
return {
|
||||||
|
status: "unconfigured",
|
||||||
|
realRoot: "",
|
||||||
|
operatorName: "",
|
||||||
|
country: "",
|
||||||
|
version: "",
|
||||||
|
content: { ...EMPTY_CONTENT },
|
||||||
|
errorMessage: ""
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// src/netx/kb-runtime.ts
|
||||||
|
var STORE_KEY = Symbol.for("dsh-netxops.kb-store");
|
||||||
function store() {
|
function store() {
|
||||||
const root = globalThis;
|
const root = globalThis;
|
||||||
let current = root[STORE_KEY];
|
let current = root[STORE_KEY];
|
||||||
if (current === undefined) {
|
if (current === undefined) {
|
||||||
current = { connection: undefined, listeners: new Set };
|
current = { snapshot: unconfiguredKbSnapshot(), listeners: new Set };
|
||||||
root[STORE_KEY] = current;
|
root[STORE_KEY] = current;
|
||||||
}
|
}
|
||||||
return current;
|
return current;
|
||||||
}
|
}
|
||||||
|
function getKbContext() {
|
||||||
|
return { ...store().snapshot, content: { ...store().snapshot.content } };
|
||||||
|
}
|
||||||
|
function watchKbContext(listener) {
|
||||||
|
const state = store();
|
||||||
|
state.listeners.add(listener);
|
||||||
|
return () => {
|
||||||
|
state.listeners.delete(listener);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// src/netx/runtime.ts
|
||||||
|
var STORE_KEY2 = Symbol.for("dsh-netxops.connection-store");
|
||||||
|
function store2() {
|
||||||
|
const root = globalThis;
|
||||||
|
let current = root[STORE_KEY2];
|
||||||
|
if (current === undefined) {
|
||||||
|
current = { connection: undefined, listeners: new Set };
|
||||||
|
root[STORE_KEY2] = current;
|
||||||
|
}
|
||||||
|
return current;
|
||||||
|
}
|
||||||
function getNetxConnection() {
|
function getNetxConnection() {
|
||||||
return store().connection;
|
return store2().connection;
|
||||||
}
|
}
|
||||||
function watchNetxConnection(listener) {
|
function watchNetxConnection(listener) {
|
||||||
const state = store();
|
const state = store2();
|
||||||
state.listeners.add(listener);
|
state.listeners.add(listener);
|
||||||
return () => {
|
return () => {
|
||||||
state.listeners.delete(listener);
|
state.listeners.delete(listener);
|
||||||
|
|
@ -1749,6 +1847,7 @@ function applyGroupToolsPlugin(ctx, options) {
|
||||||
remountTools();
|
remountTools();
|
||||||
});
|
});
|
||||||
ctx.inject(["skills"], (skillsCtx) => {
|
ctx.inject(["skills"], (skillsCtx) => {
|
||||||
|
let unregisterKbSkill;
|
||||||
const remountSkills = () => {
|
const remountSkills = () => {
|
||||||
const gen = ++skillGeneration;
|
const gen = ++skillGeneration;
|
||||||
unregisterSkills?.();
|
unregisterSkills?.();
|
||||||
|
|
@ -1766,15 +1865,27 @@ function applyGroupToolsPlugin(ctx, options) {
|
||||||
skillsCtx.logger.warn("%s: skill register failed: %s", options.name, error);
|
skillsCtx.logger.warn("%s: skill register failed: %s", options.name, error);
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
const remountKbSkill = () => {
|
||||||
|
unregisterKbSkill?.();
|
||||||
|
unregisterKbSkill = undefined;
|
||||||
|
unregisterKbSkill = registerKbContextSkill(skillsCtx, getKbContext());
|
||||||
|
};
|
||||||
remountSkills();
|
remountSkills();
|
||||||
|
remountKbSkill();
|
||||||
const stopSkillWatch = watchNetxConnection(() => {
|
const stopSkillWatch = watchNetxConnection(() => {
|
||||||
remountSkills();
|
remountSkills();
|
||||||
});
|
});
|
||||||
|
const stopKbWatch = watchKbContext(() => {
|
||||||
|
remountKbSkill();
|
||||||
|
});
|
||||||
skillsCtx.effect(() => () => {
|
skillsCtx.effect(() => () => {
|
||||||
skillGeneration += 1;
|
skillGeneration += 1;
|
||||||
stopSkillWatch();
|
stopSkillWatch();
|
||||||
|
stopKbWatch();
|
||||||
unregisterSkills?.();
|
unregisterSkills?.();
|
||||||
unregisterSkills = undefined;
|
unregisterSkills = undefined;
|
||||||
|
unregisterKbSkill?.();
|
||||||
|
unregisterKbSkill = undefined;
|
||||||
}, `${options.name}: dispose skills`);
|
}, `${options.name}: dispose skills`);
|
||||||
});
|
});
|
||||||
ctx.effect(() => () => {
|
ctx.effect(() => () => {
|
||||||
|
|
|
||||||
627
lib/client.js
627
lib/client.js
|
|
@ -244,6 +244,399 @@ function setImTargetSelected(current, target, selected) {
|
||||||
return normalized ? [...without, normalized] : without;
|
return normalized ? [...without, normalized] : without;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// src/netx/kb-manifest.ts
|
||||||
|
var import_node_fs = (() => ({}));
|
||||||
|
|
||||||
|
// node:path
|
||||||
|
function assertPath(path) {
|
||||||
|
if (typeof path !== "string")
|
||||||
|
throw TypeError("Path must be a string. Received " + JSON.stringify(path));
|
||||||
|
}
|
||||||
|
function normalizeStringPosix(path, allowAboveRoot) {
|
||||||
|
var res = "", lastSegmentLength = 0, lastSlash = -1, dots = 0, code;
|
||||||
|
for (var i = 0;i <= path.length; ++i) {
|
||||||
|
if (i < path.length)
|
||||||
|
code = path.charCodeAt(i);
|
||||||
|
else if (code === 47)
|
||||||
|
break;
|
||||||
|
else
|
||||||
|
code = 47;
|
||||||
|
if (code === 47) {
|
||||||
|
if (lastSlash === i - 1 || dots === 1)
|
||||||
|
;
|
||||||
|
else if (lastSlash !== i - 1 && dots === 2) {
|
||||||
|
if (res.length < 2 || lastSegmentLength !== 2 || res.charCodeAt(res.length - 1) !== 46 || res.charCodeAt(res.length - 2) !== 46) {
|
||||||
|
if (res.length > 2) {
|
||||||
|
var lastSlashIndex = res.lastIndexOf("/");
|
||||||
|
if (lastSlashIndex !== res.length - 1) {
|
||||||
|
if (lastSlashIndex === -1)
|
||||||
|
res = "", lastSegmentLength = 0;
|
||||||
|
else
|
||||||
|
res = res.slice(0, lastSlashIndex), lastSegmentLength = res.length - 1 - res.lastIndexOf("/");
|
||||||
|
lastSlash = i, dots = 0;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
} else if (res.length === 2 || res.length === 1) {
|
||||||
|
res = "", lastSegmentLength = 0, lastSlash = i, dots = 0;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (allowAboveRoot) {
|
||||||
|
if (res.length > 0)
|
||||||
|
res += "/..";
|
||||||
|
else
|
||||||
|
res = "..";
|
||||||
|
lastSegmentLength = 2;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if (res.length > 0)
|
||||||
|
res += "/" + path.slice(lastSlash + 1, i);
|
||||||
|
else
|
||||||
|
res = path.slice(lastSlash + 1, i);
|
||||||
|
lastSegmentLength = i - lastSlash - 1;
|
||||||
|
}
|
||||||
|
lastSlash = i, dots = 0;
|
||||||
|
} else if (code === 46 && dots !== -1)
|
||||||
|
++dots;
|
||||||
|
else
|
||||||
|
dots = -1;
|
||||||
|
}
|
||||||
|
return res;
|
||||||
|
}
|
||||||
|
function _format(sep, pathObject) {
|
||||||
|
var dir = pathObject.dir || pathObject.root, base = pathObject.base || (pathObject.name || "") + (pathObject.ext || "");
|
||||||
|
if (!dir)
|
||||||
|
return base;
|
||||||
|
if (dir === pathObject.root)
|
||||||
|
return dir + base;
|
||||||
|
return dir + sep + base;
|
||||||
|
}
|
||||||
|
function resolve() {
|
||||||
|
var resolvedPath = "", resolvedAbsolute = false, cwd;
|
||||||
|
for (var i = arguments.length - 1;i >= -1 && !resolvedAbsolute; i--) {
|
||||||
|
var path;
|
||||||
|
if (i >= 0)
|
||||||
|
path = arguments[i];
|
||||||
|
else {
|
||||||
|
if (cwd === undefined)
|
||||||
|
cwd = process.cwd();
|
||||||
|
path = cwd;
|
||||||
|
}
|
||||||
|
if (assertPath(path), path.length === 0)
|
||||||
|
continue;
|
||||||
|
resolvedPath = path + "/" + resolvedPath, resolvedAbsolute = path.charCodeAt(0) === 47;
|
||||||
|
}
|
||||||
|
if (resolvedPath = normalizeStringPosix(resolvedPath, !resolvedAbsolute), resolvedAbsolute)
|
||||||
|
if (resolvedPath.length > 0)
|
||||||
|
return "/" + resolvedPath;
|
||||||
|
else
|
||||||
|
return "/";
|
||||||
|
else if (resolvedPath.length > 0)
|
||||||
|
return resolvedPath;
|
||||||
|
else
|
||||||
|
return ".";
|
||||||
|
}
|
||||||
|
function normalize(path) {
|
||||||
|
if (assertPath(path), path.length === 0)
|
||||||
|
return ".";
|
||||||
|
var isAbsolute = path.charCodeAt(0) === 47, trailingSeparator = path.charCodeAt(path.length - 1) === 47;
|
||||||
|
if (path = normalizeStringPosix(path, !isAbsolute), path.length === 0 && !isAbsolute)
|
||||||
|
path = ".";
|
||||||
|
if (path.length > 0 && trailingSeparator)
|
||||||
|
path += "/";
|
||||||
|
if (isAbsolute)
|
||||||
|
return "/" + path;
|
||||||
|
return path;
|
||||||
|
}
|
||||||
|
function isAbsolute(path) {
|
||||||
|
return assertPath(path), path.length > 0 && path.charCodeAt(0) === 47;
|
||||||
|
}
|
||||||
|
function join() {
|
||||||
|
if (arguments.length === 0)
|
||||||
|
return ".";
|
||||||
|
var joined;
|
||||||
|
for (var i = 0;i < arguments.length; ++i) {
|
||||||
|
var arg = arguments[i];
|
||||||
|
if (assertPath(arg), arg.length > 0)
|
||||||
|
if (joined === undefined)
|
||||||
|
joined = arg;
|
||||||
|
else
|
||||||
|
joined += "/" + arg;
|
||||||
|
}
|
||||||
|
if (joined === undefined)
|
||||||
|
return ".";
|
||||||
|
return normalize(joined);
|
||||||
|
}
|
||||||
|
function relative(from, to) {
|
||||||
|
if (assertPath(from), assertPath(to), from === to)
|
||||||
|
return "";
|
||||||
|
if (from = resolve(from), to = resolve(to), from === to)
|
||||||
|
return "";
|
||||||
|
var fromStart = 1;
|
||||||
|
for (;fromStart < from.length; ++fromStart)
|
||||||
|
if (from.charCodeAt(fromStart) !== 47)
|
||||||
|
break;
|
||||||
|
var fromEnd = from.length, fromLen = fromEnd - fromStart, toStart = 1;
|
||||||
|
for (;toStart < to.length; ++toStart)
|
||||||
|
if (to.charCodeAt(toStart) !== 47)
|
||||||
|
break;
|
||||||
|
var toEnd = to.length, toLen = toEnd - toStart, length = fromLen < toLen ? fromLen : toLen, lastCommonSep = -1, i = 0;
|
||||||
|
for (;i <= length; ++i) {
|
||||||
|
if (i === length) {
|
||||||
|
if (toLen > length) {
|
||||||
|
if (to.charCodeAt(toStart + i) === 47)
|
||||||
|
return to.slice(toStart + i + 1);
|
||||||
|
else if (i === 0)
|
||||||
|
return to.slice(toStart + i);
|
||||||
|
} else if (fromLen > length) {
|
||||||
|
if (from.charCodeAt(fromStart + i) === 47)
|
||||||
|
lastCommonSep = i;
|
||||||
|
else if (i === 0)
|
||||||
|
lastCommonSep = 0;
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
var fromCode = from.charCodeAt(fromStart + i), toCode = to.charCodeAt(toStart + i);
|
||||||
|
if (fromCode !== toCode)
|
||||||
|
break;
|
||||||
|
else if (fromCode === 47)
|
||||||
|
lastCommonSep = i;
|
||||||
|
}
|
||||||
|
var out = "";
|
||||||
|
for (i = fromStart + lastCommonSep + 1;i <= fromEnd; ++i)
|
||||||
|
if (i === fromEnd || from.charCodeAt(i) === 47)
|
||||||
|
if (out.length === 0)
|
||||||
|
out += "..";
|
||||||
|
else
|
||||||
|
out += "/..";
|
||||||
|
if (out.length > 0)
|
||||||
|
return out + to.slice(toStart + lastCommonSep);
|
||||||
|
else {
|
||||||
|
if (toStart += lastCommonSep, to.charCodeAt(toStart) === 47)
|
||||||
|
++toStart;
|
||||||
|
return to.slice(toStart);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function _makeLong(path) {
|
||||||
|
return path;
|
||||||
|
}
|
||||||
|
function dirname(path) {
|
||||||
|
if (assertPath(path), path.length === 0)
|
||||||
|
return ".";
|
||||||
|
var code = path.charCodeAt(0), hasRoot = code === 47, end = -1, matchedSlash = true;
|
||||||
|
for (var i = path.length - 1;i >= 1; --i)
|
||||||
|
if (code = path.charCodeAt(i), code === 47) {
|
||||||
|
if (!matchedSlash) {
|
||||||
|
end = i;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
} else
|
||||||
|
matchedSlash = false;
|
||||||
|
if (end === -1)
|
||||||
|
return hasRoot ? "/" : ".";
|
||||||
|
if (hasRoot && end === 1)
|
||||||
|
return "//";
|
||||||
|
return path.slice(0, end);
|
||||||
|
}
|
||||||
|
function basename(path, ext) {
|
||||||
|
if (ext !== undefined && typeof ext !== "string")
|
||||||
|
throw TypeError('"ext" argument must be a string');
|
||||||
|
assertPath(path);
|
||||||
|
var start = 0, end = -1, matchedSlash = true, i;
|
||||||
|
if (ext !== undefined && ext.length > 0 && ext.length <= path.length) {
|
||||||
|
if (ext.length === path.length && ext === path)
|
||||||
|
return "";
|
||||||
|
var extIdx = ext.length - 1, firstNonSlashEnd = -1;
|
||||||
|
for (i = path.length - 1;i >= 0; --i) {
|
||||||
|
var code = path.charCodeAt(i);
|
||||||
|
if (code === 47) {
|
||||||
|
if (!matchedSlash) {
|
||||||
|
start = i + 1;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if (firstNonSlashEnd === -1)
|
||||||
|
matchedSlash = false, firstNonSlashEnd = i + 1;
|
||||||
|
if (extIdx >= 0)
|
||||||
|
if (code === ext.charCodeAt(extIdx)) {
|
||||||
|
if (--extIdx === -1)
|
||||||
|
end = i;
|
||||||
|
} else
|
||||||
|
extIdx = -1, end = firstNonSlashEnd;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (start === end)
|
||||||
|
end = firstNonSlashEnd;
|
||||||
|
else if (end === -1)
|
||||||
|
end = path.length;
|
||||||
|
return path.slice(start, end);
|
||||||
|
} else {
|
||||||
|
for (i = path.length - 1;i >= 0; --i)
|
||||||
|
if (path.charCodeAt(i) === 47) {
|
||||||
|
if (!matchedSlash) {
|
||||||
|
start = i + 1;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
} else if (end === -1)
|
||||||
|
matchedSlash = false, end = i + 1;
|
||||||
|
if (end === -1)
|
||||||
|
return "";
|
||||||
|
return path.slice(start, end);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function extname(path) {
|
||||||
|
assertPath(path);
|
||||||
|
var startDot = -1, startPart = 0, end = -1, matchedSlash = true, preDotState = 0;
|
||||||
|
for (var i = path.length - 1;i >= 0; --i) {
|
||||||
|
var code = path.charCodeAt(i);
|
||||||
|
if (code === 47) {
|
||||||
|
if (!matchedSlash) {
|
||||||
|
startPart = i + 1;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (end === -1)
|
||||||
|
matchedSlash = false, end = i + 1;
|
||||||
|
if (code === 46) {
|
||||||
|
if (startDot === -1)
|
||||||
|
startDot = i;
|
||||||
|
else if (preDotState !== 1)
|
||||||
|
preDotState = 1;
|
||||||
|
} else if (startDot !== -1)
|
||||||
|
preDotState = -1;
|
||||||
|
}
|
||||||
|
if (startDot === -1 || end === -1 || preDotState === 0 || preDotState === 1 && startDot === end - 1 && startDot === startPart + 1)
|
||||||
|
return "";
|
||||||
|
return path.slice(startDot, end);
|
||||||
|
}
|
||||||
|
function format(pathObject) {
|
||||||
|
if (pathObject === null || typeof pathObject !== "object")
|
||||||
|
throw TypeError('The "pathObject" argument must be of type Object. Received type ' + typeof pathObject);
|
||||||
|
return _format("/", pathObject);
|
||||||
|
}
|
||||||
|
function parse(path) {
|
||||||
|
assertPath(path);
|
||||||
|
var ret = { root: "", dir: "", base: "", ext: "", name: "" };
|
||||||
|
if (path.length === 0)
|
||||||
|
return ret;
|
||||||
|
var code = path.charCodeAt(0), isAbsolute2 = code === 47, start;
|
||||||
|
if (isAbsolute2)
|
||||||
|
ret.root = "/", start = 1;
|
||||||
|
else
|
||||||
|
start = 0;
|
||||||
|
var startDot = -1, startPart = 0, end = -1, matchedSlash = true, i = path.length - 1, preDotState = 0;
|
||||||
|
for (;i >= start; --i) {
|
||||||
|
if (code = path.charCodeAt(i), code === 47) {
|
||||||
|
if (!matchedSlash) {
|
||||||
|
startPart = i + 1;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (end === -1)
|
||||||
|
matchedSlash = false, end = i + 1;
|
||||||
|
if (code === 46) {
|
||||||
|
if (startDot === -1)
|
||||||
|
startDot = i;
|
||||||
|
else if (preDotState !== 1)
|
||||||
|
preDotState = 1;
|
||||||
|
} else if (startDot !== -1)
|
||||||
|
preDotState = -1;
|
||||||
|
}
|
||||||
|
if (startDot === -1 || end === -1 || preDotState === 0 || preDotState === 1 && startDot === end - 1 && startDot === startPart + 1) {
|
||||||
|
if (end !== -1)
|
||||||
|
if (startPart === 0 && isAbsolute2)
|
||||||
|
ret.base = ret.name = path.slice(1, end);
|
||||||
|
else
|
||||||
|
ret.base = ret.name = path.slice(startPart, end);
|
||||||
|
} else {
|
||||||
|
if (startPart === 0 && isAbsolute2)
|
||||||
|
ret.name = path.slice(1, startDot), ret.base = path.slice(1, end);
|
||||||
|
else
|
||||||
|
ret.name = path.slice(startPart, startDot), ret.base = path.slice(startPart, end);
|
||||||
|
ret.ext = path.slice(startDot, end);
|
||||||
|
}
|
||||||
|
if (startPart > 0)
|
||||||
|
ret.dir = path.slice(0, startPart - 1);
|
||||||
|
else if (isAbsolute2)
|
||||||
|
ret.dir = "/";
|
||||||
|
return ret;
|
||||||
|
}
|
||||||
|
var sep = "/";
|
||||||
|
var delimiter = ":";
|
||||||
|
var posix = ((p) => (p.posix = p, p))({ resolve, normalize, isAbsolute, join, relative, _makeLong, dirname, basename, extname, format, parse, sep, delimiter, win32: null, posix: null });
|
||||||
|
|
||||||
|
// src/netx/kb-manifest.ts
|
||||||
|
var EMPTY_CONTENT = {
|
||||||
|
regions: false,
|
||||||
|
theory: false,
|
||||||
|
packet: false,
|
||||||
|
skills: false
|
||||||
|
};
|
||||||
|
function unconfiguredKbSnapshot() {
|
||||||
|
return {
|
||||||
|
status: "unconfigured",
|
||||||
|
realRoot: "",
|
||||||
|
operatorName: "",
|
||||||
|
country: "",
|
||||||
|
version: "",
|
||||||
|
content: { ...EMPTY_CONTENT },
|
||||||
|
errorMessage: ""
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// src/client/kb-status-view.ts
|
||||||
|
var NETXOPS_RPC_CHANNEL2 = "/netxops";
|
||||||
|
var KB_STATUS_ENDPOINT = "kb.status";
|
||||||
|
var KB_RESOLVE_ENDPOINT = "kb.resolve";
|
||||||
|
function asKbSnapshot(value) {
|
||||||
|
const empty = unconfiguredKbSnapshot();
|
||||||
|
if (value === null || typeof value !== "object" || Array.isArray(value))
|
||||||
|
return empty;
|
||||||
|
const row = value;
|
||||||
|
const status = row.status === "configured" || row.status === "error" || row.status === "unconfigured" ? row.status : "unconfigured";
|
||||||
|
const contentRaw = row.content;
|
||||||
|
const content = { ...empty.content };
|
||||||
|
if (contentRaw !== null && typeof contentRaw === "object" && !Array.isArray(contentRaw)) {
|
||||||
|
for (const [key, flag] of Object.entries(contentRaw)) {
|
||||||
|
content[key] = flag === true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
status,
|
||||||
|
realRoot: typeof row.realRoot === "string" ? row.realRoot : "",
|
||||||
|
operatorName: typeof row.operatorName === "string" ? row.operatorName : "",
|
||||||
|
country: typeof row.country === "string" ? row.country : "",
|
||||||
|
version: typeof row.version === "string" ? row.version : "",
|
||||||
|
content,
|
||||||
|
errorMessage: typeof row.errorMessage === "string" ? row.errorMessage : ""
|
||||||
|
};
|
||||||
|
}
|
||||||
|
async function fetchKbStatus(call, signal) {
|
||||||
|
const result = await call(NETXOPS_RPC_CHANNEL2, KB_STATUS_ENDPOINT, {}, signal);
|
||||||
|
if (result !== null && typeof result === "object" && result.ok === true) {
|
||||||
|
return asKbSnapshot(result.value);
|
||||||
|
}
|
||||||
|
return unconfiguredKbSnapshot();
|
||||||
|
}
|
||||||
|
async function resolveKbPath(call, path, signal) {
|
||||||
|
const result = await call(NETXOPS_RPC_CHANNEL2, KB_RESOLVE_ENDPOINT, { path }, signal);
|
||||||
|
if (result !== null && typeof result === "object" && result.ok === true) {
|
||||||
|
return asKbSnapshot(result.value);
|
||||||
|
}
|
||||||
|
return unconfiguredKbSnapshot();
|
||||||
|
}
|
||||||
|
function kbStatusTone(status) {
|
||||||
|
if (status === "configured")
|
||||||
|
return "ok";
|
||||||
|
if (status === "error")
|
||||||
|
return "err";
|
||||||
|
if (status === "unconfigured")
|
||||||
|
return "warn";
|
||||||
|
return "neutral";
|
||||||
|
}
|
||||||
|
|
||||||
// src/client/styles.ts
|
// src/client/styles.ts
|
||||||
var STYLE_ID = "dsh-netxops-settings-css";
|
var STYLE_ID = "dsh-netxops-settings-css";
|
||||||
var CSS = `
|
var CSS = `
|
||||||
|
|
@ -285,7 +678,9 @@ var CSS = `
|
||||||
.dsh-nx-groupTitle{font-size:13px;font-weight:600;line-height:1.5;color:var(--dsw-alias-label-primary,#1f2329)}
|
.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-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-imTargetList{display:flex;flex-direction:column;gap:8px;padding:4px 0 2px}
|
||||||
.dsh-nx-settings-actions{display:flex;gap:8px;align-items:center;flex-wrap:wrap;margin-top:4px}
|
.dsh-nx-pathRow{display:flex;gap:8px;align-items:stretch}
|
||||||
|
.dsh-nx-pathRow>input{flex:1;min-width:0}
|
||||||
|
.dsh-nx-pathRow>.dsh-nx-btn{flex:none;align-self:stretch}
|
||||||
.dsh-nx-settings-msg{font-size:12px;color:var(--dsw-alias-label-secondary,#646a73)}
|
.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.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-msg.err{color:var(--dsw-alias-state-error-primary,#d54941)}
|
||||||
|
|
@ -466,13 +861,26 @@ function phaseLocaleKey(phase) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
function StatusBadge(props) {
|
function StatusBadge(props) {
|
||||||
const tone = alarmPushTone(props.phase);
|
const tone = props.tone ?? (props.phase !== undefined ? alarmPushTone(props.phase) : "neutral");
|
||||||
const className = tone === "ok" ? "dsh-nx-status dsh-nx-statusOk" : tone === "warn" ? "dsh-nx-status dsh-nx-statusWarn" : tone === "err" ? "dsh-nx-status dsh-nx-statusErr" : "dsh-nx-status";
|
const className = tone === "ok" ? "dsh-nx-status dsh-nx-statusOk" : tone === "warn" ? "dsh-nx-status dsh-nx-statusWarn" : tone === "err" ? "dsh-nx-status dsh-nx-statusErr" : "dsh-nx-status";
|
||||||
return /* @__PURE__ */ jsx_runtime.jsx("span", {
|
return /* @__PURE__ */ jsx_runtime.jsx("span", {
|
||||||
className,
|
className,
|
||||||
children: props.label
|
children: props.label
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
function kbBadgeLabel(t, snapshot) {
|
||||||
|
if (!snapshot || snapshot.status === "unconfigured") {
|
||||||
|
return t("kbStatusUnconfigured");
|
||||||
|
}
|
||||||
|
if (snapshot.status === "error") {
|
||||||
|
return fillTemplate(t("kbStatusError"), { detail: snapshot.errorMessage || "error" });
|
||||||
|
}
|
||||||
|
return fillTemplate(t("kbStatusConfigured"), {
|
||||||
|
operator: snapshot.operatorName,
|
||||||
|
country: snapshot.country,
|
||||||
|
version: snapshot.version
|
||||||
|
});
|
||||||
|
}
|
||||||
function fillTemplate(template, vars) {
|
function fillTemplate(template, vars) {
|
||||||
return template.replace(/\{(\w+)\}/g, (match, name) => Object.prototype.hasOwnProperty.call(vars, name) ? vars[name] : match);
|
return template.replace(/\{(\w+)\}/g, (match, name) => Object.prototype.hasOwnProperty.call(vars, name) ? vars[name] : match);
|
||||||
}
|
}
|
||||||
|
|
@ -907,6 +1315,92 @@ function NetxopsCard(props) {
|
||||||
})
|
})
|
||||||
]
|
]
|
||||||
}),
|
}),
|
||||||
|
/* @__PURE__ */ jsx_runtime.jsxs("div", {
|
||||||
|
className: "dsh-nx-settings-card",
|
||||||
|
children: [
|
||||||
|
/* @__PURE__ */ jsx_runtime.jsx("h3", {
|
||||||
|
children: t("sectionKnowledge")
|
||||||
|
}),
|
||||||
|
/* @__PURE__ */ jsx_runtime.jsxs("div", {
|
||||||
|
className: "dsh-nx-settings-field",
|
||||||
|
children: [
|
||||||
|
/* @__PURE__ */ jsx_runtime.jsxs("div", {
|
||||||
|
className: "dsh-nx-field-head",
|
||||||
|
children: [
|
||||||
|
/* @__PURE__ */ jsx_runtime.jsx("label", {
|
||||||
|
htmlFor: "netxops-kb-root",
|
||||||
|
children: t("kbRoot")
|
||||||
|
}),
|
||||||
|
/* @__PURE__ */ jsx_runtime.jsxs("span", {
|
||||||
|
className: "dsh-nx-badges",
|
||||||
|
children: [
|
||||||
|
/* @__PURE__ */ jsx_runtime.jsx(StatusBadge, {
|
||||||
|
tone: kbStatusTone(state.kbStatus?.status ?? "unconfigured"),
|
||||||
|
label: kbBadgeLabel(t, state.kbStatus)
|
||||||
|
}),
|
||||||
|
state.kbRoot.overridden ? /* @__PURE__ */ jsx_runtime.jsxs(jsx_runtime.Fragment, {
|
||||||
|
children: [
|
||||||
|
/* @__PURE__ */ jsx_runtime.jsx("span", {
|
||||||
|
className: "dsh-nx-badge",
|
||||||
|
children: t("overridden")
|
||||||
|
}),
|
||||||
|
/* @__PURE__ */ jsx_runtime.jsx("button", {
|
||||||
|
type: "button",
|
||||||
|
className: "dsh-nx-reset",
|
||||||
|
disabled,
|
||||||
|
onClick: () => {
|
||||||
|
props.resetField("kbRoot");
|
||||||
|
},
|
||||||
|
children: t("reset")
|
||||||
|
})
|
||||||
|
]
|
||||||
|
}) : null
|
||||||
|
]
|
||||||
|
})
|
||||||
|
]
|
||||||
|
}),
|
||||||
|
/* @__PURE__ */ jsx_runtime.jsxs("div", {
|
||||||
|
className: "dsh-nx-pathRow",
|
||||||
|
children: [
|
||||||
|
/* @__PURE__ */ jsx_runtime.jsx("input", {
|
||||||
|
id: "netxops-kb-root",
|
||||||
|
className: state.kbRoot.invalid ? "dsh-nx-inputInvalid" : undefined,
|
||||||
|
type: "text",
|
||||||
|
value: state.kbRoot.text,
|
||||||
|
disabled,
|
||||||
|
"aria-invalid": state.kbRoot.invalid || undefined,
|
||||||
|
onChange: (event) => {
|
||||||
|
props.edit("kbRoot", event.target.value);
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
/* @__PURE__ */ jsx_runtime.jsx("button", {
|
||||||
|
type: "button",
|
||||||
|
className: "dsh-nx-btn",
|
||||||
|
disabled: disabled || !state.kbDirectoryPickerReady,
|
||||||
|
onClick: () => {
|
||||||
|
props.browseKbRoot();
|
||||||
|
},
|
||||||
|
children: t("kbBrowse")
|
||||||
|
})
|
||||||
|
]
|
||||||
|
}),
|
||||||
|
/* @__PURE__ */ jsx_runtime.jsx("p", {
|
||||||
|
className: state.kbRoot.invalid ? "dsh-nx-invalid" : "dsh-nx-hint",
|
||||||
|
children: state.kbRoot.invalid ? t("invalid") : state.kbDirectoryPickerReady ? t("kbRootHint") : t("kbBrowseUnavailable")
|
||||||
|
}),
|
||||||
|
state.kbStatus?.status === "error" && state.kbStatus.errorMessage ? /* @__PURE__ */ jsx_runtime.jsx("p", {
|
||||||
|
className: "dsh-nx-invalid",
|
||||||
|
role: "status",
|
||||||
|
children: state.kbStatus.errorMessage
|
||||||
|
}) : null,
|
||||||
|
state.kbStatus?.status === "configured" && state.kbStatus.realRoot ? /* @__PURE__ */ jsx_runtime.jsx("p", {
|
||||||
|
className: "dsh-nx-hint",
|
||||||
|
children: state.kbStatus.realRoot
|
||||||
|
}) : null
|
||||||
|
]
|
||||||
|
})
|
||||||
|
]
|
||||||
|
}),
|
||||||
/* @__PURE__ */ jsx_runtime.jsxs("div", {
|
/* @__PURE__ */ jsx_runtime.jsxs("div", {
|
||||||
className: "dsh-nx-settings-card",
|
className: "dsh-nx-settings-card",
|
||||||
children: [
|
children: [
|
||||||
|
|
@ -1307,6 +1801,11 @@ class NetxopsCardController {
|
||||||
};
|
};
|
||||||
rpcCall;
|
rpcCall;
|
||||||
alarmPushStatus = null;
|
alarmPushStatus = null;
|
||||||
|
kbStatus = null;
|
||||||
|
kbPreview = null;
|
||||||
|
kbDirectoryPicker;
|
||||||
|
kbBrowseInFlight = false;
|
||||||
|
kbStatusInFlight = false;
|
||||||
imDeliveryCatalog = { ...EMPTY_IM_DELIVERY_CATALOG };
|
imDeliveryCatalog = { ...EMPTY_IM_DELIVERY_CATALOG };
|
||||||
sessionsExportStatus = null;
|
sessionsExportStatus = null;
|
||||||
sessionsExportBusy = false;
|
sessionsExportBusy = false;
|
||||||
|
|
@ -1324,6 +1823,7 @@ class NetxopsCardController {
|
||||||
textField("apiUrl"),
|
textField("apiUrl"),
|
||||||
textField("lang"),
|
textField("lang"),
|
||||||
textField("nmsProvider"),
|
textField("nmsProvider"),
|
||||||
|
textField("kbRoot"),
|
||||||
booleanFieldPersistFalse("groupOpsInPreset"),
|
booleanFieldPersistFalse("groupOpsInPreset"),
|
||||||
booleanField("groupOpsPublic"),
|
booleanField("groupOpsPublic"),
|
||||||
booleanField("groupTopologyInPreset"),
|
booleanField("groupTopologyInPreset"),
|
||||||
|
|
@ -1338,6 +1838,7 @@ class NetxopsCardController {
|
||||||
this.store = this.form.bind(() => this.projection());
|
this.store = this.form.bind(() => this.projection());
|
||||||
scope.subscribe(() => {
|
scope.subscribe(() => {
|
||||||
this.readCredential();
|
this.readCredential();
|
||||||
|
this.refreshKbPreview();
|
||||||
});
|
});
|
||||||
this.readCredential();
|
this.readCredential();
|
||||||
}
|
}
|
||||||
|
|
@ -1362,6 +1863,11 @@ class NetxopsCardController {
|
||||||
this.alarmPushStatus = null;
|
this.alarmPushStatus = null;
|
||||||
changed = true;
|
changed = true;
|
||||||
}
|
}
|
||||||
|
if (this.kbStatus !== null || this.kbPreview !== null) {
|
||||||
|
this.kbStatus = null;
|
||||||
|
this.kbPreview = null;
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
if (this.imDeliveryCatalog.options.length > 0 || this.imDeliveryCatalog.available !== true) {
|
if (this.imDeliveryCatalog.options.length > 0 || this.imDeliveryCatalog.available !== true) {
|
||||||
this.imDeliveryCatalog = { ...EMPTY_IM_DELIVERY_CATALOG };
|
this.imDeliveryCatalog = { ...EMPTY_IM_DELIVERY_CATALOG };
|
||||||
changed = true;
|
changed = true;
|
||||||
|
|
@ -1376,14 +1882,25 @@ class NetxopsCardController {
|
||||||
}
|
}
|
||||||
this.startStatusPoll();
|
this.startStatusPoll();
|
||||||
this.refreshAlarmPushStatus();
|
this.refreshAlarmPushStatus();
|
||||||
|
this.refreshKbStatus();
|
||||||
|
this.refreshKbPreview();
|
||||||
this.refreshImDeliveryCatalog();
|
this.refreshImDeliveryCatalog();
|
||||||
this.refreshSessionsExportStatus();
|
this.refreshSessionsExportStatus();
|
||||||
}
|
}
|
||||||
|
setDirectoryPicker(picker) {
|
||||||
|
const ready = picker !== undefined;
|
||||||
|
if (this.kbDirectoryPicker === picker && this.kbDirectoryPicker !== undefined === ready) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.kbDirectoryPicker = picker;
|
||||||
|
this.store.set(this.projection());
|
||||||
|
}
|
||||||
startStatusPoll() {
|
startStatusPoll() {
|
||||||
if (this.pollTimer !== undefined)
|
if (this.pollTimer !== undefined)
|
||||||
return;
|
return;
|
||||||
this.pollTimer = setInterval(() => {
|
this.pollTimer = setInterval(() => {
|
||||||
this.refreshAlarmPushStatus();
|
this.refreshAlarmPushStatus();
|
||||||
|
this.refreshKbStatus();
|
||||||
this.refreshImDeliveryCatalog();
|
this.refreshImDeliveryCatalog();
|
||||||
this.refreshSessionsExportStatus();
|
this.refreshSessionsExportStatus();
|
||||||
}, STATUS_POLL_MS);
|
}, STATUS_POLL_MS);
|
||||||
|
|
@ -1446,6 +1963,58 @@ class NetxopsCardController {
|
||||||
this.exportStatusInFlight = false;
|
this.exportStatusInFlight = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
async refreshKbStatus() {
|
||||||
|
const call = this.rpcCall;
|
||||||
|
if (call === undefined || this.kbStatusInFlight)
|
||||||
|
return;
|
||||||
|
this.kbStatusInFlight = true;
|
||||||
|
try {
|
||||||
|
const next = await fetchKbStatus(call);
|
||||||
|
const prev = this.kbStatus;
|
||||||
|
if (prev && prev.status === next.status && prev.realRoot === next.realRoot && prev.operatorName === next.operatorName && prev.country === next.country && prev.version === next.version && prev.errorMessage === next.errorMessage)
|
||||||
|
return;
|
||||||
|
this.kbStatus = next;
|
||||||
|
this.store.set(this.projection());
|
||||||
|
} catch {} finally {
|
||||||
|
this.kbStatusInFlight = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
async refreshKbPreview() {
|
||||||
|
const call = this.rpcCall;
|
||||||
|
if (call === undefined)
|
||||||
|
return;
|
||||||
|
const draft = this.form.field("kbRoot").text.trim();
|
||||||
|
const saved = (this.scope.getSnapshot().value?.kbRoot ?? "").trim();
|
||||||
|
if (draft === saved) {
|
||||||
|
if (this.kbPreview !== null) {
|
||||||
|
this.kbPreview = null;
|
||||||
|
this.store.set(this.projection());
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const next = draft === "" ? unconfiguredKbSnapshot() : await resolveKbPath(call, draft);
|
||||||
|
const prev = this.kbPreview;
|
||||||
|
if (prev && prev.status === next.status && prev.realRoot === next.realRoot && prev.operatorName === next.operatorName && prev.country === next.country && prev.version === next.version && prev.errorMessage === next.errorMessage)
|
||||||
|
return;
|
||||||
|
this.kbPreview = next;
|
||||||
|
this.store.set(this.projection());
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
browseKbRoot() {
|
||||||
|
const picker = this.kbDirectoryPicker;
|
||||||
|
if (picker === undefined || this.kbBrowseInFlight)
|
||||||
|
return;
|
||||||
|
this.kbBrowseInFlight = true;
|
||||||
|
picker.pick().then((path) => {
|
||||||
|
if (typeof path === "string" && path.trim() !== "") {
|
||||||
|
this.form.actions().edit("kbRoot", path);
|
||||||
|
this.refreshKbPreview();
|
||||||
|
}
|
||||||
|
}).catch(() => {}).finally(() => {
|
||||||
|
this.kbBrowseInFlight = false;
|
||||||
|
});
|
||||||
|
}
|
||||||
exportAllSessions() {
|
exportAllSessions() {
|
||||||
if (this.exportInFlight !== undefined || this.sessionsExportBusy)
|
if (this.exportInFlight !== undefined || this.sessionsExportBusy)
|
||||||
return;
|
return;
|
||||||
|
|
@ -1485,6 +2054,7 @@ class NetxopsCardController {
|
||||||
apiUrl: this.form.field("apiUrl"),
|
apiUrl: this.form.field("apiUrl"),
|
||||||
lang: this.form.field("lang"),
|
lang: this.form.field("lang"),
|
||||||
nmsProvider: this.form.field("nmsProvider"),
|
nmsProvider: this.form.field("nmsProvider"),
|
||||||
|
kbRoot: this.form.field("kbRoot"),
|
||||||
groupOpsInPreset: this.form.field("groupOpsInPreset"),
|
groupOpsInPreset: this.form.field("groupOpsInPreset"),
|
||||||
groupOpsPublic: this.form.field("groupOpsPublic"),
|
groupOpsPublic: this.form.field("groupOpsPublic"),
|
||||||
groupTopologyInPreset: this.form.field("groupTopologyInPreset"),
|
groupTopologyInPreset: this.form.field("groupTopologyInPreset"),
|
||||||
|
|
@ -1500,6 +2070,8 @@ class NetxopsCardController {
|
||||||
apiTokenWritable: this.credential.remoteReady && this.credential.writable,
|
apiTokenWritable: this.credential.remoteReady && this.credential.writable,
|
||||||
apiTokenRemoteReady: this.credential.remoteReady,
|
apiTokenRemoteReady: this.credential.remoteReady,
|
||||||
alarmPushStatus: this.alarmPushStatus,
|
alarmPushStatus: this.alarmPushStatus,
|
||||||
|
kbStatus: this.kbPreview ?? this.kbStatus,
|
||||||
|
kbDirectoryPickerReady: this.kbDirectoryPicker !== undefined,
|
||||||
imDeliveryCatalog: this.imDeliveryCatalog,
|
imDeliveryCatalog: this.imDeliveryCatalog,
|
||||||
sessionsExportStatus: this.sessionsExportStatus,
|
sessionsExportStatus: this.sessionsExportStatus,
|
||||||
sessionsExportBusy: this.sessionsExportBusy,
|
sessionsExportBusy: this.sessionsExportBusy,
|
||||||
|
|
@ -1550,11 +2122,29 @@ class NetxopsCardController {
|
||||||
this.readCredential();
|
this.readCredential();
|
||||||
}
|
}
|
||||||
inject() {
|
inject() {
|
||||||
|
const actions = this.form.actions();
|
||||||
return {
|
return {
|
||||||
hooks: { netxopsCard: this.store },
|
hooks: { netxopsCard: this.store },
|
||||||
...this.form.actions(),
|
...actions,
|
||||||
|
edit: (field, text) => {
|
||||||
|
actions.edit(field, text);
|
||||||
|
if (field === "kbRoot")
|
||||||
|
this.refreshKbPreview();
|
||||||
|
},
|
||||||
|
discard: () => {
|
||||||
|
actions.discard();
|
||||||
|
this.refreshKbPreview();
|
||||||
|
},
|
||||||
|
resetField: (field) => {
|
||||||
|
actions.resetField(field);
|
||||||
|
if (field === "kbRoot")
|
||||||
|
this.refreshKbPreview();
|
||||||
|
},
|
||||||
exportAllSessions: () => {
|
exportAllSessions: () => {
|
||||||
this.exportAllSessions();
|
this.exportAllSessions();
|
||||||
|
},
|
||||||
|
browseKbRoot: () => {
|
||||||
|
this.browseKbRoot();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
@ -1575,9 +2165,10 @@ function refOf(snapshot) {
|
||||||
// src/client/locales.ts
|
// src/client/locales.ts
|
||||||
var en = {
|
var en = {
|
||||||
title: "Netx Ops",
|
title: "Netx Ops",
|
||||||
description: "API, token, capabilities, and alarm delivery.",
|
description: "API, token, capabilities, knowledge base, and alarm delivery.",
|
||||||
sectionConnection: "Connection",
|
sectionConnection: "Connection",
|
||||||
sectionCapabilities: "Capability groups",
|
sectionCapabilities: "Capability groups",
|
||||||
|
sectionKnowledge: "Knowledge base",
|
||||||
sectionAlarms: "Key-alarm delivery",
|
sectionAlarms: "Key-alarm delivery",
|
||||||
sectionExport: "Session export",
|
sectionExport: "Session export",
|
||||||
apiUrl: "API URL",
|
apiUrl: "API URL",
|
||||||
|
|
@ -1591,6 +2182,13 @@ var en = {
|
||||||
groupTopology: "topology",
|
groupTopology: "topology",
|
||||||
groupInPreset: "In Netx Ops preset",
|
groupInPreset: "In Netx Ops preset",
|
||||||
groupPublic: "Publish to other presets",
|
groupPublic: "Publish to other presets",
|
||||||
|
kbRoot: "Package root",
|
||||||
|
kbRootHint: "Folder with MANIFEST.json (operator-subset v1.0). Empty = pure netx.",
|
||||||
|
kbBrowse: "Browse…",
|
||||||
|
kbBrowseUnavailable: "Directory picker unavailable — paste an absolute path.",
|
||||||
|
kbStatusConfigured: "Knowledge base: {operator} ({country}) v{version}",
|
||||||
|
kbStatusUnconfigured: "Knowledge base: not configured (pure netx)",
|
||||||
|
kbStatusError: "Knowledge base: error — {detail}",
|
||||||
alarmPushEnabled: "Key-alarm push",
|
alarmPushEnabled: "Key-alarm push",
|
||||||
alarmPushStatus: "Status",
|
alarmPushStatus: "Status",
|
||||||
alarmPushPhaseDisabled: "Off",
|
alarmPushPhaseDisabled: "Off",
|
||||||
|
|
@ -1637,9 +2235,10 @@ var en = {
|
||||||
};
|
};
|
||||||
var zh = {
|
var zh = {
|
||||||
title: "Netx Ops",
|
title: "Netx Ops",
|
||||||
description: "API、Token、能力组与告警投递。",
|
description: "API、Token、能力组、知识库与告警投递。",
|
||||||
sectionConnection: "连接",
|
sectionConnection: "连接",
|
||||||
sectionCapabilities: "能力组",
|
sectionCapabilities: "能力组",
|
||||||
|
sectionKnowledge: "知识库",
|
||||||
sectionAlarms: "关键告警投递",
|
sectionAlarms: "关键告警投递",
|
||||||
sectionExport: "会话导出",
|
sectionExport: "会话导出",
|
||||||
apiUrl: "API 地址",
|
apiUrl: "API 地址",
|
||||||
|
|
@ -1653,6 +2252,13 @@ var zh = {
|
||||||
groupTopology: "topology",
|
groupTopology: "topology",
|
||||||
groupInPreset: "在 Netx Ops 预设中启用",
|
groupInPreset: "在 Netx Ops 预设中启用",
|
||||||
groupPublic: "对其他预设公开",
|
groupPublic: "对其他预设公开",
|
||||||
|
kbRoot: "知识包根目录",
|
||||||
|
kbRootHint: "含 MANIFEST.json 的运营商子集包(v1.0)。留空=纯 netx。",
|
||||||
|
kbBrowse: "浏览…",
|
||||||
|
kbBrowseUnavailable: "目录选择器不可用 — 请粘贴绝对路径。",
|
||||||
|
kbStatusConfigured: "知识库: {operator}({country}) v{version}",
|
||||||
|
kbStatusUnconfigured: "知识库: 未配置(纯 netx)",
|
||||||
|
kbStatusError: "知识库: 错误 — {detail}",
|
||||||
alarmPushEnabled: "关键告警推送",
|
alarmPushEnabled: "关键告警推送",
|
||||||
alarmPushStatus: "状态",
|
alarmPushStatus: "状态",
|
||||||
alarmPushPhaseDisabled: "未开启",
|
alarmPushPhaseDisabled: "未开启",
|
||||||
|
|
@ -1744,6 +2350,17 @@ function apply(ctx) {
|
||||||
card.setAlarmPushRpc(undefined);
|
card.setAlarmPushRpc(undefined);
|
||||||
}, "netxops: clear alarm-push rpc");
|
}, "netxops: clear alarm-push rpc");
|
||||||
});
|
});
|
||||||
|
ctx.inject(["remote.directoryPicker"], (dpCtx) => {
|
||||||
|
const picker = dpCtx.remote?.directoryPicker;
|
||||||
|
if (!picker || typeof picker.pick !== "function") {
|
||||||
|
dpCtx.logger.warn("netxops: remote.directoryPicker.pick unavailable — browse button disabled");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
card.setDirectoryPicker(picker);
|
||||||
|
dpCtx.effect(() => () => {
|
||||||
|
card.setDirectoryPicker(undefined);
|
||||||
|
}, "netxops: clear directory picker");
|
||||||
|
});
|
||||||
ctx.slots.inject("settings.section", () => ctx.slots.register({
|
ctx.slots.inject("settings.section", () => ctx.slots.register({
|
||||||
name: "settings.section",
|
name: "settings.section",
|
||||||
id: NETXOPS_NS,
|
id: NETXOPS_NS,
|
||||||
|
|
|
||||||
386
lib/index.js
386
lib/index.js
|
|
@ -2,9 +2,9 @@ import { createRequire } from "node:module";
|
||||||
var __require = /* @__PURE__ */ createRequire(import.meta.url);
|
var __require = /* @__PURE__ */ createRequire(import.meta.url);
|
||||||
|
|
||||||
// src/index.ts
|
// src/index.ts
|
||||||
import { cpSync, existsSync as existsSync2, mkdirSync, rmSync, writeFileSync } from "node:fs";
|
import { cpSync, existsSync as existsSync3, mkdirSync, rmSync, writeFileSync } from "node:fs";
|
||||||
import { homedir as homedir3 } from "node:os";
|
import { homedir as homedir3 } from "node:os";
|
||||||
import { dirname as dirname2, join as join3 } from "node:path";
|
import { dirname as dirname3, join as join4 } from "node:path";
|
||||||
import { fileURLToPath as fileURLToPath2 } from "node:url";
|
import { fileURLToPath as fileURLToPath2 } from "node:url";
|
||||||
import z from "@deepseek-ai/schemastery";
|
import z from "@deepseek-ai/schemastery";
|
||||||
import { credentialRef } from "@deepseek-ai/dsh-credentials";
|
import { credentialRef } from "@deepseek-ai/dsh-credentials";
|
||||||
|
|
@ -785,28 +785,331 @@ async function registerGroupSkills(ctx, groupIds, providerLabel) {
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// src/netx/runtime.ts
|
// src/netx/kb-context-skill.ts
|
||||||
var STORE_KEY2 = Symbol.for("dsh-netxops.connection-store");
|
var SKILL_NAME = "kb-context";
|
||||||
|
function skillBody(snapshot) {
|
||||||
|
if (snapshot.status === "configured") {
|
||||||
|
const flags = Object.entries(snapshot.content).filter(([, on]) => on).map(([key]) => key).join(", ") || "(none)";
|
||||||
|
return {
|
||||||
|
description: "Operator knowledge-base context for this Host (paths + identity from MANIFEST).",
|
||||||
|
content: [
|
||||||
|
"## Knowledge base (configured)",
|
||||||
|
"",
|
||||||
|
"When troubleshooting with operator playbooks or docs, **compose paths from this root**.",
|
||||||
|
"Do not invent another operator or country.",
|
||||||
|
"",
|
||||||
|
`| Field | Value |`,
|
||||||
|
`| --- | --- |`,
|
||||||
|
`| kbStatus | configured |`,
|
||||||
|
`| kbRoot | \`${snapshot.realRoot}\` |`,
|
||||||
|
`| kbOperator | ${snapshot.operatorName} |`,
|
||||||
|
`| kbCountry | ${snapshot.country} |`,
|
||||||
|
`| kbVersion | ${snapshot.version} |`,
|
||||||
|
`| kbContent (on) | ${flags} |`,
|
||||||
|
"",
|
||||||
|
"Environment mirrors: `KB_ROOT`, `KB_OPERATOR`, `KB_COUNTRY`, `KB_VERSION`, `KB_CONTENT`, `KB_STATUS`."
|
||||||
|
].join(`
|
||||||
|
`)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const reason = snapshot.status === "error" ? snapshot.errorMessage || "invalid knowledge base" : "kbRoot is empty";
|
||||||
|
return {
|
||||||
|
description: "Knowledge-base context is unavailable — stay on pure netx evidence.",
|
||||||
|
content: [
|
||||||
|
"## Knowledge base (unavailable)",
|
||||||
|
"",
|
||||||
|
`kbStatus=\`${snapshot.status}\`. ${reason}`,
|
||||||
|
"",
|
||||||
|
"**Do not invent an operator, country, or KB paths.**",
|
||||||
|
"Use only netx tools / live evidence (alarms, inventory, CLI, topology).",
|
||||||
|
"Operator-specific playbooks are out of scope until a valid MANIFEST is configured."
|
||||||
|
].join(`
|
||||||
|
`)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
function registerKbContextSkill(ctx, snapshot) {
|
||||||
|
const skillsApi = ctx.skills;
|
||||||
|
if (!skillsApi || typeof skillsApi.register !== "function") {
|
||||||
|
return () => {};
|
||||||
|
}
|
||||||
|
const body = skillBody(snapshot);
|
||||||
|
return skillsApi.register({
|
||||||
|
name: SKILL_NAME,
|
||||||
|
description: body.description,
|
||||||
|
content: body.content,
|
||||||
|
provider: "netxops-kb",
|
||||||
|
source: "custom"
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// src/netx/kb-manifest.ts
|
||||||
|
import { existsSync as existsSync2, readdirSync, readFileSync, statSync } from "node:fs";
|
||||||
|
import { dirname as dirname2, join as join3, resolve } from "node:path";
|
||||||
|
var EMPTY_CONTENT = {
|
||||||
|
regions: false,
|
||||||
|
theory: false,
|
||||||
|
packet: false,
|
||||||
|
skills: false
|
||||||
|
};
|
||||||
|
function unconfiguredKbSnapshot() {
|
||||||
|
return {
|
||||||
|
status: "unconfigured",
|
||||||
|
realRoot: "",
|
||||||
|
operatorName: "",
|
||||||
|
country: "",
|
||||||
|
version: "",
|
||||||
|
content: { ...EMPTY_CONTENT },
|
||||||
|
errorMessage: ""
|
||||||
|
};
|
||||||
|
}
|
||||||
|
function errorSnapshot(message) {
|
||||||
|
return {
|
||||||
|
status: "error",
|
||||||
|
realRoot: "",
|
||||||
|
operatorName: "",
|
||||||
|
country: "",
|
||||||
|
version: "",
|
||||||
|
content: { ...EMPTY_CONTENT },
|
||||||
|
errorMessage: message
|
||||||
|
};
|
||||||
|
}
|
||||||
|
function findManifest(kbRoot, maxDepth = 3) {
|
||||||
|
const root = resolve(kbRoot.trim());
|
||||||
|
if (!kbRoot.trim()) {
|
||||||
|
return { paths: [], error: "kbRoot is empty" };
|
||||||
|
}
|
||||||
|
let rootStat;
|
||||||
|
try {
|
||||||
|
rootStat = statSync(root);
|
||||||
|
} catch {
|
||||||
|
return { paths: [], error: `kbRoot not found: ${root}` };
|
||||||
|
}
|
||||||
|
if (!rootStat.isDirectory()) {
|
||||||
|
return { paths: [], error: `kbRoot is not a directory: ${root}` };
|
||||||
|
}
|
||||||
|
const direct = join3(root, "MANIFEST.json");
|
||||||
|
if (existsSync2(direct)) {
|
||||||
|
try {
|
||||||
|
if (statSync(direct).isFile())
|
||||||
|
return { paths: [direct] };
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
const found = [];
|
||||||
|
const walk = (dir, depth) => {
|
||||||
|
if (depth > maxDepth)
|
||||||
|
return;
|
||||||
|
const candidate = join3(dir, "MANIFEST.json");
|
||||||
|
if (existsSync2(candidate)) {
|
||||||
|
try {
|
||||||
|
if (statSync(candidate).isFile())
|
||||||
|
found.push(candidate);
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
if (depth === maxDepth)
|
||||||
|
return;
|
||||||
|
let entries;
|
||||||
|
try {
|
||||||
|
entries = readdirSync(dir);
|
||||||
|
} catch {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
for (const name of entries) {
|
||||||
|
if (name === "node_modules" || name === ".git")
|
||||||
|
continue;
|
||||||
|
const full = join3(dir, name);
|
||||||
|
let st;
|
||||||
|
try {
|
||||||
|
st = statSync(full);
|
||||||
|
} catch {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (st.isDirectory())
|
||||||
|
walk(full, depth + 1);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let topEntries;
|
||||||
|
try {
|
||||||
|
topEntries = readdirSync(root);
|
||||||
|
} catch {
|
||||||
|
return { paths: [], error: `cannot read kbRoot: ${root}` };
|
||||||
|
}
|
||||||
|
for (const name of topEntries) {
|
||||||
|
if (name === "node_modules" || name === ".git")
|
||||||
|
continue;
|
||||||
|
const full = join3(root, name);
|
||||||
|
let st;
|
||||||
|
try {
|
||||||
|
st = statSync(full);
|
||||||
|
} catch {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (st.isDirectory())
|
||||||
|
walk(full, 1);
|
||||||
|
}
|
||||||
|
return { paths: found };
|
||||||
|
}
|
||||||
|
function asNonEmptyString(value, field) {
|
||||||
|
if (typeof value !== "string" || value.trim() === "") {
|
||||||
|
throw new Error(`MANIFEST missing required string field: ${field}`);
|
||||||
|
}
|
||||||
|
return value.trim();
|
||||||
|
}
|
||||||
|
function parseContent(raw) {
|
||||||
|
const out = { ...EMPTY_CONTENT };
|
||||||
|
if (raw === undefined || raw === null) {
|
||||||
|
throw new Error("MANIFEST missing required object field: content");
|
||||||
|
}
|
||||||
|
if (typeof raw !== "object" || Array.isArray(raw)) {
|
||||||
|
throw new Error("MANIFEST content must be an object");
|
||||||
|
}
|
||||||
|
for (const [key, value] of Object.entries(raw)) {
|
||||||
|
out[key] = value === true;
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
function parseManifest(raw) {
|
||||||
|
let data;
|
||||||
|
try {
|
||||||
|
data = JSON.parse(raw);
|
||||||
|
} catch (error) {
|
||||||
|
throw new Error(`MANIFEST is not valid JSON: ${error instanceof Error ? error.message : String(error)}`);
|
||||||
|
}
|
||||||
|
if (data === null || typeof data !== "object" || Array.isArray(data)) {
|
||||||
|
throw new Error("MANIFEST root must be an object");
|
||||||
|
}
|
||||||
|
const row = data;
|
||||||
|
if (row.schemaVersion !== "1.0") {
|
||||||
|
throw new Error(`unsupported schemaVersion (want "1.0", got ${JSON.stringify(row.schemaVersion)})`);
|
||||||
|
}
|
||||||
|
if (row.packageType !== "operator-subset") {
|
||||||
|
throw new Error(`unsupported packageType (want "operator-subset", got ${JSON.stringify(row.packageType)})`);
|
||||||
|
}
|
||||||
|
const operator = row.operator;
|
||||||
|
if (operator === null || typeof operator !== "object" || Array.isArray(operator)) {
|
||||||
|
throw new Error("MANIFEST missing required object field: operator");
|
||||||
|
}
|
||||||
|
const op = operator;
|
||||||
|
return {
|
||||||
|
operatorName: asNonEmptyString(op.name, "operator.name"),
|
||||||
|
country: asNonEmptyString(op.country, "operator.country"),
|
||||||
|
version: asNonEmptyString(row.version, "version"),
|
||||||
|
content: parseContent(row.content)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
function resolveKbRoot(kbRoot, maxDepth = 3) {
|
||||||
|
const trimmed = kbRoot.trim();
|
||||||
|
if (!trimmed)
|
||||||
|
return unconfiguredKbSnapshot();
|
||||||
|
const located = findManifest(trimmed, maxDepth);
|
||||||
|
if (located.error)
|
||||||
|
return errorSnapshot(located.error);
|
||||||
|
if (located.paths.length === 0) {
|
||||||
|
return errorSnapshot(`no MANIFEST.json under ${resolve(trimmed)} (maxDepth=${maxDepth})`);
|
||||||
|
}
|
||||||
|
if (located.paths.length > 1) {
|
||||||
|
return errorSnapshot(`ambiguous MANIFEST.json (${located.paths.length} hits); pick a unique package root`);
|
||||||
|
}
|
||||||
|
const manifestPath = located.paths[0];
|
||||||
|
let raw;
|
||||||
|
try {
|
||||||
|
raw = readFileSync(manifestPath, "utf8");
|
||||||
|
} catch (error) {
|
||||||
|
return errorSnapshot(`cannot read MANIFEST: ${error instanceof Error ? error.message : String(error)}`);
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const parsed = parseManifest(raw);
|
||||||
|
return {
|
||||||
|
status: "configured",
|
||||||
|
realRoot: dirname2(manifestPath),
|
||||||
|
operatorName: parsed.operatorName,
|
||||||
|
country: parsed.country,
|
||||||
|
version: parsed.version,
|
||||||
|
content: parsed.content,
|
||||||
|
errorMessage: ""
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
return errorSnapshot(error instanceof Error ? error.message : String(error));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// src/netx/kb-runtime.ts
|
||||||
|
var STORE_KEY2 = Symbol.for("dsh-netxops.kb-store");
|
||||||
|
var ENV_KEYS = [
|
||||||
|
"KB_ROOT",
|
||||||
|
"KB_OPERATOR",
|
||||||
|
"KB_COUNTRY",
|
||||||
|
"KB_VERSION",
|
||||||
|
"KB_CONTENT",
|
||||||
|
"KB_STATUS"
|
||||||
|
];
|
||||||
function store2() {
|
function store2() {
|
||||||
const root = globalThis;
|
const root = globalThis;
|
||||||
let current = root[STORE_KEY2];
|
let current = root[STORE_KEY2];
|
||||||
if (current === undefined) {
|
if (current === undefined) {
|
||||||
current = { connection: undefined, listeners: new Set };
|
current = { snapshot: unconfiguredKbSnapshot(), listeners: new Set };
|
||||||
root[STORE_KEY2] = current;
|
root[STORE_KEY2] = current;
|
||||||
}
|
}
|
||||||
return current;
|
return current;
|
||||||
}
|
}
|
||||||
function publishNetxConnection(next) {
|
function getKbContext() {
|
||||||
|
return { ...store2().snapshot, content: { ...store2().snapshot.content } };
|
||||||
|
}
|
||||||
|
function publishKbContext(next) {
|
||||||
const state = store2();
|
const state = store2();
|
||||||
|
state.snapshot = {
|
||||||
|
...next,
|
||||||
|
content: { ...next.content }
|
||||||
|
};
|
||||||
|
for (const listener of state.listeners)
|
||||||
|
listener();
|
||||||
|
}
|
||||||
|
function watchKbContext(listener) {
|
||||||
|
const state = store2();
|
||||||
|
state.listeners.add(listener);
|
||||||
|
return () => {
|
||||||
|
state.listeners.delete(listener);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
function applyKbEnv(snapshot) {
|
||||||
|
for (const key of ENV_KEYS) {
|
||||||
|
delete process.env[key];
|
||||||
|
}
|
||||||
|
process.env.KB_STATUS = snapshot.status;
|
||||||
|
if (snapshot.status !== "configured")
|
||||||
|
return;
|
||||||
|
process.env.KB_ROOT = snapshot.realRoot;
|
||||||
|
process.env.KB_OPERATOR = snapshot.operatorName;
|
||||||
|
process.env.KB_COUNTRY = snapshot.country;
|
||||||
|
process.env.KB_VERSION = snapshot.version;
|
||||||
|
process.env.KB_CONTENT = JSON.stringify(snapshot.content);
|
||||||
|
}
|
||||||
|
function resetKbContext() {
|
||||||
|
publishKbContext(unconfiguredKbSnapshot());
|
||||||
|
applyKbEnv(unconfiguredKbSnapshot());
|
||||||
|
}
|
||||||
|
|
||||||
|
// src/netx/runtime.ts
|
||||||
|
var STORE_KEY3 = Symbol.for("dsh-netxops.connection-store");
|
||||||
|
function store3() {
|
||||||
|
const root = globalThis;
|
||||||
|
let current = root[STORE_KEY3];
|
||||||
|
if (current === undefined) {
|
||||||
|
current = { connection: undefined, listeners: new Set };
|
||||||
|
root[STORE_KEY3] = current;
|
||||||
|
}
|
||||||
|
return current;
|
||||||
|
}
|
||||||
|
function publishNetxConnection(next) {
|
||||||
|
const state = store3();
|
||||||
state.connection = next;
|
state.connection = next;
|
||||||
for (const listener of state.listeners)
|
for (const listener of state.listeners)
|
||||||
listener();
|
listener();
|
||||||
}
|
}
|
||||||
function getNetxConnection() {
|
function getNetxConnection() {
|
||||||
return store2().connection;
|
return store3().connection;
|
||||||
}
|
}
|
||||||
function watchNetxConnection(listener) {
|
function watchNetxConnection(listener) {
|
||||||
const state = store2();
|
const state = store3();
|
||||||
state.listeners.add(listener);
|
state.listeners.add(listener);
|
||||||
return () => {
|
return () => {
|
||||||
state.listeners.delete(listener);
|
state.listeners.delete(listener);
|
||||||
|
|
@ -3390,33 +3693,34 @@ var Config = z.object({
|
||||||
groupOpsInPreset: z.boolean().default(true),
|
groupOpsInPreset: z.boolean().default(true),
|
||||||
groupOpsPublic: z.boolean().default(false),
|
groupOpsPublic: z.boolean().default(false),
|
||||||
groupTopologyInPreset: z.boolean().default(false),
|
groupTopologyInPreset: z.boolean().default(false),
|
||||||
groupTopologyPublic: z.boolean().default(false)
|
groupTopologyPublic: z.boolean().default(false),
|
||||||
|
kbRoot: z.string().default("")
|
||||||
});
|
});
|
||||||
function packageRoot() {
|
function packageRoot() {
|
||||||
return join3(dirname2(fileURLToPath2(import.meta.url)), "..");
|
return join4(dirname3(fileURLToPath2(import.meta.url)), "..");
|
||||||
}
|
}
|
||||||
function resolveDshHome() {
|
function resolveDshHome() {
|
||||||
const fromEnv = process.env.DSH_HOME?.trim();
|
const fromEnv = process.env.DSH_HOME?.trim();
|
||||||
if (fromEnv !== undefined && fromEnv.length > 0)
|
if (fromEnv !== undefined && fromEnv.length > 0)
|
||||||
return fromEnv;
|
return fromEnv;
|
||||||
return join3(homedir3(), ".dsh");
|
return join4(homedir3(), ".dsh");
|
||||||
}
|
}
|
||||||
function ensureAgentPresetInstalled(logger) {
|
function ensureAgentPresetInstalled(logger) {
|
||||||
const src = join3(packageRoot(), "presets", NETXOPS_PRESET_ID);
|
const src = join4(packageRoot(), "presets", NETXOPS_PRESET_ID);
|
||||||
const composition = join3(src, "agent.cordis.yml");
|
const composition = join4(src, "agent.cordis.yml");
|
||||||
if (!existsSync2(composition)) {
|
if (!existsSync3(composition)) {
|
||||||
logger.warn("netxops: bundled preset missing at %s — skip user-preset install", src);
|
logger.warn("netxops: bundled preset missing at %s — skip user-preset install", src);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const destParent = join3(resolveDshHome(), ".agent-presets");
|
const destParent = join4(resolveDshHome(), ".agent-presets");
|
||||||
const dest = join3(destParent, NETXOPS_PRESET_ID);
|
const dest = join4(destParent, NETXOPS_PRESET_ID);
|
||||||
try {
|
try {
|
||||||
mkdirSync(destParent, { recursive: true });
|
mkdirSync(destParent, { recursive: true });
|
||||||
if (existsSync2(dest)) {
|
if (existsSync3(dest)) {
|
||||||
rmSync(dest, { recursive: true, force: true });
|
rmSync(dest, { recursive: true, force: true });
|
||||||
}
|
}
|
||||||
cpSync(src, dest, { recursive: true });
|
cpSync(src, dest, { recursive: true });
|
||||||
writeFileSync(join3(dest, ".dsh-netxops-managed"), `${new Date().toISOString()}
|
writeFileSync(join4(dest, ".dsh-netxops-managed"), `${new Date().toISOString()}
|
||||||
`, "utf8");
|
`, "utf8");
|
||||||
logger.info("netxops: agent preset installed at %s", dest);
|
logger.info("netxops: agent preset installed at %s", dest);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|
@ -3508,6 +3812,14 @@ function apply(ctx, config = Config({})) {
|
||||||
toolCallTimeoutMs: current.toolCallTimeoutMs,
|
toolCallTimeoutMs: current.toolCallTimeoutMs,
|
||||||
groups
|
groups
|
||||||
});
|
});
|
||||||
|
const kb = resolveKbRoot(current.kbRoot ?? "");
|
||||||
|
publishKbContext(kb);
|
||||||
|
applyKbEnv(kb);
|
||||||
|
if (kb.status === "configured") {
|
||||||
|
ctx.logger.info("netxops: knowledge base configured → %s (%s / %s v%s)", kb.realRoot, kb.operatorName, kb.country, kb.version);
|
||||||
|
} else if (kb.status === "error") {
|
||||||
|
ctx.logger.warn("netxops: knowledge base error — %s", kb.errorMessage);
|
||||||
|
}
|
||||||
restartAlarmPush(current, apiUrl, token);
|
restartAlarmPush(current, apiUrl, token);
|
||||||
if (!tokenConfigured) {
|
if (!tokenConfigured) {
|
||||||
ctx.logger.warn("netxops: published connection → %s tokenConfigured=false (set credential %s)", apiUrl, current.tokenCredentialRef);
|
ctx.logger.warn("netxops: published connection → %s tokenConfigured=false (set credential %s)", apiUrl, current.tokenCredentialRef);
|
||||||
|
|
@ -3559,6 +3871,7 @@ function apply(ctx, config = Config({})) {
|
||||||
});
|
});
|
||||||
ctx.inject(["skills"], (skillsCtx) => {
|
ctx.inject(["skills"], (skillsCtx) => {
|
||||||
let unregisterSkills;
|
let unregisterSkills;
|
||||||
|
let unregisterKbSkill;
|
||||||
let generation2 = 0;
|
let generation2 = 0;
|
||||||
const remountPublicSkills = () => {
|
const remountPublicSkills = () => {
|
||||||
const gen = ++generation2;
|
const gen = ++generation2;
|
||||||
|
|
@ -3579,14 +3892,27 @@ function apply(ctx, config = Config({})) {
|
||||||
skillsCtx.logger.warn("netxops: public skill register failed: %s", error);
|
skillsCtx.logger.warn("netxops: public skill register failed: %s", error);
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
const remountKbSkill = () => {
|
||||||
|
unregisterKbSkill?.();
|
||||||
|
unregisterKbSkill = undefined;
|
||||||
|
const snapshot = getKbContext();
|
||||||
|
unregisterKbSkill = registerKbContextSkill(skillsCtx, snapshot);
|
||||||
|
skillsCtx.logger.info("netxops: kb-context skill status=%s", snapshot.status);
|
||||||
|
};
|
||||||
remountPublicSkills();
|
remountPublicSkills();
|
||||||
|
remountKbSkill();
|
||||||
const stopWatch = watchNetxConnection(() => {
|
const stopWatch = watchNetxConnection(() => {
|
||||||
remountPublicSkills();
|
remountPublicSkills();
|
||||||
});
|
});
|
||||||
|
const stopKbWatch = watchKbContext(() => {
|
||||||
|
remountKbSkill();
|
||||||
|
});
|
||||||
skillsCtx.effect(() => () => {
|
skillsCtx.effect(() => () => {
|
||||||
generation2 += 1;
|
generation2 += 1;
|
||||||
stopWatch();
|
stopWatch();
|
||||||
|
stopKbWatch();
|
||||||
unregisterSkills?.();
|
unregisterSkills?.();
|
||||||
|
unregisterKbSkill?.();
|
||||||
}, "netxops: dispose public skills");
|
}, "netxops: dispose public skills");
|
||||||
});
|
});
|
||||||
ctx.inject(["connection"], (connCtx) => {
|
ctx.inject(["connection"], (connCtx) => {
|
||||||
|
|
@ -3596,10 +3922,17 @@ function apply(ctx, config = Config({})) {
|
||||||
connCtx.logger.warn("netxops: connection.rpc.handle unavailable — alarm status UI disabled");
|
connCtx.logger.warn("netxops: connection.rpc.handle unavailable — alarm status UI disabled");
|
||||||
} else {
|
} else {
|
||||||
connCtx.effect(() => {
|
connCtx.effect(() => {
|
||||||
const dispose = rpc.handle(NETXOPS_RPC_CHANNEL, async (endpoint) => {
|
const dispose = rpc.handle(NETXOPS_RPC_CHANNEL, async (endpoint, payload) => {
|
||||||
if (endpoint === "alarm-push.status") {
|
if (endpoint === "alarm-push.status") {
|
||||||
return { ok: true, value: getAlarmPushStatus() };
|
return { ok: true, value: getAlarmPushStatus() };
|
||||||
}
|
}
|
||||||
|
if (endpoint === "kb.status") {
|
||||||
|
return { ok: true, value: getKbContext() };
|
||||||
|
}
|
||||||
|
if (endpoint === "kb.resolve") {
|
||||||
|
const path = extractKbResolvePath(payload);
|
||||||
|
return { ok: true, value: resolveKbRoot(path) };
|
||||||
|
}
|
||||||
if (endpoint === "sessions.export.status") {
|
if (endpoint === "sessions.export.status") {
|
||||||
const value = await getSessionsExportStatus(ctx);
|
const value = await getSessionsExportStatus(ctx);
|
||||||
return { ok: true, value };
|
return { ok: true, value };
|
||||||
|
|
@ -3672,8 +4005,23 @@ function apply(ctx, config = Config({})) {
|
||||||
stopAlarmPush = undefined;
|
stopAlarmPush = undefined;
|
||||||
resetAlarmSession();
|
resetAlarmSession();
|
||||||
resetAlarmPushStatus();
|
resetAlarmPushStatus();
|
||||||
|
resetKbContext();
|
||||||
}, "netxops: dispose host bridge");
|
}, "netxops: dispose host bridge");
|
||||||
}
|
}
|
||||||
|
function extractKbResolvePath(payload) {
|
||||||
|
if (payload === null || typeof payload !== "object" || Array.isArray(payload))
|
||||||
|
return "";
|
||||||
|
const row = payload;
|
||||||
|
if (typeof row.path === "string")
|
||||||
|
return row.path;
|
||||||
|
const args = row.args;
|
||||||
|
if (args !== null && typeof args === "object" && !Array.isArray(args)) {
|
||||||
|
const path = args.path;
|
||||||
|
if (typeof path === "string")
|
||||||
|
return path;
|
||||||
|
}
|
||||||
|
return "";
|
||||||
|
}
|
||||||
export {
|
export {
|
||||||
name,
|
name,
|
||||||
inject,
|
inject,
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
{
|
{
|
||||||
"name": "dsh-netxops",
|
"name": "dsh-netxops",
|
||||||
"version": "0.1.28",
|
"version": "0.1.29",
|
||||||
"description": "DeepSeek Harness Netx Ops: capability groups ops/topology + multi IM delivery + session export",
|
"description": "DeepSeek Harness Netx Ops: capability groups ops/topology + multi IM delivery + session export + knowledge-base MANIFEST",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"private": false,
|
"private": false,
|
||||||
|
|
|
||||||
|
|
@ -19,6 +19,7 @@ import {
|
||||||
type ImDeliveryTarget,
|
type ImDeliveryTarget,
|
||||||
} from '../netx/im-targets.ts'
|
} from '../netx/im-targets.ts'
|
||||||
import { alarmPushTone, type AlarmPushPhase } from './alarm-push-status-view.ts'
|
import { alarmPushTone, type AlarmPushPhase } from './alarm-push-status-view.ts'
|
||||||
|
import { kbStatusTone, type KbSnapshot } from './kb-status-view.ts'
|
||||||
import { ensureStyles } from './styles.ts'
|
import { ensureStyles } from './styles.ts'
|
||||||
import { sessionsExportReasonLocaleKey } from './sessions-export-view.ts'
|
import { sessionsExportReasonLocaleKey } from './sessions-export-view.ts'
|
||||||
import type {} from '@deepseek-ai/dsh-client-ui-settings/client'
|
import type {} from '@deepseek-ai/dsh-client-ui-settings/client'
|
||||||
|
|
@ -38,10 +39,12 @@ function phaseLocaleKey(phase: AlarmPushPhase): NetxopsLocaleKey {
|
||||||
}
|
}
|
||||||
|
|
||||||
function StatusBadge(props: {
|
function StatusBadge(props: {
|
||||||
phase: AlarmPushPhase
|
phase?: AlarmPushPhase
|
||||||
|
tone?: 'ok' | 'warn' | 'err' | 'neutral'
|
||||||
label: string
|
label: string
|
||||||
}) {
|
}) {
|
||||||
const tone = alarmPushTone(props.phase)
|
const tone = props.tone
|
||||||
|
?? (props.phase !== undefined ? alarmPushTone(props.phase) : 'neutral')
|
||||||
const className = tone === 'ok'
|
const className = tone === 'ok'
|
||||||
? 'dsh-nx-status dsh-nx-statusOk'
|
? 'dsh-nx-status dsh-nx-statusOk'
|
||||||
: tone === 'warn'
|
: tone === 'warn'
|
||||||
|
|
@ -52,6 +55,23 @@ function StatusBadge(props: {
|
||||||
return <span className={className}>{props.label}</span>
|
return <span className={className}>{props.label}</span>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function kbBadgeLabel(
|
||||||
|
t: (key: NetxopsLocaleKey) => string,
|
||||||
|
snapshot: KbSnapshot | null,
|
||||||
|
): string {
|
||||||
|
if (!snapshot || snapshot.status === 'unconfigured') {
|
||||||
|
return t('kbStatusUnconfigured')
|
||||||
|
}
|
||||||
|
if (snapshot.status === 'error') {
|
||||||
|
return fillTemplate(t('kbStatusError'), { detail: snapshot.errorMessage || 'error' })
|
||||||
|
}
|
||||||
|
return fillTemplate(t('kbStatusConfigured'), {
|
||||||
|
operator: snapshot.operatorName,
|
||||||
|
country: snapshot.country,
|
||||||
|
version: snapshot.version,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
function fillTemplate(template: string, vars: Record<string, string>): string {
|
function fillTemplate(template: string, vars: Record<string, string>): string {
|
||||||
return template.replace(/\{(\w+)\}/g, (match, name: string) => (
|
return template.replace(/\{(\w+)\}/g, (match, name: string) => (
|
||||||
Object.prototype.hasOwnProperty.call(vars, name) ? vars[name]! : match
|
Object.prototype.hasOwnProperty.call(vars, name) ? vars[name]! : match
|
||||||
|
|
@ -461,6 +481,68 @@ export function NetxopsCard(props: NetxopsCardProps) {
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="dsh-nx-settings-card">
|
||||||
|
<h3>{t('sectionKnowledge')}</h3>
|
||||||
|
<div className="dsh-nx-settings-field">
|
||||||
|
<div className="dsh-nx-field-head">
|
||||||
|
<label htmlFor="netxops-kb-root">{t('kbRoot')}</label>
|
||||||
|
<span className="dsh-nx-badges">
|
||||||
|
<StatusBadge
|
||||||
|
tone={kbStatusTone(state.kbStatus?.status ?? 'unconfigured')}
|
||||||
|
label={kbBadgeLabel(t, state.kbStatus)}
|
||||||
|
/>
|
||||||
|
{state.kbRoot.overridden
|
||||||
|
? (
|
||||||
|
<>
|
||||||
|
<span className="dsh-nx-badge">{t('overridden')}</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="dsh-nx-reset"
|
||||||
|
disabled={disabled}
|
||||||
|
onClick={() => { props.resetField('kbRoot') }}
|
||||||
|
>
|
||||||
|
{t('reset')}
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
: null}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="dsh-nx-pathRow">
|
||||||
|
<input
|
||||||
|
id="netxops-kb-root"
|
||||||
|
className={state.kbRoot.invalid ? 'dsh-nx-inputInvalid' : undefined}
|
||||||
|
type="text"
|
||||||
|
value={state.kbRoot.text}
|
||||||
|
disabled={disabled}
|
||||||
|
aria-invalid={state.kbRoot.invalid || undefined}
|
||||||
|
onChange={(event) => { props.edit('kbRoot', event.target.value) }}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="dsh-nx-btn"
|
||||||
|
disabled={disabled || !state.kbDirectoryPickerReady}
|
||||||
|
onClick={() => { props.browseKbRoot() }}
|
||||||
|
>
|
||||||
|
{t('kbBrowse')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<p className={state.kbRoot.invalid ? 'dsh-nx-invalid' : 'dsh-nx-hint'}>
|
||||||
|
{state.kbRoot.invalid
|
||||||
|
? t('invalid')
|
||||||
|
: state.kbDirectoryPickerReady
|
||||||
|
? t('kbRootHint')
|
||||||
|
: t('kbBrowseUnavailable')}
|
||||||
|
</p>
|
||||||
|
{state.kbStatus?.status === 'error' && state.kbStatus.errorMessage
|
||||||
|
? <p className="dsh-nx-invalid" role="status">{state.kbStatus.errorMessage}</p>
|
||||||
|
: null}
|
||||||
|
{state.kbStatus?.status === 'configured' && state.kbStatus.realRoot
|
||||||
|
? <p className="dsh-nx-hint">{state.kbStatus.realRoot}</p>
|
||||||
|
: null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="dsh-nx-settings-card">
|
<div className="dsh-nx-settings-card">
|
||||||
<h3>{t('sectionAlarms')}</h3>
|
<h3>{t('sectionAlarms')}</h3>
|
||||||
<ToggleField
|
<ToggleField
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,12 @@ import {
|
||||||
type AlarmPushRpcCall,
|
type AlarmPushRpcCall,
|
||||||
type AlarmPushStatus,
|
type AlarmPushStatus,
|
||||||
} from './alarm-push-status-view.ts'
|
} from './alarm-push-status-view.ts'
|
||||||
|
import {
|
||||||
|
fetchKbStatus,
|
||||||
|
resolveKbPath,
|
||||||
|
type KbSnapshot,
|
||||||
|
} from './kb-status-view.ts'
|
||||||
|
import { unconfiguredKbSnapshot } from '../netx/kb-manifest.ts'
|
||||||
import {
|
import {
|
||||||
EMPTY_IM_DELIVERY_CATALOG,
|
EMPTY_IM_DELIVERY_CATALOG,
|
||||||
fetchImDeliveryCatalog,
|
fetchImDeliveryCatalog,
|
||||||
|
|
@ -49,6 +55,8 @@ export interface NetxopsSettings {
|
||||||
groupTopologyInPreset?: boolean
|
groupTopologyInPreset?: boolean
|
||||||
groupTopologyPublic?: boolean
|
groupTopologyPublic?: boolean
|
||||||
nmsProvider?: string
|
nmsProvider?: string
|
||||||
|
/** Operator-subset knowledge package root (Host-local path). */
|
||||||
|
kbRoot?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
interface CredentialState {
|
interface CredentialState {
|
||||||
|
|
@ -66,6 +74,7 @@ export interface NetxopsCardState extends CardShell {
|
||||||
groupTopologyInPreset: CardFieldState
|
groupTopologyInPreset: CardFieldState
|
||||||
groupTopologyPublic: CardFieldState
|
groupTopologyPublic: CardFieldState
|
||||||
nmsProvider: CardFieldState
|
nmsProvider: CardFieldState
|
||||||
|
kbRoot: CardFieldState
|
||||||
alarmPushEnabled: CardFieldState
|
alarmPushEnabled: CardFieldState
|
||||||
alarmDeliverDsh: CardFieldState
|
alarmDeliverDsh: CardFieldState
|
||||||
alarmDeliverIm: CardFieldState
|
alarmDeliverIm: CardFieldState
|
||||||
|
|
@ -78,6 +87,10 @@ export interface NetxopsCardState extends CardShell {
|
||||||
apiTokenRemoteReady: boolean
|
apiTokenRemoteReady: boolean
|
||||||
/** Host WSS status when Connection RPC is available; otherwise null. */
|
/** Host WSS status when Connection RPC is available; otherwise null. */
|
||||||
alarmPushStatus: AlarmPushStatus | null
|
alarmPushStatus: AlarmPushStatus | null
|
||||||
|
/** Latest KB snapshot (saved root, or live preview while editing). */
|
||||||
|
kbStatus: KbSnapshot | null
|
||||||
|
/** Soft-injected `remote.directoryPicker` is available. */
|
||||||
|
kbDirectoryPickerReady: boolean
|
||||||
/** Saved IM delivery targets for the picker (soft-depends on dsh-im-ops). */
|
/** Saved IM delivery targets for the picker (soft-depends on dsh-im-ops). */
|
||||||
imDeliveryCatalog: ImDeliveryCatalog
|
imDeliveryCatalog: ImDeliveryCatalog
|
||||||
/** Bulk session-export readiness from Host RPC; null when RPC is absent. */
|
/** Bulk session-export readiness from Host RPC; null when RPC is absent. */
|
||||||
|
|
@ -101,6 +114,12 @@ export interface NetxopsCardFace extends CardActions {
|
||||||
}
|
}
|
||||||
/** Download every durable session on this Host as one ZIP. */
|
/** Download every durable session on this Host as one ZIP. */
|
||||||
exportAllSessions: () => void
|
exportAllSessions: () => void
|
||||||
|
/** Open the Host directory picker and write the chosen path into kbRoot. */
|
||||||
|
browseKbRoot: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
type DirectoryPickerRemote = {
|
||||||
|
pick: (signal?: AbortSignal) => Promise<string | null>
|
||||||
}
|
}
|
||||||
|
|
||||||
type CredentialsRemote = {
|
type CredentialsRemote = {
|
||||||
|
|
@ -119,6 +138,11 @@ export class NetxopsCardController {
|
||||||
}
|
}
|
||||||
private rpcCall: AlarmPushRpcCall | undefined
|
private rpcCall: AlarmPushRpcCall | undefined
|
||||||
private alarmPushStatus: AlarmPushStatus | null = null
|
private alarmPushStatus: AlarmPushStatus | null = null
|
||||||
|
private kbStatus: KbSnapshot | null = null
|
||||||
|
private kbPreview: KbSnapshot | null = null
|
||||||
|
private kbDirectoryPicker: DirectoryPickerRemote | undefined
|
||||||
|
private kbBrowseInFlight = false
|
||||||
|
private kbStatusInFlight = false
|
||||||
private imDeliveryCatalog: ImDeliveryCatalog = { ...EMPTY_IM_DELIVERY_CATALOG }
|
private imDeliveryCatalog: ImDeliveryCatalog = { ...EMPTY_IM_DELIVERY_CATALOG }
|
||||||
private sessionsExportStatus: SessionsExportStatus | null = null
|
private sessionsExportStatus: SessionsExportStatus | null = null
|
||||||
private sessionsExportBusy = false
|
private sessionsExportBusy = false
|
||||||
|
|
@ -140,6 +164,7 @@ export class NetxopsCardController {
|
||||||
textField('apiUrl'),
|
textField('apiUrl'),
|
||||||
textField('lang'),
|
textField('lang'),
|
||||||
textField('nmsProvider'),
|
textField('nmsProvider'),
|
||||||
|
textField('kbRoot'),
|
||||||
booleanFieldPersistFalse('groupOpsInPreset'),
|
booleanFieldPersistFalse('groupOpsInPreset'),
|
||||||
booleanField('groupOpsPublic'),
|
booleanField('groupOpsPublic'),
|
||||||
booleanField('groupTopologyInPreset'),
|
booleanField('groupTopologyInPreset'),
|
||||||
|
|
@ -154,7 +179,10 @@ export class NetxopsCardController {
|
||||||
[{ field: API_TOKEN_FIELD, write: text => this.writeToken(text) }],
|
[{ field: API_TOKEN_FIELD, write: text => this.writeToken(text) }],
|
||||||
)
|
)
|
||||||
this.store = this.form.bind(() => this.projection())
|
this.store = this.form.bind(() => this.projection())
|
||||||
scope.subscribe(() => { void this.readCredential() })
|
scope.subscribe(() => {
|
||||||
|
void this.readCredential()
|
||||||
|
void this.refreshKbPreview()
|
||||||
|
})
|
||||||
void this.readCredential()
|
void this.readCredential()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -171,7 +199,7 @@ export class NetxopsCardController {
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Soft-inject Host Connection RPC used to read alarm-push WSS status.
|
* Soft-inject Host Connection RPC used to read alarm-push WSS status + KB status.
|
||||||
* @param call - `ctx.connection.rpc.call`, or undefined when connection leaves.
|
* @param call - `ctx.connection.rpc.call`, or undefined when connection leaves.
|
||||||
*/
|
*/
|
||||||
setAlarmPushRpc(call: AlarmPushRpcCall | undefined): void {
|
setAlarmPushRpc(call: AlarmPushRpcCall | undefined): void {
|
||||||
|
|
@ -183,6 +211,11 @@ export class NetxopsCardController {
|
||||||
this.alarmPushStatus = null
|
this.alarmPushStatus = null
|
||||||
changed = true
|
changed = true
|
||||||
}
|
}
|
||||||
|
if (this.kbStatus !== null || this.kbPreview !== null) {
|
||||||
|
this.kbStatus = null
|
||||||
|
this.kbPreview = null
|
||||||
|
changed = true
|
||||||
|
}
|
||||||
if (this.imDeliveryCatalog.options.length > 0 || this.imDeliveryCatalog.available !== true) {
|
if (this.imDeliveryCatalog.options.length > 0 || this.imDeliveryCatalog.available !== true) {
|
||||||
this.imDeliveryCatalog = { ...EMPTY_IM_DELIVERY_CATALOG }
|
this.imDeliveryCatalog = { ...EMPTY_IM_DELIVERY_CATALOG }
|
||||||
changed = true
|
changed = true
|
||||||
|
|
@ -196,14 +229,29 @@ export class NetxopsCardController {
|
||||||
}
|
}
|
||||||
this.startStatusPoll()
|
this.startStatusPoll()
|
||||||
void this.refreshAlarmPushStatus()
|
void this.refreshAlarmPushStatus()
|
||||||
|
void this.refreshKbStatus()
|
||||||
|
void this.refreshKbPreview()
|
||||||
void this.refreshImDeliveryCatalog()
|
void this.refreshImDeliveryCatalog()
|
||||||
void this.refreshSessionsExportStatus()
|
void this.refreshSessionsExportStatus()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Soft-inject `remote.directoryPicker` for the knowledge-base browse button.
|
||||||
|
*/
|
||||||
|
setDirectoryPicker(picker: DirectoryPickerRemote | undefined): void {
|
||||||
|
const ready = picker !== undefined
|
||||||
|
if (this.kbDirectoryPicker === picker && (this.kbDirectoryPicker !== undefined) === ready) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
this.kbDirectoryPicker = picker
|
||||||
|
this.store.set(this.projection())
|
||||||
|
}
|
||||||
|
|
||||||
private startStatusPoll(): void {
|
private startStatusPoll(): void {
|
||||||
if (this.pollTimer !== undefined) return
|
if (this.pollTimer !== undefined) return
|
||||||
this.pollTimer = setInterval(() => {
|
this.pollTimer = setInterval(() => {
|
||||||
void this.refreshAlarmPushStatus()
|
void this.refreshAlarmPushStatus()
|
||||||
|
void this.refreshKbStatus()
|
||||||
void this.refreshImDeliveryCatalog()
|
void this.refreshImDeliveryCatalog()
|
||||||
void this.refreshSessionsExportStatus()
|
void this.refreshSessionsExportStatus()
|
||||||
}, STATUS_POLL_MS)
|
}, STATUS_POLL_MS)
|
||||||
|
|
@ -293,6 +341,86 @@ export class NetxopsCardController {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async refreshKbStatus(): Promise<void> {
|
||||||
|
const call = this.rpcCall
|
||||||
|
if (call === undefined || this.kbStatusInFlight) return
|
||||||
|
this.kbStatusInFlight = true
|
||||||
|
try {
|
||||||
|
const next = await fetchKbStatus(call)
|
||||||
|
const prev = this.kbStatus
|
||||||
|
if (
|
||||||
|
prev
|
||||||
|
&& prev.status === next.status
|
||||||
|
&& prev.realRoot === next.realRoot
|
||||||
|
&& prev.operatorName === next.operatorName
|
||||||
|
&& prev.country === next.country
|
||||||
|
&& prev.version === next.version
|
||||||
|
&& prev.errorMessage === next.errorMessage
|
||||||
|
) return
|
||||||
|
this.kbStatus = next
|
||||||
|
this.store.set(this.projection())
|
||||||
|
} catch {
|
||||||
|
// Keep last good snapshot; next poll retries.
|
||||||
|
} finally {
|
||||||
|
this.kbStatusInFlight = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async refreshKbPreview(): Promise<void> {
|
||||||
|
const call = this.rpcCall
|
||||||
|
if (call === undefined) return
|
||||||
|
const draft = this.form.field('kbRoot').text.trim()
|
||||||
|
const saved = (this.scope.getSnapshot().value?.kbRoot ?? '').trim()
|
||||||
|
if (draft === saved) {
|
||||||
|
if (this.kbPreview !== null) {
|
||||||
|
this.kbPreview = null
|
||||||
|
this.store.set(this.projection())
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const next = draft === ''
|
||||||
|
? unconfiguredKbSnapshot()
|
||||||
|
: await resolveKbPath(call, draft)
|
||||||
|
const prev = this.kbPreview
|
||||||
|
if (
|
||||||
|
prev
|
||||||
|
&& prev.status === next.status
|
||||||
|
&& prev.realRoot === next.realRoot
|
||||||
|
&& prev.operatorName === next.operatorName
|
||||||
|
&& prev.country === next.country
|
||||||
|
&& prev.version === next.version
|
||||||
|
&& prev.errorMessage === next.errorMessage
|
||||||
|
) return
|
||||||
|
this.kbPreview = next
|
||||||
|
this.store.set(this.projection())
|
||||||
|
} catch {
|
||||||
|
// Keep last preview; edits retry via scope subscribe.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Open the Host OS directory chooser and write the path into kbRoot.
|
||||||
|
*/
|
||||||
|
browseKbRoot(): void {
|
||||||
|
const picker = this.kbDirectoryPicker
|
||||||
|
if (picker === undefined || this.kbBrowseInFlight) return
|
||||||
|
this.kbBrowseInFlight = true
|
||||||
|
void picker.pick()
|
||||||
|
.then((path) => {
|
||||||
|
if (typeof path === 'string' && path.trim() !== '') {
|
||||||
|
this.form.actions().edit('kbRoot', path)
|
||||||
|
void this.refreshKbPreview()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
// Picker cancel / host refusal — leave draft unchanged.
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
this.kbBrowseInFlight = false
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Download every durable session as one ZIP through the browser download manager.
|
* Download every durable session as one ZIP through the browser download manager.
|
||||||
*/
|
*/
|
||||||
|
|
@ -337,6 +465,7 @@ export class NetxopsCardController {
|
||||||
apiUrl: this.form.field('apiUrl'),
|
apiUrl: this.form.field('apiUrl'),
|
||||||
lang: this.form.field('lang'),
|
lang: this.form.field('lang'),
|
||||||
nmsProvider: this.form.field('nmsProvider'),
|
nmsProvider: this.form.field('nmsProvider'),
|
||||||
|
kbRoot: this.form.field('kbRoot'),
|
||||||
groupOpsInPreset: this.form.field('groupOpsInPreset'),
|
groupOpsInPreset: this.form.field('groupOpsInPreset'),
|
||||||
groupOpsPublic: this.form.field('groupOpsPublic'),
|
groupOpsPublic: this.form.field('groupOpsPublic'),
|
||||||
groupTopologyInPreset: this.form.field('groupTopologyInPreset'),
|
groupTopologyInPreset: this.form.field('groupTopologyInPreset'),
|
||||||
|
|
@ -352,6 +481,8 @@ export class NetxopsCardController {
|
||||||
apiTokenWritable: this.credential.remoteReady && this.credential.writable,
|
apiTokenWritable: this.credential.remoteReady && this.credential.writable,
|
||||||
apiTokenRemoteReady: this.credential.remoteReady,
|
apiTokenRemoteReady: this.credential.remoteReady,
|
||||||
alarmPushStatus: this.alarmPushStatus,
|
alarmPushStatus: this.alarmPushStatus,
|
||||||
|
kbStatus: this.kbPreview ?? this.kbStatus,
|
||||||
|
kbDirectoryPickerReady: this.kbDirectoryPicker !== undefined,
|
||||||
imDeliveryCatalog: this.imDeliveryCatalog,
|
imDeliveryCatalog: this.imDeliveryCatalog,
|
||||||
sessionsExportStatus: this.sessionsExportStatus,
|
sessionsExportStatus: this.sessionsExportStatus,
|
||||||
sessionsExportBusy: this.sessionsExportBusy,
|
sessionsExportBusy: this.sessionsExportBusy,
|
||||||
|
|
@ -407,10 +538,24 @@ export class NetxopsCardController {
|
||||||
}
|
}
|
||||||
|
|
||||||
inject(): NetxopsCardFace {
|
inject(): NetxopsCardFace {
|
||||||
|
const actions = this.form.actions()
|
||||||
return {
|
return {
|
||||||
hooks: { netxopsCard: this.store },
|
hooks: { netxopsCard: this.store },
|
||||||
...this.form.actions(),
|
...actions,
|
||||||
|
edit: (field, text) => {
|
||||||
|
actions.edit(field, text)
|
||||||
|
if (field === 'kbRoot') void this.refreshKbPreview()
|
||||||
|
},
|
||||||
|
discard: () => {
|
||||||
|
actions.discard()
|
||||||
|
void this.refreshKbPreview()
|
||||||
|
},
|
||||||
|
resetField: (field) => {
|
||||||
|
actions.resetField(field)
|
||||||
|
if (field === 'kbRoot') void this.refreshKbPreview()
|
||||||
|
},
|
||||||
exportAllSessions: () => { this.exportAllSessions() },
|
exportAllSessions: () => { this.exportAllSessions() },
|
||||||
|
browseKbRoot: () => { this.browseKbRoot() },
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -65,7 +65,7 @@ export function apply(ctx: ClientContext): void {
|
||||||
}, 'netxops: credential invalidations')
|
}, 'netxops: credential invalidations')
|
||||||
})
|
})
|
||||||
|
|
||||||
// Soft-inject Connection so the card can poll host WSS status.
|
// Soft-inject Connection so the card can poll host WSS status + KB status.
|
||||||
ctx.inject(['connection'], (connCtx) => {
|
ctx.inject(['connection'], (connCtx) => {
|
||||||
const call = connCtx.connection?.rpc?.call?.bind(connCtx.connection.rpc)
|
const call = connCtx.connection?.rpc?.call?.bind(connCtx.connection.rpc)
|
||||||
if (typeof call !== 'function') {
|
if (typeof call !== 'function') {
|
||||||
|
|
@ -78,6 +78,20 @@ export function apply(ctx: ClientContext): void {
|
||||||
}, 'netxops: clear alarm-push rpc')
|
}, 'netxops: clear alarm-push rpc')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Soft-inject Host directory picker for knowledge-base browse.
|
||||||
|
ctx.inject(['remote.directoryPicker'], (dpCtx) => {
|
||||||
|
const picker = (dpCtx as { remote?: { directoryPicker?: { pick?: (signal?: AbortSignal) => Promise<string | null> } } })
|
||||||
|
.remote?.directoryPicker
|
||||||
|
if (!picker || typeof picker.pick !== 'function') {
|
||||||
|
dpCtx.logger.warn('netxops: remote.directoryPicker.pick unavailable — browse button disabled')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
card.setDirectoryPicker(picker)
|
||||||
|
dpCtx.effect(() => () => {
|
||||||
|
card.setDirectoryPicker(undefined)
|
||||||
|
}, 'netxops: clear directory picker')
|
||||||
|
})
|
||||||
|
|
||||||
ctx.slots.inject('settings.section', () => ctx.slots.register({
|
ctx.slots.inject('settings.section', () => ctx.slots.register({
|
||||||
name: 'settings.section',
|
name: 'settings.section',
|
||||||
id: NETXOPS_NS,
|
id: NETXOPS_NS,
|
||||||
|
|
|
||||||
80
src/client/kb-status-view.ts
Normal file
80
src/client/kb-status-view.ts
Normal file
|
|
@ -0,0 +1,80 @@
|
||||||
|
/**
|
||||||
|
* Browser helpers for knowledge-base status via Host Connection RPC.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { KbSnapshot, KbStatus } from '../netx/kb-manifest.ts'
|
||||||
|
import { unconfiguredKbSnapshot } from '../netx/kb-manifest.ts'
|
||||||
|
import type { AlarmPushRpcCall } from './alarm-push-status-view.ts'
|
||||||
|
|
||||||
|
/** Same channel as host `NETXOPS_RPC_CHANNEL`. */
|
||||||
|
export const NETXOPS_RPC_CHANNEL = '/netxops'
|
||||||
|
|
||||||
|
export const KB_STATUS_ENDPOINT = 'kb.status'
|
||||||
|
export const KB_RESOLVE_ENDPOINT = 'kb.resolve'
|
||||||
|
|
||||||
|
export type { KbSnapshot, KbStatus }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Normalize a host KB snapshot for the card.
|
||||||
|
*/
|
||||||
|
export function asKbSnapshot(value: unknown): KbSnapshot {
|
||||||
|
const empty = unconfiguredKbSnapshot()
|
||||||
|
if (value === null || typeof value !== 'object' || Array.isArray(value)) return empty
|
||||||
|
const row = value as Record<string, unknown>
|
||||||
|
const status = row.status === 'configured' || row.status === 'error' || row.status === 'unconfigured'
|
||||||
|
? row.status
|
||||||
|
: 'unconfigured'
|
||||||
|
const contentRaw = row.content
|
||||||
|
const content = { ...empty.content }
|
||||||
|
if (contentRaw !== null && typeof contentRaw === 'object' && !Array.isArray(contentRaw)) {
|
||||||
|
for (const [key, flag] of Object.entries(contentRaw as Record<string, unknown>)) {
|
||||||
|
content[key] = flag === true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
status,
|
||||||
|
realRoot: typeof row.realRoot === 'string' ? row.realRoot : '',
|
||||||
|
operatorName: typeof row.operatorName === 'string' ? row.operatorName : '',
|
||||||
|
country: typeof row.country === 'string' ? row.country : '',
|
||||||
|
version: typeof row.version === 'string' ? row.version : '',
|
||||||
|
content,
|
||||||
|
errorMessage: typeof row.errorMessage === 'string' ? row.errorMessage : '',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch the published KB snapshot from the Host.
|
||||||
|
*/
|
||||||
|
export async function fetchKbStatus(
|
||||||
|
call: AlarmPushRpcCall,
|
||||||
|
signal?: AbortSignal,
|
||||||
|
): Promise<KbSnapshot> {
|
||||||
|
const result = await call(NETXOPS_RPC_CHANNEL, KB_STATUS_ENDPOINT, {}, signal)
|
||||||
|
if (result !== null && typeof result === 'object' && (result as { ok?: boolean }).ok === true) {
|
||||||
|
return asKbSnapshot((result as { value?: unknown }).value)
|
||||||
|
}
|
||||||
|
return unconfiguredKbSnapshot()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Preview resolve for a path that may not be saved yet.
|
||||||
|
*/
|
||||||
|
export async function resolveKbPath(
|
||||||
|
call: AlarmPushRpcCall,
|
||||||
|
path: string,
|
||||||
|
signal?: AbortSignal,
|
||||||
|
): Promise<KbSnapshot> {
|
||||||
|
const result = await call(NETXOPS_RPC_CHANNEL, KB_RESOLVE_ENDPOINT, { path }, signal)
|
||||||
|
if (result !== null && typeof result === 'object' && (result as { ok?: boolean }).ok === true) {
|
||||||
|
return asKbSnapshot((result as { value?: unknown }).value)
|
||||||
|
}
|
||||||
|
return unconfiguredKbSnapshot()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Badge tone for the settings card. */
|
||||||
|
export function kbStatusTone(status: KbStatus): 'ok' | 'warn' | 'err' | 'neutral' {
|
||||||
|
if (status === 'configured') return 'ok'
|
||||||
|
if (status === 'error') return 'err'
|
||||||
|
if (status === 'unconfigured') return 'warn'
|
||||||
|
return 'neutral'
|
||||||
|
}
|
||||||
|
|
@ -5,6 +5,7 @@ export type NetxopsLocaleKey =
|
||||||
| 'description'
|
| 'description'
|
||||||
| 'sectionConnection'
|
| 'sectionConnection'
|
||||||
| 'sectionCapabilities'
|
| 'sectionCapabilities'
|
||||||
|
| 'sectionKnowledge'
|
||||||
| 'sectionAlarms'
|
| 'sectionAlarms'
|
||||||
| 'sectionExport'
|
| 'sectionExport'
|
||||||
| 'apiUrl'
|
| 'apiUrl'
|
||||||
|
|
@ -18,6 +19,13 @@ export type NetxopsLocaleKey =
|
||||||
| 'groupTopology'
|
| 'groupTopology'
|
||||||
| 'groupInPreset'
|
| 'groupInPreset'
|
||||||
| 'groupPublic'
|
| 'groupPublic'
|
||||||
|
| 'kbRoot'
|
||||||
|
| 'kbRootHint'
|
||||||
|
| 'kbBrowse'
|
||||||
|
| 'kbBrowseUnavailable'
|
||||||
|
| 'kbStatusConfigured'
|
||||||
|
| 'kbStatusUnconfigured'
|
||||||
|
| 'kbStatusError'
|
||||||
| 'alarmPushEnabled'
|
| 'alarmPushEnabled'
|
||||||
| 'alarmPushStatus'
|
| 'alarmPushStatus'
|
||||||
| 'alarmPushPhaseDisabled'
|
| 'alarmPushPhaseDisabled'
|
||||||
|
|
@ -64,9 +72,10 @@ export type NetxopsLocaleKey =
|
||||||
|
|
||||||
export const en: Record<NetxopsLocaleKey, string> = {
|
export const en: Record<NetxopsLocaleKey, string> = {
|
||||||
title: 'Netx Ops',
|
title: 'Netx Ops',
|
||||||
description: 'API, token, capabilities, and alarm delivery.',
|
description: 'API, token, capabilities, knowledge base, and alarm delivery.',
|
||||||
sectionConnection: 'Connection',
|
sectionConnection: 'Connection',
|
||||||
sectionCapabilities: 'Capability groups',
|
sectionCapabilities: 'Capability groups',
|
||||||
|
sectionKnowledge: 'Knowledge base',
|
||||||
sectionAlarms: 'Key-alarm delivery',
|
sectionAlarms: 'Key-alarm delivery',
|
||||||
sectionExport: 'Session export',
|
sectionExport: 'Session export',
|
||||||
apiUrl: 'API URL',
|
apiUrl: 'API URL',
|
||||||
|
|
@ -80,6 +89,13 @@ export const en: Record<NetxopsLocaleKey, string> = {
|
||||||
groupTopology: 'topology',
|
groupTopology: 'topology',
|
||||||
groupInPreset: 'In Netx Ops preset',
|
groupInPreset: 'In Netx Ops preset',
|
||||||
groupPublic: 'Publish to other presets',
|
groupPublic: 'Publish to other presets',
|
||||||
|
kbRoot: 'Package root',
|
||||||
|
kbRootHint: 'Folder with MANIFEST.json (operator-subset v1.0). Empty = pure netx.',
|
||||||
|
kbBrowse: 'Browse…',
|
||||||
|
kbBrowseUnavailable: 'Directory picker unavailable — paste an absolute path.',
|
||||||
|
kbStatusConfigured: 'Knowledge base: {operator} ({country}) v{version}',
|
||||||
|
kbStatusUnconfigured: 'Knowledge base: not configured (pure netx)',
|
||||||
|
kbStatusError: 'Knowledge base: error — {detail}',
|
||||||
alarmPushEnabled: 'Key-alarm push',
|
alarmPushEnabled: 'Key-alarm push',
|
||||||
alarmPushStatus: 'Status',
|
alarmPushStatus: 'Status',
|
||||||
alarmPushPhaseDisabled: 'Off',
|
alarmPushPhaseDisabled: 'Off',
|
||||||
|
|
@ -127,9 +143,10 @@ export const en: Record<NetxopsLocaleKey, string> = {
|
||||||
|
|
||||||
export const zh: Record<NetxopsLocaleKey, string> = {
|
export const zh: Record<NetxopsLocaleKey, string> = {
|
||||||
title: 'Netx Ops',
|
title: 'Netx Ops',
|
||||||
description: 'API、Token、能力组与告警投递。',
|
description: 'API、Token、能力组、知识库与告警投递。',
|
||||||
sectionConnection: '连接',
|
sectionConnection: '连接',
|
||||||
sectionCapabilities: '能力组',
|
sectionCapabilities: '能力组',
|
||||||
|
sectionKnowledge: '知识库',
|
||||||
sectionAlarms: '关键告警投递',
|
sectionAlarms: '关键告警投递',
|
||||||
sectionExport: '会话导出',
|
sectionExport: '会话导出',
|
||||||
apiUrl: 'API 地址',
|
apiUrl: 'API 地址',
|
||||||
|
|
@ -143,6 +160,13 @@ export const zh: Record<NetxopsLocaleKey, string> = {
|
||||||
groupTopology: 'topology',
|
groupTopology: 'topology',
|
||||||
groupInPreset: '在 Netx Ops 预设中启用',
|
groupInPreset: '在 Netx Ops 预设中启用',
|
||||||
groupPublic: '对其他预设公开',
|
groupPublic: '对其他预设公开',
|
||||||
|
kbRoot: '知识包根目录',
|
||||||
|
kbRootHint: '含 MANIFEST.json 的运营商子集包(v1.0)。留空=纯 netx。',
|
||||||
|
kbBrowse: '浏览…',
|
||||||
|
kbBrowseUnavailable: '目录选择器不可用 — 请粘贴绝对路径。',
|
||||||
|
kbStatusConfigured: '知识库: {operator}({country}) v{version}',
|
||||||
|
kbStatusUnconfigured: '知识库: 未配置(纯 netx)',
|
||||||
|
kbStatusError: '知识库: 错误 — {detail}',
|
||||||
alarmPushEnabled: '关键告警推送',
|
alarmPushEnabled: '关键告警推送',
|
||||||
alarmPushStatus: '状态',
|
alarmPushStatus: '状态',
|
||||||
alarmPushPhaseDisabled: '未开启',
|
alarmPushPhaseDisabled: '未开启',
|
||||||
|
|
|
||||||
|
|
@ -41,7 +41,9 @@ const CSS = `
|
||||||
.dsh-nx-groupTitle{font-size:13px;font-weight:600;line-height:1.5;color:var(--dsw-alias-label-primary,#1f2329)}
|
.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-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-imTargetList{display:flex;flex-direction:column;gap:8px;padding:4px 0 2px}
|
||||||
.dsh-nx-settings-actions{display:flex;gap:8px;align-items:center;flex-wrap:wrap;margin-top:4px}
|
.dsh-nx-pathRow{display:flex;gap:8px;align-items:stretch}
|
||||||
|
.dsh-nx-pathRow>input{flex:1;min-width:0}
|
||||||
|
.dsh-nx-pathRow>.dsh-nx-btn{flex:none;align-self:stretch}
|
||||||
.dsh-nx-settings-msg{font-size:12px;color:var(--dsw-alias-label-secondary,#646a73)}
|
.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.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-msg.err{color:var(--dsw-alias-state-error-primary,#d54941)}
|
||||||
|
|
|
||||||
86
src/index.ts
86
src/index.ts
|
|
@ -1,8 +1,9 @@
|
||||||
/**
|
/**
|
||||||
* Host-plane Netx Ops: settings (apiUrl / lang / alarm push / capability groups)
|
* Host-plane Netx Ops: settings (apiUrl / lang / alarm push / capability groups /
|
||||||
* + credentials (NETX_API_TOKEN) publish a connection snapshot. The Ops preset
|
* optional knowledge-base root) + credentials (NETX_API_TOKEN) publish a
|
||||||
* mounts selected `netx__*` groups via `dsh-netxops/tools`; groups marked public
|
* connection snapshot. The Ops preset mounts selected `netx__*` groups via
|
||||||
* also register on the host tool/skill layer for other presets.
|
* `dsh-netxops/tools`; groups marked public also register on the host tool/skill
|
||||||
|
* layer for other presets.
|
||||||
*
|
*
|
||||||
* When「关键告警推送」is on, this host dials out to netx's fixed-IP alarm hub and
|
* When「关键告警推送」is on, this host dials out to netx's fixed-IP alarm hub and
|
||||||
* opens/follows a sticky DSH session (im / WhatsApp is optional and separate).
|
* opens/follows a sticky DSH session (im / WhatsApp is optional and separate).
|
||||||
|
|
@ -11,6 +12,10 @@
|
||||||
* (`GET /api/netxops.sessions.export`) for HQ analysis — including cloud Hosts,
|
* (`GET /api/netxops.sessions.export`) for HQ analysis — including cloud Hosts,
|
||||||
* where the browser receives the archive.
|
* where the browser receives the archive.
|
||||||
*
|
*
|
||||||
|
* Knowledge base (P1): settings `kbRoot` → locate/validate MANIFEST.json →
|
||||||
|
* `process.env.KB_*` + dynamic `kb-context` skill. Unconfigured/error degrades
|
||||||
|
* to pure netx (no invented operator).
|
||||||
|
*
|
||||||
* @module dsh-netxops
|
* @module dsh-netxops
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
|
@ -39,6 +44,15 @@ import {
|
||||||
} from './netx/capability-groups.ts'
|
} from './netx/capability-groups.ts'
|
||||||
import { registerGroupSkills } from './netx/group-skills.ts'
|
import { registerGroupSkills } from './netx/group-skills.ts'
|
||||||
import { resolveImTargets } from './netx/im-targets.ts'
|
import { resolveImTargets } from './netx/im-targets.ts'
|
||||||
|
import { registerKbContextSkill } from './netx/kb-context-skill.ts'
|
||||||
|
import { resolveKbRoot } from './netx/kb-manifest.ts'
|
||||||
|
import {
|
||||||
|
applyKbEnv,
|
||||||
|
getKbContext,
|
||||||
|
publishKbContext,
|
||||||
|
resetKbContext,
|
||||||
|
watchKbContext,
|
||||||
|
} from './netx/kb-runtime.ts'
|
||||||
import { publishNetxConnection, getNetxConnection, watchNetxConnection } from './netx/runtime.ts'
|
import { publishNetxConnection, getNetxConnection, watchNetxConnection } from './netx/runtime.ts'
|
||||||
import {
|
import {
|
||||||
getSessionsExportStatus,
|
getSessionsExportStatus,
|
||||||
|
|
@ -115,6 +129,12 @@ export interface Config {
|
||||||
groupTopologyInPreset: boolean
|
groupTopologyInPreset: boolean
|
||||||
/** Publish topology tools/skills to other presets (default off). */
|
/** Publish topology tools/skills to other presets (default off). */
|
||||||
groupTopologyPublic: boolean
|
groupTopologyPublic: boolean
|
||||||
|
/**
|
||||||
|
* Absolute (or Host-local) path to an operator-subset knowledge package.
|
||||||
|
* Empty = unconfigured. Plugin locates `MANIFEST.json` (≤3 levels) and
|
||||||
|
* injects `KB_*` + `kb-context` skill.
|
||||||
|
*/
|
||||||
|
kbRoot: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export const Config: z<Config> = z.object({
|
export const Config: z<Config> = z.object({
|
||||||
|
|
@ -134,6 +154,7 @@ export const Config: z<Config> = z.object({
|
||||||
groupOpsPublic: z.boolean().default(false),
|
groupOpsPublic: z.boolean().default(false),
|
||||||
groupTopologyInPreset: z.boolean().default(false),
|
groupTopologyInPreset: z.boolean().default(false),
|
||||||
groupTopologyPublic: z.boolean().default(false),
|
groupTopologyPublic: z.boolean().default(false),
|
||||||
|
kbRoot: z.string().default(''),
|
||||||
})
|
})
|
||||||
|
|
||||||
/** Package root (parent of `lib/` or `src/` depending on launch). */
|
/** Package root (parent of `lib/` or `src/` depending on launch). */
|
||||||
|
|
@ -297,6 +318,20 @@ export function apply(ctx: Context, config: Config = Config({})): void {
|
||||||
toolCallTimeoutMs: current.toolCallTimeoutMs,
|
toolCallTimeoutMs: current.toolCallTimeoutMs,
|
||||||
groups,
|
groups,
|
||||||
})
|
})
|
||||||
|
const kb = resolveKbRoot(current.kbRoot ?? '')
|
||||||
|
publishKbContext(kb)
|
||||||
|
applyKbEnv(kb)
|
||||||
|
if (kb.status === 'configured') {
|
||||||
|
ctx.logger.info(
|
||||||
|
'netxops: knowledge base configured → %s (%s / %s v%s)',
|
||||||
|
kb.realRoot,
|
||||||
|
kb.operatorName,
|
||||||
|
kb.country,
|
||||||
|
kb.version,
|
||||||
|
)
|
||||||
|
} else if (kb.status === 'error') {
|
||||||
|
ctx.logger.warn('netxops: knowledge base error — %s', kb.errorMessage)
|
||||||
|
}
|
||||||
restartAlarmPush(current, apiUrl, token)
|
restartAlarmPush(current, apiUrl, token)
|
||||||
if (!tokenConfigured) {
|
if (!tokenConfigured) {
|
||||||
ctx.logger.warn(
|
ctx.logger.warn(
|
||||||
|
|
@ -364,6 +399,7 @@ export function apply(ctx: Context, config: Config = Config({})): void {
|
||||||
|
|
||||||
ctx.inject(['skills'], (skillsCtx) => {
|
ctx.inject(['skills'], (skillsCtx) => {
|
||||||
let unregisterSkills: (() => void) | undefined
|
let unregisterSkills: (() => void) | undefined
|
||||||
|
let unregisterKbSkill: (() => void) | undefined
|
||||||
let generation = 0
|
let generation = 0
|
||||||
const remountPublicSkills = (): void => {
|
const remountPublicSkills = (): void => {
|
||||||
const gen = ++generation
|
const gen = ++generation
|
||||||
|
|
@ -386,19 +422,36 @@ export function apply(ctx: Context, config: Config = Config({})): void {
|
||||||
skillsCtx.logger.warn('netxops: public skill register failed: %s', error)
|
skillsCtx.logger.warn('netxops: public skill register failed: %s', error)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
const remountKbSkill = (): void => {
|
||||||
|
unregisterKbSkill?.()
|
||||||
|
unregisterKbSkill = undefined
|
||||||
|
const snapshot = getKbContext()
|
||||||
|
unregisterKbSkill = registerKbContextSkill(skillsCtx, snapshot)
|
||||||
|
skillsCtx.logger.info(
|
||||||
|
'netxops: kb-context skill status=%s',
|
||||||
|
snapshot.status,
|
||||||
|
)
|
||||||
|
}
|
||||||
remountPublicSkills()
|
remountPublicSkills()
|
||||||
|
remountKbSkill()
|
||||||
const stopWatch = watchNetxConnection(() => { remountPublicSkills() })
|
const stopWatch = watchNetxConnection(() => { remountPublicSkills() })
|
||||||
|
const stopKbWatch = watchKbContext(() => { remountKbSkill() })
|
||||||
skillsCtx.effect(() => () => {
|
skillsCtx.effect(() => () => {
|
||||||
generation += 1
|
generation += 1
|
||||||
stopWatch()
|
stopWatch()
|
||||||
|
stopKbWatch()
|
||||||
unregisterSkills?.()
|
unregisterSkills?.()
|
||||||
|
unregisterKbSkill?.()
|
||||||
}, 'netxops: dispose public skills')
|
}, 'netxops: dispose public skills')
|
||||||
})
|
})
|
||||||
|
|
||||||
// Browser card: alarm-push status + IM catalog (RPC) and all-sessions ZIP (Fetch).
|
// Browser card: alarm-push status + IM catalog (RPC) and all-sessions ZIP (Fetch).
|
||||||
ctx.inject(['connection'], (connCtx) => {
|
ctx.inject(['connection'], (connCtx) => {
|
||||||
const connection = connCtx.connection as {
|
const connection = connCtx.connection as {
|
||||||
rpc?: { handle?: (channel: string, handler: (endpoint: string) => Promise<unknown>) => (() => void) | Promise<void> }
|
rpc?: { handle?: (
|
||||||
|
channel: string,
|
||||||
|
handler: (endpoint: string, payload?: unknown) => Promise<unknown>,
|
||||||
|
) => (() => void) | Promise<void> }
|
||||||
fetch?: {
|
fetch?: {
|
||||||
register?: (route: {
|
register?: (route: {
|
||||||
readonly path: string
|
readonly path: string
|
||||||
|
|
@ -414,10 +467,17 @@ export function apply(ctx: Context, config: Config = Config({})): void {
|
||||||
connCtx.effect(() => {
|
connCtx.effect(() => {
|
||||||
const dispose = rpc.handle(
|
const dispose = rpc.handle(
|
||||||
NETXOPS_RPC_CHANNEL,
|
NETXOPS_RPC_CHANNEL,
|
||||||
async (endpoint: string) => {
|
async (endpoint: string, payload?: unknown) => {
|
||||||
if (endpoint === 'alarm-push.status') {
|
if (endpoint === 'alarm-push.status') {
|
||||||
return { ok: true, value: getAlarmPushStatus() }
|
return { ok: true, value: getAlarmPushStatus() }
|
||||||
}
|
}
|
||||||
|
if (endpoint === 'kb.status') {
|
||||||
|
return { ok: true, value: getKbContext() }
|
||||||
|
}
|
||||||
|
if (endpoint === 'kb.resolve') {
|
||||||
|
const path = extractKbResolvePath(payload)
|
||||||
|
return { ok: true, value: resolveKbRoot(path) }
|
||||||
|
}
|
||||||
if (endpoint === 'sessions.export.status') {
|
if (endpoint === 'sessions.export.status') {
|
||||||
const value = await getSessionsExportStatus(ctx)
|
const value = await getSessionsExportStatus(ctx)
|
||||||
return { ok: true, value }
|
return { ok: true, value }
|
||||||
|
|
@ -492,5 +552,19 @@ export function apply(ctx: Context, config: Config = Config({})): void {
|
||||||
stopAlarmPush = undefined
|
stopAlarmPush = undefined
|
||||||
resetAlarmSession()
|
resetAlarmSession()
|
||||||
resetAlarmPushStatus()
|
resetAlarmPushStatus()
|
||||||
|
resetKbContext()
|
||||||
}, 'netxops: dispose host bridge')
|
}, 'netxops: dispose host bridge')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Pull `path` from either `{ path }` or `{ args: { path } }` RPC payloads. */
|
||||||
|
function extractKbResolvePath(payload: unknown): string {
|
||||||
|
if (payload === null || typeof payload !== 'object' || Array.isArray(payload)) return ''
|
||||||
|
const row = payload as Record<string, unknown>
|
||||||
|
if (typeof row.path === 'string') return row.path
|
||||||
|
const args = row.args
|
||||||
|
if (args !== null && typeof args === 'object' && !Array.isArray(args)) {
|
||||||
|
const path = (args as Record<string, unknown>).path
|
||||||
|
if (typeof path === 'string') return path
|
||||||
|
}
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,8 @@ import {
|
||||||
type NetxCapabilityGroupId,
|
type NetxCapabilityGroupId,
|
||||||
} from './capability-groups.ts'
|
} from './capability-groups.ts'
|
||||||
import { registerGroupSkills } from './group-skills.ts'
|
import { registerGroupSkills } from './group-skills.ts'
|
||||||
|
import { registerKbContextSkill } from './kb-context-skill.ts'
|
||||||
|
import { getKbContext, watchKbContext } from './kb-runtime.ts'
|
||||||
import { getNetxConnection, watchNetxConnection } from './runtime.ts'
|
import { getNetxConnection, watchNetxConnection } from './runtime.ts'
|
||||||
import { registerNetxTools } from './tools.ts'
|
import { registerNetxTools } from './tools.ts'
|
||||||
|
|
||||||
|
|
@ -72,6 +74,7 @@ export function applyGroupToolsPlugin(ctx: Context, options: GroupToolsPluginOpt
|
||||||
const stopToolWatch = watchNetxConnection(() => { remountTools() })
|
const stopToolWatch = watchNetxConnection(() => { remountTools() })
|
||||||
|
|
||||||
ctx.inject(['skills'], (skillsCtx) => {
|
ctx.inject(['skills'], (skillsCtx) => {
|
||||||
|
let unregisterKbSkill: (() => void) | undefined
|
||||||
const remountSkills = (): void => {
|
const remountSkills = (): void => {
|
||||||
const gen = ++skillGeneration
|
const gen = ++skillGeneration
|
||||||
unregisterSkills?.()
|
unregisterSkills?.()
|
||||||
|
|
@ -88,14 +91,24 @@ export function applyGroupToolsPlugin(ctx: Context, options: GroupToolsPluginOpt
|
||||||
skillsCtx.logger.warn('%s: skill register failed: %s', options.name, error)
|
skillsCtx.logger.warn('%s: skill register failed: %s', options.name, error)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
const remountKbSkill = (): void => {
|
||||||
|
unregisterKbSkill?.()
|
||||||
|
unregisterKbSkill = undefined
|
||||||
|
unregisterKbSkill = registerKbContextSkill(skillsCtx, getKbContext())
|
||||||
|
}
|
||||||
|
|
||||||
remountSkills()
|
remountSkills()
|
||||||
|
remountKbSkill()
|
||||||
const stopSkillWatch = watchNetxConnection(() => { remountSkills() })
|
const stopSkillWatch = watchNetxConnection(() => { remountSkills() })
|
||||||
|
const stopKbWatch = watchKbContext(() => { remountKbSkill() })
|
||||||
skillsCtx.effect(() => () => {
|
skillsCtx.effect(() => () => {
|
||||||
skillGeneration += 1
|
skillGeneration += 1
|
||||||
stopSkillWatch()
|
stopSkillWatch()
|
||||||
|
stopKbWatch()
|
||||||
unregisterSkills?.()
|
unregisterSkills?.()
|
||||||
unregisterSkills = undefined
|
unregisterSkills = undefined
|
||||||
|
unregisterKbSkill?.()
|
||||||
|
unregisterKbSkill = undefined
|
||||||
}, `${options.name}: dispose skills`)
|
}, `${options.name}: dispose skills`)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
|
||||||
85
src/netx/kb-context-skill.ts
Normal file
85
src/netx/kb-context-skill.ts
Normal file
|
|
@ -0,0 +1,85 @@
|
||||||
|
/**
|
||||||
|
* Dynamic `kb-context` skill — model-visible annotation of the KB snapshot.
|
||||||
|
* Tools/scripts still use `process.env.KB_*` from `applyKbEnv`.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { Context } from '@deepseek-ai/cordis'
|
||||||
|
import type { KbSnapshot } from './kb-manifest.ts'
|
||||||
|
|
||||||
|
const SKILL_NAME = 'kb-context'
|
||||||
|
|
||||||
|
function skillBody(snapshot: KbSnapshot): { description: string; content: string } {
|
||||||
|
if (snapshot.status === 'configured') {
|
||||||
|
const flags = Object.entries(snapshot.content)
|
||||||
|
.filter(([, on]) => on)
|
||||||
|
.map(([key]) => key)
|
||||||
|
.join(', ') || '(none)'
|
||||||
|
return {
|
||||||
|
description:
|
||||||
|
'Operator knowledge-base context for this Host (paths + identity from MANIFEST).',
|
||||||
|
content: [
|
||||||
|
'## Knowledge base (configured)',
|
||||||
|
'',
|
||||||
|
'When troubleshooting with operator playbooks or docs, **compose paths from this root**.',
|
||||||
|
'Do not invent another operator or country.',
|
||||||
|
'',
|
||||||
|
`| Field | Value |`,
|
||||||
|
`| --- | --- |`,
|
||||||
|
`| kbStatus | configured |`,
|
||||||
|
`| kbRoot | \`${snapshot.realRoot}\` |`,
|
||||||
|
`| kbOperator | ${snapshot.operatorName} |`,
|
||||||
|
`| kbCountry | ${snapshot.country} |`,
|
||||||
|
`| kbVersion | ${snapshot.version} |`,
|
||||||
|
`| kbContent (on) | ${flags} |`,
|
||||||
|
'',
|
||||||
|
'Environment mirrors: `KB_ROOT`, `KB_OPERATOR`, `KB_COUNTRY`, `KB_VERSION`, `KB_CONTENT`, `KB_STATUS`.',
|
||||||
|
].join('\n'),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const reason = snapshot.status === 'error'
|
||||||
|
? (snapshot.errorMessage || 'invalid knowledge base')
|
||||||
|
: 'kbRoot is empty'
|
||||||
|
|
||||||
|
return {
|
||||||
|
description:
|
||||||
|
'Knowledge-base context is unavailable — stay on pure netx evidence.',
|
||||||
|
content: [
|
||||||
|
'## Knowledge base (unavailable)',
|
||||||
|
'',
|
||||||
|
`kbStatus=\`${snapshot.status}\`. ${reason}`,
|
||||||
|
'',
|
||||||
|
'**Do not invent an operator, country, or KB paths.**',
|
||||||
|
'Use only netx tools / live evidence (alarms, inventory, CLI, topology).',
|
||||||
|
'Operator-specific playbooks are out of scope until a valid MANIFEST is configured.',
|
||||||
|
].join('\n'),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Register (or replace) the `kb-context` skill for the current snapshot.
|
||||||
|
* @returns disposer that unregisters the skill.
|
||||||
|
*/
|
||||||
|
export function registerKbContextSkill(
|
||||||
|
ctx: Context,
|
||||||
|
snapshot: KbSnapshot,
|
||||||
|
): () => void {
|
||||||
|
const skillsApi = (ctx as { skills?: { register: (skill: {
|
||||||
|
name: string
|
||||||
|
description: string
|
||||||
|
content: string
|
||||||
|
provider?: string
|
||||||
|
source: string
|
||||||
|
}) => () => void } }).skills
|
||||||
|
if (!skillsApi || typeof skillsApi.register !== 'function') {
|
||||||
|
return () => {}
|
||||||
|
}
|
||||||
|
const body = skillBody(snapshot)
|
||||||
|
return skillsApi.register({
|
||||||
|
name: SKILL_NAME,
|
||||||
|
description: body.description,
|
||||||
|
content: body.content,
|
||||||
|
provider: 'netxops-kb',
|
||||||
|
source: 'custom',
|
||||||
|
})
|
||||||
|
}
|
||||||
261
src/netx/kb-manifest.ts
Normal file
261
src/netx/kb-manifest.ts
Normal file
|
|
@ -0,0 +1,261 @@
|
||||||
|
/**
|
||||||
|
* Locate and validate an operator-subset MANIFEST.json under a kbRoot.
|
||||||
|
*
|
||||||
|
* Contract (MANIFEST v1.0):
|
||||||
|
* - schemaVersion === "1.0"
|
||||||
|
* - packageType === "operator-subset"
|
||||||
|
* - operator.name / operator.country, version required
|
||||||
|
* - content.* boolean flags (missing → false)
|
||||||
|
*
|
||||||
|
* Location: `${kbRoot}/MANIFEST.json`, else recurse ≤ maxDepth and accept
|
||||||
|
* exactly one hit (0 or >1 → error).
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs'
|
||||||
|
import { dirname, join, resolve } from 'node:path'
|
||||||
|
|
||||||
|
/** Known content flags; unknown keys are preserved when boolean. */
|
||||||
|
export interface KbContentFlags {
|
||||||
|
regions: boolean
|
||||||
|
theory: boolean
|
||||||
|
packet: boolean
|
||||||
|
skills: boolean
|
||||||
|
[key: string]: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export type KbStatus = 'unconfigured' | 'configured' | 'error'
|
||||||
|
|
||||||
|
export interface KbSnapshot {
|
||||||
|
status: KbStatus
|
||||||
|
/** Absolute real package root (directory that holds MANIFEST.json). */
|
||||||
|
realRoot: string
|
||||||
|
operatorName: string
|
||||||
|
country: string
|
||||||
|
version: string
|
||||||
|
content: KbContentFlags
|
||||||
|
errorMessage: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const EMPTY_CONTENT: KbContentFlags = {
|
||||||
|
regions: false,
|
||||||
|
theory: false,
|
||||||
|
packet: false,
|
||||||
|
skills: false,
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Empty / unconfigured snapshot (kbRoot blank). */
|
||||||
|
export function unconfiguredKbSnapshot(): KbSnapshot {
|
||||||
|
return {
|
||||||
|
status: 'unconfigured',
|
||||||
|
realRoot: '',
|
||||||
|
operatorName: '',
|
||||||
|
country: '',
|
||||||
|
version: '',
|
||||||
|
content: { ...EMPTY_CONTENT },
|
||||||
|
errorMessage: '',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function errorSnapshot(message: string): KbSnapshot {
|
||||||
|
return {
|
||||||
|
status: 'error',
|
||||||
|
realRoot: '',
|
||||||
|
operatorName: '',
|
||||||
|
country: '',
|
||||||
|
version: '',
|
||||||
|
content: { ...EMPTY_CONTENT },
|
||||||
|
errorMessage: message,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Recursively collect `MANIFEST.json` paths under `root`, depth-limited.
|
||||||
|
* Depth 0 = root itself; children are depth 1..maxDepth.
|
||||||
|
*/
|
||||||
|
export function findManifest(
|
||||||
|
kbRoot: string,
|
||||||
|
maxDepth = 3,
|
||||||
|
): { paths: string[]; error?: string } {
|
||||||
|
const root = resolve(kbRoot.trim())
|
||||||
|
if (!kbRoot.trim()) {
|
||||||
|
return { paths: [], error: 'kbRoot is empty' }
|
||||||
|
}
|
||||||
|
let rootStat
|
||||||
|
try {
|
||||||
|
rootStat = statSync(root)
|
||||||
|
} catch {
|
||||||
|
return { paths: [], error: `kbRoot not found: ${root}` }
|
||||||
|
}
|
||||||
|
if (!rootStat.isDirectory()) {
|
||||||
|
return { paths: [], error: `kbRoot is not a directory: ${root}` }
|
||||||
|
}
|
||||||
|
|
||||||
|
const direct = join(root, 'MANIFEST.json')
|
||||||
|
if (existsSync(direct)) {
|
||||||
|
try {
|
||||||
|
if (statSync(direct).isFile()) return { paths: [direct] }
|
||||||
|
} catch {
|
||||||
|
// fall through to search
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const found: string[] = []
|
||||||
|
const walk = (dir: string, depth: number): void => {
|
||||||
|
if (depth > maxDepth) return
|
||||||
|
const candidate = join(dir, 'MANIFEST.json')
|
||||||
|
if (existsSync(candidate)) {
|
||||||
|
try {
|
||||||
|
if (statSync(candidate).isFile()) found.push(candidate)
|
||||||
|
} catch {
|
||||||
|
// ignore unreadable
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (depth === maxDepth) return
|
||||||
|
let entries: string[]
|
||||||
|
try {
|
||||||
|
entries = readdirSync(dir)
|
||||||
|
} catch {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
for (const name of entries) {
|
||||||
|
if (name === 'node_modules' || name === '.git') continue
|
||||||
|
const full = join(dir, name)
|
||||||
|
let st
|
||||||
|
try {
|
||||||
|
st = statSync(full)
|
||||||
|
} catch {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if (st.isDirectory()) walk(full, depth + 1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Root already checked; search nested packages at depth 1..maxDepth.
|
||||||
|
let topEntries: string[]
|
||||||
|
try {
|
||||||
|
topEntries = readdirSync(root)
|
||||||
|
} catch {
|
||||||
|
return { paths: [], error: `cannot read kbRoot: ${root}` }
|
||||||
|
}
|
||||||
|
for (const name of topEntries) {
|
||||||
|
if (name === 'node_modules' || name === '.git') continue
|
||||||
|
const full = join(root, name)
|
||||||
|
let st
|
||||||
|
try {
|
||||||
|
st = statSync(full)
|
||||||
|
} catch {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if (st.isDirectory()) walk(full, 1)
|
||||||
|
}
|
||||||
|
return { paths: found }
|
||||||
|
}
|
||||||
|
|
||||||
|
function asNonEmptyString(value: unknown, field: string): string {
|
||||||
|
if (typeof value !== 'string' || value.trim() === '') {
|
||||||
|
throw new Error(`MANIFEST missing required string field: ${field}`)
|
||||||
|
}
|
||||||
|
return value.trim()
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseContent(raw: unknown): KbContentFlags {
|
||||||
|
const out: KbContentFlags = { ...EMPTY_CONTENT }
|
||||||
|
if (raw === undefined || raw === null) {
|
||||||
|
throw new Error('MANIFEST missing required object field: content')
|
||||||
|
}
|
||||||
|
if (typeof raw !== 'object' || Array.isArray(raw)) {
|
||||||
|
throw new Error('MANIFEST content must be an object')
|
||||||
|
}
|
||||||
|
for (const [key, value] of Object.entries(raw as Record<string, unknown>)) {
|
||||||
|
out[key] = value === true
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse and validate MANIFEST JSON text (UTF-8).
|
||||||
|
* @throws Error with a short message when invalid.
|
||||||
|
*/
|
||||||
|
export function parseManifest(raw: string): {
|
||||||
|
operatorName: string
|
||||||
|
country: string
|
||||||
|
version: string
|
||||||
|
content: KbContentFlags
|
||||||
|
} {
|
||||||
|
let data: unknown
|
||||||
|
try {
|
||||||
|
data = JSON.parse(raw)
|
||||||
|
} catch (error) {
|
||||||
|
throw new Error(
|
||||||
|
`MANIFEST is not valid JSON: ${error instanceof Error ? error.message : String(error)}`,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (data === null || typeof data !== 'object' || Array.isArray(data)) {
|
||||||
|
throw new Error('MANIFEST root must be an object')
|
||||||
|
}
|
||||||
|
const row = data as Record<string, unknown>
|
||||||
|
if (row.schemaVersion !== '1.0') {
|
||||||
|
throw new Error(
|
||||||
|
`unsupported schemaVersion (want "1.0", got ${JSON.stringify(row.schemaVersion)})`,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (row.packageType !== 'operator-subset') {
|
||||||
|
throw new Error(
|
||||||
|
`unsupported packageType (want "operator-subset", got ${JSON.stringify(row.packageType)})`,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
const operator = row.operator
|
||||||
|
if (operator === null || typeof operator !== 'object' || Array.isArray(operator)) {
|
||||||
|
throw new Error('MANIFEST missing required object field: operator')
|
||||||
|
}
|
||||||
|
const op = operator as Record<string, unknown>
|
||||||
|
return {
|
||||||
|
operatorName: asNonEmptyString(op.name, 'operator.name'),
|
||||||
|
country: asNonEmptyString(op.country, 'operator.country'),
|
||||||
|
version: asNonEmptyString(row.version, 'version'),
|
||||||
|
content: parseContent(row.content),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve kbRoot → KbSnapshot (unconfigured / configured / error).
|
||||||
|
*/
|
||||||
|
export function resolveKbRoot(kbRoot: string, maxDepth = 3): KbSnapshot {
|
||||||
|
const trimmed = kbRoot.trim()
|
||||||
|
if (!trimmed) return unconfiguredKbSnapshot()
|
||||||
|
|
||||||
|
const located = findManifest(trimmed, maxDepth)
|
||||||
|
if (located.error) return errorSnapshot(located.error)
|
||||||
|
if (located.paths.length === 0) {
|
||||||
|
return errorSnapshot(`no MANIFEST.json under ${resolve(trimmed)} (maxDepth=${maxDepth})`)
|
||||||
|
}
|
||||||
|
if (located.paths.length > 1) {
|
||||||
|
return errorSnapshot(
|
||||||
|
`ambiguous MANIFEST.json (${located.paths.length} hits); pick a unique package root`,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const manifestPath = located.paths[0]!
|
||||||
|
let raw: string
|
||||||
|
try {
|
||||||
|
raw = readFileSync(manifestPath, 'utf8')
|
||||||
|
} catch (error) {
|
||||||
|
return errorSnapshot(
|
||||||
|
`cannot read MANIFEST: ${error instanceof Error ? error.message : String(error)}`,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const parsed = parseManifest(raw)
|
||||||
|
return {
|
||||||
|
status: 'configured',
|
||||||
|
realRoot: dirname(manifestPath),
|
||||||
|
operatorName: parsed.operatorName,
|
||||||
|
country: parsed.country,
|
||||||
|
version: parsed.version,
|
||||||
|
content: parsed.content,
|
||||||
|
errorMessage: '',
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
return errorSnapshot(error instanceof Error ? error.message : String(error))
|
||||||
|
}
|
||||||
|
}
|
||||||
90
src/netx/kb-runtime.ts
Normal file
90
src/netx/kb-runtime.ts
Normal file
|
|
@ -0,0 +1,90 @@
|
||||||
|
/**
|
||||||
|
* Process-local knowledge-base snapshot shared between the host settings
|
||||||
|
* bridge, RPC, and dynamic kb-context skill registration.
|
||||||
|
*
|
||||||
|
* Uses `Symbol.for` on `globalThis` so host + agent-tools bundles share one store
|
||||||
|
* even when Bun emits them as separate ESM files.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { KbSnapshot } from './kb-manifest.ts'
|
||||||
|
import { unconfiguredKbSnapshot } from './kb-manifest.ts'
|
||||||
|
|
||||||
|
type Listener = () => void
|
||||||
|
|
||||||
|
interface Store {
|
||||||
|
snapshot: KbSnapshot
|
||||||
|
listeners: Set<Listener>
|
||||||
|
}
|
||||||
|
|
||||||
|
const STORE_KEY = Symbol.for('dsh-netxops.kb-store')
|
||||||
|
|
||||||
|
const ENV_KEYS = [
|
||||||
|
'KB_ROOT',
|
||||||
|
'KB_OPERATOR',
|
||||||
|
'KB_COUNTRY',
|
||||||
|
'KB_VERSION',
|
||||||
|
'KB_CONTENT',
|
||||||
|
'KB_STATUS',
|
||||||
|
] as const
|
||||||
|
|
||||||
|
function store(): Store {
|
||||||
|
const root = globalThis as typeof globalThis & { [STORE_KEY]?: Store }
|
||||||
|
let current = root[STORE_KEY]
|
||||||
|
if (current === undefined) {
|
||||||
|
current = { snapshot: unconfiguredKbSnapshot(), listeners: new Set() }
|
||||||
|
root[STORE_KEY] = current
|
||||||
|
}
|
||||||
|
return current
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @returns the last published KB snapshot. */
|
||||||
|
export function getKbContext(): KbSnapshot {
|
||||||
|
return { ...store().snapshot, content: { ...store().snapshot.content } }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Publish the latest KB snapshot for UI / skill remounts.
|
||||||
|
* @param next - resolved snapshot from `resolveKbRoot`.
|
||||||
|
*/
|
||||||
|
export function publishKbContext(next: KbSnapshot): void {
|
||||||
|
const state = store()
|
||||||
|
state.snapshot = {
|
||||||
|
...next,
|
||||||
|
content: { ...next.content },
|
||||||
|
}
|
||||||
|
for (const listener of state.listeners) listener()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Subscribe to KB snapshot publishes (settings remounts).
|
||||||
|
* @param listener - called synchronously after each publish.
|
||||||
|
* @returns disposer.
|
||||||
|
*/
|
||||||
|
export function watchKbContext(listener: Listener): () => void {
|
||||||
|
const state = store()
|
||||||
|
state.listeners.add(listener)
|
||||||
|
return () => { state.listeners.delete(listener) }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mirror the snapshot into `process.env.KB_*` for tools / future KB skill packs.
|
||||||
|
* Clears identity fields when status is not `configured`.
|
||||||
|
*/
|
||||||
|
export function applyKbEnv(snapshot: KbSnapshot): void {
|
||||||
|
for (const key of ENV_KEYS) {
|
||||||
|
delete process.env[key]
|
||||||
|
}
|
||||||
|
process.env.KB_STATUS = snapshot.status
|
||||||
|
if (snapshot.status !== 'configured') return
|
||||||
|
process.env.KB_ROOT = snapshot.realRoot
|
||||||
|
process.env.KB_OPERATOR = snapshot.operatorName
|
||||||
|
process.env.KB_COUNTRY = snapshot.country
|
||||||
|
process.env.KB_VERSION = snapshot.version
|
||||||
|
process.env.KB_CONTENT = JSON.stringify(snapshot.content)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Reset store + env to unconfigured (plugin dispose). */
|
||||||
|
export function resetKbContext(): void {
|
||||||
|
publishKbContext(unconfiguredKbSnapshot())
|
||||||
|
applyKbEnv(unconfiguredKbSnapshot())
|
||||||
|
}
|
||||||
151
test/kb-manifest.test.mjs
Normal file
151
test/kb-manifest.test.mjs
Normal file
|
|
@ -0,0 +1,151 @@
|
||||||
|
/**
|
||||||
|
* MANIFEST locate + validate for operator-subset knowledge packages.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import assert from 'node:assert/strict'
|
||||||
|
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'
|
||||||
|
import { tmpdir } from 'node:os'
|
||||||
|
import { join } from 'node:path'
|
||||||
|
import test from 'node:test'
|
||||||
|
|
||||||
|
import {
|
||||||
|
findManifest,
|
||||||
|
parseManifest,
|
||||||
|
resolveKbRoot,
|
||||||
|
} from '../src/netx/kb-manifest.ts'
|
||||||
|
|
||||||
|
function validManifest(overrides = {}) {
|
||||||
|
return JSON.stringify({
|
||||||
|
schemaVersion: '1.0',
|
||||||
|
packageType: 'operator-subset',
|
||||||
|
operator: { name: 'IOH', country: 'ID' },
|
||||||
|
version: '2026.09.01',
|
||||||
|
content: { regions: true, theory: false, packet: true },
|
||||||
|
...overrides,
|
||||||
|
}, null, 2)
|
||||||
|
}
|
||||||
|
|
||||||
|
function withTemp(run) {
|
||||||
|
const root = mkdtempSync(join(tmpdir(), 'netxops-kb-'))
|
||||||
|
try {
|
||||||
|
run(root)
|
||||||
|
} finally {
|
||||||
|
rmSync(root, { recursive: true, force: true })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
test('empty kbRoot → unconfigured', () => {
|
||||||
|
const snap = resolveKbRoot('')
|
||||||
|
assert.equal(snap.status, 'unconfigured')
|
||||||
|
assert.equal(snap.realRoot, '')
|
||||||
|
assert.equal(snap.errorMessage, '')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('direct MANIFEST at kbRoot', () => {
|
||||||
|
withTemp((root) => {
|
||||||
|
writeFileSync(join(root, 'MANIFEST.json'), validManifest(), 'utf8')
|
||||||
|
const snap = resolveKbRoot(root)
|
||||||
|
assert.equal(snap.status, 'configured')
|
||||||
|
assert.equal(snap.realRoot, root)
|
||||||
|
assert.equal(snap.operatorName, 'IOH')
|
||||||
|
assert.equal(snap.country, 'ID')
|
||||||
|
assert.equal(snap.version, '2026.09.01')
|
||||||
|
assert.equal(snap.content.regions, true)
|
||||||
|
assert.equal(snap.content.theory, false)
|
||||||
|
assert.equal(snap.content.packet, true)
|
||||||
|
assert.equal(snap.content.skills, false)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
test('nested one level under maxDepth', () => {
|
||||||
|
withTemp((root) => {
|
||||||
|
const pkg = join(root, 'packages', 'ioh')
|
||||||
|
mkdirSync(pkg, { recursive: true })
|
||||||
|
writeFileSync(join(pkg, 'MANIFEST.json'), validManifest({
|
||||||
|
operator: { name: 'XL', country: 'ID' },
|
||||||
|
version: '1.2.3',
|
||||||
|
}), 'utf8')
|
||||||
|
const snap = resolveKbRoot(root)
|
||||||
|
assert.equal(snap.status, 'configured')
|
||||||
|
assert.equal(snap.realRoot, pkg)
|
||||||
|
assert.equal(snap.operatorName, 'XL')
|
||||||
|
assert.equal(snap.version, '1.2.3')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
test('zero manifests → error', () => {
|
||||||
|
withTemp((root) => {
|
||||||
|
mkdirSync(join(root, 'empty'), { recursive: true })
|
||||||
|
const snap = resolveKbRoot(root)
|
||||||
|
assert.equal(snap.status, 'error')
|
||||||
|
assert.match(snap.errorMessage, /no MANIFEST\.json/)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
test('multiple nested manifests → error', () => {
|
||||||
|
withTemp((root) => {
|
||||||
|
const a = join(root, 'a')
|
||||||
|
const b = join(root, 'b')
|
||||||
|
mkdirSync(a, { recursive: true })
|
||||||
|
mkdirSync(b, { recursive: true })
|
||||||
|
writeFileSync(join(a, 'MANIFEST.json'), validManifest(), 'utf8')
|
||||||
|
writeFileSync(join(b, 'MANIFEST.json'), validManifest({
|
||||||
|
operator: { name: 'Other', country: 'XX' },
|
||||||
|
}), 'utf8')
|
||||||
|
const snap = resolveKbRoot(root)
|
||||||
|
assert.equal(snap.status, 'error')
|
||||||
|
assert.match(snap.errorMessage, /ambiguous/)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
test('bad JSON → error', () => {
|
||||||
|
withTemp((root) => {
|
||||||
|
writeFileSync(join(root, 'MANIFEST.json'), '{not-json', 'utf8')
|
||||||
|
const snap = resolveKbRoot(root)
|
||||||
|
assert.equal(snap.status, 'error')
|
||||||
|
assert.match(snap.errorMessage, /not valid JSON/)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
test('wrong schemaVersion / packageType rejected', () => {
|
||||||
|
assert.throws(
|
||||||
|
() => parseManifest(validManifest({ schemaVersion: '2.0' })),
|
||||||
|
/schemaVersion/,
|
||||||
|
)
|
||||||
|
assert.throws(
|
||||||
|
() => parseManifest(validManifest({ packageType: 'full-tree' })),
|
||||||
|
/packageType/,
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('content missing keys default false; missing content object fails', () => {
|
||||||
|
const parsed = parseManifest(validManifest({ content: { regions: true } }))
|
||||||
|
assert.equal(parsed.content.regions, true)
|
||||||
|
assert.equal(parsed.content.theory, false)
|
||||||
|
assert.equal(parsed.content.packet, false)
|
||||||
|
const withoutContent = JSON.stringify({
|
||||||
|
schemaVersion: '1.0',
|
||||||
|
packageType: 'operator-subset',
|
||||||
|
operator: { name: 'IOH', country: 'ID' },
|
||||||
|
version: '1',
|
||||||
|
})
|
||||||
|
assert.throws(() => parseManifest(withoutContent), /content/)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('findManifest respects maxDepth', () => {
|
||||||
|
withTemp((root) => {
|
||||||
|
const deep = join(root, 'a', 'b', 'c', 'd')
|
||||||
|
mkdirSync(deep, { recursive: true })
|
||||||
|
writeFileSync(join(deep, 'MANIFEST.json'), validManifest(), 'utf8')
|
||||||
|
const tooDeep = findManifest(root, 3)
|
||||||
|
assert.equal(tooDeep.paths.length, 0)
|
||||||
|
const ok = findManifest(root, 4)
|
||||||
|
assert.equal(ok.paths.length, 1)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
test('missing kbRoot path → error', () => {
|
||||||
|
const snap = resolveKbRoot(join(tmpdir(), 'netxops-kb-missing-' + Date.now()))
|
||||||
|
assert.equal(snap.status, 'error')
|
||||||
|
assert.match(snap.errorMessage, /not found/)
|
||||||
|
})
|
||||||
39
test/kb-runtime.test.mjs
Normal file
39
test/kb-runtime.test.mjs
Normal file
|
|
@ -0,0 +1,39 @@
|
||||||
|
/**
|
||||||
|
* KB runtime env mirroring.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import assert from 'node:assert/strict'
|
||||||
|
import test from 'node:test'
|
||||||
|
|
||||||
|
import { resolveKbRoot } from '../src/netx/kb-manifest.ts'
|
||||||
|
import { applyKbEnv, getKbContext, publishKbContext, resetKbContext } from '../src/netx/kb-runtime.ts'
|
||||||
|
|
||||||
|
test('applyKbEnv clears identity when unconfigured', () => {
|
||||||
|
process.env.KB_ROOT = '/tmp/x'
|
||||||
|
process.env.KB_OPERATOR = 'IOH'
|
||||||
|
applyKbEnv(resolveKbRoot(''))
|
||||||
|
assert.equal(process.env.KB_STATUS, 'unconfigured')
|
||||||
|
assert.equal(process.env.KB_ROOT, undefined)
|
||||||
|
assert.equal(process.env.KB_OPERATOR, undefined)
|
||||||
|
resetKbContext()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('publishKbContext updates store', () => {
|
||||||
|
publishKbContext({
|
||||||
|
status: 'configured',
|
||||||
|
realRoot: '/kb',
|
||||||
|
operatorName: 'IOH',
|
||||||
|
country: 'ID',
|
||||||
|
version: '1',
|
||||||
|
content: { regions: true, theory: false, packet: false, skills: false },
|
||||||
|
errorMessage: '',
|
||||||
|
})
|
||||||
|
const snap = getKbContext()
|
||||||
|
assert.equal(snap.status, 'configured')
|
||||||
|
assert.equal(snap.operatorName, 'IOH')
|
||||||
|
applyKbEnv(snap)
|
||||||
|
assert.equal(process.env.KB_OPERATOR, 'IOH')
|
||||||
|
assert.equal(process.env.KB_COUNTRY, 'ID')
|
||||||
|
assert.ok(process.env.KB_CONTENT?.includes('"regions":true'))
|
||||||
|
resetKbContext()
|
||||||
|
})
|
||||||
Loading…
Add table
Add a link
Reference in a new issue