adonisjs-server-stats 1.13.0 → 1.14.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/README.md +3 -2
- package/dist/configure.js +6 -1
- package/dist/core/collectors/collector.d.ts +83 -0
- package/dist/core/core/types.d.ts +186 -0
- package/dist/core/debug/types.d.ts +467 -0
- package/dist/core/index.js +184 -169
- package/dist/core/types.d.ts +855 -167
- package/dist/react/{CacheSection-CX2duJuc.js → CacheSection-DkLtukWz.js} +1 -1
- package/dist/react/{CacheTab-CgT4t0oZ.js → CacheTab-DWeXnEBI.js} +51 -46
- package/dist/react/ConfigContent-Bwvfl_G7.js +350 -0
- package/dist/react/{ConfigSection-Dt0FyeaW.js → ConfigSection-DaVbFPDg.js} +2 -2
- package/dist/react/{ConfigTab-KtiTtsrj.js → ConfigTab-D4640WJZ.js} +2 -2
- package/dist/react/{CustomPaneTab-15QoEplP.js → CustomPaneTab-DglmAVMC.js} +1 -1
- package/dist/react/{EmailsSection-YcJYR5eA.js → EmailsSection-vYx6ExTb.js} +1 -1
- package/dist/react/{EmailsTab-CUjubln_.js → EmailsTab-DKDrRmRH.js} +54 -49
- package/dist/react/{EventsSection-GGPoYSNv.js → EventsSection-CapH5xut.js} +1 -1
- package/dist/react/{EventsTab-Wdwr0_ny.js → EventsTab-BNVEySAm.js} +1 -1
- package/dist/react/{InternalsContent-BNOnSoi9.js → InternalsContent-C9lIA92C.js} +1 -1
- package/dist/react/{InternalsSection-BwrTfpjA.js → InternalsSection-lOv_UcSV.js} +1 -1
- package/dist/react/{InternalsTab--RD-L1dX.js → InternalsTab-BygoSorC.js} +1 -1
- package/dist/react/{JobsSection-BqqIh_DR.js → JobsSection-ZIQsJWid.js} +1 -1
- package/dist/react/{JobsTab-CBrU-ryL.js → JobsTab-DXQzXrDt.js} +1 -1
- package/dist/react/{LogEntryRow-BOrRkhRU.js → LogEntryRow-BWkHE51-.js} +1 -1
- package/dist/react/{LogsSection-Cm_lphM6.js → LogsSection-ConXdBkL.js} +2 -2
- package/dist/react/LogsTab-CJM47LPn.js +82 -0
- package/dist/react/{OverviewSection-XF7bakyM.js → OverviewSection-DOMu2qvl.js} +131 -131
- package/dist/react/{QueriesSection-DsQBKrNK.js → QueriesSection-DFsaOSJI.js} +1 -1
- package/dist/react/{QueriesTab-CxCC1GVq.js → QueriesTab-CY9CG_7L.js} +1 -1
- package/dist/react/RequestsSection-CC-eVHsl.js +326 -0
- package/dist/react/{RoutesSection-D0y5JQP4.js → RoutesSection-p1DMq41y.js} +1 -1
- package/dist/react/{RoutesTab-Y_alJVMV.js → RoutesTab-Cnqy8UcK.js} +1 -1
- package/dist/react/{SplitPaneWrapper-CZl1ouIT.js → SplitPaneWrapper-XgkA0QxE.js} +1 -1
- package/dist/react/{TimelineTab-HyqZpfbp.js → TimelineTab-1YOERxe5.js} +3 -3
- package/dist/react/index-DOSlCpZ9.js +1159 -0
- package/dist/react/index.js +1 -1
- package/dist/react/useApiClient-CtEG7rHs.js +11 -0
- package/dist/src/collectors/queue_collector.js +22 -6
- package/dist/src/config/deprecation_migration.js +11 -0
- package/dist/src/dashboard/cache_handlers.js +22 -2
- package/dist/src/dashboard/flush_manager.d.ts +2 -0
- package/dist/src/dashboard/flush_manager.js +17 -0
- package/dist/src/dashboard/integrations/config_inspector.d.ts +0 -1
- package/dist/src/dashboard/integrations/config_inspector.js +4 -2
- package/dist/src/dashboard/paginate_helper.d.ts +5 -0
- package/dist/src/dashboard/paginate_helper.js +16 -4
- package/dist/src/dashboard/query_explain_handler.d.ts +6 -1
- package/dist/src/dashboard/query_explain_handler.js +12 -2
- package/dist/src/dashboard/write_queue.d.ts +13 -0
- package/dist/src/dashboard/write_queue.js +34 -2
- package/dist/src/debug/debug_store.js +8 -1
- package/dist/src/debug/event_collector.js +4 -0
- package/dist/src/debug/query_collector.js +2 -0
- package/dist/src/debug/ring_buffer.js +8 -1
- package/dist/src/debug/trace_collector.js +34 -10
- package/dist/src/debug/types.d.ts +2 -0
- package/dist/src/define_config.js +1 -0
- package/dist/src/edge/client/dashboard.js +2 -2
- package/dist/src/edge/client/debug-panel-deferred.js +1 -1
- package/dist/src/edge/client/stats-bar.js +1 -1
- package/dist/src/edge/client-vue/dashboard.js +5 -5
- package/dist/src/edge/client-vue/debug-panel-deferred.js +2 -2
- package/dist/src/engine/request_metrics.js +8 -10
- package/dist/src/log_stream/log_stream_service.d.ts +1 -0
- package/dist/src/log_stream/log_stream_service.js +7 -5
- package/dist/src/prometheus/prometheus_collector.d.ts +9 -0
- package/dist/src/prometheus/prometheus_collector.js +11 -0
- package/dist/src/provider/dashboard_init.js +15 -0
- package/dist/src/provider/dashboard_setup.js +9 -2
- package/dist/src/provider/email_bridge.js +6 -2
- package/dist/src/provider/email_helpers.d.ts +9 -1
- package/dist/src/provider/email_helpers.js +32 -4
- package/dist/src/provider/pino_hook.d.ts +8 -0
- package/dist/src/provider/pino_hook.js +19 -1
- package/dist/src/provider/provider_helpers_extra.d.ts +2 -0
- package/dist/src/provider/provider_helpers_extra.js +17 -2
- package/dist/src/provider/server_stats_provider.js +19 -1
- package/dist/src/provider/shutdown_helpers.js +2 -0
- package/dist/src/provider/toolbar_setup.js +17 -14
- package/dist/src/routes/dashboard_routes.js +96 -62
- package/dist/src/routes/debug_routes.js +45 -35
- package/dist/src/routes/no_session_middleware.d.ts +6 -3
- package/dist/src/routes/no_session_middleware.js +7 -3
- package/dist/src/routes/register_routes.d.ts +8 -0
- package/dist/src/routes/register_routes.js +19 -0
- package/dist/src/types.d.ts +31 -0
- package/dist/src/utils/app_import.js +12 -4
- package/dist/vue/CacheSection-CxEBVVkF.js +156 -0
- package/dist/vue/ConfigSection-BiRAiaHj.js +415 -0
- package/dist/vue/EmailsSection-Dl44qyqY.js +208 -0
- package/dist/vue/{EmailsTab-CwIF1fik.js → EmailsTab-Diya54CP.js} +6 -5
- package/dist/vue/{EventsSection-BpgkWIwM.js → EventsSection-CWjeitjU.js} +1 -1
- package/dist/vue/{InternalsTab-521fxYYj.js → InternalsTab-Z3c82glB.js} +15 -15
- package/dist/vue/{JobsSection-DrghFEKL.js → JobsSection-DOBb4LjZ.js} +1 -1
- package/dist/vue/LogsSection-CXx-HOWJ.js +260 -0
- package/dist/vue/{OverviewSection-BAgZTPjY.js → OverviewSection-CyfNQ8uV.js} +318 -302
- package/dist/vue/{QueriesSection-CUpwhp7u.js → QueriesSection-CXBsFp-y.js} +1 -1
- package/dist/vue/{RequestsSection-D8P2xpF2.js → RequestsSection-YTIaZGZd.js} +151 -145
- package/dist/vue/{RoutesSection-0qB81hTT.js → RoutesSection-CDKMey49.js} +1 -1
- package/dist/vue/composables/useApiClient.d.ts +8 -1
- package/dist/vue/index-14x39RI_.js +1239 -0
- package/dist/vue/index.js +1 -1
- package/dist/vue/style.css +1 -1
- package/package.json +6 -3
- package/dist/react/ConfigContent-CnsEI4j3.js +0 -397
- package/dist/react/LogsTab-jKwv9G7Q.js +0 -79
- package/dist/react/RequestsSection-D8cMbZU0.js +0 -321
- package/dist/react/index-CsprmgzI.js +0 -1121
- package/dist/react/useApiClient-BVtNCmnL.js +0 -9
- package/dist/vue/CacheSection-DRqV3YX2.js +0 -149
- package/dist/vue/ConfigSection-C6pQCHAL.js +0 -576
- package/dist/vue/EmailsSection-BTNw3ZU2.js +0 -206
- package/dist/vue/LogsSection-BDxx9Bfi.js +0 -258
- package/dist/vue/index-30jLw-_w.js +0 -1232
- /package/dist/core/{api-client.d.ts → core/api-client.d.ts} +0 -0
- /package/dist/core/{config-utils.d.ts → core/config-utils.d.ts} +0 -0
- /package/dist/core/{constants.d.ts → core/constants.d.ts} +0 -0
- /package/dist/core/{dashboard-api.d.ts → core/dashboard-api.d.ts} +0 -0
- /package/dist/core/{dashboard-data-controller.d.ts → core/dashboard-data-controller.d.ts} +0 -0
- /package/dist/core/{dashboard-data-helpers.d.ts → core/dashboard-data-helpers.d.ts} +0 -0
- /package/dist/core/{debug-data-controller.d.ts → core/debug-data-controller.d.ts} +0 -0
- /package/dist/core/{define-config-helpers.d.ts → core/define-config-helpers.d.ts} +0 -0
- /package/dist/core/{explain-utils.d.ts → core/explain-utils.d.ts} +0 -0
- /package/dist/core/{feature-detect-helpers.d.ts → core/feature-detect-helpers.d.ts} +0 -0
- /package/dist/core/{feature-detect.d.ts → core/feature-detect.d.ts} +0 -0
- /package/dist/core/{field-resolvers.d.ts → core/field-resolvers.d.ts} +0 -0
- /package/dist/core/{formatters-helpers.d.ts → core/formatters-helpers.d.ts} +0 -0
- /package/dist/core/{formatters.d.ts → core/formatters.d.ts} +0 -0
- /package/dist/core/{history-buffer.d.ts → core/history-buffer.d.ts} +0 -0
- /package/dist/core/{icons.d.ts → core/icons.d.ts} +0 -0
- /package/dist/core/{index.d.ts → core/index.d.ts} +0 -0
- /package/dist/core/{internals-utils.d.ts → core/internals-utils.d.ts} +0 -0
- /package/dist/core/{job-utils.d.ts → core/job-utils.d.ts} +0 -0
- /package/dist/core/{log-utils-helpers.d.ts → core/log-utils-helpers.d.ts} +0 -0
- /package/dist/core/{log-utils.d.ts → core/log-utils.d.ts} +0 -0
- /package/dist/core/{metrics.d.ts → core/metrics.d.ts} +0 -0
- /package/dist/core/{pagination.d.ts → core/pagination.d.ts} +0 -0
- /package/dist/core/{queries-columns.d.ts → core/queries-columns.d.ts} +0 -0
- /package/dist/core/{queries-controller.d.ts → core/queries-controller.d.ts} +0 -0
- /package/dist/core/{query-utils.d.ts → core/query-utils.d.ts} +0 -0
- /package/dist/core/{resizable-columns.d.ts → core/resizable-columns.d.ts} +0 -0
- /package/dist/core/{routes.d.ts → core/routes.d.ts} +0 -0
- /package/dist/core/{server-stats-controller.d.ts → core/server-stats-controller.d.ts} +0 -0
- /package/dist/core/{sparkline.d.ts → core/sparkline.d.ts} +0 -0
- /package/dist/core/{split-pane.d.ts → core/split-pane.d.ts} +0 -0
- /package/dist/core/{theme.d.ts → core/theme.d.ts} +0 -0
- /package/dist/core/{trace-utils.d.ts → core/trace-utils.d.ts} +0 -0
- /package/dist/core/{transmit-adapter.d.ts → core/transmit-adapter.d.ts} +0 -0
- /package/dist/core/{transmit-helpers.d.ts → core/transmit-helpers.d.ts} +0 -0
- /package/dist/core/{types-dashboard.d.ts → core/types-dashboard.d.ts} +0 -0
- /package/dist/core/{types-diagnostics.d.ts → core/types-diagnostics.d.ts} +0 -0
package/dist/react/index.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { B as s, D as t, c as r, J as u, M as g, d as o, d, S as D, T as S, e as h, a as B, u as T, f as b, g as l, h as m } from "./index-
|
|
1
|
+
import { B as s, D as t, c as r, J as u, M as g, d as o, d, S as D, T as S, e as h, a as B, u as T, f as b, g as l, h as m } from "./index-DOSlCpZ9.js";
|
|
2
2
|
export {
|
|
3
3
|
s as Badge,
|
|
4
4
|
t as DashboardPage,
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { useRef as t, useEffect as u, useCallback as c } from "react";
|
|
2
|
+
import { ApiClient as i } from "adonisjs-server-stats/core";
|
|
3
|
+
function o(e = "", n) {
|
|
4
|
+
const r = t(null);
|
|
5
|
+
return u(() => {
|
|
6
|
+
r.current = null;
|
|
7
|
+
}, [e, n]), c(() => (r.current || (r.current = new i({ baseUrl: e, authToken: n })), r.current), [e, n]);
|
|
8
|
+
}
|
|
9
|
+
export {
|
|
10
|
+
o as u
|
|
11
|
+
};
|
|
@@ -7,12 +7,9 @@ const QUEUE_DEFAULTS = {
|
|
|
7
7
|
queueFailed: 0,
|
|
8
8
|
queueWorkerCount: 0,
|
|
9
9
|
};
|
|
10
|
-
/** Fetch job counts from a BullMQ queue. */
|
|
11
|
-
async function fetchQueueCounts(
|
|
12
|
-
const { Queue } = await import('bullmq');
|
|
13
|
-
const queue = new Queue(queueName, { connection });
|
|
10
|
+
/** Fetch job counts from a long-lived BullMQ queue instance. */
|
|
11
|
+
async function fetchQueueCounts(queue) {
|
|
14
12
|
const [counts, workers] = await Promise.all([queue.getJobCounts(), queue.getWorkers()]);
|
|
15
|
-
await queue.close();
|
|
16
13
|
return {
|
|
17
14
|
queueActive: counts.active ?? 0,
|
|
18
15
|
queueWaiting: counts.waiting ?? 0,
|
|
@@ -62,9 +59,28 @@ export function queueCollector(opts) {
|
|
|
62
59
|
connectionError: false,
|
|
63
60
|
missingConnection: false,
|
|
64
61
|
};
|
|
62
|
+
// The BullMQ Queue owns a Redis connection. Create it once and reuse it
|
|
63
|
+
// across every poll tick so a rejected getJobCounts()/getWorkers() can never
|
|
64
|
+
// leak a connection (the previous per-tick `new Queue(...)` leaked one on
|
|
65
|
+
// every failure because close() was skipped). The queue is created lazily on
|
|
66
|
+
// first collect so a missing `bullmq` peer dependency degrades gracefully.
|
|
67
|
+
let queue = null;
|
|
68
|
+
async function getQueue(connection) {
|
|
69
|
+
if (queue)
|
|
70
|
+
return queue;
|
|
71
|
+
const { Queue } = await import('bullmq');
|
|
72
|
+
queue = new Queue(queueName, { connection });
|
|
73
|
+
return queue;
|
|
74
|
+
}
|
|
65
75
|
return {
|
|
66
76
|
name: 'queue',
|
|
67
77
|
label: `queue — ${queueName} @ ${opts.connection?.host ?? '?'}:${opts.connection?.port ?? '?'}`,
|
|
78
|
+
async stop() {
|
|
79
|
+
if (queue) {
|
|
80
|
+
await queue.close().catch(() => { });
|
|
81
|
+
queue = null;
|
|
82
|
+
}
|
|
83
|
+
},
|
|
68
84
|
getConfig() {
|
|
69
85
|
return {
|
|
70
86
|
queueName,
|
|
@@ -84,7 +100,7 @@ export function queueCollector(opts) {
|
|
|
84
100
|
return QUEUE_DEFAULTS;
|
|
85
101
|
}
|
|
86
102
|
try {
|
|
87
|
-
return await fetchQueueCounts(
|
|
103
|
+
return await fetchQueueCounts(await getQueue(opts.connection));
|
|
88
104
|
}
|
|
89
105
|
catch (error) {
|
|
90
106
|
handleQueueError(error, queueName, opts.connection, warned);
|
|
@@ -39,6 +39,10 @@ const SIMPLE_DEPRECATIONS = [
|
|
|
39
39
|
newName: 'authorize',
|
|
40
40
|
before: () => ['shouldShow: (ctx) => ...'],
|
|
41
41
|
after: () => ['authorize: (ctx) => ...'],
|
|
42
|
+
note: () => [
|
|
43
|
+
'Your `shouldShow` guard still fully protects the dashboard — nothing breaks.',
|
|
44
|
+
'`authorize` is just the new name for the same access check.',
|
|
45
|
+
],
|
|
42
46
|
},
|
|
43
47
|
{
|
|
44
48
|
key: 'channelName',
|
|
@@ -171,6 +175,7 @@ export function logDeprecationWarnings(config) {
|
|
|
171
175
|
new: dep.newName,
|
|
172
176
|
before: dep.before(config),
|
|
173
177
|
after: dep.after(config),
|
|
178
|
+
note: dep.note?.(config),
|
|
174
179
|
});
|
|
175
180
|
}
|
|
176
181
|
}
|
|
@@ -192,6 +197,12 @@ export function logDeprecationWarnings(config) {
|
|
|
192
197
|
for (const line of entry.after) {
|
|
193
198
|
lines.push(` ${dim('after:')} ${line}`);
|
|
194
199
|
}
|
|
200
|
+
if (entry.note) {
|
|
201
|
+
lines.push('');
|
|
202
|
+
for (const line of entry.note) {
|
|
203
|
+
lines.push(` ${dim(line)}`);
|
|
204
|
+
}
|
|
205
|
+
}
|
|
195
206
|
lines.push('');
|
|
196
207
|
}
|
|
197
208
|
lines.push(` ${dim('No rush — the old names still work. They will be removed in the next major version.')}`);
|
|
@@ -1,4 +1,18 @@
|
|
|
1
1
|
import { clamp } from '../utils/math_helpers.js';
|
|
2
|
+
/**
|
|
3
|
+
* Optional allow-list prefix for single-key cache operations (get/delete).
|
|
4
|
+
* Restricts which Redis keys the dashboard may read or delete so an attacker
|
|
5
|
+
* can't target arbitrary keys (sessions, rate-limit counters). Empty/unset
|
|
6
|
+
* means unrestricted (back-compat default for local dev).
|
|
7
|
+
*/
|
|
8
|
+
function getCacheKeyPrefix() {
|
|
9
|
+
return (process.env.SERVER_STATS_CACHE_KEY_PREFIX ?? '').trim();
|
|
10
|
+
}
|
|
11
|
+
/** Check whether a cache key is permitted under the configured prefix. */
|
|
12
|
+
function isKeyAllowed(key) {
|
|
13
|
+
const prefix = getCacheKeyPrefix();
|
|
14
|
+
return prefix === '' || key.startsWith(prefix);
|
|
15
|
+
}
|
|
2
16
|
/**
|
|
3
17
|
* Handle GET /cache-stats — list cache keys and overall stats.
|
|
4
18
|
*/
|
|
@@ -26,8 +40,11 @@ export async function handleCacheKey(inspectors, { params, response }) {
|
|
|
26
40
|
const inspector = await inspectors.getCacheInspector();
|
|
27
41
|
if (!inspector)
|
|
28
42
|
return response.notFound({ error: 'Cache not available' });
|
|
43
|
+
const key = decodeURIComponent(params.key);
|
|
44
|
+
if (!isKeyAllowed(key))
|
|
45
|
+
return response.forbidden({ error: 'Cache key not permitted' });
|
|
29
46
|
try {
|
|
30
|
-
const detail = await inspector.getKey(
|
|
47
|
+
const detail = await inspector.getKey(key);
|
|
31
48
|
return detail ? response.json(detail) : response.notFound({ error: 'Key not found' });
|
|
32
49
|
}
|
|
33
50
|
catch {
|
|
@@ -41,8 +58,11 @@ export async function handleCacheKeyDelete(inspectors, { params, response }) {
|
|
|
41
58
|
const inspector = await inspectors.getCacheInspector();
|
|
42
59
|
if (!inspector)
|
|
43
60
|
return response.notFound({ error: 'Cache not available' });
|
|
61
|
+
const key = decodeURIComponent(params.key);
|
|
62
|
+
if (!isKeyAllowed(key))
|
|
63
|
+
return response.forbidden({ error: 'Cache key not permitted' });
|
|
44
64
|
try {
|
|
45
|
-
return (await inspector.deleteKey(
|
|
65
|
+
return (await inspector.deleteKey(key))
|
|
46
66
|
? response.json({ deleted: true })
|
|
47
67
|
: response.notFound({ error: 'Key not found' });
|
|
48
68
|
}
|
|
@@ -11,6 +11,7 @@ export declare class FlushManager {
|
|
|
11
11
|
pendingEmails: EmailRecord[];
|
|
12
12
|
private flushTimer;
|
|
13
13
|
private flushing;
|
|
14
|
+
private inFlight;
|
|
14
15
|
private db;
|
|
15
16
|
constructor(getDb: () => Knex | null);
|
|
16
17
|
persistRequest(input: PersistRequestInput, dashboardPath: string): void;
|
|
@@ -21,5 +22,6 @@ export declare class FlushManager {
|
|
|
21
22
|
private backpressure;
|
|
22
23
|
private scheduleFlush;
|
|
23
24
|
flush(): Promise<void>;
|
|
25
|
+
private runFlush;
|
|
24
26
|
private takeSnapshot;
|
|
25
27
|
}
|
|
@@ -9,6 +9,7 @@ export class FlushManager {
|
|
|
9
9
|
pendingEmails = [];
|
|
10
10
|
flushTimer = null;
|
|
11
11
|
flushing = false;
|
|
12
|
+
inFlight = null;
|
|
12
13
|
db;
|
|
13
14
|
constructor(getDb) {
|
|
14
15
|
this.db = getDb;
|
|
@@ -43,6 +44,10 @@ export class FlushManager {
|
|
|
43
44
|
clearTimeout(this.flushTimer);
|
|
44
45
|
this.flushTimer = null;
|
|
45
46
|
}
|
|
47
|
+
// Await any in-flight (timer-triggered) flush first, otherwise the final
|
|
48
|
+
// flush below early-returns and queued data is lost at shutdown.
|
|
49
|
+
if (this.inFlight)
|
|
50
|
+
await this.inFlight.catch(() => { });
|
|
46
51
|
await this.flush().catch(() => { });
|
|
47
52
|
}
|
|
48
53
|
backpressure(q) {
|
|
@@ -67,6 +72,18 @@ export class FlushManager {
|
|
|
67
72
|
if (this.flushing || !db)
|
|
68
73
|
return;
|
|
69
74
|
this.flushing = true;
|
|
75
|
+
// Track the in-flight flush so stop() can await it before issuing a final
|
|
76
|
+
// flush, instead of racing the flushing guard and dropping queued data.
|
|
77
|
+
const run = this.runFlush(db);
|
|
78
|
+
this.inFlight = run;
|
|
79
|
+
try {
|
|
80
|
+
await run;
|
|
81
|
+
}
|
|
82
|
+
finally {
|
|
83
|
+
this.inFlight = null;
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
async runFlush(db) {
|
|
70
87
|
const snap = this.takeSnapshot();
|
|
71
88
|
if (!snap) {
|
|
72
89
|
this.flushing = false;
|
|
@@ -44,8 +44,10 @@ const SENSITIVE_PATTERNS = [
|
|
|
44
44
|
/app[_-]key/i,
|
|
45
45
|
];
|
|
46
46
|
const REDACTED_DISPLAY = '••••••••';
|
|
47
|
-
function redact(
|
|
48
|
-
|
|
47
|
+
function redact(_value) {
|
|
48
|
+
// Never include the plaintext value: the redacted object is serialized
|
|
49
|
+
// to the browser, so the secret must not leave the server.
|
|
50
|
+
return { __redacted: true, display: REDACTED_DISPLAY };
|
|
49
51
|
}
|
|
50
52
|
// ---------------------------------------------------------------------------
|
|
51
53
|
// ConfigInspector
|
|
@@ -6,6 +6,11 @@
|
|
|
6
6
|
*/
|
|
7
7
|
import type { PaginatedResult, PaginateOptions } from './dashboard_types.js';
|
|
8
8
|
import type { Knex } from 'knex';
|
|
9
|
+
/**
|
|
10
|
+
* Clamp a raw perPage value to a safe range (1..200) to prevent DoS via
|
|
11
|
+
* unbounded result sets on the single sqlite connection.
|
|
12
|
+
*/
|
|
13
|
+
export declare function clampPerPage(value: number): number;
|
|
9
14
|
/**
|
|
10
15
|
* Execute a paginated query within a transaction.
|
|
11
16
|
*/
|
|
@@ -4,20 +4,32 @@
|
|
|
4
4
|
* Wraps COUNT + SELECT in a single transaction so the pool connection
|
|
5
5
|
* is acquired only once instead of two separate acquire/release cycles.
|
|
6
6
|
*/
|
|
7
|
+
import { clamp } from '../utils/math_helpers.js';
|
|
8
|
+
/**
|
|
9
|
+
* Clamp a raw perPage value to a safe range (1..200) to prevent DoS via
|
|
10
|
+
* unbounded result sets on the single sqlite connection.
|
|
11
|
+
*/
|
|
12
|
+
export function clampPerPage(value) {
|
|
13
|
+
const n = Number(value);
|
|
14
|
+
if (!Number.isFinite(n))
|
|
15
|
+
return 25;
|
|
16
|
+
return clamp(Math.trunc(n), 1, 200);
|
|
17
|
+
}
|
|
7
18
|
/**
|
|
8
19
|
* Execute a paginated query within a transaction.
|
|
9
20
|
*/
|
|
10
21
|
export async function executePaginate(db, opts) {
|
|
22
|
+
const perPage = clampPerPage(opts.perPage);
|
|
11
23
|
return db.transaction(async (trx) => {
|
|
12
24
|
const countQuery = trx(opts.table);
|
|
13
25
|
if (opts.applyFilters)
|
|
14
26
|
opts.applyFilters(countQuery);
|
|
15
27
|
const [{ count: totalRaw }] = await countQuery.count('* as count');
|
|
16
28
|
const total = Number(totalRaw);
|
|
17
|
-
const offset = (opts.page - 1) *
|
|
29
|
+
const offset = (opts.page - 1) * perPage;
|
|
18
30
|
const dataQuery = trx(opts.table)
|
|
19
31
|
.orderBy('created_at', 'desc')
|
|
20
|
-
.limit(
|
|
32
|
+
.limit(perPage)
|
|
21
33
|
.offset(offset);
|
|
22
34
|
if (opts.applyFilters)
|
|
23
35
|
opts.applyFilters(dataQuery);
|
|
@@ -26,8 +38,8 @@ export async function executePaginate(db, opts) {
|
|
|
26
38
|
data,
|
|
27
39
|
total,
|
|
28
40
|
page: opts.page,
|
|
29
|
-
perPage
|
|
30
|
-
lastPage: Math.ceil(total /
|
|
41
|
+
perPage,
|
|
42
|
+
lastPage: Math.ceil(total / perPage),
|
|
31
43
|
};
|
|
32
44
|
});
|
|
33
45
|
}
|
|
@@ -1,12 +1,17 @@
|
|
|
1
1
|
import type { DashboardStore } from './dashboard_store.js';
|
|
2
2
|
import type { HttpContext } from '@adonisjs/core/http';
|
|
3
3
|
import type { ApplicationService } from '@adonisjs/core/types';
|
|
4
|
+
/**
|
|
5
|
+
* Reject SQL containing statement separators / comment sequences that could
|
|
6
|
+
* enable stacked statements or comment-based injection into the EXPLAIN prefix.
|
|
7
|
+
*/
|
|
8
|
+
export declare function hasUnsafeSqlTokens(sqlText: string): boolean;
|
|
4
9
|
export type DbDialect = 'pg' | 'sqlite' | 'mysql' | 'mssql' | 'unknown';
|
|
5
10
|
export interface AppDbClient {
|
|
6
11
|
raw: (sql: string, bindings: unknown[]) => Promise<unknown>;
|
|
7
12
|
dialect: DbDialect;
|
|
8
13
|
}
|
|
9
|
-
/** Get the Lucid
|
|
14
|
+
/** Get the Lucid read client for running EXPLAIN. */
|
|
10
15
|
export declare function getAppDbClient(app: ApplicationService, connectionName?: string): Promise<AppDbClient | null>;
|
|
11
16
|
/** Build the EXPLAIN SQL for the detected dialect. */
|
|
12
17
|
export declare function buildExplainSql(sql: string, dialect: DbDialect): string;
|
|
@@ -12,6 +12,13 @@ async function fetchQueryRecord(dashboardStore, id) {
|
|
|
12
12
|
function isSelectQuery(sqlText) {
|
|
13
13
|
return sqlText.trim().toUpperCase().startsWith('SELECT');
|
|
14
14
|
}
|
|
15
|
+
/**
|
|
16
|
+
* Reject SQL containing statement separators / comment sequences that could
|
|
17
|
+
* enable stacked statements or comment-based injection into the EXPLAIN prefix.
|
|
18
|
+
*/
|
|
19
|
+
export function hasUnsafeSqlTokens(sqlText) {
|
|
20
|
+
return /;|--|\/\*/.test(sqlText);
|
|
21
|
+
}
|
|
15
22
|
/** Parse bindings from a stored query. */
|
|
16
23
|
function parseBindings(raw) {
|
|
17
24
|
if (!raw)
|
|
@@ -39,14 +46,14 @@ function detectDialect(client) {
|
|
|
39
46
|
return 'mssql';
|
|
40
47
|
return 'unknown';
|
|
41
48
|
}
|
|
42
|
-
/** Get the Lucid
|
|
49
|
+
/** Get the Lucid read client for running EXPLAIN. */
|
|
43
50
|
export async function getAppDbClient(app, connectionName) {
|
|
44
51
|
try {
|
|
45
52
|
const lucid = await app.container.make('lucid.db');
|
|
46
53
|
const conn = connectionName
|
|
47
54
|
? lucid.connection(connectionName)
|
|
48
55
|
: lucid.connection();
|
|
49
|
-
const client = conn.
|
|
56
|
+
const client = conn.getReadClient();
|
|
50
57
|
const dialect = detectDialect(client);
|
|
51
58
|
return {
|
|
52
59
|
raw: client.raw.bind(client),
|
|
@@ -111,6 +118,9 @@ export async function handleQueryExplain(dashboardStore, app, ctx) {
|
|
|
111
118
|
if (!isSelectQuery(query.sql_text)) {
|
|
112
119
|
return response.badRequest({ error: 'EXPLAIN is only supported for SELECT queries' });
|
|
113
120
|
}
|
|
121
|
+
if (hasUnsafeSqlTokens(query.sql_text)) {
|
|
122
|
+
return response.badRequest({ error: 'EXPLAIN rejected: query contains disallowed tokens' });
|
|
123
|
+
}
|
|
114
124
|
const appDb = await getAppDbClient(app, query.connection || undefined);
|
|
115
125
|
if (!appDb) {
|
|
116
126
|
return response.serviceUnavailable({ error: 'App database connection not available' });
|
|
@@ -13,8 +13,21 @@ export declare function markWarned(path: string): void;
|
|
|
13
13
|
/**
|
|
14
14
|
* Normalize a SQL query by replacing literal values with `?` placeholders.
|
|
15
15
|
* Used for grouping identical query patterns.
|
|
16
|
+
*
|
|
17
|
+
* Numeric replacement is restricted to value contexts (a digit run preceded
|
|
18
|
+
* by whitespace, a comma, a paren, or an operator) so identifiers that contain
|
|
19
|
+
* digits — e.g. `orders_2024` — are left intact and distinct queries are not
|
|
20
|
+
* merged together.
|
|
16
21
|
*/
|
|
17
22
|
export declare function normalizeSql(sql: string): string;
|
|
23
|
+
/**
|
|
24
|
+
* Redact/truncate SQL bindings before persistence so secret-looking values
|
|
25
|
+
* (long tokens, hashes, keys) are not stored in cleartext.
|
|
26
|
+
*
|
|
27
|
+
* Conservative: only long strings are truncated; short values (ids, flags,
|
|
28
|
+
* emails, ordinary params) pass through so normal capture is unaffected.
|
|
29
|
+
*/
|
|
30
|
+
export declare function sanitizeBindings(bindings: unknown): unknown;
|
|
18
31
|
export interface PreparedQuery {
|
|
19
32
|
sql_text: string;
|
|
20
33
|
sql_normalized: string;
|
|
@@ -22,15 +22,47 @@ export function markWarned(path) {
|
|
|
22
22
|
/**
|
|
23
23
|
* Normalize a SQL query by replacing literal values with `?` placeholders.
|
|
24
24
|
* Used for grouping identical query patterns.
|
|
25
|
+
*
|
|
26
|
+
* Numeric replacement is restricted to value contexts (a digit run preceded
|
|
27
|
+
* by whitespace, a comma, a paren, or an operator) so identifiers that contain
|
|
28
|
+
* digits — e.g. `orders_2024` — are left intact and distinct queries are not
|
|
29
|
+
* merged together.
|
|
25
30
|
*/
|
|
26
31
|
export function normalizeSql(sql) {
|
|
27
32
|
return sql
|
|
28
33
|
.replace(/'[^']*'/g, '?')
|
|
29
|
-
.replace(
|
|
34
|
+
.replace(/(^|[\s,(=<>+\-*/])\d+(\.\d+)?\b/g, '$1?')
|
|
30
35
|
.replace(/\s+/g, ' ')
|
|
31
36
|
.trim();
|
|
32
37
|
}
|
|
33
38
|
// ---------------------------------------------------------------------------
|
|
39
|
+
// Binding hygiene
|
|
40
|
+
// ---------------------------------------------------------------------------
|
|
41
|
+
/** Max length for a persisted string binding before it is truncated. */
|
|
42
|
+
const MAX_BINDING_LEN = 256;
|
|
43
|
+
/**
|
|
44
|
+
* Redact/truncate SQL bindings before persistence so secret-looking values
|
|
45
|
+
* (long tokens, hashes, keys) are not stored in cleartext.
|
|
46
|
+
*
|
|
47
|
+
* Conservative: only long strings are truncated; short values (ids, flags,
|
|
48
|
+
* emails, ordinary params) pass through so normal capture is unaffected.
|
|
49
|
+
*/
|
|
50
|
+
export function sanitizeBindings(bindings) {
|
|
51
|
+
if (Array.isArray(bindings))
|
|
52
|
+
return bindings.map(sanitizeBindings);
|
|
53
|
+
if (typeof bindings === 'string' && bindings.length > MAX_BINDING_LEN) {
|
|
54
|
+
return bindings.slice(0, MAX_BINDING_LEN) + `…[truncated ${bindings.length} chars]`;
|
|
55
|
+
}
|
|
56
|
+
if (bindings && typeof bindings === 'object') {
|
|
57
|
+
const out = {};
|
|
58
|
+
for (const [k, v] of Object.entries(bindings)) {
|
|
59
|
+
out[k] = sanitizeBindings(v);
|
|
60
|
+
}
|
|
61
|
+
return out;
|
|
62
|
+
}
|
|
63
|
+
return bindings;
|
|
64
|
+
}
|
|
65
|
+
// ---------------------------------------------------------------------------
|
|
34
66
|
// Pure data-prep functions
|
|
35
67
|
// ---------------------------------------------------------------------------
|
|
36
68
|
/**
|
|
@@ -46,7 +78,7 @@ export function prepareRequestRows(requests) {
|
|
|
46
78
|
.map((q) => ({
|
|
47
79
|
sql_text: q.sql,
|
|
48
80
|
sql_normalized: normalizeSql(q.sql),
|
|
49
|
-
bindings: q.bindings ? JSON.stringify(q.bindings) : null,
|
|
81
|
+
bindings: q.bindings ? JSON.stringify(sanitizeBindings(q.bindings)) : null,
|
|
50
82
|
duration: round(q.duration),
|
|
51
83
|
method: q.method,
|
|
52
84
|
model: q.model,
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { writeFile, readFile, rename, mkdir } from 'node:fs/promises';
|
|
2
2
|
import { dirname } from 'node:path';
|
|
3
|
+
import { redactTokenLinks } from '../provider/email_helpers.js';
|
|
3
4
|
import { log, bold } from '../utils/logger.js';
|
|
4
5
|
import { EmailCollector } from './email_collector.js';
|
|
5
6
|
import { EventCollector } from './event_collector.js';
|
|
@@ -103,7 +104,13 @@ export class DebugStore {
|
|
|
103
104
|
const events = this.events.getEvents();
|
|
104
105
|
parts.push(`"events":${JSON.stringify(events)},`);
|
|
105
106
|
await new Promise((resolve) => setImmediate(resolve));
|
|
106
|
-
|
|
107
|
+
// Strip token-bearing links (reset/verify/magic URLs, ?token=…) from email
|
|
108
|
+
// bodies before they hit disk in cleartext.
|
|
109
|
+
const emails = this.emails.getEmails().map((email) => ({
|
|
110
|
+
...email,
|
|
111
|
+
html: typeof email.html === 'string' ? redactTokenLinks(email.html) : email.html,
|
|
112
|
+
text: typeof email.text === 'string' ? redactTokenLinks(email.text) : email.text,
|
|
113
|
+
}));
|
|
107
114
|
parts.push(`"emails":${JSON.stringify(emails)}`);
|
|
108
115
|
if (this.traces) {
|
|
109
116
|
await new Promise((resolve) => setImmediate(resolve));
|
|
@@ -87,6 +87,10 @@ export class EventCollector {
|
|
|
87
87
|
start(emitter) {
|
|
88
88
|
if (!emitter || typeof emitter.emit !== 'function')
|
|
89
89
|
return;
|
|
90
|
+
// Idempotent: a second start() must not capture the already-patched emit as
|
|
91
|
+
// the "original", which would corrupt emitter.emit permanently.
|
|
92
|
+
if (this.originalEmit)
|
|
93
|
+
return;
|
|
90
94
|
this.emitter = emitter;
|
|
91
95
|
this.originalEmit = emitter.emit.bind(emitter);
|
|
92
96
|
emitter.emit = (event, data) => {
|
|
@@ -122,6 +122,8 @@ export class QueryCollector {
|
|
|
122
122
|
}
|
|
123
123
|
clear() {
|
|
124
124
|
this.buffer.clear();
|
|
125
|
+
// Invalidate the cached summary so totals aren't stale after a clear().
|
|
126
|
+
this.cachedSummary = null;
|
|
125
127
|
}
|
|
126
128
|
/** Register a callback that fires whenever a new query is recorded. */
|
|
127
129
|
onNewItem(cb) {
|
|
@@ -23,7 +23,14 @@ export class RingBuffer {
|
|
|
23
23
|
if (this.count < this.capacity) {
|
|
24
24
|
this.count++;
|
|
25
25
|
}
|
|
26
|
-
|
|
26
|
+
// The write itself is infallible; a throwing subscriber must not escape and
|
|
27
|
+
// corrupt callers that rely on push() never failing.
|
|
28
|
+
try {
|
|
29
|
+
this.pushCallback?.(item);
|
|
30
|
+
}
|
|
31
|
+
catch (err) {
|
|
32
|
+
console.error('[server-stats] ring buffer push callback threw:', err);
|
|
33
|
+
}
|
|
27
34
|
}
|
|
28
35
|
/** Returns all items in insertion order (oldest first). */
|
|
29
36
|
toArray() {
|
|
@@ -6,6 +6,12 @@ import { RingBuffer } from './ring_buffer.js';
|
|
|
6
6
|
* Module-level singleton reference for the `trace()` helper.
|
|
7
7
|
*/
|
|
8
8
|
const globalRef = { current: null };
|
|
9
|
+
/**
|
|
10
|
+
* Tracks whether console.warn is currently patched by a TraceCollector, so a
|
|
11
|
+
* second instance (or a skipped stop()) cannot double-wrap it and leak the
|
|
12
|
+
* patch permanently.
|
|
13
|
+
*/
|
|
14
|
+
const consoleWarnPatch = { active: false };
|
|
9
15
|
/**
|
|
10
16
|
* Wrap an async function in a traced span.
|
|
11
17
|
*
|
|
@@ -101,9 +107,14 @@ export class TraceCollector {
|
|
|
101
107
|
const parentId = ctx.currentSpanId;
|
|
102
108
|
const spanId = String(ctx.nextSpanId++);
|
|
103
109
|
ctx.currentSpanId = spanId;
|
|
110
|
+
let errored = false;
|
|
104
111
|
try {
|
|
105
112
|
return await fn();
|
|
106
113
|
}
|
|
114
|
+
catch (err) {
|
|
115
|
+
errored = true;
|
|
116
|
+
throw err;
|
|
117
|
+
}
|
|
107
118
|
finally {
|
|
108
119
|
const duration = performance.now() - start;
|
|
109
120
|
ctx.spans.push({
|
|
@@ -113,6 +124,7 @@ export class TraceCollector {
|
|
|
113
124
|
category,
|
|
114
125
|
startOffset: round(start - ctx.requestStart),
|
|
115
126
|
duration: round(duration),
|
|
127
|
+
error: errored ? true : undefined,
|
|
116
128
|
});
|
|
117
129
|
ctx.currentSpanId = parentId;
|
|
118
130
|
}
|
|
@@ -145,28 +157,40 @@ export class TraceCollector {
|
|
|
145
157
|
};
|
|
146
158
|
emitter.on('db:query', this.dbHandler);
|
|
147
159
|
}
|
|
148
|
-
// Intercept console.warn to capture warnings per-request
|
|
149
|
-
|
|
150
|
-
console.warn
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
160
|
+
// Intercept console.warn to capture warnings per-request. Guard against
|
|
161
|
+
// double-wrapping: if another instance (or a prior start() without a
|
|
162
|
+
// matching stop()) already patched console.warn, don't wrap it again —
|
|
163
|
+
// that would capture the patched fn as the "original" and leak the patch.
|
|
164
|
+
if (!consoleWarnPatch.active) {
|
|
165
|
+
consoleWarnPatch.active = true;
|
|
166
|
+
this.originalConsoleWarn = console.warn;
|
|
167
|
+
console.warn = (...args) => {
|
|
168
|
+
const ctx = this.als.getStore();
|
|
169
|
+
if (ctx) {
|
|
170
|
+
ctx.warnings.push(args.map(String).join(' '));
|
|
171
|
+
}
|
|
172
|
+
this.originalConsoleWarn.apply(console, args);
|
|
173
|
+
};
|
|
174
|
+
}
|
|
157
175
|
}
|
|
158
176
|
/** Unhook event listeners and restore console.warn. */
|
|
159
177
|
stop() {
|
|
160
178
|
if (this.emitter && this.dbHandler) {
|
|
161
179
|
this.emitter.off('db:query', this.dbHandler);
|
|
162
180
|
}
|
|
181
|
+
// Only restore console.warn if this instance was the one that patched it.
|
|
163
182
|
if (this.originalConsoleWarn) {
|
|
164
183
|
console.warn = this.originalConsoleWarn;
|
|
184
|
+
consoleWarnPatch.active = false;
|
|
165
185
|
}
|
|
166
186
|
this.dbHandler = null;
|
|
167
187
|
this.emitter = null;
|
|
168
188
|
this.originalConsoleWarn = null;
|
|
169
|
-
|
|
189
|
+
// Only relinquish the global reference if it still points at this instance,
|
|
190
|
+
// so stopping an older instance can't clear a newer one's registration.
|
|
191
|
+
if (globalRef.current === this) {
|
|
192
|
+
globalRef.current = null;
|
|
193
|
+
}
|
|
170
194
|
}
|
|
171
195
|
getTraces() {
|
|
172
196
|
return this.buffer.toArray().reverse();
|
|
@@ -220,6 +220,8 @@ export interface TraceSpan {
|
|
|
220
220
|
startOffset: number;
|
|
221
221
|
/** Span duration in milliseconds. */
|
|
222
222
|
duration: number;
|
|
223
|
+
/** True when the wrapped function threw. */
|
|
224
|
+
error?: boolean;
|
|
223
225
|
/** Optional metadata (query bindings, status code, etc.). */
|
|
224
226
|
metadata?: Record<string, unknown>;
|
|
225
227
|
}
|