Fix Desktop link-install boot and show Netx Ops settings.

Prefer shared schemastery 3.18.2 peers (Desktop 3.18.4 alone breaks Config), and register settings.section eagerly with a deferred form scope so the Settings nav appears without waiting on configForms.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
oliver 2026-10-09 19:28:32 +08:00
parent 3861006c13
commit 838ff2fed4
6 changed files with 487 additions and 149 deletions

View file

@ -24,6 +24,30 @@ No local Python / `netx_mcp` install is required for DSH. (OpenClaw and other MC
`@deepseek-ai/*` 由 **DSH Host profile** 提供,**不**声明为可从公共 registry 安装的依赖。仓库根目录 `pnpm install` 只会装公开包(如 `fflate`)。完整运行请用上面的 `dsh plugin add`,不要指望裸 clone 能从 npm 拉齐 Harness 内部包。 `@deepseek-ai/*` 由 **DSH Host profile** 提供,**不**声明为可从公共 registry 安装的依赖。仓库根目录 `pnpm install` 只会装公开包(如 `fflate`)。完整运行请用上面的 `dsh plugin add`,不要指望裸 clone 能从 npm 拉齐 Harness 内部包。
## Desktop 本地开发(`link:` 路径安装)
Desktop「添加插件」填本地目录时,会写成 `link:D:/…/netxops`。Node 从**工作区真实路径**解析 `@deepseek-ai/*`,不会走到 Desktop profile 的 `node_modules`,于是 Host 行报 `Cannot find package '@deepseek-ai/schemastery'`,插件页显示 **netxops 未运行**(预设行可能仍正常)。
装完本地路径后在仓库根执行一次(把 Host 的 `@deepseek-ai/*` peer junction 进本仓库 `node_modules`):
```powershell
bun run link:peers
# 或 PowerShell: .\scripts\link-dsh-peers.ps1
```
脚本优先用 `~/.dsh/profiles/node_modules` 里那一套 peers(schemastery 3.18.2 + dsh-*),**不要**单独链 Desktop profile 里的 schemastery 3.18.4——混链会导致 `Config({})` 变成空代理,Desktop 直接 **web boot: dsh-netxops failed**。
然后**重启 Desktop**(或在插件页关掉再启用 dsh-netxops)。症状对照:
| 现象 | 原因 |
|------|------|
| `Cannot find package '@deepseek-ai/schemastery'` / **netxops 未运行** | 未跑 `link:peers` |
| **web boot: dsh-netxops failed** | peers 版本错乱(例如链了 Desktop 的 schemastery 3.18.4)→ 重跑 `bun run link:peers` 再重启 |
改 Host/Client 后照常 `bun run bundle`,一般不用重装;换机器或清过本仓库 `node_modules` 才需再 `link:peers`。
备选:`npm pack` 后在 Desktop 安装 `.tgz`(复制进 profile,不走 `link:`,也就不用 link peers)。
## Install ## Install
```powershell ```powershell

View file

@ -2656,6 +2656,98 @@ function namespaceCandidates(ctx) {
out.push(NETXOPS_NS); out.push(NETXOPS_NS);
return out; return out;
} }
function createMemoryScope(initial) {
let value = { ...initial };
let revision = 0;
const listeners = new Set;
const notify = () => {
for (const listener of listeners)
listener();
};
return {
getSnapshot: () => ({
status: "ready",
value,
writable: true,
revision
}),
subscribe: (listener) => {
listeners.add(listener);
return () => {
listeners.delete(listener);
};
},
set: async (field, next) => {
value = { ...value, [field]: next };
revision += 1;
notify();
return true;
},
unset: async (field) => {
const next = { ...value };
delete next[field];
value = next;
revision += 1;
notify();
return true;
}
};
}
function createDeferredScope(fallback) {
let inner = fallback;
const outerListeners = new Set;
let innerOff;
const relay = () => {
for (const listener of outerListeners)
listener();
};
const bindInner = () => {
innerOff?.();
innerOff = inner.subscribe(relay);
};
bindInner();
return {
getSnapshot: () => inner.getSnapshot(),
subscribe: (listener) => {
outerListeners.add(listener);
return () => {
outerListeners.delete(listener);
};
},
set: (field, next) => inner.set(field, next),
unset: (field) => inner.unset(field),
attach: (real) => {
if (inner === real)
return;
inner = real;
bindInner();
relay();
}
};
}
var MEMORY_DEFAULTS = {
apiUrl: "http://127.0.0.1:8890",
lang: "zh",
thinkingLanguage: "auto",
replyLanguage: "follow-user",
nmsProvider: "zte-ume",
tokenCredentialRef: "NETX_API_TOKEN",
alarmPushEnabled: false,
alarmDeliverDsh: true,
alarmDeliverIm: false,
imBotId: "",
imTargetId: "",
imTargets: "",
groupOpsInPreset: true,
groupOpsPublic: false,
groupTopologyInPreset: false,
groupTopologyPublic: false,
groupBizMonitorInPreset: false,
groupBizMonitorPublic: false,
kbRoot: "",
groupKbInPreset: true,
groupKbPublic: false
};
function apply(ctx) { function apply(ctx) {
ctx.effect(() => { ctx.effect(() => {
try { try {
@ -2670,43 +2762,36 @@ function apply(ctx) {
} }
}, "netxops: locales"); }, "netxops: locales");
const t = ctx.locale.bind(LOCALE_NS); const t = ctx.locale.bind(LOCALE_NS);
let card; const deferred = createDeferredScope(createMemoryScope(MEMORY_DEFAULTS));
let wiredExtras = false; const card = new NetxopsCardController(deferred, ctx);
let sectionRegistered = false;
let pluginsItemId; let pluginsItemId;
const ensureCard = (scope) => { let wiredExtras = false;
if (card) const ensureExtras = () => {
return card; if (wiredExtras)
card = new NetxopsCardController(scope, ctx); return;
if (!wiredExtras) {
wiredExtras = true; wiredExtras = true;
wireOptionalServices(ctx, card);
}
return card;
};
const registerSection = (owner) => {
if (sectionRegistered)
return () => {};
try { try {
const off = ctx.slots.inject("settings.section", () => ctx.slots.register({ wireOptionalServices(ctx, card);
} catch (error) {
ctx.logger?.warn?.("netxops: optional remotes skipped: %s", error);
}
};
try {
ctx.slots.inject("settings.section", () => ctx.slots.register({
name: "settings.section", name: "settings.section",
id: NETXOPS_NS, id: NETXOPS_NS,
order: 24, order: 24,
label: () => t("title"), label: () => t("title"),
locale: LOCALE_NS, locale: LOCALE_NS,
inject: () => owner.inject() inject: () => {
ensureExtras();
return card.inject();
}
}, NetxopsCard)); }, NetxopsCard));
sectionRegistered = true;
return () => {
sectionRegistered = false;
off();
};
} catch (error) { } catch (error) {
ctx.logger?.warn?.("netxops: settings.section unavailable: %s", error); ctx.logger?.warn?.("netxops: settings.section unavailable: %s", error);
return () => {};
} }
}; const registerPluginsItem = (id) => {
const registerPluginsItem = (owner, id) => {
if (pluginsItemId === id) if (pluginsItemId === id)
return () => {}; return () => {};
try { try {
@ -2716,7 +2801,10 @@ function apply(ctx) {
order: 24, order: 24,
label: () => t("title"), label: () => t("title"),
locale: LOCALE_NS, locale: LOCALE_NS,
inject: () => owner.inject() inject: () => {
ensureExtras();
return card.inject();
}
}, NetxopsCard)); }, NetxopsCard));
pluginsItemId = id; pluginsItemId = id;
return () => { return () => {
@ -2729,17 +2817,6 @@ function apply(ctx) {
return () => {}; return () => {};
} }
}; };
const mountUi = (owner, ns, opts) => {
const offs = [];
if (opts.section)
offs.push(registerSection(owner));
if (opts.pluginsItem)
offs.push(registerPluginsItem(owner, ns));
return () => {
for (const off of offs)
off();
};
};
const tryGetForm = (forms, candidates) => { const tryGetForm = (forms, candidates) => {
if (typeof forms.get !== "function") if (typeof forms.get !== "function")
return; return;
@ -2752,6 +2829,12 @@ function apply(ctx) {
} }
return; return;
}; };
const attachForm = (hit, source) => {
ctx.logger?.info?.("netxops: attach form via %s (%s)", source, hit.ns);
deferred.attach(hit.scope);
ensureExtras();
return registerPluginsItem(hit.ns);
};
ctx.inject(["configForms"], (formsCtx) => { ctx.inject(["configForms"], (formsCtx) => {
const forms = formsCtx.configForms; const forms = formsCtx.configForms;
if (!forms) { if (!forms) {
@ -2772,35 +2855,42 @@ function apply(ctx) {
formsCtx.logger?.warn?.("netxops: served %s but configForms.get failed", ns); formsCtx.logger?.warn?.("netxops: served %s but configForms.get failed", ns);
return () => {}; return () => {};
} }
formsCtx.logger?.info?.("netxops: settings via whileServed (%s)", hit.ns); return attachForm(hit, "whileServed");
const owner = ensureCard(hit.scope);
return mountUi(owner, hit.ns, { section: true, pluginsItem: true });
}), "netxops: configForms whileServed"); }), "netxops: configForms whileServed");
} }
const immediate = tryGetForm(forms, candidates); const immediate = tryGetForm(forms, candidates);
if (immediate) { if (immediate) {
formsCtx.logger?.info?.("netxops: settings eager mount (%s)", immediate.ns); formsCtx.effect(() => attachForm(immediate, "eager"), "netxops: configForms eager attach");
const owner = ensureCard(immediate.scope);
formsCtx.effect(() => mountUi(owner, immediate.ns, { section: true, pluginsItem: true }), "netxops: configForms eager slots");
} else if (typeof forms.whileServed !== "function") { } else if (typeof forms.whileServed !== "function") {
formsCtx.logger?.warn?.("netxops: configForms present but no get/whileServed match for %s", candidates.join(",")); formsCtx.logger?.warn?.("netxops: configForms present but no get/whileServed match for %s", candidates.join(","));
} }
}); });
ctx.inject(["settingsScope"], (scopeCtx) => { ctx.inject(["settingsScope"], (scopeCtx) => {
if (card)
return;
const binder = scopeCtx.settingsScope; const binder = scopeCtx.settingsScope;
if (!binder || typeof binder.bind !== "function") { if (!binder || typeof binder.bind !== "function") {
scopeCtx.logger?.warn?.("netxops: settingsScope present but .bind missing"); scopeCtx.logger?.warn?.("netxops: settingsScope present but .bind missing");
return; return;
} }
scopeCtx.logger?.info?.("netxops: settings card via settingsScope"); scopeCtx.logger?.info?.("netxops: attach form via settingsScope");
const owner = ensureCard(binder.bind({ namespace: NETXOPS_NS })); deferred.attach(binder.bind({ namespace: NETXOPS_NS }));
scopeCtx.effect(() => registerSection(owner), "netxops: settings.section"); ensureExtras();
}); });
} }
function wireOptionalServices(ctx, card) { function wireOptionalServices(ctx, card) {
ctx.inject(["remote.credentials"], (credCtx) => { const soft = (deps, run, label) => {
try {
ctx.inject(deps, (inner) => {
try {
run(inner);
} catch (error) {
inner.logger?.warn?.("netxops: %s handler failed: %s", label, error);
}
});
} catch (error) {
ctx.logger?.warn?.("netxops: soft-inject %s skipped: %s", label, error);
}
};
soft(["remote.credentials"], (credCtx) => {
card.setCredentialsAvailable(true); card.setCredentialsAvailable(true);
credCtx.effect(() => { credCtx.effect(() => {
const off = credCtx.remote.$on("credentials/reference-updated", (ref) => { const off = credCtx.remote.$on("credentials/reference-updated", (ref) => {
@ -2811,32 +2901,32 @@ function wireOptionalServices(ctx, card) {
card.setCredentialsAvailable(false); card.setCredentialsAvailable(false);
}; };
}, "netxops: credential invalidations"); }, "netxops: credential invalidations");
}); }, "remote.credentials");
ctx.inject(["connection"], (connCtx) => { soft(["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") {
connCtx.logger.warn("netxops: connection.rpc.call unavailable — alarm status UI disabled"); connCtx.logger?.warn?.("netxops: connection.rpc.call unavailable — alarm status UI disabled");
return; return;
} }
card.setAlarmPushRpc(call); card.setAlarmPushRpc(call);
connCtx.effect(() => () => { connCtx.effect(() => () => {
card.setAlarmPushRpc(undefined); card.setAlarmPushRpc(undefined);
}, "netxops: clear alarm-push rpc"); }, "netxops: clear alarm-push rpc");
}); }, "connection");
const bindDirectoryPicker = (picker) => { const bindDirectoryPicker = (picker) => {
if (!picker || typeof picker.pick !== "function") if (!picker || typeof picker.pick !== "function")
return; return;
card.setDirectoryPicker(picker); card.setDirectoryPicker(picker);
}; };
bindDirectoryPicker(ctx.remote?.directoryPicker); bindDirectoryPicker(ctx.remote?.directoryPicker);
ctx.inject(["remote.directoryPicker"], (dpCtx) => { soft(["remote.directoryPicker"], (dpCtx) => {
const viaGet = typeof dpCtx.get === "function" ? dpCtx.get("remote.directoryPicker") : undefined; const viaGet = typeof dpCtx.get === "function" ? dpCtx.get("remote.directoryPicker") : undefined;
const viaNested = dpCtx.remote?.directoryPicker; const viaNested = dpCtx.remote?.directoryPicker;
bindDirectoryPicker(viaGet ?? viaNested); bindDirectoryPicker(viaGet ?? viaNested);
dpCtx.effect(() => () => { dpCtx.effect(() => () => {
card.setDirectoryPicker(undefined); card.setDirectoryPicker(undefined);
}, "netxops: clear directory picker"); }, "netxops: clear directory picker");
}); }, "remote.directoryPicker");
} }
return module.exports; return module.exports;

View file

@ -1,6 +1,6 @@
{ {
"name": "dsh-netxops", "name": "dsh-netxops",
"version": "0.1.44", "version": "0.1.45",
"description": "DeepSeek Harness Netx Ops: ops/topology + IM delivery + session export + knowledge-base MANIFEST/_skills + localSkills", "description": "DeepSeek Harness Netx Ops: ops/topology + IM delivery + session export + knowledge-base MANIFEST/_skills + localSkills",
"license": "MIT", "license": "MIT",
"type": "module", "type": "module",
@ -56,7 +56,9 @@
"bundle": "bun run scripts/gen-preset-patch.mjs && bun run scripts/build-host.mjs && bun run scripts/build-client.mjs", "bundle": "bun run scripts/gen-preset-patch.mjs && bun run scripts/build-host.mjs && bun run scripts/build-client.mjs",
"bundle:host": "bun run scripts/build-host.mjs", "bundle:host": "bun run scripts/build-host.mjs",
"bundle:client": "bun run scripts/build-client.mjs", "bundle:client": "bun run scripts/build-client.mjs",
"gen:preset-patch": "bun run scripts/gen-preset-patch.mjs" "gen:preset-patch": "bun run scripts/gen-preset-patch.mjs",
"link:peers": "node scripts/link-dsh-peers.mjs --profile desktop",
"link:peers:ps1": "powershell -NoProfile -File ./scripts/link-dsh-peers.ps1"
}, },
"dsh": { "dsh": {
"bundle": { "bundle": {

100
scripts/link-dsh-peers.mjs Normal file
View file

@ -0,0 +1,100 @@
/**
* Desktop / pnpm `link:` installs resolve `@deepseek-ai/*` from this package's
* real path (the workspace), not from the profile `node_modules`. GitHub/file
* installs work because the package is copied under the profile.
*
* Junction Host peer packages from the DSH installation into
* `./node_modules/@deepseek-ai/*` so linked local installs can boot.
*
* Usage:
* bun run link:peers
* node scripts/link-dsh-peers.mjs --profile desktop
* .\scripts\link-dsh-peers.ps1
*/
import { existsSync, lstatSync, mkdirSync, readFileSync, rmSync, symlinkSync } from 'node:fs'
import { homedir } from 'node:os'
import { dirname, join } from 'node:path'
import { fileURLToPath } from 'node:url'
const root = join(dirname(fileURLToPath(import.meta.url)), '..')
const dshHome = process.env.DSH_HOME?.trim() || join(homedir(), '.dsh')
/** Runtime imports from src/index.ts (+ commonly needed neighbors). */
const peers = [
'schemastery',
'cordis',
'dsh-credentials',
'dsh-settings',
'dsh-tools',
'dsh-llm',
]
function profileArg() {
const idx = process.argv.indexOf('--profile')
if (idx >= 0 && process.argv[idx + 1]) return process.argv[idx + 1]
return 'desktop'
}
/**
* Prefer the shared healed fallback `profiles/node_modules` (one coherent peer
* set: schemastery 3.18.2 + dsh-* 0.1.5-rc.2). Do NOT prefer the Desktop
* profile's schemastery first — Desktop 0.2 ships 3.18.4 which, when linked
* alone into this package, makes `Config({})` return cosmokit volatile proxies
* instead of defaults and Host apply fails ("web boot: dsh-netxops failed").
* Do NOT createRequire() — that can walk into an unrelated checkout via NODE_PATH.
*/
function candidateDirs(profile) {
return [
join(dshHome, 'profiles', 'node_modules', '@deepseek-ai'),
join(dshHome, 'profiles', profile, 'node_modules', '@deepseek-ai'),
join(dshHome, 'profiles', 'web', 'node_modules', '@deepseek-ai'),
]
}
function resolvePeer(shortName, dirs) {
for (const dir of dirs) {
const target = join(dir, shortName)
if (existsSync(join(target, 'package.json'))) return target
}
return null
}
function linkOne(shortName, target) {
const dest = join(root, 'node_modules', '@deepseek-ai', shortName)
mkdirSync(dirname(dest), { recursive: true })
if (existsSync(dest)) {
const st = lstatSync(dest)
if (st.isSymbolicLink() || st.isDirectory()) {
rmSync(dest, { recursive: true, force: true })
} else {
throw new Error(`refusing to replace non-link path ${dest}`)
}
}
symlinkSync(target, dest, 'junction')
const ver = JSON.parse(readFileSync(join(target, 'package.json'), 'utf8')).version
console.log(`linked @deepseek-ai/${shortName}@${ver} <- ${target}`)
}
const profile = profileArg()
const dirs = candidateDirs(profile)
console.log(`DSH_HOME=${dshHome} profile=${profile}`)
console.log(`search:\n ${dirs.join('\n ')}`)
let missing = 0
for (const name of peers) {
const target = resolvePeer(name, dirs)
if (!target) {
console.error(`missing peer @deepseek-ai/${name}`)
missing++
continue
}
linkOne(name, target)
}
if (missing > 0) {
console.error(`\n${missing} peer(s) missing — run Desktop/web once so profiles/node_modules is healed.`)
process.exit(1)
}
console.log('\nDone. Restart DeepSeek Harness Desktop (or disable/enable dsh-netxops).')

View file

@ -11,13 +11,20 @@
# .\scripts\link-dsh-peers.ps1 -DshHome $env:DSH_HOME # .\scripts\link-dsh-peers.ps1 -DshHome $env:DSH_HOME
param( param(
[string]$DshHome = $(if ($env:DSH_HOME) { $env:DSH_HOME } else { Join-Path $env:USERPROFILE '.dsh' }) [string]$DshHome = $(if ($env:DSH_HOME) { $env:DSH_HOME } else { Join-Path $env:USERPROFILE '.dsh' }),
[string]$Profile = 'desktop'
) )
$ErrorActionPreference = 'Stop' $ErrorActionPreference = 'Stop'
$Root = Split-Path -Parent $PSScriptRoot $Root = Split-Path -Parent $PSScriptRoot
$SrcRoot = Join-Path $DshHome 'profiles\node_modules\@deepseek-ai'
$DstRoot = Join-Path $Root 'node_modules\@deepseek-ai' $DstRoot = Join-Path $Root 'node_modules\@deepseek-ai'
# Shared heal first — profile-local schemastery (Desktop 0.2 = 3.18.4) breaks
# Config({}) when mixed with shared dsh-* 0.1.5-rc.2 (volatile proxies, Host fail).
$SearchRoots = @(
(Join-Path $DshHome 'profiles\node_modules\@deepseek-ai'),
(Join-Path $DshHome "profiles\$Profile\node_modules\@deepseek-ai"),
(Join-Path $DshHome 'profiles\web\node_modules\@deepseek-ai')
)
# Runtime imports from src/index.ts (and peers they commonly need nearby). # Runtime imports from src/index.ts (and peers they commonly need nearby).
$Peers = @( $Peers = @(
@ -25,22 +32,26 @@ $Peers = @(
'cordis', 'cordis',
'dsh-credentials', 'dsh-credentials',
'dsh-settings', 'dsh-settings',
'dsh-mcp-client', 'dsh-tools',
'dsh-tools' 'dsh-llm'
) )
if (-not (Test-Path $SrcRoot)) {
throw "DSH installation fallback missing: $SrcRoot`nRun ``dsh web`` once (or any profile) so dsh heals profiles/node_modules."
}
New-Item -ItemType Directory -Force -Path $DstRoot | Out-Null New-Item -ItemType Directory -Force -Path $DstRoot | Out-Null
Write-Host "DSH_HOME=$DshHome profile=$Profile"
foreach ($name in $Peers) { foreach ($name in $Peers) {
$src = Join-Path $SrcRoot $name $src = $null
$dst = Join-Path $DstRoot $name foreach ($root in $SearchRoots) {
if (-not (Test-Path $src)) { $candidate = Join-Path $root $name
throw "Peer not found under installation fallback: $src" if (Test-Path (Join-Path $candidate 'package.json')) {
$src = $candidate
break
} }
}
if (-not $src) {
throw "Peer @deepseek-ai/$name not found under:`n $($SearchRoots -join "`n ")"
}
$dst = Join-Path $DstRoot $name
if (Test-Path $dst) { if (Test-Path $dst) {
$item = Get-Item $dst -Force $item = Get-Item $dst -Force
if ($item.LinkType -eq 'Junction' -or $item.Attributes -band [IO.FileAttributes]::ReparsePoint) { if ($item.LinkType -eq 'Junction' -or $item.Attributes -band [IO.FileAttributes]::ReparsePoint) {
@ -50,7 +61,8 @@ foreach ($name in $Peers) {
} }
} }
New-Item -ItemType Junction -Path $dst -Target $src | Out-Null New-Item -ItemType Junction -Path $dst -Target $src | Out-Null
Write-Host "linked @deepseek-ai/$name -> $src" $ver = (Get-Content (Join-Path $src 'package.json') -Raw | ConvertFrom-Json).version
Write-Host "linked @deepseek-ai/$name@$ver <- $src"
} }
Write-Host "Done. Re-run: dsh web" Write-Host "Done. Restart DeepSeek Harness Desktop (or disable/enable dsh-netxops)."

View file

@ -6,10 +6,13 @@
* (`configForms` replaced it) and a hard wait kills web boot. * (`configForms` replaced it) and a hard wait kills web boot.
* *
* Surfaces (dual-stack), matching working plugins like dsh-im-ops: * Surfaces (dual-stack), matching working plugins like dsh-im-ops:
* - Always register `settings.section` once a form scope is available * - Always register `settings.section` in `apply` (Settings sidebar).
* (Settings sidebar — what operators look for). * Gate only the form transport on `configForms` / `settingsScope` —
* - On ≥0.1.7 also register `plugins.item` via `configForms.whileServed` * never gate the nav entry itself (Desktop 0.2 often never serves
* (Plugins page companion card). * our ns via whileServed, which previously hid the whole page).
* - Soft-attach real form scopes into a deferred memory scope so Save
* still writes the host entry when the transport is ready.
* - On ≥0.1.7 also register `plugins.item` via `configForms.whileServed`.
*/ */
import type { Context as ClientContext } from '@deepseek-ai/cordis' import type { Context as ClientContext } from '@deepseek-ai/cordis'
@ -81,6 +84,106 @@ type SettingsScopeLike = {
bind?: (spec: { namespace: string }) => SettingsFormScope<NetxopsSettings> bind?: (spec: { namespace: string }) => SettingsFormScope<NetxopsSettings>
} }
type DeferredSettingsScope = SettingsFormScope<NetxopsSettings> & {
attach: (real: SettingsFormScope<NetxopsSettings>) => void
}
/** In-memory form so Settings nav can mount before configForms attaches. */
function createMemoryScope(initial: NetxopsSettings): SettingsFormScope<NetxopsSettings> {
let value: NetxopsSettings = { ...initial }
let revision = 0
const listeners = new Set<() => void>()
const notify = (): void => {
for (const listener of listeners) listener()
}
return {
getSnapshot: () => ({
status: 'ready',
value,
writable: true,
revision,
}),
subscribe: (listener) => {
listeners.add(listener)
return () => {
listeners.delete(listener)
}
},
set: async (field, next) => {
value = { ...value, [field]: next as never }
revision += 1
notify()
return true
},
unset: async (field) => {
const next = { ...value }
delete next[field as keyof NetxopsSettings]
value = next
revision += 1
notify()
return true
},
}
}
/** Swap the live transport without recreating the card controller. */
function createDeferredScope(
fallback: SettingsFormScope<NetxopsSettings>,
): DeferredSettingsScope {
let inner = fallback
const outerListeners = new Set<() => void>()
let innerOff: (() => void) | undefined
const relay = (): void => {
for (const listener of outerListeners) listener()
}
const bindInner = (): void => {
innerOff?.()
innerOff = inner.subscribe(relay)
}
bindInner()
return {
getSnapshot: () => inner.getSnapshot(),
subscribe: (listener) => {
outerListeners.add(listener)
return () => {
outerListeners.delete(listener)
}
},
set: (field, next) => inner.set(field, next),
unset: (field) => inner.unset(field),
attach: (real) => {
if (inner === real) return
inner = real
bindInner()
relay()
},
}
}
const MEMORY_DEFAULTS: NetxopsSettings = {
apiUrl: 'http://127.0.0.1:8890',
lang: 'zh',
thinkingLanguage: 'auto',
replyLanguage: 'follow-user',
nmsProvider: 'zte-ume',
tokenCredentialRef: 'NETX_API_TOKEN',
alarmPushEnabled: false,
alarmDeliverDsh: true,
alarmDeliverIm: false,
imBotId: '',
imTargetId: '',
imTargets: '',
groupOpsInPreset: true,
groupOpsPublic: false,
groupTopologyInPreset: false,
groupTopologyPublic: false,
groupBizMonitorInPreset: false,
groupBizMonitorPublic: false,
kbRoot: '',
groupKbInPreset: true,
groupKbPublic: false,
}
export function apply(ctx: ClientContext): void { export function apply(ctx: ClientContext): void {
ctx.effect(() => { ctx.effect(() => {
try { try {
@ -96,46 +199,42 @@ export function apply(ctx: ClientContext): void {
}, 'netxops: locales') }, 'netxops: locales')
const t = ctx.locale.bind(LOCALE_NS) as (key: NetxopsLocaleKey) => string const t = ctx.locale.bind(LOCALE_NS) as (key: NetxopsLocaleKey) => string
let card: NetxopsCardController | undefined const deferred = createDeferredScope(createMemoryScope(MEMORY_DEFAULTS))
let wiredExtras = false const card = new NetxopsCardController(deferred, ctx)
let sectionRegistered = false
let pluginsItemId: string | undefined let pluginsItemId: string | undefined
let wiredExtras = false
const ensureCard = (scope: SettingsFormScope<NetxopsSettings>): NetxopsCardController => { const ensureExtras = (): void => {
if (card) return card if (wiredExtras) return
card = new NetxopsCardController(scope, ctx)
if (!wiredExtras) {
wiredExtras = true wiredExtras = true
// Soft-inject optional remotes only after the section is up. Unknown
// inject keys on some Desktop builds must not take down web boot.
try {
wireOptionalServices(ctx, card) wireOptionalServices(ctx, card)
} catch (error) {
ctx.logger?.warn?.('netxops: optional remotes skipped: %s', error)
} }
return card
} }
/** Settings sidebar page — same surface im-ops uses (must not wait on whileServed alone). */ /** Settings sidebar — same unconditional surface im-ops uses. */
const registerSection = (owner: NetxopsCardController): (() => void) => {
if (sectionRegistered) return () => {}
try { try {
const off = 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,
order: 24, order: 24,
label: () => t('title'), label: () => t('title'),
locale: LOCALE_NS, locale: LOCALE_NS,
inject: () => owner.inject(), inject: () => {
ensureExtras()
return card.inject()
},
}, NetxopsCard)) }, NetxopsCard))
sectionRegistered = true
return () => {
sectionRegistered = false
off()
}
} catch (error) { } catch (error) {
ctx.logger?.warn?.('netxops: settings.section unavailable: %s', error) ctx.logger?.warn?.('netxops: settings.section unavailable: %s', error)
return () => {}
}
} }
/** Plugins-page companion card (DSH ≥0.1.7 / 0.2.0). */ /** Plugins-page companion card (DSH ≥0.1.7 / 0.2.0). */
const registerPluginsItem = (owner: NetxopsCardController, id: string): (() => void) => { const registerPluginsItem = (id: string): (() => void) => {
if (pluginsItemId === id) return () => {} if (pluginsItemId === id) return () => {}
try { try {
const off = ctx.slots.inject('plugins.item', () => ctx.slots.register({ const off = ctx.slots.inject('plugins.item', () => ctx.slots.register({
@ -144,7 +243,10 @@ export function apply(ctx: ClientContext): void {
order: 24, order: 24,
label: () => t('title'), label: () => t('title'),
locale: LOCALE_NS, locale: LOCALE_NS,
inject: () => owner.inject(), inject: () => {
ensureExtras()
return card.inject()
},
}, NetxopsCard)) }, NetxopsCard))
pluginsItemId = id pluginsItemId = id
return () => { return () => {
@ -157,19 +259,6 @@ export function apply(ctx: ClientContext): void {
} }
} }
const mountUi = (
owner: NetxopsCardController,
ns: string,
opts: { section: boolean, pluginsItem: boolean },
): (() => void) => {
const offs: Array<() => void> = []
if (opts.section) offs.push(registerSection(owner))
if (opts.pluginsItem) offs.push(registerPluginsItem(owner, ns))
return () => {
for (const off of offs) off()
}
}
const tryGetForm = ( const tryGetForm = (
forms: ConfigFormsLike, forms: ConfigFormsLike,
candidates: readonly string[], candidates: readonly string[],
@ -186,7 +275,17 @@ export function apply(ctx: ClientContext): void {
return undefined return undefined
} }
// DSH ≥0.1.7 / 0.2.0 — configForms. const attachForm = (
hit: { ns: string, scope: SettingsFormScope<NetxopsSettings> },
source: string,
): (() => void) => {
ctx.logger?.info?.('netxops: attach form via %s (%s)', source, hit.ns)
deferred.attach(hit.scope)
ensureExtras()
return registerPluginsItem(hit.ns)
}
// DSH ≥0.1.7 / 0.2.0 — configForms (persist + plugins.item).
ctx.inject(['configForms'], (formsCtx) => { ctx.inject(['configForms'], (formsCtx) => {
const forms = (formsCtx as { configForms?: ConfigFormsLike }).configForms const forms = (formsCtx as { configForms?: ConfigFormsLike }).configForms
if (!forms) { if (!forms) {
@ -212,43 +311,54 @@ export function apply(ctx: ClientContext): void {
formsCtx.logger?.warn?.('netxops: served %s but configForms.get failed', ns) formsCtx.logger?.warn?.('netxops: served %s but configForms.get failed', ns)
return () => {} return () => {}
} }
formsCtx.logger?.info?.('netxops: settings via whileServed (%s)', hit.ns) return attachForm(hit, 'whileServed')
const owner = ensureCard(hit.scope)
return mountUi(owner, hit.ns, { section: true, pluginsItem: true })
}), 'netxops: configForms whileServed') }), 'netxops: configForms whileServed')
} }
// Eager fallback: do not leave Settings blank if whileServed is late/empty.
const immediate = tryGetForm(forms, candidates) const immediate = tryGetForm(forms, candidates)
if (immediate) { if (immediate) {
formsCtx.logger?.info?.('netxops: settings eager mount (%s)', immediate.ns)
const owner = ensureCard(immediate.scope)
formsCtx.effect( formsCtx.effect(
() => mountUi(owner, immediate.ns, { section: true, pluginsItem: true }), () => attachForm(immediate, 'eager'),
'netxops: configForms eager slots', 'netxops: configForms eager attach',
) )
} else if (typeof forms.whileServed !== 'function') { } else if (typeof forms.whileServed !== 'function') {
formsCtx.logger?.warn?.('netxops: configForms present but no get/whileServed match for %s', candidates.join(',')) formsCtx.logger?.warn?.(
'netxops: configForms present but no get/whileServed match for %s',
candidates.join(','),
)
} }
}) })
// DSH ≤0.1.5 — settingsScope binder + settings.section only. // DSH ≤0.1.5 — settingsScope binder.
ctx.inject(['settingsScope'], (scopeCtx) => { ctx.inject(['settingsScope'], (scopeCtx) => {
if (card) return
const binder = (scopeCtx as { settingsScope?: SettingsScopeLike }).settingsScope const binder = (scopeCtx as { settingsScope?: SettingsScopeLike }).settingsScope
if (!binder || typeof binder.bind !== 'function') { if (!binder || typeof binder.bind !== 'function') {
scopeCtx.logger?.warn?.('netxops: settingsScope present but .bind missing') scopeCtx.logger?.warn?.('netxops: settingsScope present but .bind missing')
return return
} }
scopeCtx.logger?.info?.('netxops: settings card via settingsScope') scopeCtx.logger?.info?.('netxops: attach form via settingsScope')
const owner = ensureCard(binder.bind({ namespace: NETXOPS_NS })) deferred.attach(binder.bind({ namespace: NETXOPS_NS }))
scopeCtx.effect(() => registerSection(owner), 'netxops: settings.section') ensureExtras()
}) })
} }
/** Soft-wire credentials / connection / directoryPicker when present. */ /** Soft-wire credentials / connection / directoryPicker when present. */
function wireOptionalServices(ctx: ClientContext, card: NetxopsCardController): void { function wireOptionalServices(ctx: ClientContext, card: NetxopsCardController): void {
ctx.inject(['remote.credentials'], (credCtx) => { const soft = (deps: string[], run: (c: ClientContext) => void, label: string): void => {
try {
ctx.inject(deps, (inner) => {
try {
run(inner)
} catch (error) {
inner.logger?.warn?.('netxops: %s handler failed: %s', label, error)
}
})
} catch (error) {
ctx.logger?.warn?.('netxops: soft-inject %s skipped: %s', label, error)
}
}
soft(['remote.credentials'], (credCtx) => {
card.setCredentialsAvailable(true) card.setCredentialsAvailable(true)
credCtx.effect(() => { credCtx.effect(() => {
const off = credCtx.remote.$on('credentials/reference-updated', (ref) => { const off = credCtx.remote.$on('credentials/reference-updated', (ref) => {
@ -259,19 +369,19 @@ function wireOptionalServices(ctx: ClientContext, card: NetxopsCardController):
card.setCredentialsAvailable(false) card.setCredentialsAvailable(false)
} }
}, 'netxops: credential invalidations') }, 'netxops: credential invalidations')
}) }, 'remote.credentials')
ctx.inject(['connection'], (connCtx) => { soft(['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') {
connCtx.logger.warn('netxops: connection.rpc.call unavailable — alarm status UI disabled') connCtx.logger?.warn?.('netxops: connection.rpc.call unavailable — alarm status UI disabled')
return return
} }
card.setAlarmPushRpc(call) card.setAlarmPushRpc(call)
connCtx.effect(() => () => { connCtx.effect(() => () => {
card.setAlarmPushRpc(undefined) card.setAlarmPushRpc(undefined)
}, 'netxops: clear alarm-push rpc') }, 'netxops: clear alarm-push rpc')
}) }, 'connection')
const bindDirectoryPicker = (picker: { pick?: (signal?: AbortSignal) => Promise<string | null> } | undefined): void => { const bindDirectoryPicker = (picker: { pick?: (signal?: AbortSignal) => Promise<string | null> } | undefined): void => {
if (!picker || typeof picker.pick !== 'function') return if (!picker || typeof picker.pick !== 'function') return
@ -281,7 +391,7 @@ function wireOptionalServices(ctx: ClientContext, card: NetxopsCardController):
(ctx as { remote?: { directoryPicker?: { pick?: (signal?: AbortSignal) => Promise<string | null> } } }) (ctx as { remote?: { directoryPicker?: { pick?: (signal?: AbortSignal) => Promise<string | null> } } })
.remote?.directoryPicker, .remote?.directoryPicker,
) )
ctx.inject(['remote.directoryPicker'], (dpCtx) => { soft(['remote.directoryPicker'], (dpCtx) => {
const viaGet = typeof (dpCtx as { get?: (name: string) => unknown }).get === 'function' const viaGet = typeof (dpCtx as { get?: (name: string) => unknown }).get === 'function'
? (dpCtx as { get: (name: string) => unknown }).get('remote.directoryPicker') as ? (dpCtx as { get: (name: string) => unknown }).get('remote.directoryPicker') as
| { pick?: (signal?: AbortSignal) => Promise<string | null> } | { pick?: (signal?: AbortSignal) => Promise<string | null> }
@ -293,5 +403,5 @@ function wireOptionalServices(ctx: ClientContext, card: NetxopsCardController):
dpCtx.effect(() => () => { dpCtx.effect(() => () => {
card.setDirectoryPicker(undefined) card.setDirectoryPicker(undefined)
}, 'netxops: clear directory picker') }, 'netxops: clear directory picker')
}) }, 'remote.directoryPicker')
} }