jxp 4.1.0 → 4.2.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.
- package/.env.sample +14 -0
- package/dist/bin/index_audit.d.ts +3 -0
- package/dist/bin/index_audit.d.ts.map +1 -0
- package/dist/bin/index_audit.js +66 -0
- package/dist/bin/index_audit.js.map +1 -0
- package/dist/bin/server.d.ts.map +1 -1
- package/dist/bin/server.js +6 -1
- package/dist/bin/server.js.map +1 -1
- package/dist/libs/builtin_models.d.ts +21 -0
- package/dist/libs/builtin_models.d.ts.map +1 -0
- package/dist/libs/builtin_models.js +125 -0
- package/dist/libs/builtin_models.js.map +1 -0
- package/dist/libs/bulkwrite_guard.d.ts +1 -0
- package/dist/libs/bulkwrite_guard.d.ts.map +1 -1
- package/dist/libs/bulkwrite_guard.js +43 -0
- package/dist/libs/bulkwrite_guard.js.map +1 -1
- package/dist/libs/docs-auth.d.ts.map +1 -1
- package/dist/libs/docs-auth.js +3 -1
- package/dist/libs/docs-auth.js.map +1 -1
- package/dist/libs/docs.js +32 -2
- package/dist/libs/docs.js.map +1 -1
- package/dist/libs/groups.js +3 -4
- package/dist/libs/groups.js.map +1 -1
- package/dist/libs/index_diagnostics.d.ts +144 -0
- package/dist/libs/index_diagnostics.d.ts.map +1 -0
- package/dist/libs/index_diagnostics.js +671 -0
- package/dist/libs/index_diagnostics.js.map +1 -0
- package/dist/libs/jxp.d.ts.map +1 -1
- package/dist/libs/jxp.js +185 -70
- package/dist/libs/jxp.js.map +1 -1
- package/dist/libs/load-config.d.ts +2 -0
- package/dist/libs/load-config.d.ts.map +1 -1
- package/dist/libs/load-config.js +10 -0
- package/dist/libs/load-config.js.map +1 -1
- package/dist/libs/login.js +3 -8
- package/dist/libs/login.js.map +1 -1
- package/dist/libs/parse_byte_size.d.ts +10 -0
- package/dist/libs/parse_byte_size.d.ts.map +1 -0
- package/dist/libs/parse_byte_size.js +80 -0
- package/dist/libs/parse_byte_size.js.map +1 -0
- package/dist/libs/query_limits.js +104 -12
- package/dist/libs/query_limits.js.map +1 -1
- package/dist/libs/request_log.d.ts +44 -0
- package/dist/libs/request_log.d.ts.map +1 -0
- package/dist/libs/request_log.js +229 -0
- package/dist/libs/request_log.js.map +1 -0
- package/dist/libs/schema.js +2 -2
- package/dist/libs/schema.js.map +1 -1
- package/dist/libs/security.js +25 -20
- package/dist/libs/security.js.map +1 -1
- package/dist/libs/setup.js +3 -4
- package/dist/libs/setup.js.map +1 -1
- package/dist/libs/startup.d.ts +4 -0
- package/dist/libs/startup.d.ts.map +1 -1
- package/dist/libs/startup.js +17 -0
- package/dist/libs/startup.js.map +1 -1
- package/dist/models/indexquerylog_model.d.ts +19 -0
- package/dist/models/indexquerylog_model.d.ts.map +1 -0
- package/dist/models/indexquerylog_model.js +32 -0
- package/dist/models/indexquerylog_model.js.map +1 -0
- package/dist/models/test_model.js +1 -1
- package/dist/models/test_model.js.map +1 -1
- package/dist/types/jxp-config.d.ts +22 -0
- package/dist/types/jxp-config.d.ts.map +1 -1
- package/dist/types/schema-fields.d.ts +4 -0
- package/dist/types/schema-fields.d.ts.map +1 -1
- package/docs/api.md +16 -2
- package/docs/bulk_writes.md +9 -2
- package/docs/changelog.md +120 -1
- package/docs/configuration.md +39 -0
- package/docs/index.md +1 -0
- package/docs/index_diagnostics.md +127 -0
- package/docs/schemas.md +3 -1
- package/mkdocs.yml +1 -0
- package/package.json +18 -7
- package/templates/assets/diagnostics.js +352 -0
- package/templates/diagnostics.pug +94 -0
- package/templates/index.pug +1 -0
- package/templates/sidebar.pug +8 -0
package/docs/index.md
CHANGED
|
@@ -17,6 +17,7 @@ JXP 4 requires **Node.js 22+**, ships as compiled TypeScript, and uses environme
|
|
|
17
17
|
* Hooks for business logic
|
|
18
18
|
* Stateless and easy to deploy
|
|
19
19
|
* WebSocket change subscriptions
|
|
20
|
+
* [Index diagnostics](index_diagnostics.md) — schema vs DB indexes and optional query monitoring
|
|
20
21
|
|
|
21
22
|
## Links
|
|
22
23
|
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
# Index diagnostics and query monitoring
|
|
2
|
+
|
|
3
|
+
JXP can compare **Mongoose schema indexes** to indexes in MongoDB, optionally sync them, and (when enabled) sample read queries with `explain('executionStats')` to flag likely unindexed or inefficient access patterns.
|
|
4
|
+
|
|
5
|
+
## Index audit (schema vs database)
|
|
6
|
+
|
|
7
|
+
Mongoose provides `Model.diffIndexes()` and `Model.syncIndexes()`. JXP wraps these for all loaded models.
|
|
8
|
+
|
|
9
|
+
### CLI
|
|
10
|
+
|
|
11
|
+
```bash
|
|
12
|
+
npm run indexes
|
|
13
|
+
# or
|
|
14
|
+
npx jxp-indexes
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
| Flag | Description |
|
|
18
|
+
|------|-------------|
|
|
19
|
+
| `--json` | Machine-readable report |
|
|
20
|
+
| `--unused` | Include `$indexStats` hints for indexes with zero ops |
|
|
21
|
+
| `--sync` | Apply `syncIndexes()` (create missing, drop extras) |
|
|
22
|
+
| `--confirm DROP_EXTRA_INDEXES` | Required with `--sync` |
|
|
23
|
+
|
|
24
|
+
Example:
|
|
25
|
+
|
|
26
|
+
```bash
|
|
27
|
+
npm run indexes -- --json
|
|
28
|
+
npm run indexes -- --sync --confirm DROP_EXTRA_INDEXES
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
Exit code `1` when any collection has missing or extra indexes (audit mode only).
|
|
32
|
+
|
|
33
|
+
Requires `MODEL_DIR` and Mongo connection env vars (same as the API server).
|
|
34
|
+
|
|
35
|
+
### Web UI
|
|
36
|
+
|
|
37
|
+
Open **`/docs/diagnostics`** in the API docs browser (same sign-in as `/docs/api`). The page lets admins:
|
|
38
|
+
|
|
39
|
+
- Refresh the index audit and view missing/extra indexes per collection
|
|
40
|
+
- Browse the query log (from MongoDB when the `IndexQueryLog` model is loaded)
|
|
41
|
+
- Sync indexes after typing `DROP_EXTRA_INDEXES`
|
|
42
|
+
|
|
43
|
+
Use an **admin** API key in the docs top bar.
|
|
44
|
+
|
|
45
|
+
### Admin HTTP API
|
|
46
|
+
|
|
47
|
+
Admin authentication required (`security.admin_only`).
|
|
48
|
+
|
|
49
|
+
| Method | Path | Description |
|
|
50
|
+
|--------|------|-------------|
|
|
51
|
+
| GET | `/diagnostics/indexes` | Audit report (`?refresh=true`, `?unused=true`) |
|
|
52
|
+
| GET | `/diagnostics/queries` | Recent query monitor entries + config |
|
|
53
|
+
| POST | `/diagnostics/indexes/sync` | Body `{ "confirm": "DROP_EXTRA_INDEXES" }` |
|
|
54
|
+
|
|
55
|
+
## Query monitor
|
|
56
|
+
|
|
57
|
+
MongoDB does not emit “missing index” events. When the monitor is enabled, JXP samples read operations (`find`, `findOne`, `count`, `countDocuments`, `distinct`), runs `explain('executionStats')` on a **clone** of the query (async, non-blocking), and records **alert** / **warn** entries.
|
|
58
|
+
|
|
59
|
+
### Where queries are stored
|
|
60
|
+
|
|
61
|
+
| Layer | Purpose |
|
|
62
|
+
|-------|---------|
|
|
63
|
+
| **`IndexQueryLog` model** (MongoDB) | Primary store — one document per flagged query. Auto-loaded from the jxp package (`indexquerylog_model.js`). TTL via `INDEX_QUERY_LOG_RETENTION_DAYS` (default 30). |
|
|
64
|
+
| **In-memory ring buffer** | Fast fallback when the model is unavailable; also used for recent samples before persistence. Size: `QUERY_INDEX_BUFFER_SIZE`. |
|
|
65
|
+
|
|
66
|
+
The `IndexQueryLog` collection is excluded from query monitoring (reads/writes to the log do not create new log entries). Explain runs use `query.clone().explain()` with an internal flag so Mongoose post hooks do not treat the explain as another sampled query (no monitor loop). The model is a **built-in** — you do not need `indexquerylog_model.js` in your app's `MODEL_DIR` unless you want to override it (see [Configuration — Built-in models](configuration.md#built-in-models)). Admins can also list logs via `GET /api/indexquerylog` with normal JXP filters.
|
|
67
|
+
|
|
68
|
+
Query monitor is enabled in development by default (`QUERY_INDEX_MONITOR=true`). In production, set `INDEX_DIAGNOSTICS_ENABLED=true` and tune `QUERY_INDEX_SAMPLE_RATE` (e.g. `0.02`).
|
|
69
|
+
|
|
70
|
+
**Heuristics (approximate):**
|
|
71
|
+
|
|
72
|
+
- **alert** — `COLLSCAN` (or nested collection scan) with high `totalDocsExamined` vs `nReturned`
|
|
73
|
+
- **warn** — index used but many more documents examined than returned
|
|
74
|
+
- **ignore** — small collections (below threshold), or benign plans
|
|
75
|
+
|
|
76
|
+
`explain` with `executionStats` executes the query again — use sampling in production.
|
|
77
|
+
|
|
78
|
+
### Environment variables
|
|
79
|
+
|
|
80
|
+
| Variable | Default (dev) | Default (production) |
|
|
81
|
+
|----------|---------------|----------------------|
|
|
82
|
+
| `INDEX_DIAGNOSTICS_ENABLED` | off | off (master enable for prod) |
|
|
83
|
+
| `QUERY_INDEX_MONITOR` | `true` | `false` |
|
|
84
|
+
| `QUERY_INDEX_SAMPLE_RATE` | `1.0` | `0.02` |
|
|
85
|
+
| `QUERY_INDEX_MIN_DOCS_EXAMINED` | `20` | `100` |
|
|
86
|
+
| `QUERY_INDEX_DOCS_EXAMINED_RATIO` | `5` | `10` |
|
|
87
|
+
| `QUERY_INDEX_SMALL_COLLECTION_THRESHOLD` | `500` | `1000` |
|
|
88
|
+
| `QUERY_INDEX_BUFFER_SIZE` | `200` | `200` |
|
|
89
|
+
| `INDEX_QUERY_LOG_RETENTION_DAYS` | `30` | `30` |
|
|
90
|
+
|
|
91
|
+
In production, set `INDEX_DIAGNOSTICS_ENABLED=true` (and tune sample rate) to enable monitoring.
|
|
92
|
+
|
|
93
|
+
### Config object
|
|
94
|
+
|
|
95
|
+
```javascript
|
|
96
|
+
JXP({
|
|
97
|
+
index_diagnostics: {
|
|
98
|
+
enabled: true,
|
|
99
|
+
query_monitor: {
|
|
100
|
+
enabled: true,
|
|
101
|
+
sample_rate: 0.05,
|
|
102
|
+
min_docs_examined: 100,
|
|
103
|
+
},
|
|
104
|
+
},
|
|
105
|
+
});
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
Register the monitor **before** models load (the sample `server.ts` calls `registerQueryIndexMonitor()` before `JXP()`).
|
|
109
|
+
|
|
110
|
+
## Programmatic use
|
|
111
|
+
|
|
112
|
+
```javascript
|
|
113
|
+
const {
|
|
114
|
+
auditAllModels,
|
|
115
|
+
syncAllModels,
|
|
116
|
+
loadModelsFromDir,
|
|
117
|
+
classifyExplain,
|
|
118
|
+
} = require("jxp/libs/index_diagnostics");
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
## Complementary tools
|
|
122
|
+
|
|
123
|
+
- MongoDB [Profiler](https://www.mongodb.com/docs/manual/tutorial/manage-the-database-profiler/) and Compass — slow query analysis
|
|
124
|
+
- Atlas Performance Advisor — index suggestions (Atlas)
|
|
125
|
+
- `db.collection.explain()` — manual plan inspection
|
|
126
|
+
|
|
127
|
+
JXP diagnostics do not replace these; they integrate index drift checks into your JXP app workflow.
|
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`.
|
|
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
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.
|
|
4
|
+
"version": "4.2.0",
|
|
5
5
|
"private": false,
|
|
6
6
|
"main": "dist/libs/jxp.js",
|
|
7
7
|
"types": "dist/libs/jxp.d.ts",
|
|
@@ -39,6 +39,14 @@
|
|
|
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": {
|
|
@@ -47,13 +55,15 @@
|
|
|
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 --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/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"
|
|
57
67
|
},
|
|
58
68
|
"repository": {
|
|
59
69
|
"type": "git",
|
|
@@ -70,15 +80,16 @@
|
|
|
70
80
|
"url": "https://github.com/WorkSpaceMan/jxp/issues"
|
|
71
81
|
},
|
|
72
82
|
"dependencies": {
|
|
83
|
+
"@json2csv/plainjs": "^7.0.6",
|
|
73
84
|
"bcryptjs": "^3.0.2",
|
|
85
|
+
"bytes": "^3.1.2",
|
|
74
86
|
"commander": "^13.1.0",
|
|
75
87
|
"dotenv": "^16.5.0",
|
|
76
88
|
"glob": "11.1.0",
|
|
77
89
|
"js-yaml": "4.1.1",
|
|
78
|
-
"@json2csv/plainjs": "^7.0.6",
|
|
79
90
|
"jsonwebtoken": "^9.0.2",
|
|
80
|
-
"markdown-it": "^14.1.1",
|
|
81
91
|
"jxp-helper": "^1.4.3",
|
|
92
|
+
"markdown-it": "^14.1.1",
|
|
82
93
|
"moment": "^2.30.1",
|
|
83
94
|
"mongoose": "6.13.9",
|
|
84
95
|
"mongoose-friendly": "^0.1.4",
|
|
@@ -96,9 +107,9 @@
|
|
|
96
107
|
"ws": "^8.18.1"
|
|
97
108
|
},
|
|
98
109
|
"devDependencies": {
|
|
99
|
-
"@types/markdown-it": "^14.1.2",
|
|
100
110
|
"@types/bcryptjs": "^2.4.6",
|
|
101
111
|
"@types/jsonwebtoken": "^9.0.9",
|
|
112
|
+
"@types/markdown-it": "^14.1.2",
|
|
102
113
|
"@types/morgan": "^1.9.9",
|
|
103
114
|
"@types/node": "^22.15.21",
|
|
104
115
|
"@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, "&")
|
|
249
|
+
.replace(/</g, "<")
|
|
250
|
+
.replace(/>/g, ">")
|
|
251
|
+
.replace(/"/g, """);
|
|
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
|
+
})();
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
extends layout
|
|
2
|
+
|
|
3
|
+
block content
|
|
4
|
+
h1.h3.mb-1 Index diagnostics
|
|
5
|
+
p.text-muted.mb-4
|
|
6
|
+
| Compare schema indexes to MongoDB, review slow-query samples, and sync indexes.
|
|
7
|
+
| Requires an
|
|
8
|
+
strong admin
|
|
9
|
+
| API key (filled automatically after docs sign-in, or paste into the top bar).
|
|
10
|
+
|
|
11
|
+
ul#diag-tabs.nav.nav-tabs(role="tablist")
|
|
12
|
+
li.nav-item(role="presentation")
|
|
13
|
+
button.nav-link.active#tab-indexes-btn(type="button" data-bs-toggle="tab" data-bs-target="#tab-indexes" role="tab") Indexes
|
|
14
|
+
li.nav-item(role="presentation")
|
|
15
|
+
button.nav-link#tab-queries-btn(type="button" data-bs-toggle="tab" data-bs-target="#tab-queries" role="tab") Query log
|
|
16
|
+
|
|
17
|
+
.tab-content.mt-3
|
|
18
|
+
#tab-indexes.tab-pane.fade.show.active(role="tabpanel")
|
|
19
|
+
.d-flex.flex-wrap.gap-2.mb-3
|
|
20
|
+
button#diag-refresh-indexes.btn.btn-primary.btn-sm(type="button")
|
|
21
|
+
i.bi.bi-arrow-clockwise.me-1
|
|
22
|
+
| Refresh audit
|
|
23
|
+
.form-check.form-check-inline.mb-0.align-self-center
|
|
24
|
+
input#diag-unused.form-check-input(type="checkbox")
|
|
25
|
+
label.form-check-label(for="diag-unused") Include unused ($indexStats)
|
|
26
|
+
#diag-indexes-status.small.text-muted.mb-2
|
|
27
|
+
#diag-indexes-summary
|
|
28
|
+
.table-responsive
|
|
29
|
+
table#diag-indexes-table.table.table-sm.table-hover.d-none
|
|
30
|
+
thead
|
|
31
|
+
tr
|
|
32
|
+
th Model
|
|
33
|
+
th Collection
|
|
34
|
+
th Status
|
|
35
|
+
th Missing
|
|
36
|
+
th Extra
|
|
37
|
+
tbody
|
|
38
|
+
|
|
39
|
+
hr.mt-4
|
|
40
|
+
h2.h5 Sync indexes
|
|
41
|
+
p.small.text-muted
|
|
42
|
+
| Creates indexes defined in Mongoose schemas and drops DB indexes not in the schema.
|
|
43
|
+
| Type
|
|
44
|
+
code= sync_confirm
|
|
45
|
+
| to confirm.
|
|
46
|
+
.row.g-2.align-items-end
|
|
47
|
+
.col-md-6
|
|
48
|
+
input#diag-sync-confirm.form-control.form-control-sm(type="text" placeholder=sync_confirm autocomplete="off")
|
|
49
|
+
.col-auto
|
|
50
|
+
button#diag-sync-btn.btn.btn-outline-danger.btn-sm(type="button" disabled) Sync indexes
|
|
51
|
+
|
|
52
|
+
#tab-queries.tab-pane.fade(role="tabpanel")
|
|
53
|
+
.d-flex.flex-wrap.gap-2.mb-3
|
|
54
|
+
button#diag-refresh-queries.btn.btn-primary.btn-sm(type="button")
|
|
55
|
+
i.bi.bi-arrow-clockwise.me-1
|
|
56
|
+
| Refresh log
|
|
57
|
+
select#diag-query-severity.form-select.form-select-sm(style="width:auto")
|
|
58
|
+
option(value="") All severities
|
|
59
|
+
option(value="alert") alert
|
|
60
|
+
option(value="warn") warn
|
|
61
|
+
.diag-model-filter(style="min-width:10rem;max-width:14rem")
|
|
62
|
+
input#diag-query-model.form-control.form-control-sm(
|
|
63
|
+
type="text"
|
|
64
|
+
list="diag-query-model-list"
|
|
65
|
+
placeholder="All models"
|
|
66
|
+
autocomplete="off"
|
|
67
|
+
aria-label="Filter by model"
|
|
68
|
+
)
|
|
69
|
+
datalist#diag-query-model-list
|
|
70
|
+
each opt in model_filter_options
|
|
71
|
+
option(value=opt.value)= opt.label
|
|
72
|
+
#diag-queries-status.small.text-muted.mb-2
|
|
73
|
+
#diag-monitor-config.small.mb-2
|
|
74
|
+
#diag-query-monitor-help.d-none
|
|
75
|
+
.table-responsive
|
|
76
|
+
table#diag-queries-table.table.table-sm.table-hover.d-none
|
|
77
|
+
thead
|
|
78
|
+
tr
|
|
79
|
+
th When
|
|
80
|
+
th Model
|
|
81
|
+
th Op
|
|
82
|
+
th Severity
|
|
83
|
+
th Docs examined
|
|
84
|
+
th Returned
|
|
85
|
+
th Scan
|
|
86
|
+
th Reason
|
|
87
|
+
th Filter
|
|
88
|
+
tbody
|
|
89
|
+
|
|
90
|
+
block javascripts
|
|
91
|
+
script.
|
|
92
|
+
window.JXP_DIAG_SYNC_CONFIRM = !{JSON.stringify(sync_confirm)};
|
|
93
|
+
window.JXP_DIAG_MODEL_FILTER_OPTIONS = !{JSON.stringify(model_filter_options)};
|
|
94
|
+
script(src="/docs/assets/diagnostics.js" defer)
|