jxp 4.1.1 → 5.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (135) hide show
  1. package/.env.sample +30 -0
  2. package/README.md +2 -0
  3. package/dist/bin/index_audit.d.ts +3 -0
  4. package/dist/bin/index_audit.d.ts.map +1 -0
  5. package/dist/bin/index_audit.js +66 -0
  6. package/dist/bin/index_audit.js.map +1 -0
  7. package/dist/bin/mcp-stdio-bridge.d.ts +3 -0
  8. package/dist/bin/mcp-stdio-bridge.d.ts.map +1 -0
  9. package/dist/bin/mcp-stdio-bridge.js +87 -0
  10. package/dist/bin/mcp-stdio-bridge.js.map +1 -0
  11. package/dist/bin/server.d.ts.map +1 -1
  12. package/dist/bin/server.js +8 -2
  13. package/dist/bin/server.js.map +1 -1
  14. package/dist/libs/builtin_models.d.ts +21 -0
  15. package/dist/libs/builtin_models.d.ts.map +1 -0
  16. package/dist/libs/builtin_models.js +125 -0
  17. package/dist/libs/builtin_models.js.map +1 -0
  18. package/dist/libs/docs-auth.d.ts.map +1 -1
  19. package/dist/libs/docs-auth.js +6 -2
  20. package/dist/libs/docs-auth.js.map +1 -1
  21. package/dist/libs/docs.js +105 -44
  22. package/dist/libs/docs.js.map +1 -1
  23. package/dist/libs/groups.js +3 -4
  24. package/dist/libs/groups.js.map +1 -1
  25. package/dist/libs/index_diagnostics.d.ts +144 -0
  26. package/dist/libs/index_diagnostics.d.ts.map +1 -0
  27. package/dist/libs/index_diagnostics.js +671 -0
  28. package/dist/libs/index_diagnostics.js.map +1 -0
  29. package/dist/libs/jxp.d.ts.map +1 -1
  30. package/dist/libs/jxp.js +97 -331
  31. package/dist/libs/jxp.js.map +1 -1
  32. package/dist/libs/load-config.d.ts +2 -0
  33. package/dist/libs/load-config.d.ts.map +1 -1
  34. package/dist/libs/load-config.js +10 -0
  35. package/dist/libs/load-config.js.map +1 -1
  36. package/dist/libs/login.js +3 -8
  37. package/dist/libs/login.js.map +1 -1
  38. package/dist/libs/mcp/auth.d.ts +4 -0
  39. package/dist/libs/mcp/auth.d.ts.map +1 -0
  40. package/dist/libs/mcp/auth.js +49 -0
  41. package/dist/libs/mcp/auth.js.map +1 -0
  42. package/dist/libs/mcp/config.d.ts +21 -0
  43. package/dist/libs/mcp/config.d.ts.map +1 -0
  44. package/dist/libs/mcp/config.js +66 -0
  45. package/dist/libs/mcp/config.js.map +1 -0
  46. package/dist/libs/mcp/context.d.ts +5 -0
  47. package/dist/libs/mcp/context.d.ts.map +1 -0
  48. package/dist/libs/mcp/context.js +14 -0
  49. package/dist/libs/mcp/context.js.map +1 -0
  50. package/dist/libs/mcp/describe.d.ts +13 -0
  51. package/dist/libs/mcp/describe.d.ts.map +1 -0
  52. package/dist/libs/mcp/describe.js +36 -0
  53. package/dist/libs/mcp/describe.js.map +1 -0
  54. package/dist/libs/mcp/docs_auth.d.ts +5 -0
  55. package/dist/libs/mcp/docs_auth.d.ts.map +1 -0
  56. package/dist/libs/mcp/docs_auth.js +36 -0
  57. package/dist/libs/mcp/docs_auth.js.map +1 -0
  58. package/dist/libs/mcp/dynamic_import.d.ts +6 -0
  59. package/dist/libs/mcp/dynamic_import.d.ts.map +1 -0
  60. package/dist/libs/mcp/dynamic_import.js +12 -0
  61. package/dist/libs/mcp/dynamic_import.js.map +1 -0
  62. package/dist/libs/mcp/execute_tool.d.ts +10 -0
  63. package/dist/libs/mcp/execute_tool.d.ts.map +1 -0
  64. package/dist/libs/mcp/execute_tool.js +65 -0
  65. package/dist/libs/mcp/execute_tool.js.map +1 -0
  66. package/dist/libs/mcp/guides/default.md +67 -0
  67. package/dist/libs/mcp/guides.d.ts +14 -0
  68. package/dist/libs/mcp/guides.d.ts.map +1 -0
  69. package/dist/libs/mcp/guides.js +127 -0
  70. package/dist/libs/mcp/guides.js.map +1 -0
  71. package/dist/libs/mcp/model_visibility.d.ts +31 -0
  72. package/dist/libs/mcp/model_visibility.d.ts.map +1 -0
  73. package/dist/libs/mcp/model_visibility.js +73 -0
  74. package/dist/libs/mcp/model_visibility.js.map +1 -0
  75. package/dist/libs/mcp/mount.d.ts +10 -0
  76. package/dist/libs/mcp/mount.d.ts.map +1 -0
  77. package/dist/libs/mcp/mount.js +68 -0
  78. package/dist/libs/mcp/mount.js.map +1 -0
  79. package/dist/libs/mcp/read_service.d.ts +23 -0
  80. package/dist/libs/mcp/read_service.d.ts.map +1 -0
  81. package/dist/libs/mcp/read_service.js +141 -0
  82. package/dist/libs/mcp/read_service.js.map +1 -0
  83. package/dist/libs/mcp/server.d.ts +23 -0
  84. package/dist/libs/mcp/server.d.ts.map +1 -0
  85. package/dist/libs/mcp/server.js +111 -0
  86. package/dist/libs/mcp/server.js.map +1 -0
  87. package/dist/libs/parse_byte_size.d.ts +10 -0
  88. package/dist/libs/parse_byte_size.d.ts.map +1 -0
  89. package/dist/libs/parse_byte_size.js +80 -0
  90. package/dist/libs/parse_byte_size.js.map +1 -0
  91. package/dist/libs/query_limits.js +99 -13
  92. package/dist/libs/query_limits.js.map +1 -1
  93. package/dist/libs/read_handlers.d.ts +13 -0
  94. package/dist/libs/read_handlers.d.ts.map +1 -0
  95. package/dist/libs/read_handlers.js +381 -0
  96. package/dist/libs/read_handlers.js.map +1 -0
  97. package/dist/libs/schema.js +2 -2
  98. package/dist/libs/schema.js.map +1 -1
  99. package/dist/libs/schema_serialize.d.ts +3 -0
  100. package/dist/libs/schema_serialize.d.ts.map +1 -0
  101. package/dist/libs/schema_serialize.js +45 -0
  102. package/dist/libs/schema_serialize.js.map +1 -0
  103. package/dist/libs/security.js +7 -11
  104. package/dist/libs/security.js.map +1 -1
  105. package/dist/libs/setup.js +3 -4
  106. package/dist/libs/setup.js.map +1 -1
  107. package/dist/libs/startup.d.ts +4 -0
  108. package/dist/libs/startup.d.ts.map +1 -1
  109. package/dist/libs/startup.js +17 -0
  110. package/dist/libs/startup.js.map +1 -1
  111. package/dist/models/indexquerylog_model.d.ts +19 -0
  112. package/dist/models/indexquerylog_model.d.ts.map +1 -0
  113. package/dist/models/indexquerylog_model.js +32 -0
  114. package/dist/models/indexquerylog_model.js.map +1 -0
  115. package/dist/types/jxp-config.d.ts +29 -0
  116. package/dist/types/jxp-config.d.ts.map +1 -1
  117. package/dist/types/schema-fields.d.ts +4 -0
  118. package/dist/types/schema-fields.d.ts.map +1 -1
  119. package/docs/api.md +16 -2
  120. package/docs/changelog.md +101 -1
  121. package/docs/configuration.md +39 -0
  122. package/docs/index.md +2 -0
  123. package/docs/index_diagnostics.md +127 -0
  124. package/docs/mcp.md +134 -0
  125. package/docs/schemas.md +3 -1
  126. package/mkdocs.yml +2 -0
  127. package/package.json +26 -9
  128. package/templates/assets/diagnostics.js +352 -0
  129. package/templates/assets/docs.css +107 -0
  130. package/templates/assets/mcp-console.js +343 -0
  131. package/templates/diagnostics.pug +94 -0
  132. package/templates/index.pug +38 -0
  133. package/templates/mcp.pug +112 -0
  134. package/templates/navbar.pug +2 -0
  135. package/templates/sidebar.pug +12 -0
