mirror of
https://github.com/hansjone/netxops.git
synced 2026-10-09 03:53:15 +08:00
Add Netx Ops Plugins settings card for URL and token.
Ship the browser half so Settings → Plugins can configure apiUrl/lang/python and NETX_API_TOKEN without OS env vars. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
45922ffd29
commit
fd1a86ec2f
14 changed files with 1492 additions and 57 deletions
4
.gitignore
vendored
4
.gitignore
vendored
|
|
@ -1,6 +1,8 @@
|
|||
node_modules/
|
||||
dist/
|
||||
lib/
|
||||
# keep published client artifact; ignore other build junk under lib/
|
||||
lib/*
|
||||
!lib/client.js
|
||||
*.log
|
||||
.DS_Store
|
||||
.env
|
||||
|
|
|
|||
28
README.md
28
README.md
|
|
@ -1,46 +1,44 @@
|
|||
# netxops — Netx Ops for DeepSeek Harness
|
||||
|
||||
Public **DeepSeek Harness** package: host bridge (settings + credentials → netx MCP) and an **ops agent preset** (persona + playbooks).
|
||||
Public **DeepSeek Harness** package: host bridge + **Plugins settings card** + ops agent preset.
|
||||
|
||||
- GitHub: https://github.com/hansjone/netxops
|
||||
- Topic: [`dsh-plugin`](https://github.com/topics/dsh-plugin)
|
||||
- npm name: `dsh-netxops`
|
||||
- Package: `dsh-netxops`
|
||||
- Brand: **Netx Ops**
|
||||
|
||||
## What you get
|
||||
|
||||
| Piece | Where | Purpose |
|
||||
|-------|--------|---------|
|
||||
| Host plugin `dsh-netxops` | `src/index.ts` + `cordis.patch.yml` | `apiUrl` / `lang` in settings; token as credential `NETX_API_TOKEN`; mounts `mcp__netx__*` |
|
||||
| Host plugin | `src/index.ts` + `cordis.patch.yml` | settings `netxops` + credential `NETX_API_TOKEN` → `mcp__netx__*` |
|
||||
| Plugins card | `src/client/` → `lib/client.js` | Settings → Plugins → fill URL / token in UI |
|
||||
| Agent preset | `presets/netxops/` | Persona + UME / managed-NE skills |
|
||||
|
||||
**Not OS env:** do not put `NETX_API_URL` / `NETX_API_TOKEN` in system environment for day-to-day use — configure inside DSH (settings + credentials). See [docs/INSTALL.md](docs/INSTALL.md).
|
||||
**No OS env required** for day-to-day use. See [docs/INSTALL.md](docs/INSTALL.md).
|
||||
|
||||
**Not in v1:** topology canvas MCP, Excel report plugin, Plugins settings **card** UI (Host namespace is ready; browser card follows DSH cookbook).
|
||||
**Not in v1:** topology canvas MCP, Excel report plugin.
|
||||
|
||||
## Quick start
|
||||
|
||||
```powershell
|
||||
# 1) Host bridge + MCP tools
|
||||
dsh plugin --profile web add github:hansjone/netxops
|
||||
|
||||
# 2) Token (credentials store, same family as model keys)
|
||||
powershell -File .\scripts\set-netx-token.ps1 -Token "nxt_…"
|
||||
|
||||
# 3) Ops preset (persona + skills)
|
||||
powershell -File .\scripts\link-preset.ps1
|
||||
# Then: Settings → Plugins → Netx Ops → paste token + API URL → Save
|
||||
```
|
||||
|
||||
Then open DSH → session preset **Netx Ops**.
|
||||
|
||||
## Local debug
|
||||
|
||||
Against [`DeepSeekHarness`](https://github.com/deepseek-ai/deepseek-harness) checkout: [examples/local-debug/README.md](examples/local-debug/README.md).
|
||||
[examples/local-debug/README.md](examples/local-debug/README.md)
|
||||
|
||||
```powershell
|
||||
bun run bundle # rebuild Plugins card after src/client changes
|
||||
```
|
||||
|
||||
## Related
|
||||
|
||||
- Data plane: [hansjone/netx](https://github.com/hansjone/netx)
|
||||
- Porting from oclaw: [docs/PORTING.md](docs/PORTING.md)
|
||||
- Porting: [docs/PORTING.md](docs/PORTING.md)
|
||||
|
||||
## License
|
||||
|
||||
|
|
|
|||
|
|
@ -11,9 +11,9 @@ pip install "git+https://github.com/hansjone/netx.git#subdirectory=packages/netx
|
|||
python -c "import netx_mcp; print('ok')"
|
||||
```
|
||||
|
||||
You do **not** need to export `NETX_API_URL` / `NETX_API_TOKEN` in the OS environment for normal use.
|
||||
You do **not** need OS environment variables for `NETX_API_URL` / `NETX_API_TOKEN`.
|
||||
|
||||
## 1) Install host bridge (URL + token + MCP tools)
|
||||
## 1) Install host bridge + Plugins settings card
|
||||
|
||||
```bash
|
||||
dsh plugin --profile <name> add github:hansjone/netxops
|
||||
|
|
@ -21,43 +21,25 @@ dsh plugin --profile <name> add github:hansjone/netxops
|
|||
dsh plugin --profile <name> add /path/to/netxops
|
||||
```
|
||||
|
||||
This applies `cordis.patch.yml`, which mounts package `dsh-netxops`. That plugin:
|
||||
This mounts `dsh-netxops` (host + browser halves):
|
||||
|
||||
- Registers settings namespace **`netxops`** (`apiUrl`, `lang`, `pythonCommand`, …)
|
||||
- Reads bearer token from credentials reference **`NETX_API_TOKEN`** (same store as model keys: `~/.dsh/.credentials.yaml`)
|
||||
- Spawns `python -m netx_mcp` and registers `mcp__netx__*` on the **host** tool registry
|
||||
- Host: settings namespace **`netxops`**, credential **`NETX_API_TOKEN`**, spawns `mcp__netx__*`
|
||||
- Browser: **Settings → Plugins → Netx Ops** card (API URL / lang / python + token)
|
||||
|
||||
### Fill API URL / language
|
||||
### Fill in the UI (recommended)
|
||||
|
||||
**Target UX:** Settings → **Plugins** → **Netx Ops** card (settings `apiUrl` / `lang`).
|
||||
The Host settings namespace is already registered; a dedicated Plugins settings card (browser half) follows the [DSH settings-card cookbook](https://github.com/deepseek-ai/deepseek-harness/blob/master/docs/cookbook/adding-a-settings-card.md) and will land in a follow-up.
|
||||
1. Open `dsh web` → **Settings** → **Plugins** → **Plugin configuration**
|
||||
2. Expand **Netx Ops**
|
||||
3. Paste **API token** (stored as credential `NETX_API_TOKEN`, never in settings JSON)
|
||||
4. Set **API URL** (e.g. `http://127.0.0.1:8890`), optional lang / python command
|
||||
5. **Save** — MCP remounts with the new values
|
||||
|
||||
**Until the card ships**, change composition defaults in the profile patch / plugin config:
|
||||
|
||||
```yaml
|
||||
# ~/.dsh/profiles/<name>/… or override the dsh-netxops row
|
||||
config:
|
||||
apiUrl: http://10.0.0.5:8890
|
||||
lang: zh
|
||||
pythonCommand: python
|
||||
```
|
||||
|
||||
Or edit the installed bundle row after `dsh plugin add`.
|
||||
|
||||
### Fill netx API token (secret)
|
||||
|
||||
Same credential store as DeepSeek API keys — **not** a process env var:
|
||||
### Optional CLI for token only
|
||||
|
||||
```powershell
|
||||
powershell -File .\scripts\set-netx-token.ps1 -Token "nxt_…"
|
||||
```
|
||||
|
||||
```bash
|
||||
./scripts/set-netx-token.sh 'nxt_…'
|
||||
```
|
||||
|
||||
This writes / updates `refs.NETX_API_TOKEN` under `%USERPROFILE%\.dsh\.credentials.yaml` (or `$DSH_HOME`). Restart is not required if DSH is watching credentials; otherwise restart `dsh web`.
|
||||
|
||||
## 2) Install the agent preset (persona + skills)
|
||||
|
||||
```powershell
|
||||
|
|
@ -72,12 +54,18 @@ Open `dsh web`, new session → preset **Netx Ops**.
|
|||
|
||||
## Verify
|
||||
|
||||
1. Tools include `mcp__netx__queryUmeAlarms` (and siblings).
|
||||
2. “Critical Top by host” → aggregate + Result/Evidence shell.
|
||||
3. Single `host_name` alarm query → host-scoped tools only.
|
||||
1. Settings → Plugins shows **Netx Ops** card; token badge becomes “Configured” after save.
|
||||
2. Tools include `mcp__netx__queryUmeAlarms` (and siblings).
|
||||
3. “Critical Top by host” / single `host_name` alarm query work.
|
||||
|
||||
See [TOOL_MAP.md](TOOL_MAP.md).
|
||||
|
||||
## Local debug (DeepSeekHarness checkout)
|
||||
|
||||
See [examples/local-debug/README.md](../examples/local-debug/README.md).
|
||||
|
||||
Rebuild the Plugins card after editing `src/client/`:
|
||||
|
||||
```powershell
|
||||
bun run bundle
|
||||
```
|
||||
|
|
|
|||
|
|
@ -11,7 +11,7 @@
|
|||
|
||||
Adaptations: removed oclaw Admin / WhatsApp / `ume_alarm_xlsx_report` / wiki capture / skill_auto_install; fiber/offline recipes use Raw/aggregate MCP only.
|
||||
|
||||
**Config UX:** not OS env. Token via DSH credentials (same as model keys); `apiUrl` via settings / composition config. Plugins settings **card** (browser half) TBD per DSH cookbook.
|
||||
**Config UX:** Settings → Plugins → **Netx Ops** card (`src/client/` → `lib/client.js`). Token via credentials `NETX_API_TOKEN`; `apiUrl` / `lang` / `pythonCommand` via settings namespace `netxops`.
|
||||
|
||||
## Stay in oclaw
|
||||
|
||||
|
|
@ -19,7 +19,6 @@ Gateway, WhatsApp/Weixin, Admin MCP UI, specialist router, memory-wiki, ops-ai H
|
|||
|
||||
## Phase 2 candidates
|
||||
|
||||
- Plugins settings card (`dsh.client`) for apiUrl + token on Settings → Plugins
|
||||
- `ume_alarm_xlsx_report` + thin HTTP client as a DSH tool plugin
|
||||
- UME sync context inject
|
||||
- `ops-ip-knowledge-playbook` + KB content
|
||||
|
|
|
|||
|
|
@ -28,6 +28,7 @@ Adjust paths if your checkout differs.
|
|||
|
||||
## C. Verify
|
||||
|
||||
1. New session → preset **Netx Ops**
|
||||
2. Tools include `mcp__netx__aggregateUmeAlarms`
|
||||
3. Ask Critical Top / single host alarms
|
||||
1. Settings → Plugins → **Netx Ops** card shows URL / token fields (after `bun run bundle` if you edited `src/client/`)
|
||||
2. New session → preset **Netx Ops**
|
||||
3. Tools include `mcp__netx__aggregateUmeAlarms`
|
||||
4. Ask Critical Top / single host alarms
|
||||
|
|
|
|||
655
lib/client.js
Normal file
655
lib/client.js
Normal file
|
|
@ -0,0 +1,655 @@
|
|||
window.__ModuleLoader__.load({
|
||||
id: "dsh-netxops",
|
||||
factory: (require) => {
|
||||
var module = { exports: {} };
|
||||
var exports = module.exports;
|
||||
Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
|
||||
var __defProp = Object.defineProperty;
|
||||
var __getOwnPropNames = Object.getOwnPropertyNames;
|
||||
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
|
||||
var __hasOwnProp = Object.prototype.hasOwnProperty;
|
||||
function __accessProp(key) {
|
||||
return this[key];
|
||||
}
|
||||
var __toCommonJS = (from) => {
|
||||
var entry = (__moduleCache ??= new WeakMap).get(from), desc;
|
||||
if (entry)
|
||||
return entry;
|
||||
entry = __defProp({}, "__esModule", { value: true });
|
||||
if (from && typeof from === "object" || typeof from === "function") {
|
||||
for (var key of __getOwnPropNames(from))
|
||||
if (!__hasOwnProp.call(entry, key))
|
||||
__defProp(entry, key, {
|
||||
get: __accessProp.bind(from, key),
|
||||
enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable
|
||||
});
|
||||
}
|
||||
__moduleCache.set(from, entry);
|
||||
return entry;
|
||||
};
|
||||
var __moduleCache;
|
||||
var __returnValue = (v) => v;
|
||||
function __exportSetter(name, newValue) {
|
||||
this[name] = __returnValue.bind(null, newValue);
|
||||
}
|
||||
var __export = (target, all) => {
|
||||
for (var name in all)
|
||||
__defProp(target, name, {
|
||||
get: all[name],
|
||||
enumerable: true,
|
||||
configurable: true,
|
||||
set: __exportSetter.bind(all, name)
|
||||
});
|
||||
};
|
||||
|
||||
// src/client/index.ts
|
||||
var exports_client = {};
|
||||
__export(exports_client, {
|
||||
inject: () => inject,
|
||||
apply: () => apply
|
||||
});
|
||||
module.exports = __toCommonJS(exports_client);
|
||||
|
||||
// src/client/NetxopsCard.tsx
|
||||
var import_react = require("react");
|
||||
|
||||
// src/client/styles.ts
|
||||
var STYLE_ID = "dsh-netxops-card-css";
|
||||
var CSS = `
|
||||
.dsh-nx-card{list-style:none;border:.5px solid var(--dsw-alias-border-l4);border-radius:16px;background:var(--dsw-alias-bg-layer-3);transition:border-color .16s,background .16s}
|
||||
.dsh-nx-card:hover{border-color:var(--dsw-alias-label-dimmed)}
|
||||
.dsh-nx-cardOpen{background:var(--dsw-alias-bg-layer-2);border-color:var(--dsw-alias-label-dimmed)}
|
||||
.dsh-nx-header{width:100%;appearance:none;border:0;background:none;font:inherit;color:inherit;text-align:left;cursor:pointer;display:flex;align-items:center;gap:12px;padding:14px 16px;border-radius:12px}
|
||||
.dsh-nx-header:focus-visible{outline:2px solid var(--dsw-alias-brand-primary);outline-offset:-2px}
|
||||
.dsh-nx-headText{flex:1;min-width:0;display:flex;flex-direction:column;gap:4px}
|
||||
.dsh-nx-name{font-size:15px;font-weight:600;line-height:1.4;color:var(--dsw-alias-label-primary)}
|
||||
.dsh-nx-desc{font-size:13px;line-height:1.5;color:var(--dsw-alias-label-tertiary)}
|
||||
.dsh-nx-pending{flex:none;border-radius:999px;padding:1px 8px;font-size:11px;line-height:17px;font-weight:500;white-space:nowrap;background:var(--dsw-alias-bg-module-platform);color:var(--dsw-alias-label-secondary)}
|
||||
.dsh-nx-chevron{flex:none;color:var(--dsw-alias-label-tertiary);transition:transform .16s}
|
||||
.dsh-nx-chevronOpen{transform:rotate(180deg)}
|
||||
.dsh-nx-body{border-top:.5px solid var(--dsw-alias-border-l2);margin:0 16px;padding-bottom:8px}
|
||||
.dsh-nx-readOnly{margin:12px 0 0;font-size:12px;line-height:1.5;color:var(--dsw-alias-label-tertiary)}
|
||||
.dsh-nx-field{display:flex;flex-direction:column;gap:6px;padding:12px 0}
|
||||
.dsh-nx-field+.dsh-nx-field{border-top:.5px solid var(--dsw-alias-border-l2)}
|
||||
.dsh-nx-fieldHead{display:flex;align-items:center;gap:8px}
|
||||
.dsh-nx-label{flex:1;min-width:0;font-size:13px;font-weight:500;line-height:1.5;color:var(--dsw-alias-label-primary)}
|
||||
.dsh-nx-badges{display:inline-flex;align-items:center;gap:8px}
|
||||
.dsh-nx-badge{border-radius:999px;padding:1px 8px;font-size:11px;line-height:17px;white-space:nowrap;font-weight:500;background:var(--dsw-alias-bg-module-platform);color:var(--dsw-alias-label-secondary)}
|
||||
.dsh-nx-badgeMuted{border-radius:999px;padding:1px 8px;font-size:11px;line-height:17px;white-space:nowrap;color:var(--dsw-alias-label-tertiary)}
|
||||
.dsh-nx-reset{border:none;background:none;padding:0;font:inherit;font-size:12px;line-height:1.5;color:var(--dsw-alias-label-secondary);cursor:pointer}
|
||||
.dsh-nx-reset:hover:not(:disabled){color:var(--dsw-alias-label-primary)}
|
||||
.dsh-nx-input{height:34px;padding:0 12px;border:.5px solid var(--dsw-alias-border-l4);border-radius:8px;background:var(--dsw-alias-bg-layer-3);font:inherit;font-size:13px;line-height:1.5;color:var(--dsw-alias-label-primary)}
|
||||
.dsh-nx-input:focus-visible{outline:none;border-color:var(--dsw-alias-brand-primary)}
|
||||
.dsh-nx-inputInvalid{border-color:var(--dsw-alias-label-error)}
|
||||
.dsh-nx-hint{margin:0;font-size:12px;line-height:1.5;color:var(--dsw-alias-label-tertiary)}
|
||||
.dsh-nx-invalid{margin:0;font-size:12px;line-height:1.5;color:var(--dsw-alias-label-error)}
|
||||
.dsh-nx-footer{display:flex;align-items:center;justify-content:flex-end;gap:8px;padding:12px 0 4px;border-top:.5px solid var(--dsw-alias-border-l2)}
|
||||
.dsh-nx-failed{flex:1;min-width:0;margin:0;font-size:12px;line-height:1.5;color:var(--dsw-alias-label-error)}
|
||||
.dsh-nx-btn{appearance:none;border:1px solid transparent;border-radius:8px;padding:5px 14px;font:inherit;font-size:13px;line-height:1.5;cursor:pointer}
|
||||
.dsh-nx-discard{border-color:var(--dsw-alias-border-l2);background:none;color:var(--dsw-alias-label-secondary)}
|
||||
.dsh-nx-discard:hover:not(:disabled){color:var(--dsw-alias-label-primary);border-color:var(--dsw-alias-label-dimmed)}
|
||||
.dsh-nx-save{background:var(--dsw-alias-label-primary);color:var(--dsw-alias-bg-layer-3)}
|
||||
.dsh-nx-btn:disabled{opacity:.4;cursor:default}
|
||||
`;
|
||||
function ensureStyles() {
|
||||
if (typeof document === "undefined")
|
||||
return;
|
||||
if (document.getElementById(STYLE_ID))
|
||||
return;
|
||||
const tag = document.createElement("style");
|
||||
tag.id = STYLE_ID;
|
||||
tag.textContent = CSS;
|
||||
document.head.appendChild(tag);
|
||||
}
|
||||
|
||||
// src/client/NetxopsCard.tsx
|
||||
var jsx_dev_runtime = require("react/jsx-dev-runtime");
|
||||
function ValueField(props) {
|
||||
return /* @__PURE__ */ jsx_dev_runtime.jsxDEV("div", {
|
||||
className: "dsh-nx-field",
|
||||
children: [
|
||||
/* @__PURE__ */ jsx_dev_runtime.jsxDEV("div", {
|
||||
className: "dsh-nx-fieldHead",
|
||||
children: [
|
||||
/* @__PURE__ */ jsx_dev_runtime.jsxDEV("label", {
|
||||
className: "dsh-nx-label",
|
||||
htmlFor: props.id,
|
||||
children: props.label
|
||||
}, undefined, false, undefined, this),
|
||||
props.field.overridden ? /* @__PURE__ */ jsx_dev_runtime.jsxDEV("span", {
|
||||
className: "dsh-nx-badges",
|
||||
children: [
|
||||
/* @__PURE__ */ jsx_dev_runtime.jsxDEV("span", {
|
||||
className: "dsh-nx-badge",
|
||||
children: props.overriddenLabel
|
||||
}, undefined, false, undefined, this),
|
||||
/* @__PURE__ */ jsx_dev_runtime.jsxDEV("button", {
|
||||
type: "button",
|
||||
className: "dsh-nx-reset",
|
||||
disabled: props.disabled,
|
||||
onClick: props.onReset,
|
||||
children: props.resetLabel
|
||||
}, undefined, false, undefined, this)
|
||||
]
|
||||
}, undefined, true, undefined, this) : null
|
||||
]
|
||||
}, undefined, true, undefined, this),
|
||||
/* @__PURE__ */ jsx_dev_runtime.jsxDEV("input", {
|
||||
id: props.id,
|
||||
className: props.field.invalid ? "dsh-nx-input dsh-nx-inputInvalid" : "dsh-nx-input",
|
||||
type: "text",
|
||||
value: props.field.text,
|
||||
disabled: props.disabled,
|
||||
"aria-invalid": props.field.invalid || undefined,
|
||||
onChange: (event) => {
|
||||
props.onEdit(event.target.value);
|
||||
}
|
||||
}, undefined, false, undefined, this),
|
||||
/* @__PURE__ */ jsx_dev_runtime.jsxDEV("p", {
|
||||
className: props.field.invalid ? "dsh-nx-invalid" : "dsh-nx-hint",
|
||||
children: props.field.invalid ? props.invalidLabel : props.hint
|
||||
}, undefined, false, undefined, this)
|
||||
]
|
||||
}, undefined, true, undefined, this);
|
||||
}
|
||||
function NetxopsCard(props) {
|
||||
ensureStyles();
|
||||
const { t } = props;
|
||||
const state = props.useNetxopsCard((snapshot) => snapshot);
|
||||
const [open, setOpen] = import_react.useState(false);
|
||||
const saveStarted = import_react.useRef(false);
|
||||
import_react.useEffect(() => {
|
||||
if (state.saving) {
|
||||
saveStarted.current = true;
|
||||
return;
|
||||
}
|
||||
if (!saveStarted.current)
|
||||
return;
|
||||
saveStarted.current = false;
|
||||
if (!state.dirty && !state.failed)
|
||||
setOpen(false);
|
||||
}, [state.dirty, state.failed, state.saving]);
|
||||
if (!state.available)
|
||||
return null;
|
||||
const title = t("title");
|
||||
const disabled = !state.writable;
|
||||
const blocked = !state.dirty || state.invalid || state.saving;
|
||||
return /* @__PURE__ */ jsx_dev_runtime.jsxDEV("li", {
|
||||
className: open ? "dsh-nx-card dsh-nx-cardOpen" : "dsh-nx-card",
|
||||
children: [
|
||||
/* @__PURE__ */ jsx_dev_runtime.jsxDEV("button", {
|
||||
type: "button",
|
||||
className: "dsh-nx-header",
|
||||
"aria-expanded": open,
|
||||
"aria-label": `${t(open ? "collapse" : "expand")}: ${title}`,
|
||||
onClick: () => {
|
||||
setOpen(!open);
|
||||
},
|
||||
children: [
|
||||
/* @__PURE__ */ jsx_dev_runtime.jsxDEV("span", {
|
||||
className: "dsh-nx-headText",
|
||||
children: [
|
||||
/* @__PURE__ */ jsx_dev_runtime.jsxDEV("span", {
|
||||
className: "dsh-nx-name",
|
||||
children: title
|
||||
}, undefined, false, undefined, this),
|
||||
/* @__PURE__ */ jsx_dev_runtime.jsxDEV("span", {
|
||||
className: "dsh-nx-desc",
|
||||
children: t("description")
|
||||
}, undefined, false, undefined, this)
|
||||
]
|
||||
}, undefined, true, undefined, this),
|
||||
state.dirty ? /* @__PURE__ */ jsx_dev_runtime.jsxDEV("span", {
|
||||
className: "dsh-nx-pending",
|
||||
children: t("unsaved")
|
||||
}, undefined, false, undefined, this) : null,
|
||||
/* @__PURE__ */ jsx_dev_runtime.jsxDEV("span", {
|
||||
className: open ? "dsh-nx-chevron dsh-nx-chevronOpen" : "dsh-nx-chevron",
|
||||
"aria-hidden": true,
|
||||
children: "▾"
|
||||
}, undefined, false, undefined, this)
|
||||
]
|
||||
}, undefined, true, undefined, this),
|
||||
open ? /* @__PURE__ */ jsx_dev_runtime.jsxDEV("div", {
|
||||
className: "dsh-nx-body",
|
||||
children: [
|
||||
!state.writable ? /* @__PURE__ */ jsx_dev_runtime.jsxDEV("p", {
|
||||
className: "dsh-nx-readOnly",
|
||||
role: "status",
|
||||
children: t("readOnly")
|
||||
}, undefined, false, undefined, this) : null,
|
||||
/* @__PURE__ */ jsx_dev_runtime.jsxDEV("div", {
|
||||
className: "dsh-nx-field",
|
||||
children: [
|
||||
/* @__PURE__ */ jsx_dev_runtime.jsxDEV("div", {
|
||||
className: "dsh-nx-fieldHead",
|
||||
children: [
|
||||
/* @__PURE__ */ jsx_dev_runtime.jsxDEV("label", {
|
||||
className: "dsh-nx-label",
|
||||
htmlFor: "netxops-api-token",
|
||||
children: t("apiToken")
|
||||
}, undefined, false, undefined, this),
|
||||
/* @__PURE__ */ jsx_dev_runtime.jsxDEV("span", {
|
||||
className: "dsh-nx-badges",
|
||||
children: /* @__PURE__ */ jsx_dev_runtime.jsxDEV("span", {
|
||||
className: state.apiTokenConfigured ? "dsh-nx-badge" : "dsh-nx-badgeMuted",
|
||||
children: state.apiTokenConfigured ? t("apiTokenSet") : t("apiTokenUnset")
|
||||
}, undefined, false, undefined, this)
|
||||
}, undefined, false, undefined, this)
|
||||
]
|
||||
}, undefined, true, undefined, this),
|
||||
/* @__PURE__ */ jsx_dev_runtime.jsxDEV("input", {
|
||||
id: "netxops-api-token",
|
||||
className: "dsh-nx-input",
|
||||
type: "password",
|
||||
autoComplete: "off",
|
||||
value: state.apiToken.text,
|
||||
disabled: !state.apiTokenWritable,
|
||||
onChange: (event) => {
|
||||
props.edit("apiToken", event.target.value);
|
||||
}
|
||||
}, undefined, false, undefined, this),
|
||||
/* @__PURE__ */ jsx_dev_runtime.jsxDEV("p", {
|
||||
className: "dsh-nx-hint",
|
||||
children: t("apiTokenHint")
|
||||
}, undefined, false, undefined, this)
|
||||
]
|
||||
}, undefined, true, undefined, this),
|
||||
/* @__PURE__ */ jsx_dev_runtime.jsxDEV(ValueField, {
|
||||
id: "netxops-api-url",
|
||||
label: t("apiUrl"),
|
||||
hint: t("apiUrlHint"),
|
||||
field: state.apiUrl,
|
||||
overriddenLabel: t("overridden"),
|
||||
resetLabel: t("reset"),
|
||||
invalidLabel: t("invalid"),
|
||||
disabled,
|
||||
onEdit: (text) => {
|
||||
props.edit("apiUrl", text);
|
||||
},
|
||||
onReset: () => {
|
||||
props.resetField("apiUrl");
|
||||
}
|
||||
}, undefined, false, undefined, this),
|
||||
/* @__PURE__ */ jsx_dev_runtime.jsxDEV(ValueField, {
|
||||
id: "netxops-lang",
|
||||
label: t("lang"),
|
||||
hint: t("langHint"),
|
||||
field: state.lang,
|
||||
overriddenLabel: t("overridden"),
|
||||
resetLabel: t("reset"),
|
||||
invalidLabel: t("invalid"),
|
||||
disabled,
|
||||
onEdit: (text) => {
|
||||
props.edit("lang", text);
|
||||
},
|
||||
onReset: () => {
|
||||
props.resetField("lang");
|
||||
}
|
||||
}, undefined, false, undefined, this),
|
||||
/* @__PURE__ */ jsx_dev_runtime.jsxDEV(ValueField, {
|
||||
id: "netxops-python",
|
||||
label: t("pythonCommand"),
|
||||
hint: t("pythonCommandHint"),
|
||||
field: state.pythonCommand,
|
||||
overriddenLabel: t("overridden"),
|
||||
resetLabel: t("reset"),
|
||||
invalidLabel: t("invalid"),
|
||||
disabled,
|
||||
onEdit: (text) => {
|
||||
props.edit("pythonCommand", text);
|
||||
},
|
||||
onReset: () => {
|
||||
props.resetField("pythonCommand");
|
||||
}
|
||||
}, undefined, false, undefined, this),
|
||||
/* @__PURE__ */ jsx_dev_runtime.jsxDEV("div", {
|
||||
className: "dsh-nx-footer",
|
||||
children: [
|
||||
state.failed ? /* @__PURE__ */ jsx_dev_runtime.jsxDEV("p", {
|
||||
className: "dsh-nx-failed",
|
||||
role: "status",
|
||||
children: t("saveFailed")
|
||||
}, undefined, false, undefined, this) : null,
|
||||
/* @__PURE__ */ jsx_dev_runtime.jsxDEV("button", {
|
||||
type: "button",
|
||||
className: "dsh-nx-btn dsh-nx-discard",
|
||||
disabled: !state.dirty || state.saving,
|
||||
onClick: props.discard,
|
||||
children: t("discard")
|
||||
}, undefined, false, undefined, this),
|
||||
/* @__PURE__ */ jsx_dev_runtime.jsxDEV("button", {
|
||||
type: "button",
|
||||
className: "dsh-nx-btn dsh-nx-save",
|
||||
disabled: blocked,
|
||||
onClick: props.save,
|
||||
children: t(state.saving ? "saving" : "save")
|
||||
}, undefined, false, undefined, this)
|
||||
]
|
||||
}, undefined, true, undefined, this)
|
||||
]
|
||||
}, undefined, true, undefined, this) : null
|
||||
]
|
||||
}, undefined, true, undefined, this);
|
||||
}
|
||||
|
||||
// src/client/card-form.ts
|
||||
var import_dsh_client_store = require("@deepseek-ai/dsh-client-store");
|
||||
function textField(field) {
|
||||
return {
|
||||
field,
|
||||
format: (value) => typeof value === "string" ? value : "",
|
||||
parse: (text) => {
|
||||
const trimmed = text.trim();
|
||||
return trimmed === "" ? { kind: "clear" } : { kind: "set", value: trimmed };
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
class CardForm {
|
||||
scope;
|
||||
specs;
|
||||
secretSpecs;
|
||||
staged = new Map;
|
||||
listeners = new Set;
|
||||
saving = false;
|
||||
failed = false;
|
||||
constructor(scope, specs, secrets = []) {
|
||||
this.scope = scope;
|
||||
this.specs = new Map(specs.map((spec) => [spec.field, spec]));
|
||||
this.secretSpecs = new Map(secrets.map((spec) => [spec.field, spec]));
|
||||
scope.subscribe(() => {
|
||||
this.publish();
|
||||
});
|
||||
}
|
||||
bind(project) {
|
||||
const store = import_dsh_client_store.createSnapshotStore(project());
|
||||
this.listeners.add(() => {
|
||||
store.set(project());
|
||||
});
|
||||
return store;
|
||||
}
|
||||
shell() {
|
||||
const snapshot = this.scope.getSnapshot();
|
||||
const plan = this.plan();
|
||||
return {
|
||||
available: snapshot.status === "ready",
|
||||
writable: snapshot.writable,
|
||||
dirty: plan.length > 0,
|
||||
invalid: plan.some((item) => item.run === undefined),
|
||||
saving: this.saving,
|
||||
failed: this.failed
|
||||
};
|
||||
}
|
||||
field(field) {
|
||||
const staged = this.staged.get(field);
|
||||
if (this.secretSpecs.has(field)) {
|
||||
return { text: staged?.text ?? "", overridden: false, invalid: false };
|
||||
}
|
||||
const spec = this.spec(field);
|
||||
if (staged === undefined) {
|
||||
return { text: spec.format(this.sectionValue(field)), overridden: this.stored(field), invalid: false };
|
||||
}
|
||||
const write = staged.clear ? { kind: "clear" } : spec.parse(staged.text);
|
||||
return {
|
||||
text: staged.text,
|
||||
overridden: write?.kind === "set",
|
||||
invalid: write === undefined
|
||||
};
|
||||
}
|
||||
actions() {
|
||||
return {
|
||||
edit: (field, text) => {
|
||||
this.stage(field, { text, clear: false });
|
||||
},
|
||||
resetField: (field) => {
|
||||
this.stage(field, { text: this.spec(field).format(this.baseValue(field)), clear: true });
|
||||
},
|
||||
save: () => {
|
||||
this.save();
|
||||
},
|
||||
discard: () => {
|
||||
if (this.staged.size === 0 && !this.failed)
|
||||
return;
|
||||
this.staged.clear();
|
||||
this.failed = false;
|
||||
this.publish();
|
||||
}
|
||||
};
|
||||
}
|
||||
async save() {
|
||||
const plan = this.plan();
|
||||
const writes = plan.flatMap((item) => item.run === undefined ? [] : [item.run]);
|
||||
if (plan.length === 0 || this.saving || writes.length !== plan.length)
|
||||
return;
|
||||
this.saving = true;
|
||||
this.failed = false;
|
||||
this.publish();
|
||||
let landed = true;
|
||||
for (const write of writes) {
|
||||
landed = await write() && landed;
|
||||
}
|
||||
if (landed)
|
||||
this.staged.clear();
|
||||
this.saving = false;
|
||||
this.failed = !landed;
|
||||
this.publish();
|
||||
}
|
||||
plan() {
|
||||
const plan = [];
|
||||
for (const [field, staged] of this.staged) {
|
||||
const secret = this.secretSpecs.get(field);
|
||||
if (secret !== undefined) {
|
||||
const value = staged.text.trim();
|
||||
if (value !== "")
|
||||
plan.push({ field, run: () => secret.write(value) });
|
||||
continue;
|
||||
}
|
||||
const spec = this.spec(field);
|
||||
if (staged.clear) {
|
||||
if (this.stored(field))
|
||||
plan.push({ field, run: () => this.clear(field) });
|
||||
continue;
|
||||
}
|
||||
if (staged.text === spec.format(this.sectionValue(field)))
|
||||
continue;
|
||||
const write = spec.parse(staged.text);
|
||||
if (write === undefined)
|
||||
plan.push({ field, run: undefined });
|
||||
else if (write.kind === "clear")
|
||||
plan.push({ field, run: () => this.clear(field) });
|
||||
else
|
||||
plan.push({ field, run: () => this.store(field, write.value) });
|
||||
}
|
||||
return plan;
|
||||
}
|
||||
async clear(field) {
|
||||
await this.scope.unset(field);
|
||||
return !this.stored(field);
|
||||
}
|
||||
async store(field, value) {
|
||||
await this.scope.set(field, value);
|
||||
return this.userLayer()?.[field] === value;
|
||||
}
|
||||
stage(field, edit) {
|
||||
this.staged.set(field, edit);
|
||||
this.failed = false;
|
||||
this.publish();
|
||||
}
|
||||
spec(field) {
|
||||
const spec = this.specs.get(field);
|
||||
if (spec === undefined)
|
||||
throw new Error(`netxops card has no field ${field}`);
|
||||
return spec;
|
||||
}
|
||||
snapshotOf() {
|
||||
return this.scope.getSnapshot();
|
||||
}
|
||||
sectionValue(field) {
|
||||
return this.snapshotOf().value?.[field];
|
||||
}
|
||||
baseValue(field) {
|
||||
return this.snapshotOf().base?.[field];
|
||||
}
|
||||
userLayer() {
|
||||
return this.snapshotOf().user;
|
||||
}
|
||||
stored(field) {
|
||||
const user = this.userLayer();
|
||||
return user !== undefined && Object.hasOwn(user, field);
|
||||
}
|
||||
publish() {
|
||||
for (const listener of this.listeners)
|
||||
listener();
|
||||
}
|
||||
}
|
||||
|
||||
// src/client/controller.ts
|
||||
var NETXOPS_NS = "netxops";
|
||||
var DEFAULT_TOKEN_REF = "NETX_API_TOKEN";
|
||||
var API_TOKEN_FIELD = "apiToken";
|
||||
|
||||
class NetxopsCardController {
|
||||
scope;
|
||||
ctx;
|
||||
form;
|
||||
store;
|
||||
credential = { ref: "", configured: false, writable: true };
|
||||
constructor(scope, ctx) {
|
||||
this.scope = scope;
|
||||
this.ctx = ctx;
|
||||
this.form = new CardForm(scope, [textField("apiUrl"), textField("lang"), textField("pythonCommand")], [{ field: API_TOKEN_FIELD, write: (text) => this.writeToken(text) }]);
|
||||
this.store = this.form.bind(() => this.projection());
|
||||
scope.subscribe(() => {
|
||||
this.readCredential();
|
||||
});
|
||||
this.readCredential();
|
||||
}
|
||||
projection() {
|
||||
return {
|
||||
...this.form.shell(),
|
||||
apiUrl: this.form.field("apiUrl"),
|
||||
lang: this.form.field("lang"),
|
||||
pythonCommand: this.form.field("pythonCommand"),
|
||||
apiToken: this.form.field(API_TOKEN_FIELD),
|
||||
apiTokenConfigured: this.credential.configured,
|
||||
apiTokenWritable: this.credential.writable
|
||||
};
|
||||
}
|
||||
async readCredential() {
|
||||
const ref = refOf(this.scope.getSnapshot());
|
||||
if (ref !== this.credential.ref) {
|
||||
this.credential = { ref, configured: false, writable: true };
|
||||
this.store.set(this.projection());
|
||||
}
|
||||
const response = await this.ctx.remote.credentials.describe([ref]);
|
||||
if (!response.ok || ref !== refOf(this.scope.getSnapshot()))
|
||||
return;
|
||||
const view = response.value[ref];
|
||||
const next = {
|
||||
ref,
|
||||
configured: view?.configured ?? false,
|
||||
writable: view?.writable ?? true
|
||||
};
|
||||
if (next.configured === this.credential.configured && next.writable === this.credential.writable)
|
||||
return;
|
||||
this.credential = next;
|
||||
this.store.set(this.projection());
|
||||
}
|
||||
refreshCredential(ref) {
|
||||
if (ref !== this.credential.ref)
|
||||
return;
|
||||
this.readCredential();
|
||||
}
|
||||
inject() {
|
||||
return { hooks: { netxopsCard: this.store }, ...this.form.actions() };
|
||||
}
|
||||
async writeToken(value) {
|
||||
await this.ctx.remote.credentials.set(refOf(this.scope.getSnapshot()), value);
|
||||
await this.readCredential();
|
||||
return this.credential.configured;
|
||||
}
|
||||
}
|
||||
function refOf(snapshot) {
|
||||
const declared = snapshot.value?.tokenCredentialRef;
|
||||
return declared !== undefined && declared.length > 0 ? declared : DEFAULT_TOKEN_REF;
|
||||
}
|
||||
|
||||
// src/client/locales.ts
|
||||
var en = {
|
||||
title: "Netx Ops",
|
||||
description: "UME API endpoint and bearer token for mcp__netx__* tools.",
|
||||
apiUrl: "API URL",
|
||||
apiUrlHint: "netx REST root, e.g. http://127.0.0.1:8890",
|
||||
lang: "Language",
|
||||
langHint: "Passed as NETX_LANG (zh / en).",
|
||||
pythonCommand: "Python command",
|
||||
pythonCommandHint: "Executable that can run `python -m netx_mcp`.",
|
||||
apiToken: "API token",
|
||||
apiTokenHint: "Stored as credential NETX_API_TOKEN (never written into settings). Leave blank to keep the current token.",
|
||||
apiTokenSet: "Configured",
|
||||
apiTokenUnset: "Not set",
|
||||
overridden: "Overridden",
|
||||
reset: "Reset",
|
||||
invalid: "Invalid value",
|
||||
expand: "Expand",
|
||||
collapse: "Collapse",
|
||||
unsaved: "Unsaved",
|
||||
readOnly: "This document is read-only.",
|
||||
saveFailed: "Save failed — drafts kept for correction.",
|
||||
discard: "Discard",
|
||||
save: "Save",
|
||||
saving: "Saving…"
|
||||
};
|
||||
var zh = {
|
||||
title: "Netx Ops",
|
||||
description: "UME API 地址与 Bearer Token,供 mcp__netx__* 工具使用。",
|
||||
apiUrl: "API 地址",
|
||||
apiUrlHint: "netx REST 根地址,例如 http://127.0.0.1:8890",
|
||||
lang: "语言",
|
||||
langHint: "传给 MCP 的 NETX_LANG(zh / en)。",
|
||||
pythonCommand: "Python 命令",
|
||||
pythonCommandHint: "能执行 `python -m netx_mcp` 的解释器。",
|
||||
apiToken: "API Token",
|
||||
apiTokenHint: "写入凭据 NETX_API_TOKEN(不会进 settings)。留空表示保留已有 token。",
|
||||
apiTokenSet: "已配置",
|
||||
apiTokenUnset: "未设置",
|
||||
overridden: "已覆盖",
|
||||
reset: "重置",
|
||||
invalid: "无效值",
|
||||
expand: "展开",
|
||||
collapse: "收起",
|
||||
unsaved: "未保存",
|
||||
readOnly: "当前文档只读。",
|
||||
saveFailed: "保存失败,草稿已保留以便修改。",
|
||||
discard: "丢弃",
|
||||
save: "保存",
|
||||
saving: "保存中…"
|
||||
};
|
||||
|
||||
// src/client/index.ts
|
||||
var LOCALE_NS = "settings.netxops";
|
||||
var inject = [
|
||||
"slots",
|
||||
"locale",
|
||||
"remote",
|
||||
"remote.credentials",
|
||||
"settingsScope"
|
||||
];
|
||||
function apply(ctx) {
|
||||
ctx.effect(() => ctx.locale.register(LOCALE_NS, { zh, en }), "netxops: locales");
|
||||
const card = new NetxopsCardController(ctx.settingsScope.bind({ namespace: NETXOPS_NS }), ctx);
|
||||
ctx.effect(() => ctx.remote.$on("credentials/reference-updated", (ref) => {
|
||||
card.refreshCredential(ref);
|
||||
}), "netxops: credential invalidations");
|
||||
ctx.slots.inject("settings.plugin.item", () => ctx.slots.register({
|
||||
name: "settings.plugin.item",
|
||||
key: NETXOPS_NS,
|
||||
locale: LOCALE_NS,
|
||||
inject: () => card.inject()
|
||||
}, NetxopsCard));
|
||||
}
|
||||
|
||||
return module.exports;
|
||||
}
|
||||
});
|
||||
32
package.json
32
package.json
|
|
@ -1,17 +1,19 @@
|
|||
{
|
||||
"name": "dsh-netxops",
|
||||
"version": "0.1.1",
|
||||
"description": "DeepSeek Harness Netx Ops: host bridge (settings/credentials → netx MCP) + agent preset",
|
||||
"version": "0.1.3",
|
||||
"description": "DeepSeek Harness Netx Ops: host bridge + Plugins settings card + agent preset",
|
||||
"license": "MIT",
|
||||
"type": "module",
|
||||
"private": false,
|
||||
"main": "./src/index.ts",
|
||||
"exports": {
|
||||
".": "./src/index.ts",
|
||||
"./client": "./lib/client.js",
|
||||
"./package.json": "./package.json"
|
||||
},
|
||||
"files": [
|
||||
"src/",
|
||||
"lib/client.js",
|
||||
"presets/",
|
||||
"cordis.patch.yml",
|
||||
"docs/",
|
||||
|
|
@ -36,9 +38,21 @@
|
|||
"url": "https://github.com/hansjone/netxops/issues"
|
||||
},
|
||||
"homepage": "https://github.com/hansjone/netxops#readme",
|
||||
"scripts": {
|
||||
"bundle": "bun run scripts/build-client.mjs"
|
||||
},
|
||||
"dsh": {
|
||||
"bundle": {
|
||||
"patch": "./cordis.patch.yml"
|
||||
},
|
||||
"client": {
|
||||
"platform": "web",
|
||||
"inject": [
|
||||
"@deepseek-ai/dsh-client-locale",
|
||||
"@deepseek-ai/dsh-client-ui-settings",
|
||||
"@deepseek-ai/dsh-client-ui-settings-plugins",
|
||||
"@deepseek-ai/dsh-api-remotes"
|
||||
]
|
||||
}
|
||||
},
|
||||
"peerDependencies": {
|
||||
|
|
@ -53,6 +67,18 @@
|
|||
"@deepseek-ai/dsh-tool-skill": "*",
|
||||
"@deepseek-ai/dsh-tool-fs": "*",
|
||||
"@deepseek-ai/dsh-tool-jobs": "*",
|
||||
"@deepseek-ai/dsh-agent-instructions": "*"
|
||||
"@deepseek-ai/dsh-agent-instructions": "*",
|
||||
"@deepseek-ai/dsh-client-store": "*",
|
||||
"@deepseek-ai/dsh-client-locale": "*",
|
||||
"@deepseek-ai/dsh-client-ui-settings": "*",
|
||||
"@deepseek-ai/dsh-client-ui-settings-plugins": "*",
|
||||
"@deepseek-ai/dsh-client-ui-slots": "*",
|
||||
"@deepseek-ai/dsh-client-ui-renderer": "*",
|
||||
"@deepseek-ai/dsh-api-remotes": "*",
|
||||
"react": "^18.2.0 || ^19.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "~18.3.1",
|
||||
"typescript": "^5.8.0"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
69
scripts/build-client.mjs
Normal file
69
scripts/build-client.mjs
Normal file
|
|
@ -0,0 +1,69 @@
|
|||
/**
|
||||
* Build lib/client.js as a DSH ModuleLoader factory artifact using Bun's bundler.
|
||||
* Usage: bun run scripts/build-client.mjs
|
||||
*/
|
||||
import { mkdirSync, writeFileSync } from 'node:fs'
|
||||
import { dirname, join } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
const root = join(dirname(fileURLToPath(import.meta.url)), '..')
|
||||
const entry = join(root, 'src/client/index.ts')
|
||||
const outFile = join(root, 'lib', 'client.js')
|
||||
const id = 'dsh-netxops'
|
||||
|
||||
const external = [
|
||||
'react',
|
||||
'react/jsx-runtime',
|
||||
'react/jsx-dev-runtime',
|
||||
'@deepseek-ai/cordis',
|
||||
'@deepseek-ai/dsh-client-store',
|
||||
'@deepseek-ai/dsh-client-locale',
|
||||
'@deepseek-ai/dsh-client-locale/client',
|
||||
'@deepseek-ai/dsh-client-ui-settings',
|
||||
'@deepseek-ai/dsh-client-ui-settings/client',
|
||||
'@deepseek-ai/dsh-client-ui-settings-plugins',
|
||||
'@deepseek-ai/dsh-client-ui-settings-plugins/client',
|
||||
'@deepseek-ai/dsh-client-ui-slots',
|
||||
'@deepseek-ai/dsh-client-ui-renderer',
|
||||
'@deepseek-ai/dsh-client-ui-renderer/client',
|
||||
'@deepseek-ai/dsh-client-ui-primitives',
|
||||
'@deepseek-ai/dsh-api-remotes',
|
||||
'@deepseek-ai/dsh-api-remotes/client',
|
||||
'@deepseek-ai/dsh-client-connection',
|
||||
'@deepseek-ai/dsh-client-connection/client',
|
||||
]
|
||||
|
||||
const result = await Bun.build({
|
||||
entrypoints: [entry],
|
||||
target: 'browser',
|
||||
format: 'cjs',
|
||||
sourcemap: 'none',
|
||||
minify: false,
|
||||
external,
|
||||
})
|
||||
|
||||
if (!result.success) {
|
||||
console.error(result.logs)
|
||||
throw new Error('Bun.build failed')
|
||||
}
|
||||
|
||||
const raw = await result.outputs[0].text()
|
||||
const body = raw
|
||||
.replace(/^"use strict";\s*/m, '')
|
||||
.replace(/Object\.defineProperty\(exports,\s*"__esModule"[\s\S]*?;\s*/m, '')
|
||||
|
||||
const artifact = `window.__ModuleLoader__.load({
|
||||
id: ${JSON.stringify(id)},
|
||||
factory: (require) => {
|
||||
var module = { exports: {} };
|
||||
var exports = module.exports;
|
||||
Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
|
||||
${body}
|
||||
return module.exports;
|
||||
}
|
||||
});
|
||||
`
|
||||
|
||||
mkdirSync(dirname(outFile), { recursive: true })
|
||||
writeFileSync(outFile, artifact, 'utf8')
|
||||
console.log(`wrote ${outFile} (${artifact.length} bytes)`)
|
||||
184
src/client/NetxopsCard.tsx
Normal file
184
src/client/NetxopsCard.tsx
Normal file
|
|
@ -0,0 +1,184 @@
|
|||
/**
|
||||
* Netx Ops Plugins settings card — apiUrl / lang / python + credential token.
|
||||
*/
|
||||
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import type { InjectFace, PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import type { NetxopsCardFace, NetxopsCardState } from './controller.ts'
|
||||
import type { NetxopsLocaleKey } from './locales.ts'
|
||||
import type { CardFieldState } from './card-form.ts'
|
||||
import { ensureStyles } from './styles.ts'
|
||||
import type {} from '@deepseek-ai/dsh-client-ui-settings-plugins/client'
|
||||
|
||||
export type NetxopsCardProps =
|
||||
PropsRuntime<'settings.plugin.item'>
|
||||
& PropsLocale<'settings.netxops'>
|
||||
& InjectFace<NetxopsCardFace>
|
||||
|
||||
function ValueField(props: {
|
||||
id: string
|
||||
label: string
|
||||
hint: string
|
||||
field: CardFieldState
|
||||
overriddenLabel: string
|
||||
resetLabel: string
|
||||
invalidLabel: string
|
||||
disabled: boolean
|
||||
onEdit: (text: string) => void
|
||||
onReset: () => void
|
||||
}) {
|
||||
return (
|
||||
<div className="dsh-nx-field">
|
||||
<div className="dsh-nx-fieldHead">
|
||||
<label className="dsh-nx-label" htmlFor={props.id}>{props.label}</label>
|
||||
{props.field.overridden
|
||||
? (
|
||||
<span className="dsh-nx-badges">
|
||||
<span className="dsh-nx-badge">{props.overriddenLabel}</span>
|
||||
<button type="button" className="dsh-nx-reset" disabled={props.disabled} onClick={props.onReset}>
|
||||
{props.resetLabel}
|
||||
</button>
|
||||
</span>
|
||||
)
|
||||
: null}
|
||||
</div>
|
||||
<input
|
||||
id={props.id}
|
||||
className={props.field.invalid ? 'dsh-nx-input dsh-nx-inputInvalid' : 'dsh-nx-input'}
|
||||
type="text"
|
||||
value={props.field.text}
|
||||
disabled={props.disabled}
|
||||
aria-invalid={props.field.invalid || undefined}
|
||||
onChange={(event) => { props.onEdit(event.target.value) }}
|
||||
/>
|
||||
<p className={props.field.invalid ? 'dsh-nx-invalid' : 'dsh-nx-hint'}>
|
||||
{props.field.invalid ? props.invalidLabel : props.hint}
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function NetxopsCard(props: NetxopsCardProps) {
|
||||
ensureStyles()
|
||||
const { t } = props
|
||||
const state: NetxopsCardState = props.useNetxopsCard(snapshot => snapshot)
|
||||
const [open, setOpen] = useState(false)
|
||||
const saveStarted = useRef(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (state.saving) {
|
||||
saveStarted.current = true
|
||||
return
|
||||
}
|
||||
if (!saveStarted.current) return
|
||||
saveStarted.current = false
|
||||
if (!state.dirty && !state.failed) setOpen(false)
|
||||
}, [state.dirty, state.failed, state.saving])
|
||||
|
||||
if (!state.available) return null
|
||||
|
||||
const title = t('title' satisfies NetxopsLocaleKey)
|
||||
const disabled = !state.writable
|
||||
const blocked = !state.dirty || state.invalid || state.saving
|
||||
|
||||
return (
|
||||
<li className={open ? 'dsh-nx-card dsh-nx-cardOpen' : 'dsh-nx-card'}>
|
||||
<button
|
||||
type="button"
|
||||
className="dsh-nx-header"
|
||||
aria-expanded={open}
|
||||
aria-label={`${t(open ? 'collapse' : 'expand')}: ${title}`}
|
||||
onClick={() => { setOpen(!open) }}
|
||||
>
|
||||
<span className="dsh-nx-headText">
|
||||
<span className="dsh-nx-name">{title}</span>
|
||||
<span className="dsh-nx-desc">{t('description')}</span>
|
||||
</span>
|
||||
{state.dirty ? <span className="dsh-nx-pending">{t('unsaved')}</span> : null}
|
||||
<span className={open ? 'dsh-nx-chevron dsh-nx-chevronOpen' : 'dsh-nx-chevron'} aria-hidden>▾</span>
|
||||
</button>
|
||||
{open
|
||||
? (
|
||||
<div className="dsh-nx-body">
|
||||
{!state.writable ? <p className="dsh-nx-readOnly" role="status">{t('readOnly')}</p> : null}
|
||||
<div className="dsh-nx-field">
|
||||
<div className="dsh-nx-fieldHead">
|
||||
<label className="dsh-nx-label" htmlFor="netxops-api-token">{t('apiToken')}</label>
|
||||
<span className="dsh-nx-badges">
|
||||
<span className={state.apiTokenConfigured ? 'dsh-nx-badge' : 'dsh-nx-badgeMuted'}>
|
||||
{state.apiTokenConfigured ? t('apiTokenSet') : t('apiTokenUnset')}
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
<input
|
||||
id="netxops-api-token"
|
||||
className="dsh-nx-input"
|
||||
type="password"
|
||||
autoComplete="off"
|
||||
value={state.apiToken.text}
|
||||
disabled={!state.apiTokenWritable}
|
||||
onChange={(event) => { props.edit('apiToken', event.target.value) }}
|
||||
/>
|
||||
<p className="dsh-nx-hint">{t('apiTokenHint')}</p>
|
||||
</div>
|
||||
<ValueField
|
||||
id="netxops-api-url"
|
||||
label={t('apiUrl')}
|
||||
hint={t('apiUrlHint')}
|
||||
field={state.apiUrl}
|
||||
overriddenLabel={t('overridden')}
|
||||
resetLabel={t('reset')}
|
||||
invalidLabel={t('invalid')}
|
||||
disabled={disabled}
|
||||
onEdit={(text) => { props.edit('apiUrl', text) }}
|
||||
onReset={() => { props.resetField('apiUrl') }}
|
||||
/>
|
||||
<ValueField
|
||||
id="netxops-lang"
|
||||
label={t('lang')}
|
||||
hint={t('langHint')}
|
||||
field={state.lang}
|
||||
overriddenLabel={t('overridden')}
|
||||
resetLabel={t('reset')}
|
||||
invalidLabel={t('invalid')}
|
||||
disabled={disabled}
|
||||
onEdit={(text) => { props.edit('lang', text) }}
|
||||
onReset={() => { props.resetField('lang') }}
|
||||
/>
|
||||
<ValueField
|
||||
id="netxops-python"
|
||||
label={t('pythonCommand')}
|
||||
hint={t('pythonCommandHint')}
|
||||
field={state.pythonCommand}
|
||||
overriddenLabel={t('overridden')}
|
||||
resetLabel={t('reset')}
|
||||
invalidLabel={t('invalid')}
|
||||
disabled={disabled}
|
||||
onEdit={(text) => { props.edit('pythonCommand', text) }}
|
||||
onReset={() => { props.resetField('pythonCommand') }}
|
||||
/>
|
||||
<div className="dsh-nx-footer">
|
||||
{state.failed ? <p className="dsh-nx-failed" role="status">{t('saveFailed')}</p> : null}
|
||||
<button
|
||||
type="button"
|
||||
className="dsh-nx-btn dsh-nx-discard"
|
||||
disabled={!state.dirty || state.saving}
|
||||
onClick={props.discard}
|
||||
>
|
||||
{t('discard')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="dsh-nx-btn dsh-nx-save"
|
||||
disabled={blocked}
|
||||
onClick={props.save}
|
||||
>
|
||||
{t(state.saving ? 'saving' : 'save')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
: null}
|
||||
</li>
|
||||
)
|
||||
}
|
||||
221
src/client/card-form.ts
Normal file
221
src/client/card-form.ts
Normal file
|
|
@ -0,0 +1,221 @@
|
|||
/**
|
||||
* Shared staged-form model for the Netx Ops Plugins settings card.
|
||||
* Self-contained (cannot value-import ui-settings-plugins — client bundle purity).
|
||||
*/
|
||||
|
||||
import { createSnapshotStore, type SnapshotStore } from '@deepseek-ai/dsh-client-store'
|
||||
import type { SettingsScope, SettingsScopeSnapshot } from '@deepseek-ai/dsh-client-ui-settings/client'
|
||||
|
||||
export type FieldWrite = { kind: 'set'; value: unknown } | { kind: 'clear' }
|
||||
|
||||
export interface CardFieldSpec {
|
||||
field: string
|
||||
format: (value: unknown) => string
|
||||
parse: (text: string) => FieldWrite | undefined
|
||||
}
|
||||
|
||||
export interface CardSecretSpec {
|
||||
field: string
|
||||
write: (text: string) => Promise<boolean>
|
||||
}
|
||||
|
||||
export interface CardFieldState {
|
||||
text: string
|
||||
overridden: boolean
|
||||
invalid: boolean
|
||||
}
|
||||
|
||||
export interface CardShell {
|
||||
available: boolean
|
||||
writable: boolean
|
||||
dirty: boolean
|
||||
invalid: boolean
|
||||
saving: boolean
|
||||
failed: boolean
|
||||
}
|
||||
|
||||
export interface CardActions {
|
||||
edit: (field: string, text: string) => void
|
||||
resetField: (field: string) => void
|
||||
save: () => void
|
||||
discard: () => void
|
||||
}
|
||||
|
||||
interface StagedEdit {
|
||||
text: string
|
||||
clear: boolean
|
||||
}
|
||||
|
||||
interface PlannedWrite {
|
||||
field: string
|
||||
run: (() => Promise<boolean>) | undefined
|
||||
}
|
||||
|
||||
export function textField(field: string): CardFieldSpec {
|
||||
return {
|
||||
field,
|
||||
format: value => typeof value === 'string' ? value : '',
|
||||
parse: (text) => {
|
||||
const trimmed = text.trim()
|
||||
return trimmed === '' ? { kind: 'clear' } : { kind: 'set', value: trimmed }
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export class CardForm<T> {
|
||||
private readonly specs: Map<string, CardFieldSpec>
|
||||
private readonly secretSpecs: Map<string, CardSecretSpec>
|
||||
private readonly staged = new Map<string, StagedEdit>()
|
||||
private readonly listeners = new Set<() => void>()
|
||||
private saving = false
|
||||
private failed = false
|
||||
|
||||
constructor(
|
||||
private readonly scope: SettingsScope<T>,
|
||||
specs: CardFieldSpec[],
|
||||
secrets: CardSecretSpec[] = [],
|
||||
) {
|
||||
this.specs = new Map(specs.map(spec => [spec.field, spec]))
|
||||
this.secretSpecs = new Map(secrets.map(spec => [spec.field, spec]))
|
||||
scope.subscribe(() => { this.publish() })
|
||||
}
|
||||
|
||||
bind<S>(project: () => S): SnapshotStore<S> {
|
||||
const store = createSnapshotStore(project())
|
||||
this.listeners.add(() => { store.set(project()) })
|
||||
return store
|
||||
}
|
||||
|
||||
shell(): CardShell {
|
||||
const snapshot = this.scope.getSnapshot()
|
||||
const plan = this.plan()
|
||||
return {
|
||||
available: snapshot.status === 'ready',
|
||||
writable: snapshot.writable,
|
||||
dirty: plan.length > 0,
|
||||
invalid: plan.some(item => item.run === undefined),
|
||||
saving: this.saving,
|
||||
failed: this.failed,
|
||||
}
|
||||
}
|
||||
|
||||
field(field: string): CardFieldState {
|
||||
const staged = this.staged.get(field)
|
||||
if (this.secretSpecs.has(field)) {
|
||||
return { text: staged?.text ?? '', overridden: false, invalid: false }
|
||||
}
|
||||
const spec = this.spec(field)
|
||||
if (staged === undefined) {
|
||||
return { text: spec.format(this.sectionValue(field)), overridden: this.stored(field), invalid: false }
|
||||
}
|
||||
const write = staged.clear ? { kind: 'clear' as const } : spec.parse(staged.text)
|
||||
return {
|
||||
text: staged.text,
|
||||
overridden: write?.kind === 'set',
|
||||
invalid: write === undefined,
|
||||
}
|
||||
}
|
||||
|
||||
actions(): CardActions {
|
||||
return {
|
||||
edit: (field, text) => { this.stage(field, { text, clear: false }) },
|
||||
resetField: (field) => {
|
||||
this.stage(field, { text: this.spec(field).format(this.baseValue(field)), clear: true })
|
||||
},
|
||||
save: () => { void this.save() },
|
||||
discard: () => {
|
||||
if (this.staged.size === 0 && !this.failed) return
|
||||
this.staged.clear()
|
||||
this.failed = false
|
||||
this.publish()
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
async save(): Promise<void> {
|
||||
const plan = this.plan()
|
||||
const writes = plan.flatMap(item => item.run === undefined ? [] : [item.run])
|
||||
if (plan.length === 0 || this.saving || writes.length !== plan.length) return
|
||||
this.saving = true
|
||||
this.failed = false
|
||||
this.publish()
|
||||
let landed = true
|
||||
for (const write of writes) {
|
||||
landed = await write() && landed
|
||||
}
|
||||
if (landed) this.staged.clear()
|
||||
this.saving = false
|
||||
this.failed = !landed
|
||||
this.publish()
|
||||
}
|
||||
|
||||
private plan(): PlannedWrite[] {
|
||||
const plan: PlannedWrite[] = []
|
||||
for (const [field, staged] of this.staged) {
|
||||
const secret = this.secretSpecs.get(field)
|
||||
if (secret !== undefined) {
|
||||
const value = staged.text.trim()
|
||||
if (value !== '') plan.push({ field, run: () => secret.write(value) })
|
||||
continue
|
||||
}
|
||||
const spec = this.spec(field)
|
||||
if (staged.clear) {
|
||||
if (this.stored(field)) plan.push({ field, run: () => this.clear(field) })
|
||||
continue
|
||||
}
|
||||
if (staged.text === spec.format(this.sectionValue(field))) continue
|
||||
const write = spec.parse(staged.text)
|
||||
if (write === undefined) plan.push({ field, run: undefined })
|
||||
else if (write.kind === 'clear') plan.push({ field, run: () => this.clear(field) })
|
||||
else plan.push({ field, run: () => this.store(field, write.value) })
|
||||
}
|
||||
return plan
|
||||
}
|
||||
|
||||
private async clear(field: string): Promise<boolean> {
|
||||
await this.scope.unset(field)
|
||||
return !this.stored(field)
|
||||
}
|
||||
|
||||
private async store(field: string, value: unknown): Promise<boolean> {
|
||||
await this.scope.set(field, value)
|
||||
return this.userLayer()?.[field] === value
|
||||
}
|
||||
|
||||
private stage(field: string, edit: StagedEdit): void {
|
||||
this.staged.set(field, edit)
|
||||
this.failed = false
|
||||
this.publish()
|
||||
}
|
||||
|
||||
private spec(field: string): CardFieldSpec {
|
||||
const spec = this.specs.get(field)
|
||||
if (spec === undefined) throw new Error(`netxops card has no field ${field}`)
|
||||
return spec
|
||||
}
|
||||
|
||||
private snapshotOf(): SettingsScopeSnapshot<T> {
|
||||
return this.scope.getSnapshot()
|
||||
}
|
||||
|
||||
private sectionValue(field: string): unknown {
|
||||
return (this.snapshotOf().value as Record<string, unknown> | undefined)?.[field]
|
||||
}
|
||||
|
||||
private baseValue(field: string): unknown {
|
||||
return (this.snapshotOf().base as Record<string, unknown> | undefined)?.[field]
|
||||
}
|
||||
|
||||
private userLayer(): Record<string, unknown> | undefined {
|
||||
return this.snapshotOf().user as Record<string, unknown> | undefined
|
||||
}
|
||||
|
||||
private stored(field: string): boolean {
|
||||
const user = this.userLayer()
|
||||
return user !== undefined && Object.hasOwn(user, field)
|
||||
}
|
||||
|
||||
private publish(): void {
|
||||
for (const listener of this.listeners) listener()
|
||||
}
|
||||
}
|
||||
115
src/client/controller.ts
Normal file
115
src/client/controller.ts
Normal file
|
|
@ -0,0 +1,115 @@
|
|||
/**
|
||||
* Staged form over settings namespace `netxops` + credential NETX_API_TOKEN.
|
||||
*/
|
||||
|
||||
import type { Context as ClientContext } from '@deepseek-ai/cordis'
|
||||
import type {} from '@deepseek-ai/dsh-api-remotes/client'
|
||||
import type { SnapshotStore } from '@deepseek-ai/dsh-client-store'
|
||||
import type { SettingsScope, SettingsScopeSnapshot } from '@deepseek-ai/dsh-client-ui-settings/client'
|
||||
import {
|
||||
CardForm, textField,
|
||||
type CardActions, type CardFieldState, type CardShell,
|
||||
} from './card-form.ts'
|
||||
|
||||
export const NETXOPS_NS = 'netxops'
|
||||
const DEFAULT_TOKEN_REF = 'NETX_API_TOKEN'
|
||||
const API_TOKEN_FIELD = 'apiToken'
|
||||
|
||||
export interface NetxopsSettings {
|
||||
apiUrl?: string
|
||||
lang?: string
|
||||
pythonCommand?: string
|
||||
tokenCredentialRef?: string
|
||||
}
|
||||
|
||||
interface CredentialState {
|
||||
ref: string
|
||||
configured: boolean
|
||||
writable: boolean
|
||||
}
|
||||
|
||||
export interface NetxopsCardState extends CardShell {
|
||||
apiUrl: CardFieldState
|
||||
lang: CardFieldState
|
||||
pythonCommand: CardFieldState
|
||||
apiToken: CardFieldState
|
||||
apiTokenConfigured: boolean
|
||||
apiTokenWritable: boolean
|
||||
}
|
||||
|
||||
export interface NetxopsCardFace extends CardActions {
|
||||
hooks: {
|
||||
netxopsCard: SnapshotStore<NetxopsCardState>
|
||||
}
|
||||
}
|
||||
|
||||
export class NetxopsCardController {
|
||||
private readonly form: CardForm<NetxopsSettings>
|
||||
private readonly store: SnapshotStore<NetxopsCardState>
|
||||
private credential: CredentialState = { ref: '', configured: false, writable: true }
|
||||
|
||||
constructor(
|
||||
private readonly scope: SettingsScope<NetxopsSettings>,
|
||||
private readonly ctx: ClientContext,
|
||||
) {
|
||||
this.form = new CardForm(
|
||||
scope,
|
||||
[textField('apiUrl'), textField('lang'), textField('pythonCommand')],
|
||||
[{ field: API_TOKEN_FIELD, write: text => this.writeToken(text) }],
|
||||
)
|
||||
this.store = this.form.bind(() => this.projection())
|
||||
scope.subscribe(() => { void this.readCredential() })
|
||||
void this.readCredential()
|
||||
}
|
||||
|
||||
private projection(): NetxopsCardState {
|
||||
return {
|
||||
...this.form.shell(),
|
||||
apiUrl: this.form.field('apiUrl'),
|
||||
lang: this.form.field('lang'),
|
||||
pythonCommand: this.form.field('pythonCommand'),
|
||||
apiToken: this.form.field(API_TOKEN_FIELD),
|
||||
apiTokenConfigured: this.credential.configured,
|
||||
apiTokenWritable: this.credential.writable,
|
||||
}
|
||||
}
|
||||
|
||||
private async readCredential(): Promise<void> {
|
||||
const ref = refOf(this.scope.getSnapshot())
|
||||
if (ref !== this.credential.ref) {
|
||||
this.credential = { ref, configured: false, writable: true }
|
||||
this.store.set(this.projection())
|
||||
}
|
||||
const response = await this.ctx.remote.credentials.describe([ref])
|
||||
if (!response.ok || ref !== refOf(this.scope.getSnapshot())) return
|
||||
const view = response.value[ref]
|
||||
const next: CredentialState = {
|
||||
ref,
|
||||
configured: view?.configured ?? false,
|
||||
writable: view?.writable ?? true,
|
||||
}
|
||||
if (next.configured === this.credential.configured && next.writable === this.credential.writable) return
|
||||
this.credential = next
|
||||
this.store.set(this.projection())
|
||||
}
|
||||
|
||||
refreshCredential(ref: string): void {
|
||||
if (ref !== this.credential.ref) return
|
||||
void this.readCredential()
|
||||
}
|
||||
|
||||
inject(): NetxopsCardFace {
|
||||
return { hooks: { netxopsCard: this.store }, ...this.form.actions() }
|
||||
}
|
||||
|
||||
private async writeToken(value: string): Promise<boolean> {
|
||||
await this.ctx.remote.credentials.set(refOf(this.scope.getSnapshot()), value)
|
||||
await this.readCredential()
|
||||
return this.credential.configured
|
||||
}
|
||||
}
|
||||
|
||||
function refOf(snapshot: SettingsScopeSnapshot<NetxopsSettings>): string {
|
||||
const declared = snapshot.value?.tokenCredentialRef
|
||||
return declared !== undefined && declared.length > 0 ? declared : DEFAULT_TOKEN_REF
|
||||
}
|
||||
50
src/client/index.ts
Normal file
50
src/client/index.ts
Normal file
|
|
@ -0,0 +1,50 @@
|
|||
/**
|
||||
* Browser half — Settings → Plugins → Netx Ops card.
|
||||
*/
|
||||
|
||||
import type { Context as ClientContext } from '@deepseek-ai/cordis'
|
||||
import type {} from '@deepseek-ai/dsh-client-locale/client'
|
||||
import type {} from '@deepseek-ai/dsh-client-ui-settings/client'
|
||||
import type {} from '@deepseek-ai/dsh-client-ui-renderer/client'
|
||||
import type {} from '@deepseek-ai/dsh-api-remotes/client'
|
||||
import type {} from '@deepseek-ai/dsh-client-ui-settings-plugins/client'
|
||||
import { NetxopsCard } from './NetxopsCard.tsx'
|
||||
import { NETXOPS_NS, NetxopsCardController } from './controller.ts'
|
||||
import { en, zh, type NetxopsLocaleKey } from './locales.ts'
|
||||
|
||||
declare module '@deepseek-ai/dsh-client-ui-slots' {
|
||||
interface LocaleNamespaceMap {
|
||||
'settings.netxops': NetxopsLocaleKey
|
||||
}
|
||||
}
|
||||
|
||||
const LOCALE_NS = 'settings.netxops'
|
||||
|
||||
export const inject = [
|
||||
'slots',
|
||||
'locale',
|
||||
'remote',
|
||||
'remote.credentials',
|
||||
'settingsScope',
|
||||
]
|
||||
|
||||
export function apply(ctx: ClientContext): void {
|
||||
ctx.effect(() => ctx.locale.register(LOCALE_NS, { zh, en }), 'netxops: locales')
|
||||
|
||||
const card = new NetxopsCardController(
|
||||
ctx.settingsScope.bind({ namespace: NETXOPS_NS }),
|
||||
ctx,
|
||||
)
|
||||
|
||||
ctx.effect(
|
||||
() => ctx.remote.$on('credentials/reference-updated', (ref) => { card.refreshCredential(ref) }),
|
||||
'netxops: credential invalidations',
|
||||
)
|
||||
|
||||
ctx.slots.inject('settings.plugin.item', () => ctx.slots.register({
|
||||
name: 'settings.plugin.item',
|
||||
key: NETXOPS_NS,
|
||||
locale: LOCALE_NS,
|
||||
inject: () => card.inject(),
|
||||
}, NetxopsCard))
|
||||
}
|
||||
78
src/client/locales.ts
Normal file
78
src/client/locales.ts
Normal file
|
|
@ -0,0 +1,78 @@
|
|||
/** Locale keys for the Netx Ops Plugins settings card. */
|
||||
|
||||
export type NetxopsLocaleKey =
|
||||
| 'title'
|
||||
| 'description'
|
||||
| 'apiUrl'
|
||||
| 'apiUrlHint'
|
||||
| 'lang'
|
||||
| 'langHint'
|
||||
| 'pythonCommand'
|
||||
| 'pythonCommandHint'
|
||||
| 'apiToken'
|
||||
| 'apiTokenHint'
|
||||
| 'apiTokenSet'
|
||||
| 'apiTokenUnset'
|
||||
| 'overridden'
|
||||
| 'reset'
|
||||
| 'invalid'
|
||||
| 'expand'
|
||||
| 'collapse'
|
||||
| 'unsaved'
|
||||
| 'readOnly'
|
||||
| 'saveFailed'
|
||||
| 'discard'
|
||||
| 'save'
|
||||
| 'saving'
|
||||
|
||||
export const en: Record<NetxopsLocaleKey, string> = {
|
||||
title: 'Netx Ops',
|
||||
description: 'UME API endpoint and bearer token for mcp__netx__* tools.',
|
||||
apiUrl: 'API URL',
|
||||
apiUrlHint: 'netx REST root, e.g. http://127.0.0.1:8890',
|
||||
lang: 'Language',
|
||||
langHint: 'Passed as NETX_LANG (zh / en).',
|
||||
pythonCommand: 'Python command',
|
||||
pythonCommandHint: 'Executable that can run `python -m netx_mcp`.',
|
||||
apiToken: 'API token',
|
||||
apiTokenHint: 'Stored as credential NETX_API_TOKEN (never written into settings). Leave blank to keep the current token.',
|
||||
apiTokenSet: 'Configured',
|
||||
apiTokenUnset: 'Not set',
|
||||
overridden: 'Overridden',
|
||||
reset: 'Reset',
|
||||
invalid: 'Invalid value',
|
||||
expand: 'Expand',
|
||||
collapse: 'Collapse',
|
||||
unsaved: 'Unsaved',
|
||||
readOnly: 'This document is read-only.',
|
||||
saveFailed: 'Save failed — drafts kept for correction.',
|
||||
discard: 'Discard',
|
||||
save: 'Save',
|
||||
saving: 'Saving…',
|
||||
}
|
||||
|
||||
export const zh: Record<NetxopsLocaleKey, string> = {
|
||||
title: 'Netx Ops',
|
||||
description: 'UME API 地址与 Bearer Token,供 mcp__netx__* 工具使用。',
|
||||
apiUrl: 'API 地址',
|
||||
apiUrlHint: 'netx REST 根地址,例如 http://127.0.0.1:8890',
|
||||
lang: '语言',
|
||||
langHint: '传给 MCP 的 NETX_LANG(zh / en)。',
|
||||
pythonCommand: 'Python 命令',
|
||||
pythonCommandHint: '能执行 `python -m netx_mcp` 的解释器。',
|
||||
apiToken: 'API Token',
|
||||
apiTokenHint: '写入凭据 NETX_API_TOKEN(不会进 settings)。留空表示保留已有 token。',
|
||||
apiTokenSet: '已配置',
|
||||
apiTokenUnset: '未设置',
|
||||
overridden: '已覆盖',
|
||||
reset: '重置',
|
||||
invalid: '无效值',
|
||||
expand: '展开',
|
||||
collapse: '收起',
|
||||
unsaved: '未保存',
|
||||
readOnly: '当前文档只读。',
|
||||
saveFailed: '保存失败,草稿已保留以便修改。',
|
||||
discard: '丢弃',
|
||||
save: '保存',
|
||||
saving: '保存中…',
|
||||
}
|
||||
49
src/client/styles.ts
Normal file
49
src/client/styles.ts
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
/** Inject shared Netx Ops card styles once. */
|
||||
|
||||
const STYLE_ID = 'dsh-netxops-card-css'
|
||||
|
||||
const CSS = `
|
||||
.dsh-nx-card{list-style:none;border:.5px solid var(--dsw-alias-border-l4);border-radius:16px;background:var(--dsw-alias-bg-layer-3);transition:border-color .16s,background .16s}
|
||||
.dsh-nx-card:hover{border-color:var(--dsw-alias-label-dimmed)}
|
||||
.dsh-nx-cardOpen{background:var(--dsw-alias-bg-layer-2);border-color:var(--dsw-alias-label-dimmed)}
|
||||
.dsh-nx-header{width:100%;appearance:none;border:0;background:none;font:inherit;color:inherit;text-align:left;cursor:pointer;display:flex;align-items:center;gap:12px;padding:14px 16px;border-radius:12px}
|
||||
.dsh-nx-header:focus-visible{outline:2px solid var(--dsw-alias-brand-primary);outline-offset:-2px}
|
||||
.dsh-nx-headText{flex:1;min-width:0;display:flex;flex-direction:column;gap:4px}
|
||||
.dsh-nx-name{font-size:15px;font-weight:600;line-height:1.4;color:var(--dsw-alias-label-primary)}
|
||||
.dsh-nx-desc{font-size:13px;line-height:1.5;color:var(--dsw-alias-label-tertiary)}
|
||||
.dsh-nx-pending{flex:none;border-radius:999px;padding:1px 8px;font-size:11px;line-height:17px;font-weight:500;white-space:nowrap;background:var(--dsw-alias-bg-module-platform);color:var(--dsw-alias-label-secondary)}
|
||||
.dsh-nx-chevron{flex:none;color:var(--dsw-alias-label-tertiary);transition:transform .16s}
|
||||
.dsh-nx-chevronOpen{transform:rotate(180deg)}
|
||||
.dsh-nx-body{border-top:.5px solid var(--dsw-alias-border-l2);margin:0 16px;padding-bottom:8px}
|
||||
.dsh-nx-readOnly{margin:12px 0 0;font-size:12px;line-height:1.5;color:var(--dsw-alias-label-tertiary)}
|
||||
.dsh-nx-field{display:flex;flex-direction:column;gap:6px;padding:12px 0}
|
||||
.dsh-nx-field+.dsh-nx-field{border-top:.5px solid var(--dsw-alias-border-l2)}
|
||||
.dsh-nx-fieldHead{display:flex;align-items:center;gap:8px}
|
||||
.dsh-nx-label{flex:1;min-width:0;font-size:13px;font-weight:500;line-height:1.5;color:var(--dsw-alias-label-primary)}
|
||||
.dsh-nx-badges{display:inline-flex;align-items:center;gap:8px}
|
||||
.dsh-nx-badge{border-radius:999px;padding:1px 8px;font-size:11px;line-height:17px;white-space:nowrap;font-weight:500;background:var(--dsw-alias-bg-module-platform);color:var(--dsw-alias-label-secondary)}
|
||||
.dsh-nx-badgeMuted{border-radius:999px;padding:1px 8px;font-size:11px;line-height:17px;white-space:nowrap;color:var(--dsw-alias-label-tertiary)}
|
||||
.dsh-nx-reset{border:none;background:none;padding:0;font:inherit;font-size:12px;line-height:1.5;color:var(--dsw-alias-label-secondary);cursor:pointer}
|
||||
.dsh-nx-reset:hover:not(:disabled){color:var(--dsw-alias-label-primary)}
|
||||
.dsh-nx-input{height:34px;padding:0 12px;border:.5px solid var(--dsw-alias-border-l4);border-radius:8px;background:var(--dsw-alias-bg-layer-3);font:inherit;font-size:13px;line-height:1.5;color:var(--dsw-alias-label-primary)}
|
||||
.dsh-nx-input:focus-visible{outline:none;border-color:var(--dsw-alias-brand-primary)}
|
||||
.dsh-nx-inputInvalid{border-color:var(--dsw-alias-label-error)}
|
||||
.dsh-nx-hint{margin:0;font-size:12px;line-height:1.5;color:var(--dsw-alias-label-tertiary)}
|
||||
.dsh-nx-invalid{margin:0;font-size:12px;line-height:1.5;color:var(--dsw-alias-label-error)}
|
||||
.dsh-nx-footer{display:flex;align-items:center;justify-content:flex-end;gap:8px;padding:12px 0 4px;border-top:.5px solid var(--dsw-alias-border-l2)}
|
||||
.dsh-nx-failed{flex:1;min-width:0;margin:0;font-size:12px;line-height:1.5;color:var(--dsw-alias-label-error)}
|
||||
.dsh-nx-btn{appearance:none;border:1px solid transparent;border-radius:8px;padding:5px 14px;font:inherit;font-size:13px;line-height:1.5;cursor:pointer}
|
||||
.dsh-nx-discard{border-color:var(--dsw-alias-border-l2);background:none;color:var(--dsw-alias-label-secondary)}
|
||||
.dsh-nx-discard:hover:not(:disabled){color:var(--dsw-alias-label-primary);border-color:var(--dsw-alias-label-dimmed)}
|
||||
.dsh-nx-save{background:var(--dsw-alias-label-primary);color:var(--dsw-alias-bg-layer-3)}
|
||||
.dsh-nx-btn:disabled{opacity:.4;cursor:default}
|
||||
`
|
||||
|
||||
export function ensureStyles(): void {
|
||||
if (typeof document === 'undefined') return
|
||||
if (document.getElementById(STYLE_ID)) return
|
||||
const tag = document.createElement('style')
|
||||
tag.id = STYLE_ID
|
||||
tag.textContent = CSS
|
||||
document.head.appendChild(tag)
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue