jxp 4.1.1 → 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.
Files changed (68) hide show
  1. package/.env.sample +14 -0
  2. package/dist/bin/index_audit.d.ts +3 -0
  3. package/dist/bin/index_audit.d.ts.map +1 -0
  4. package/dist/bin/index_audit.js +66 -0
  5. package/dist/bin/index_audit.js.map +1 -0
  6. package/dist/bin/server.d.ts.map +1 -1
  7. package/dist/bin/server.js +6 -1
  8. package/dist/bin/server.js.map +1 -1
  9. package/dist/libs/builtin_models.d.ts +21 -0
  10. package/dist/libs/builtin_models.d.ts.map +1 -0
  11. package/dist/libs/builtin_models.js +125 -0
  12. package/dist/libs/builtin_models.js.map +1 -0
  13. package/dist/libs/docs-auth.d.ts.map +1 -1
  14. package/dist/libs/docs-auth.js +3 -1
  15. package/dist/libs/docs-auth.js.map +1 -1
  16. package/dist/libs/docs.js +32 -2
  17. package/dist/libs/docs.js.map +1 -1
  18. package/dist/libs/groups.js +3 -4
  19. package/dist/libs/groups.js.map +1 -1
  20. package/dist/libs/index_diagnostics.d.ts +144 -0
  21. package/dist/libs/index_diagnostics.d.ts.map +1 -0
  22. package/dist/libs/index_diagnostics.js +671 -0
  23. package/dist/libs/index_diagnostics.js.map +1 -0
  24. package/dist/libs/jxp.d.ts.map +1 -1
  25. package/dist/libs/jxp.js +94 -25
  26. package/dist/libs/jxp.js.map +1 -1
  27. package/dist/libs/load-config.d.ts +2 -0
  28. package/dist/libs/load-config.d.ts.map +1 -1
  29. package/dist/libs/load-config.js +10 -0
  30. package/dist/libs/load-config.js.map +1 -1
  31. package/dist/libs/login.js +3 -8
  32. package/dist/libs/login.js.map +1 -1
  33. package/dist/libs/parse_byte_size.d.ts +10 -0
  34. package/dist/libs/parse_byte_size.d.ts.map +1 -0
  35. package/dist/libs/parse_byte_size.js +80 -0
  36. package/dist/libs/parse_byte_size.js.map +1 -0
  37. package/dist/libs/query_limits.js +99 -13
  38. package/dist/libs/query_limits.js.map +1 -1
  39. package/dist/libs/schema.js +2 -2
  40. package/dist/libs/schema.js.map +1 -1
  41. package/dist/libs/security.js +7 -11
  42. package/dist/libs/security.js.map +1 -1
  43. package/dist/libs/setup.js +3 -4
  44. package/dist/libs/setup.js.map +1 -1
  45. package/dist/libs/startup.d.ts +4 -0
  46. package/dist/libs/startup.d.ts.map +1 -1
  47. package/dist/libs/startup.js +17 -0
  48. package/dist/libs/startup.js.map +1 -1
  49. package/dist/models/indexquerylog_model.d.ts +19 -0
  50. package/dist/models/indexquerylog_model.d.ts.map +1 -0
  51. package/dist/models/indexquerylog_model.js +32 -0
  52. package/dist/models/indexquerylog_model.js.map +1 -0
  53. package/dist/types/jxp-config.d.ts +22 -0
  54. package/dist/types/jxp-config.d.ts.map +1 -1
  55. package/dist/types/schema-fields.d.ts +4 -0
  56. package/dist/types/schema-fields.d.ts.map +1 -1
  57. package/docs/api.md +16 -2
  58. package/docs/changelog.md +66 -1
  59. package/docs/configuration.md +39 -0
  60. package/docs/index.md +1 -0
  61. package/docs/index_diagnostics.md +127 -0
  62. package/docs/schemas.md +3 -1
  63. package/mkdocs.yml +1 -0
  64. package/package.json +18 -7
  65. package/templates/assets/diagnostics.js +352 -0
  66. package/templates/diagnostics.pug +94 -0
  67. package/templates/index.pug +1 -0
  68. package/templates/sidebar.pug +8 -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
+ })();
@@ -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)
@@ -11,6 +11,7 @@ block content
11
11
  if first_guide_url
12
12
  a.btn.btn-primary(href=first_guide_url) Get started
13
13
  a.btn.btn-outline-primary(href="/docs/api") Browse API
14
+ a.btn.btn-outline-secondary(href="/docs/diagnostics") Index diagnostics
14
15
  a.btn.btn-outline-secondary(href="/docs/md/api.md") REST reference
15
16
 
16
17
  .row.g-4.mb-4
@@ -5,6 +5,11 @@ nav.docs-sidebar#docsSidebarDesktop(aria-label="Documentation navigation")
5
5
  - const label = Object.keys(nav)[0]
6
6
  - const file = Object.values(nav)[0]
7
7
  a.docs-nav-link(href="/docs/md/" + file class=(active_guide === file ? 'active' : ''))= label
8
+ .docs-sidebar-section
9
+ .docs-sidebar-title Operations
10
+ a.docs-nav-link(href="/docs/diagnostics" class=(active_section === 'diagnostics' ? 'active' : ''))
11
+ i.bi.bi-speedometer2.me-1
12
+ | Index diagnostics
8
13
  .docs-sidebar-section
9
14
  .docs-sidebar-title API
10
15
  a.docs-nav-link(href="/docs/api" class=(active_section === 'api' && !active_model ? 'active' : '')) Overview
@@ -23,6 +28,9 @@ nav.docs-sidebar#docsSidebarDesktop(aria-label="Documentation navigation")
23
28
  - const label = Object.keys(nav)[0]
24
29
  - const file = Object.values(nav)[0]
25
30
  a.docs-nav-link(href="/docs/md/" + file class=(active_guide === file ? 'active' : ''))= label
31
+ .docs-sidebar-section
32
+ .docs-sidebar-title Operations
33
+ a.docs-nav-link(href="/docs/diagnostics" class=(active_section === 'diagnostics' ? 'active' : '')) Index diagnostics
26
34
  .docs-sidebar-section
27
35
  .docs-sidebar-title API
28
36
  a.docs-nav-link(href="/docs/api" class=(active_section === 'api' && !active_model ? 'active' : '')) Overview