package/docs/mcp.md ADDED
@@ -0,0 +1,134 @@
1
+ # MCP (Model Context Protocol)
2
+
3
+ JXP can expose a **read-only** MCP server on the same HTTP port as the REST API. This lets AI clients (Cursor, LM Studio, RevEngine2 admin proxy) query your data with the same permissions as the REST API.
4
+
5
+ ## Enable
6
+
7
+ ```bash
8
+ MCP_ENABLED=true
9
+ ```
10
+
11
+ Optional settings (all prefixed `MCP_`):
12
+
13
+ | Variable | Default | Purpose |
14
+ |----------|---------|---------|
15
+ | `MCP_PATH` | `/mcp` | HTTP endpoint |
16
+ | `MCP_REQUIRE_API_KEY` | `true` | Reject unauthenticated MCP |
17
+ | `MCP_MODEL_WHITELIST` | _(empty)_ | Expose default-hidden models (e.g. `user`) |
18
+ | `MCP_MODEL_BLACKLIST` | _(empty)_ | Always hide models |
19
+ | `MCP_DEFAULT_HIDDEN_MODELS` | builtin auth slugs | Override default-deny list |
20
+ | `MCP_DEFAULT_LIMIT` | `20` | Default row limit |
21
+ | `MCP_MAX_LIMIT` | `100` | Hard cap on `limit` |
22
+ | `MCP_MAX_RESPONSE_SIZE` | `256kb` | JSON response cap |
23
+ | `MCP_MAX_CSV_SIZE` | `512kb` | CSV export cap |
24
+ | `MCP_TRUNCATE_STRINGS_AT` | `4000` | Truncate long string fields in JSON |
25
+ | `MCP_DISABLE_AUTOPOPULATE` | `true` | Ignore expensive autopopulate |
26
+ | `MCP_INSTRUCTIONS_APPEND` | _(empty)_ | Text appended to server instructions (initialize) |
27
+ | `MCP_GUIDE_FILES` | _(empty)_ | Comma-separated markdown files merged into `jxp-guide` resource |
28
+
29
+ ## LLM guidance
30
+
31
+ On connect, the MCP `initialize` response includes **server instructions** — a short workflow summary injected for the LLM.
32
+
33
+ For the full guide, read the fixed MCP resource **`jxp-guide`** (`jxp://guide`, markdown). Content is merged in order:
34
+
35
+ 1. Built-in [`default.md`](https://github.com/WorkSpaceMan/jxp/blob/main/src/libs/mcp/guides/default.md)
36
+ 2. `JXPConfig.mcp.guideFiles` (app config)
37
+ 3. `MCP_GUIDE_FILES` (env)
38
+
39
+ ### App extension (`JXPConfig.mcp`)
40
+
41
+ ```typescript
42
+ apiconfig.mcp = {
43
+ instructions: "Short app-specific note appended to server instructions.",
44
+ guideFiles: [path.join(__dirname, "../../mcp/guide.md")],
45
+ };
46
+ ```
47
+
48
+ Example: [revengine-api `mcp/guide.md`](https://github.com/WorkSpaceMan/revengine-api) adds domain context for `reader`, `segment`, etc.
49
+
50
+ ## Tools (fixed)
51
+
52
+ 1. **`jxp_list_models`** — models the caller can read via MCP
53
+ 2. **`jxp_describe_model`** — field types, links, populate hints
54
+ 3. **`jxp_find`** — list or get by `id`; supports `filter`, `search`, `populate`, `fields`, `sort`, `limit`, `page`
55
+ 4. **`jxp_count`** — count with optional `filter` / `search`
56
+ 5. **`jxp_export_csv`** — CSV text export (use `fields` + low `limit`)
57
+
58
+ Tools never change when you add models — agents call `jxp_list_models` first.
59
+
60
+ ## Authentication
61
+
62
+ Send the user's API key on every MCP HTTP request:
63
+
64
+ ```
65
+ X-API-Key: <apikey>
66
+ ```
67
+
68
+ Bearer tokens also work. MCP uses the same permission rules as `GET /api/:model`.
69
+
70
+ ## Docs playground
71
+
72
+ When `MCP_ENABLED=true`, the built-in docs UI includes **`/docs/mcp`** — a chat-style assistant that calls the same five MCP tools (protected by the same login as **Browse API**). Use the API key field in the top bar or sign in; tool calls go through `POST /docs/mcp/call` with your permissions.
73
+
74
+ Default-hidden models (unless whitelisted): built-in auth collections (`user`, `apikey`, `token`, etc.) and schemas with `internal: true`.
75
+
76
+ ## Cursor / LM Studio (stdio)
77
+
78
+ Use the `jxp-mcp` bridge (proxies stdio → HTTP, including tools, resources, and instructions):
79
+
80
+ ```json
81
+ {
82
+ "mcpServers": {
83
+ "jxp": {
84
+ "command": "npx",
85
+ "args": ["-y", "jxp-mcp"],
86
+ "env": {
87
+ "JXP_URL": "http://localhost:4001",
88
+ "JXP_API_KEY": "your-api-key"
89
+ }
90
+ }
91
+ }
92
+ }
93
+ ```
94
+
95
+ For Cursor, direct HTTP MCP also works and receives instructions automatically:
96
+
97
+ ```json
98
+ {
99
+ "mcpServers": {
100
+ "jxp": {
101
+ "url": "http://localhost:4001/mcp",
102
+ "headers": { "X-API-Key": "your-api-key" }
103
+ }
104
+ }
105
+ }
106
+ ```
107
+
108
+ ## RevEngine2 / server proxy
109
+
110
+ Backend connects to `{API_URL}/mcp` with the logged-in user's API key and proxies MCP between the admin UI and JXP. JXP's own docs UI also exposes `/docs/mcp` for local exploration.
111
+
112
+ ## Deploying `jxp-mcp`
113
+
114
+ `jxp-mcp` is **not** a separate npm package. It ships as a **CLI binary on the `jxp` package** (since v5.0.0):
115
+
116
+ ```bash
117
+ npm install jxp@5 # or depend on jxp in your app
118
+ npx jxp-mcp # stdio MCP → your HTTP /mcp endpoint
119
+ ```
120
+
121
+ | Component | Where it runs | Role |
122
+ |-----------|---------------|------|
123
+ | **JXP HTTP API** | Your server (e.g. RevEngine Docker on port 4001) | Serves REST + `/mcp` when `MCP_ENABLED=true` |
124
+ | **`jxp-mcp` bridge** | Developer's machine (Cursor, LM Studio) | stdio proxy; needs `JXP_URL` + `JXP_API_KEY` pointing at the HTTP server |
125
+
126
+ **Production API containers** do not need to run `jxp-mcp`. Enable MCP on the API (`MCP_ENABLED=true`) and connect from the IDE via HTTP or stdio.
127
+
128
+ **Publish order:** `npm publish` **jxp@5** first, then bump app dependencies (e.g. `revengine-api` `jxp: ^5.0.0`) and rebuild Docker images.
129
+
130
+ ## v1 limitations
131
+
132
+ - Read-only (no mutations)
133
+ - No `POST /query`, `/aggregate`, or `/call`
134
+ - No per-model `mcp:` schema flag (env lists only)
package/docs/schemas.md CHANGED
@@ -10,7 +10,9 @@ The schemas are based on Mongoose, so anything you can do with a [Mongoose schem
10
10
 
11
11
  ## Defining a schema
12
12
 
13
- Schemas are loaded from `MODEL_DIR` (default `./dist/models` on the sample server). Each file is named `<name>_model.js`. Each schema represents a collection in Mongo, with the Mongo collection name being the plural of the schema name. Eg. the data for `user_model.js` is stored in the `users` collection in Mongo.
13
+ Schemas are loaded from `MODEL_DIR` (default `./dist/models` on the sample server). Each file is named `<name>_model.js`. JXP also ships **built-in** models (auth, `indexquerylog`, etc.) from the package when your app does not define them see [Configuration Built-in models](configuration.md#built-in-models). Set `internal: true` on schema options to hide a model from the docs API browser.
14
+
15
+ Each schema represents a collection in Mongo, with the Mongo collection name being the plural of the schema name. Eg. the data for `user_model.js` is stored in the `users` collection in Mongo.
14
16
 
15
17
  A typical schema looks like `test_model.js` in your model directory:
16
18
  ```javascript
package/mkdocs.yml CHANGED
@@ -13,7 +13,9 @@ nav:
13
13
  - Bulk Writes: bulk_writes.md
14
14
  - Queries: queries.md
15
15
  - Caching: caching.md
16
+ - Index diagnostics: index_diagnostics.md
16
17
  - Websocket: websocket.md
17
18
  - Special Features: special.md
19
+ - MCP: mcp.md
18
20
  - Changelog: changelog.md
19
21
  theme: readthedocs
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "jxp",
3
3
  "description": "An opinionated RESTful API library based on Mongoose and Restify. Make an API by just writing Mongoose models.",
4
- "version": "4.1.1",
4
+ "version": "5.0.0",
5
5
  "private": false,
6
6
  "main": "dist/libs/jxp.js",
7
7
  "types": "dist/libs/jxp.d.ts",
@@ -39,21 +39,32 @@
39
39
  },
40
40
  "./types/jxp-config": {
41
41
  "types": "./dist/types/jxp-config.d.ts"
42
+ },
43
+ "./libs/index_diagnostics": {
44
+ "types": "./dist/libs/index_diagnostics.d.ts",
45
+ "default": "./dist/libs/index_diagnostics.js"
46
+ },
47
+ "./libs/builtin_models": {
48
+ "types": "./dist/libs/builtin_models.d.ts",
49
+ "default": "./dist/libs/builtin_models.js"
42
50
  }
43
51
  },
44
52
  "scripts": {
45
- "build": "tsc -p tsconfig.build.json",
53
+ "build": "tsc -p tsconfig.build.json && node -e \"require('fs').cpSync('src/libs/mcp/guides','dist/libs/mcp/guides',{recursive:true})\"",
46
54
  "prepublishOnly": "npm run build",
47
55
  "prepare": "node -e \"require('fs').existsSync('src')&&require('child_process').execSync('npm run build',{stdio:'inherit'})\"",
48
56
  "start": "node dist/bin/server.js",
49
57
  "dev": "nodemon --watch src --ext ts --exec 'npm run build && node dist/bin/server.js'",
50
- "test": "npm run build && mocha --require test/env.js test/bulkwrite_guard.test.js test/request_log.test.js test/test.js --timeout 1000 -b --exit",
51
- "setup": "node dist/bin/setup.js"
58
+ "test": "npm run build && mocha --require test/env.js test/builtin_models.test.js test/bulkwrite_guard.test.js test/index_diagnostics.test.js test/mcp_guides.test.js test/mcp_visibility.test.js test/parse_byte_size.test.js test/query_limits.test.js test/request_log.test.js test/test.js --timeout 1000 -b --exit",
59
+ "setup": "node dist/bin/setup.js",
60
+ "indexes": "node dist/bin/index_audit.js"
52
61
  },
53
62
  "bin": {
54
63
  "jxp": "dist/bin/setup.js",
55
64
  "jxp-setup": "dist/bin/setup.js",
56
- "jxp-add-admin": "dist/bin/init_db.js"
65
+ "jxp-add-admin": "dist/bin/init_db.js",
66
+ "jxp-indexes": "dist/bin/index_audit.js",
67
+ "jxp-mcp": "dist/bin/mcp-stdio-bridge.js"
57
68
  },
58
69
  "repository": {
59
70
  "type": "git",
@@ -70,15 +81,20 @@
70
81
  "url": "https://github.com/WorkSpaceMan/jxp/issues"
71
82
  },
72
83
  "dependencies": {
84
+ "@cfworker/json-schema": "^4.1.1",
85
+ "@json2csv/plainjs": "^7.0.6",
86
+ "@modelcontextprotocol/client": "^2.0.0-alpha.2",
87
+ "@modelcontextprotocol/node": "^2.0.0-alpha.2",
88
+ "@modelcontextprotocol/server": "^2.0.0-alpha.2",
73
89
  "bcryptjs": "^3.0.2",
90
+ "bytes": "^3.1.2",
74
91
  "commander": "^13.1.0",
75
92
  "dotenv": "^16.5.0",
76
93
  "glob": "11.1.0",
77
94
  "js-yaml": "4.1.1",
78
- "@json2csv/plainjs": "^7.0.6",
79
95
  "jsonwebtoken": "^9.0.2",
80
- "markdown-it": "^14.1.1",
81
96
  "jxp-helper": "^1.4.3",
97
+ "markdown-it": "^14.1.1",
82
98
  "moment": "^2.30.1",
83
99
  "mongoose": "6.13.9",
84
100
  "mongoose-friendly": "^0.1.4",
@@ -93,12 +109,13 @@
93
109
  "restify-errors": "^8.0.2",
94
110
  "traverse": "^0.6.11",
95
111
  "underscore": "^1.13.8",
96
- "ws": "^8.18.1"
112
+ "ws": "^8.18.1",
113
+ "zod": "^4.4.3"
97
114
  },
98
115
  "devDependencies": {
99
- "@types/markdown-it": "^14.1.2",
100
116
  "@types/bcryptjs": "^2.4.6",
101
117
  "@types/jsonwebtoken": "^9.0.9",
118
+ "@types/markdown-it": "^14.1.2",
102
119
  "@types/morgan": "^1.9.9",
103
120
  "@types/node": "^22.15.21",
104
121
  "@types/underscore": "^1.13.0",
@@ -0,0 +1,352 @@
1
+ (function () {
2
+ "use strict";
3
+
4
+ const SYNC_CONFIRM = window.JXP_DIAG_SYNC_CONFIRM || "DROP_EXTRA_INDEXES";
5
+
6
+ const REASON_LABELS = {
7
+ collection_scan: "Collection scan",
8
+ inefficient_index: "Inefficient index",
9
+ collection_scan_below_threshold: "Collection scan (below threshold)",
10
+ };
11
+
12
+ function formatReason(reason) {
13
+ if (!reason) return "—";
14
+ return REASON_LABELS[reason] || reason.replace(/_/g, " ");
15
+ }
16
+
17
+ function formatScanStage(stage) {
18
+ if (!stage) return "—";
19
+ const hasCollScan = /\bCOLLSCAN\b/.test(stage);
20
+ const cls = hasCollScan ? "text-danger fw-semibold" : "text-body-secondary";
21
+ return `<code class="${cls}">${escapeHtml(stage)}</code>`;
22
+ }
23
+
24
+ function getApiKey() {
25
+ const input = document.getElementById("docs-api-key");
26
+ return input ? input.value.trim() : "";
27
+ }
28
+
29
+ function authHeaders() {
30
+ const headers = { Accept: "application/json" };
31
+ const key = getApiKey();
32
+ if (key) headers["X-API-Key"] = key;
33
+ return headers;
34
+ }
35
+
36
+ /** Same as api-console.js: docs login stores apikey in HttpOnly cookie; expose via /docs/session */
37
+ async function loadSessionApiKey() {
38
+ const access = document.documentElement.dataset.docsAccess;
39
+ if (access !== "protected") return;
40
+ try {
41
+ const res = await fetch("/docs/session", { credentials: "same-origin" });
42
+ if (!res.ok) return;
43
+ const data = await res.json();
44
+ if (!data.apikey) return;
45
+ const input = document.getElementById("docs-api-key");
46
+ if (input) input.value = data.apikey;
47
+ } catch {
48
+ /* ignore */
49
+ }
50
+ }
51
+
52
+ function loadStoredKey() {
53
+ if (localStorage.getItem("jxp_docs_remember_key") !== "1") return;
54
+ const key = localStorage.getItem("jxp_docs_api_key");
55
+ if (!key) return;
56
+ const input = document.getElementById("docs-api-key");
57
+ const remember = document.getElementById("docs-remember-key");
58
+ if (input) input.value = key;
59
+ if (remember) remember.checked = true;
60
+ }
61
+
62
+ async function ensureApiKey() {
63
+ if (getApiKey()) return;
64
+ await loadSessionApiKey();
65
+ if (getApiKey()) return;
66
+ loadStoredKey();
67
+ }
68
+
69
+ async function apiFetch(path, opts) {
70
+ const res = await fetch(path, {
71
+ ...opts,
72
+ credentials: "same-origin",
73
+ headers: { ...authHeaders(), ...(opts && opts.headers) },
74
+ });
75
+ const text = await res.text();
76
+ let body;
77
+ try {
78
+ body = text ? JSON.parse(text) : null;
79
+ } catch {
80
+ body = text;
81
+ }
82
+ if (!res.ok) {
83
+ const msg =
84
+ (body && body.message) ||
85
+ (body && body.msg) ||
86
+ (typeof body === "string" ? body : res.statusText);
87
+ throw new Error(msg || `HTTP ${res.status}`);
88
+ }
89
+ return body;
90
+ }
91
+
92
+ function badgeClass(ok, kind) {
93
+ if (ok) return "text-bg-success";
94
+ if (kind === "missing") return "text-bg-warning";
95
+ return "text-bg-danger";
96
+ }
97
+
98
+ function renderIndexes(data) {
99
+ const table = document.getElementById("diag-indexes-table");
100
+ const tbody = table.querySelector("tbody");
101
+ const summary = document.getElementById("diag-indexes-summary");
102
+ const status = document.getElementById("diag-indexes-status");
103
+
104
+ if (!data || !data.collections) {
105
+ status.textContent = "No audit data.";
106
+ table.classList.add("d-none");
107
+ return;
108
+ }
109
+
110
+ const s = data.summary || {};
111
+ summary.innerHTML = `<span class="badge text-bg-secondary">${s.ok || 0}/${s.total || 0} OK</span>
112
+ ${s.withMissing ? `<span class="badge text-bg-warning ms-1">${s.withMissing} missing</span>` : ""}
113
+ ${s.withExtra ? `<span class="badge text-bg-danger ms-1">${s.withExtra} extra</span>` : ""}`;
114
+ status.textContent = `Generated ${data.generatedAt || ""}`;
115
+
116
+ tbody.innerHTML = "";
117
+ for (const row of data.collections) {
118
+ const tr = document.createElement("tr");
119
+ const missing =
120
+ row.missing && row.missing.length
121
+ ? row.missing.map((k) => JSON.stringify(k)).join(", ")
122
+ : "—";
123
+ const extra = row.extra && row.extra.length ? row.extra.join(", ") : "—";
124
+ const statusLabel = row.error ? "error" : row.ok ? "ok" : "drift";
125
+ tr.innerHTML = `
126
+ <td><code>${escapeHtml(row.modelName)}</code></td>
127
+ <td class="text-muted small">${escapeHtml(row.collection)}</td>
128
+ <td><span class="badge ${badgeClass(row.ok && !row.error)}">${statusLabel}</span></td>
129
+ <td class="small">${escapeHtml(missing)}</td>
130
+ <td class="small">${escapeHtml(extra)}</td>`;
131
+ tbody.appendChild(tr);
132
+ }
133
+ table.classList.remove("d-none");
134
+ }
135
+
136
+ function renderMonitorHelp(monitorStatus) {
137
+ const help = document.getElementById("diag-query-monitor-help");
138
+ const controls = document.querySelector("#tab-queries .d-flex.flex-wrap.gap-2.mb-3");
139
+ if (!help) return;
140
+
141
+ if (!monitorStatus || monitorStatus.active) {
142
+ help.classList.add("d-none");
143
+ help.innerHTML = "";
144
+ if (controls) controls.classList.remove("opacity-50");
145
+ return;
146
+ }
147
+
148
+ if (controls) controls.classList.add("opacity-50");
149
+
150
+ const hints = (monitorStatus.env_hints || [])
151
+ .map(
152
+ (h) =>
153
+ `<li><code>${escapeHtml(h.name)}=${escapeHtml(h.value)}</code>${h.comment ? ` <span class="text-muted">— ${escapeHtml(h.comment)}</span>` : ""}</li>`
154
+ )
155
+ .join("");
156
+
157
+ const prodNote = monitorStatus.is_production
158
+ ? '<p class="mb-2 small">In <strong>production</strong>, query monitoring is off unless <code>INDEX_DIAGNOSTICS_ENABLED=true</code>. Use a low sample rate to limit overhead.</p>'
159
+ : '<p class="mb-2 small">In development, monitoring is on by default. If you disabled it, set the variables below and restart the API.</p>';
160
+
161
+ const envDebug = monitorStatus.env
162
+ ? `<p class="mb-2 small text-muted">Process env: <code>QUERY_INDEX_MONITOR=${escapeHtml(monitorStatus.env.QUERY_INDEX_MONITOR ?? "(unset)")}</code>, <code>INDEX_DIAGNOSTICS_ENABLED=${escapeHtml(monitorStatus.env.INDEX_DIAGNOSTICS_ENABLED ?? "(unset)")}</code>, <code>NODE_ENV=${escapeHtml(monitorStatus.env.NODE_ENV ?? "(unset)")}</code></p>`
163
+ : "";
164
+
165
+ const regNote = monitorStatus.registration_missing
166
+ ? '<p class="mb-2 small"><strong>Your .env has monitoring enabled</strong>, but this server never called <code>registerQueryIndexMonitor()</code> before loading models. Restart the API after upgrading <code>jxp</code> (recent versions register automatically inside <code>JXP()</code>).</p>'
167
+ : "";
168
+
169
+ help.className = "alert alert-info mb-3";
170
+ help.innerHTML = `
171
+ <h2 class="h6 alert-heading mb-2">Query monitoring is disabled</h2>
172
+ <p class="mb-2 small">The API is not sampling read queries or running <code>explain('executionStats')</code>. Historical rows may still appear below if logging was on earlier.</p>
173
+ ${regNote}
174
+ ${envDebug}
175
+ ${prodNote}
176
+ <p class="mb-1 small fw-semibold">Enable via environment (.env)</p>
177
+ <ol class="small mb-2">
178
+ <li>Add to your <code>.env</code> (then restart the server):</li>
179
+ </ol>
180
+ <ul class="small mb-2">${hints}</ul>
181
+ <p class="mb-1 small fw-semibold">Or in code (<code>JXP(apiconfig)</code>)</p>
182
+ <pre class="small bg-body-secondary p-2 rounded mb-2"><code>index_diagnostics: {
183
+ enabled: true,
184
+ query_monitor: {
185
+ enabled: true,
186
+ sample_rate: ${monitorStatus.is_production ? "0.02" : "1.0"}
187
+ }
188
+ }</code></pre>
189
+ <p class="mb-0 small text-muted">Monitoring is registered at startup in <code>server.ts</code> via <code>registerQueryIndexMonitor()</code> before models load — a restart is required after changing config. See <a href="/docs/md/index_diagnostics.md">Index diagnostics</a>.</p>`;
190
+ }
191
+
192
+ function renderQueries(data) {
193
+ const table = document.getElementById("diag-queries-table");
194
+ const tbody = table.querySelector("tbody");
195
+ const status = document.getElementById("diag-queries-status");
196
+ const configEl = document.getElementById("diag-monitor-config");
197
+ const monitorStatus = data?.monitor_status;
198
+
199
+ renderMonitorHelp(monitorStatus);
200
+
201
+ if (!data) {
202
+ status.textContent = "No query log data.";
203
+ table.classList.add("d-none");
204
+ return;
205
+ }
206
+
207
+ const cfg = data.config;
208
+ if (monitorStatus?.active && cfg) {
209
+ configEl.innerHTML = `Monitor active: <code>sample_rate=${cfg.sample_rate}</code>,
210
+ <code>min_docs=${cfg.min_docs_examined}</code>
211
+ ${data.persisted ? '<span class="badge text-bg-success ms-1">MongoDB</span>' : '<span class="badge text-bg-secondary ms-1">memory buffer</span>'}`;
212
+ } else {
213
+ configEl.innerHTML =
214
+ '<span class="badge text-bg-secondary">Query monitor off</span> <span class="text-muted">— enable using the steps above</span>';
215
+ }
216
+
217
+ const entries = data.entries || [];
218
+ const total = data.total != null ? data.total : entries.length;
219
+ if (monitorStatus?.active) {
220
+ status.textContent = `${entries.length} shown${data.persisted ? ` of ${total} stored` : " (in-memory)"}`;
221
+ } else if (entries.length) {
222
+ status.textContent = `${entries.length} historical entries (monitoring currently off)`;
223
+ } else {
224
+ status.textContent = "No entries yet — enable monitoring above, then run API read traffic.";
225
+ }
226
+
227
+ tbody.innerHTML = "";
228
+ for (const e of entries) {
229
+ const tr = document.createElement("tr");
230
+ const sev = e.severity === "alert" ? "danger" : "warning";
231
+ tr.innerHTML = `
232
+ <td class="small text-nowrap">${escapeHtml(e.at || "")}</td>
233
+ <td><code>${escapeHtml(e.model)}</code></td>
234
+ <td>${escapeHtml(e.op)}</td>
235
+ <td><span class="badge text-bg-${sev}">${escapeHtml(e.severity)}</span></td>
236
+ <td>${e.totalDocsExamined ?? "—"}</td>
237
+ <td>${e.nReturned ?? "—"}</td>
238
+ <td class="small">${formatScanStage(e.stage)}</td>
239
+ <td class="small">${escapeHtml(formatReason(e.reason))}</td>
240
+ <td class="small font-monospace">${escapeHtml(e.filterSummary || "")}</td>`;
241
+ tbody.appendChild(tr);
242
+ }
243
+ table.classList.toggle("d-none", entries.length === 0);
244
+ }
245
+
246
+ function escapeHtml(s) {
247
+ return String(s)
248
+ .replace(/&/g, "&amp;")
249
+ .replace(/</g, "&lt;")
250
+ .replace(/>/g, "&gt;")
251
+ .replace(/"/g, "&quot;");
252
+ }
253
+
254
+ async function loadIndexes(refresh) {
255
+ const status = document.getElementById("diag-indexes-status");
256
+ const unused = document.getElementById("diag-unused").checked;
257
+ status.textContent = "Loading…";
258
+ try {
259
+ const q = new URLSearchParams();
260
+ if (refresh) q.set("refresh", "1");
261
+ if (unused) q.set("unused", "1");
262
+ const data = await apiFetch(`/diagnostics/indexes?${q}`);
263
+ renderIndexes(data);
264
+ } catch (err) {
265
+ status.textContent = `Error: ${err.message}`;
266
+ }
267
+ }
268
+
269
+ async function loadQueries() {
270
+ const status = document.getElementById("diag-queries-status");
271
+ const severity = document.getElementById("diag-query-severity").value;
272
+ const model = document.getElementById("diag-query-model").value.trim();
273
+ status.textContent = "Loading…";
274
+ try {
275
+ const q = new URLSearchParams({ limit: "100" });
276
+ if (severity) q.set("severity", severity);
277
+ if (model) q.set("model", model);
278
+ const data = await apiFetch(`/diagnostics/queries?${q}`);
279
+ renderQueries(data);
280
+ } catch (err) {
281
+ status.textContent = `Error: ${err.message} (admin API key required)`;
282
+ }
283
+ }
284
+
285
+ async function syncIndexes() {
286
+ const confirmInput = document.getElementById("diag-sync-confirm");
287
+ const status = document.getElementById("diag-indexes-status");
288
+ if (confirmInput.value.trim() !== SYNC_CONFIRM) {
289
+ status.textContent = `Type ${SYNC_CONFIRM} to confirm sync.`;
290
+ return;
291
+ }
292
+ status.textContent = "Syncing…";
293
+ try {
294
+ await apiFetch("/diagnostics/indexes/sync", {
295
+ method: "POST",
296
+ headers: { "Content-Type": "application/json" },
297
+ body: JSON.stringify({ confirm: SYNC_CONFIRM }),
298
+ });
299
+ status.textContent = "Sync complete. Refreshing audit…";
300
+ confirmInput.value = "";
301
+ await loadIndexes(true);
302
+ } catch (err) {
303
+ status.textContent = `Sync failed: ${err.message}`;
304
+ }
305
+ }
306
+
307
+ function wireModelFilter() {
308
+ const input = document.getElementById("diag-query-model");
309
+ if (!input) return;
310
+ let debounceTimer;
311
+ input.addEventListener("change", loadQueries);
312
+ input.addEventListener("input", () => {
313
+ clearTimeout(debounceTimer);
314
+ debounceTimer = setTimeout(loadQueries, 350);
315
+ });
316
+ }
317
+
318
+ function wireSyncConfirm() {
319
+ const input = document.getElementById("diag-sync-confirm");
320
+ const btn = document.getElementById("diag-sync-btn");
321
+ if (!input || !btn) return;
322
+ input.addEventListener("input", () => {
323
+ btn.disabled = input.value.trim() !== SYNC_CONFIRM;
324
+ });
325
+ btn.addEventListener("click", syncIndexes);
326
+ }
327
+
328
+ document.addEventListener("DOMContentLoaded", async () => {
329
+ document.getElementById("diag-refresh-indexes")?.addEventListener("click", () => loadIndexes(true));
330
+ document.getElementById("diag-refresh-queries")?.addEventListener("click", loadQueries);
331
+ document.getElementById("diag-query-severity")?.addEventListener("change", loadQueries);
332
+ wireModelFilter();
333
+ wireSyncConfirm();
334
+
335
+ const access = document.documentElement.dataset.docsAccess;
336
+ await loadSessionApiKey();
337
+ if (access !== "protected") loadStoredKey();
338
+ if (!getApiKey()) {
339
+ const status = document.getElementById("diag-indexes-status");
340
+ if (status) {
341
+ status.textContent =
342
+ "Set an admin API key in the top bar (or sign in again via /docs/login).";
343
+ }
344
+ return;
345
+ }
346
+ loadIndexes(false);
347
+
348
+ document.getElementById("tab-queries-btn")?.addEventListener("shown.bs.tab", () => {
349
+ if (getApiKey()) loadQueries();
350
+ });
351
+ });
352
+ })();