adonisjs-server-stats 1.16.0 → 1.17.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 +14 -2
- package/dist/core/core/dashboard-api.d.ts +6 -4
- package/dist/core/index.js +9 -6
- package/dist/core/types.d.ts +24 -1
- package/dist/react/{CacheSection-DkLtukWz.js → CacheSection-C-X2Tnca.js} +48 -44
- package/dist/react/{CacheTab-DWeXnEBI.js → CacheTab-D55mfmeE.js} +1 -1
- package/dist/react/{ConfigSection-DaVbFPDg.js → ConfigSection-C-HGnZfd.js} +1 -1
- package/dist/react/{ConfigTab-D4640WJZ.js → ConfigTab-CNnsOXBh.js} +1 -1
- package/dist/react/{CustomPaneTab-DglmAVMC.js → CustomPaneTab-D-267UVd.js} +1 -1
- package/dist/react/{EmailsSection-vYx6ExTb.js → EmailsSection-Bqw4DI4r.js} +65 -57
- package/dist/react/{EmailsTab-DKDrRmRH.js → EmailsTab-CkHOhj2F.js} +1 -1
- package/dist/react/{EventsSection-CapH5xut.js → EventsSection-CMeyjzVC.js} +1 -1
- package/dist/react/{EventsTab-BNVEySAm.js → EventsTab-DgS2v1HX.js} +1 -1
- package/dist/react/{JobsSection-ZIQsJWid.js → JobsSection-oWH6pbrG.js} +1 -1
- package/dist/react/{JobsTab-DXQzXrDt.js → JobsTab-zdxX_as-.js} +1 -1
- package/dist/react/{LogEntryRow-BWkHE51-.js → LogEntryRow-Cn8Ee_X0.js} +1 -1
- package/dist/react/{LogsSection-ConXdBkL.js → LogsSection-XmiI2MMs.js} +2 -2
- package/dist/react/{LogsTab-CJM47LPn.js → LogsTab-ixusY4v7.js} +2 -2
- package/dist/react/{OverviewSection-DOMu2qvl.js → OverviewSection-CvhObTLP.js} +119 -111
- package/dist/react/{QueriesSection-DFsaOSJI.js → QueriesSection-BvledwBI.js} +1 -1
- package/dist/react/{QueriesTab-CY9CG_7L.js → QueriesTab-smQQJvJa.js} +1 -1
- package/dist/react/{RequestsSection-CC-eVHsl.js → RequestsSection-DKuYpViv.js} +2 -2
- package/dist/react/{RoutesSection-p1DMq41y.js → RoutesSection-5RNVurQ6.js} +1 -1
- package/dist/react/{RoutesTab-Cnqy8UcK.js → RoutesTab-CpXNO-Rx.js} +1 -1
- package/dist/react/{SplitPaneWrapper-XgkA0QxE.js → SplitPaneWrapper-DQvJnsuk.js} +1 -1
- package/dist/react/{TimelineTab-1YOERxe5.js → TimelineTab-LHQHHKkO.js} +2 -2
- package/dist/react/components/Dashboard/overview-data-context.d.ts +15 -0
- package/dist/react/components/Dashboard/sections/OverviewSection.d.ts +9 -0
- package/dist/react/{index-DOSlCpZ9.js → index-LlFvooUN.js} +148 -141
- package/dist/react/index.js +1 -1
- package/dist/src/collectors/app_collector.d.ts +11 -0
- package/dist/src/collectors/app_collector.js +11 -0
- package/dist/src/collectors/redis_collector.js +13 -13
- package/dist/src/dashboard/cache_handlers.d.ts +17 -3
- package/dist/src/dashboard/cache_handlers.js +14 -19
- package/dist/src/dashboard/coalesce_cache.d.ts +2 -8
- package/dist/src/dashboard/coalesce_cache.js +25 -0
- package/dist/src/dashboard/dashboard_controller.d.ts +1 -0
- package/dist/src/dashboard/dashboard_controller.js +8 -3
- package/dist/src/dashboard/dashboard_store.d.ts +1 -3
- package/dist/src/dashboard/dashboard_store.js +14 -9
- package/dist/src/dashboard/dashboard_types.d.ts +2 -0
- package/dist/src/dashboard/filter_handlers.js +26 -4
- package/dist/src/dashboard/flush_manager.d.ts +2 -6
- package/dist/src/dashboard/flush_manager.js +9 -11
- package/dist/src/dashboard/format_helpers.d.ts +0 -4
- package/dist/src/dashboard/format_helpers.js +0 -19
- package/dist/src/dashboard/integrations/config_inspector.js +15 -52
- package/dist/src/dashboard/migrator_tables.js +4 -0
- package/dist/src/dashboard/paginate_helper.js +21 -11
- package/dist/src/dashboard/sensitive_patterns.d.ts +76 -0
- package/dist/src/dashboard/sensitive_patterns.js +259 -0
- package/dist/src/dashboard/write_queue.d.ts +27 -16
- package/dist/src/dashboard/write_queue.js +50 -47
- package/dist/src/debug/event_collector.d.ts +8 -0
- package/dist/src/debug/event_collector.js +12 -0
- package/dist/src/debug/query_collector.js +6 -1
- package/dist/src/debug/trace_collector.js +26 -13
- package/dist/src/edge/client/dashboard.js +2 -2
- package/dist/src/edge/client-vue/dashboard.js +5 -5
- package/dist/src/edge/views/dashboard.edge +1 -1
- package/dist/src/edge/views/debug-panel.edge +1 -1
- package/dist/src/edge/views/stats-bar.edge +1 -1
- package/dist/src/middleware/request_tracking_middleware.js +7 -2
- package/dist/src/provider/dashboard_init.d.ts +9 -1
- package/dist/src/provider/dashboard_init.js +19 -0
- package/dist/src/provider/email_helpers.js +20 -5
- package/dist/src/provider/server_stats_provider.d.ts +9 -0
- package/dist/src/provider/server_stats_provider.js +26 -7
- package/dist/src/provider/shutdown_helpers.js +3 -1
- package/dist/src/provider/toolbar_setup.js +21 -15
- package/dist/src/types.d.ts +24 -1
- package/dist/vue/CacheSection-Bm0goqX6.js +161 -0
- package/dist/vue/{ConfigSection-BiRAiaHj.js → ConfigSection-BrYslIMN.js} +1 -1
- package/dist/vue/{EmailsSection-Dl44qyqY.js → EmailsSection-B8N5vaWA.js} +89 -85
- package/dist/vue/{EventsSection-CWjeitjU.js → EventsSection-D2Y_xt0m.js} +1 -1
- package/dist/vue/{JobsSection-DOBb4LjZ.js → JobsSection-cEKqELLC.js} +1 -1
- package/dist/vue/{LogsSection-CXx-HOWJ.js → LogsSection-C3H2c0S3.js} +1 -1
- package/dist/vue/{OverviewSection-CyfNQ8uV.js → OverviewSection-I2swuHDa.js} +1 -1
- package/dist/vue/{QueriesSection-CXBsFp-y.js → QueriesSection-CF1IE3SN.js} +1 -1
- package/dist/vue/{RequestsSection-YTIaZGZd.js → RequestsSection-CuwgXzmU.js} +1 -1
- package/dist/vue/{RoutesSection-CDKMey49.js → RoutesSection-BhJCF7Rn.js} +1 -1
- package/dist/vue/composables/useDashboardData.d.ts +1 -1
- package/dist/vue/{index-14x39RI_.js → index-DUsImuIN.js} +95 -95
- package/dist/vue/index.js +1 -1
- package/package.json +12 -3
- package/dist/src/dashboard/explain_query.d.ts +0 -8
- package/dist/src/dashboard/explain_query.js +0 -22
- package/dist/src/provider/boot_initializer.d.ts +0 -28
- package/dist/src/provider/boot_initializer.js +0 -35
- package/dist/vue/CacheSection-CxEBVVkF.js +0 -156
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-LlFvooUN.js";
|
|
2
2
|
export {
|
|
3
3
|
s as Badge,
|
|
4
4
|
t as DashboardPage,
|
|
@@ -3,6 +3,17 @@ import type { MetricCollector } from './collector.js';
|
|
|
3
3
|
* Queries application-specific tables for user sessions,
|
|
4
4
|
* pending webhooks, and pending emails.
|
|
5
5
|
*
|
|
6
|
+
* **This polls your primary database** — three `COUNT(*)` queries every
|
|
7
|
+
* collection interval (3s by default), forever. Without indexes those are
|
|
8
|
+
* full table scans on every tick. If you enable this collector, make sure
|
|
9
|
+
* these exist (a plain `sessions` count uses the primary key, the other two
|
|
10
|
+
* need their filter column indexed):
|
|
11
|
+
*
|
|
12
|
+
* ```sql
|
|
13
|
+
* CREATE INDEX webhook_events_status_index ON webhook_events (status);
|
|
14
|
+
* CREATE INDEX scheduled_emails_status_index ON scheduled_emails (status);
|
|
15
|
+
* ```
|
|
16
|
+
*
|
|
6
17
|
* **Peer dependencies:** `@adonisjs/lucid`
|
|
7
18
|
*/
|
|
8
19
|
export declare function appCollector(): MetricCollector;
|
|
@@ -46,6 +46,17 @@ function warnLucidMissing() {
|
|
|
46
46
|
* Queries application-specific tables for user sessions,
|
|
47
47
|
* pending webhooks, and pending emails.
|
|
48
48
|
*
|
|
49
|
+
* **This polls your primary database** — three `COUNT(*)` queries every
|
|
50
|
+
* collection interval (3s by default), forever. Without indexes those are
|
|
51
|
+
* full table scans on every tick. If you enable this collector, make sure
|
|
52
|
+
* these exist (a plain `sessions` count uses the primary key, the other two
|
|
53
|
+
* need their filter column indexed):
|
|
54
|
+
*
|
|
55
|
+
* ```sql
|
|
56
|
+
* CREATE INDEX webhook_events_status_index ON webhook_events (status);
|
|
57
|
+
* CREATE INDEX scheduled_emails_status_index ON scheduled_emails (status);
|
|
58
|
+
* ```
|
|
59
|
+
*
|
|
49
60
|
* **Peer dependencies:** `@adonisjs/lucid`
|
|
50
61
|
*/
|
|
51
62
|
export function appCollector() {
|
|
@@ -1,8 +1,5 @@
|
|
|
1
1
|
import { appImport } from '../utils/app_import.js';
|
|
2
2
|
import { log, dim, bold } from '../utils/logger.js';
|
|
3
|
-
let warnedNotInstalled = false;
|
|
4
|
-
let warnedPingFailed = false;
|
|
5
|
-
let warnedConnectionError = false;
|
|
6
3
|
/** Default metrics returned when Redis is unavailable. */
|
|
7
4
|
const REDIS_DEFAULTS = {
|
|
8
5
|
redisOk: false,
|
|
@@ -39,14 +36,14 @@ export function computeRedisMetrics(memParsed, statsParsed, dbSize) {
|
|
|
39
36
|
};
|
|
40
37
|
}
|
|
41
38
|
/** Import Redis service, returning null if unavailable. */
|
|
42
|
-
async function importRedis() {
|
|
39
|
+
async function importRedis(warned) {
|
|
43
40
|
try {
|
|
44
41
|
const mod = await appImport('@adonisjs/redis/services/main');
|
|
45
42
|
return mod.default;
|
|
46
43
|
}
|
|
47
44
|
catch {
|
|
48
|
-
if (!
|
|
49
|
-
|
|
45
|
+
if (!warned.notInstalled) {
|
|
46
|
+
warned.notInstalled = true;
|
|
50
47
|
log.block(`Redis collector ${bold('skipped')} — @adonisjs/redis is not installed`, [
|
|
51
48
|
dim('Redis metrics will return defaults until the package is added.'),
|
|
52
49
|
`Run ${bold('node ace add @adonisjs/redis')} to install it.`,
|
|
@@ -56,10 +53,10 @@ async function importRedis() {
|
|
|
56
53
|
}
|
|
57
54
|
}
|
|
58
55
|
/** Warn once when PING fails. */
|
|
59
|
-
function warnPingFailed(pong) {
|
|
60
|
-
if (
|
|
56
|
+
function warnPingFailed(warned, pong) {
|
|
57
|
+
if (warned.pingFailed)
|
|
61
58
|
return;
|
|
62
|
-
|
|
59
|
+
warned.pingFailed = true;
|
|
63
60
|
log.block(`Redis collector ${bold('unhealthy')} — PING returned ${bold(String(pong))} instead of PONG`, [
|
|
64
61
|
dim('Redis may be down or misconfigured.'),
|
|
65
62
|
`Check your connection settings in ${bold('config/redis.ts')}.`,
|
|
@@ -71,6 +68,9 @@ function warnPingFailed(pong) {
|
|
|
71
68
|
* **Peer dependencies:** `@adonisjs/redis`
|
|
72
69
|
*/
|
|
73
70
|
export function redisCollector() {
|
|
71
|
+
// Closure-scoped like db_pool_collector's flags — module-level state would
|
|
72
|
+
// let the first instance's warnings suppress every later instance's.
|
|
73
|
+
const warned = { notInstalled: false, pingFailed: false, connectionError: false };
|
|
74
74
|
return {
|
|
75
75
|
name: 'redis',
|
|
76
76
|
label: 'redis — memory, clients, keys, hit rate',
|
|
@@ -78,13 +78,13 @@ export function redisCollector() {
|
|
|
78
78
|
return {};
|
|
79
79
|
},
|
|
80
80
|
async collect() {
|
|
81
|
-
const redis = await importRedis();
|
|
81
|
+
const redis = await importRedis(warned);
|
|
82
82
|
if (!redis)
|
|
83
83
|
return REDIS_DEFAULTS;
|
|
84
84
|
try {
|
|
85
85
|
const pong = await redis.ping();
|
|
86
86
|
if (pong !== 'PONG') {
|
|
87
|
-
warnPingFailed(pong);
|
|
87
|
+
warnPingFailed(warned, pong);
|
|
88
88
|
return REDIS_DEFAULTS;
|
|
89
89
|
}
|
|
90
90
|
const [memoryInfo, statsInfo, dbSize] = await Promise.all([
|
|
@@ -95,8 +95,8 @@ export function redisCollector() {
|
|
|
95
95
|
return computeRedisMetrics(parseRedisInfo(memoryInfo), parseRedisInfo(statsInfo), dbSize);
|
|
96
96
|
}
|
|
97
97
|
catch (error) {
|
|
98
|
-
if (!
|
|
99
|
-
|
|
98
|
+
if (!warned.connectionError) {
|
|
99
|
+
warned.connectionError = true;
|
|
100
100
|
const message = error instanceof Error ? error.message : String(error);
|
|
101
101
|
log.block(`Redis collector ${bold('error')} — failed to communicate with Redis`, [
|
|
102
102
|
dim(message),
|
|
@@ -1,14 +1,28 @@
|
|
|
1
1
|
import type { InspectorManager } from './inspector_manager.js';
|
|
2
2
|
import type { HttpContext } from '@adonisjs/core/http';
|
|
3
|
+
/**
|
|
4
|
+
* Access policy for cache operations, resolved by the controller from
|
|
5
|
+
* `advanced.cacheKeyPrefix` (or the SERVER_STATS_CACHE_KEY_PREFIX env var).
|
|
6
|
+
*
|
|
7
|
+
* The prefix restricts which Redis keys the dashboard may list, read, or
|
|
8
|
+
* delete so a dashboard viewer can't target arbitrary keys (sessions,
|
|
9
|
+
* rate-limit counters). Empty/unset stays unrestricted in dev for
|
|
10
|
+
* back-compat, but fails closed in production: single-key reads and deletes
|
|
11
|
+
* on a shared production Redis need a deliberate scope, not a default.
|
|
12
|
+
*/
|
|
13
|
+
export interface CacheAccessPolicy {
|
|
14
|
+
prefix: string;
|
|
15
|
+
inProduction: boolean;
|
|
16
|
+
}
|
|
3
17
|
/**
|
|
4
18
|
* Handle GET /cache-stats — list cache keys and overall stats.
|
|
5
19
|
*/
|
|
6
|
-
export declare function handleCacheStats(inspectors: InspectorManager, { request, response }: HttpContext): Promise<void>;
|
|
20
|
+
export declare function handleCacheStats(inspectors: InspectorManager, { request, response }: HttpContext, policy: CacheAccessPolicy): Promise<void>;
|
|
7
21
|
/**
|
|
8
22
|
* Handle GET /cache-stats/:key — get a single cache key's value and metadata.
|
|
9
23
|
*/
|
|
10
|
-
export declare function handleCacheKey(inspectors: InspectorManager, { params, response }: HttpContext): Promise<void>;
|
|
24
|
+
export declare function handleCacheKey(inspectors: InspectorManager, { params, response }: HttpContext, policy: CacheAccessPolicy): Promise<void>;
|
|
11
25
|
/**
|
|
12
26
|
* Handle DELETE /cache-stats/:key — delete a single cache key.
|
|
13
27
|
*/
|
|
14
|
-
export declare function handleCacheKeyDelete(inspectors: InspectorManager, { params, response }: HttpContext): Promise<void>;
|
|
28
|
+
export declare function handleCacheKeyDelete(inspectors: InspectorManager, { params, response }: HttpContext, policy: CacheAccessPolicy): Promise<void>;
|
|
@@ -1,27 +1,22 @@
|
|
|
1
1
|
import { clamp } from '../utils/math_helpers.js';
|
|
2
|
-
/**
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
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);
|
|
2
|
+
/** Check whether a cache key is permitted under the policy. */
|
|
3
|
+
function isKeyAllowed(key, policy) {
|
|
4
|
+
if (policy.prefix !== '')
|
|
5
|
+
return key.startsWith(policy.prefix);
|
|
6
|
+
return !policy.inProduction;
|
|
15
7
|
}
|
|
16
8
|
/**
|
|
17
9
|
* Handle GET /cache-stats — list cache keys and overall stats.
|
|
18
10
|
*/
|
|
19
|
-
export async function handleCacheStats(inspectors, { request, response }) {
|
|
11
|
+
export async function handleCacheStats(inspectors, { request, response }, policy) {
|
|
20
12
|
const inspector = await inspectors.getCacheInspector();
|
|
21
13
|
if (!inspector)
|
|
22
14
|
return response.json({ available: false, stats: null, keys: [] });
|
|
23
15
|
const qs = request.qs();
|
|
24
|
-
|
|
16
|
+
// A configured prefix scopes the listing glob the same way it scopes
|
|
17
|
+
// single-key access, so keys outside the allow-list are never enumerated.
|
|
18
|
+
const search = qs.search || qs.pattern ? `*${qs.search || qs.pattern}*` : '*';
|
|
19
|
+
const pattern = policy.prefix !== '' ? `${policy.prefix}${search}` : search;
|
|
25
20
|
try {
|
|
26
21
|
const [stats, keyList] = await Promise.all([
|
|
27
22
|
inspector.getStats(),
|
|
@@ -36,12 +31,12 @@ export async function handleCacheStats(inspectors, { request, response }) {
|
|
|
36
31
|
/**
|
|
37
32
|
* Handle GET /cache-stats/:key — get a single cache key's value and metadata.
|
|
38
33
|
*/
|
|
39
|
-
export async function handleCacheKey(inspectors, { params, response }) {
|
|
34
|
+
export async function handleCacheKey(inspectors, { params, response }, policy) {
|
|
40
35
|
const inspector = await inspectors.getCacheInspector();
|
|
41
36
|
if (!inspector)
|
|
42
37
|
return response.notFound({ error: 'Cache not available' });
|
|
43
38
|
const key = decodeURIComponent(params.key);
|
|
44
|
-
if (!isKeyAllowed(key))
|
|
39
|
+
if (!isKeyAllowed(key, policy))
|
|
45
40
|
return response.forbidden({ error: 'Cache key not permitted' });
|
|
46
41
|
try {
|
|
47
42
|
const detail = await inspector.getKey(key);
|
|
@@ -54,12 +49,12 @@ export async function handleCacheKey(inspectors, { params, response }) {
|
|
|
54
49
|
/**
|
|
55
50
|
* Handle DELETE /cache-stats/:key — delete a single cache key.
|
|
56
51
|
*/
|
|
57
|
-
export async function handleCacheKeyDelete(inspectors, { params, response }) {
|
|
52
|
+
export async function handleCacheKeyDelete(inspectors, { params, response }, policy) {
|
|
58
53
|
const inspector = await inspectors.getCacheInspector();
|
|
59
54
|
if (!inspector)
|
|
60
55
|
return response.notFound({ error: 'Cache not available' });
|
|
61
56
|
const key = decodeURIComponent(params.key);
|
|
62
|
-
if (!isKeyAllowed(key))
|
|
57
|
+
if (!isKeyAllowed(key, policy))
|
|
63
58
|
return response.forbidden({ error: 'Cache key not permitted' });
|
|
64
59
|
try {
|
|
65
60
|
return (await inspector.deleteKey(key))
|
|
@@ -1,11 +1,3 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* In-flight request coalescing with short-lived result caching.
|
|
3
|
-
*
|
|
4
|
-
* - coalesce(): prevents concurrent identical calls from executing
|
|
5
|
-
* in parallel. Only ONE executes; others get the same promise.
|
|
6
|
-
* - cached(): adds a TTL layer on top of coalesce so repeat
|
|
7
|
-
* requests within the window serve stale data instantly.
|
|
8
|
-
*/
|
|
9
1
|
export declare class CoalesceCache {
|
|
10
2
|
private inflight;
|
|
11
3
|
private resultCache;
|
|
@@ -20,6 +12,8 @@ export declare class CoalesceCache {
|
|
|
20
12
|
* fall through to coalesce().
|
|
21
13
|
*/
|
|
22
14
|
cached<T>(key: string, ttlMs: number, fn: () => Promise<T>): Promise<T>;
|
|
15
|
+
/** Drop expired entries on write; if still at the cap, drop oldest first. */
|
|
16
|
+
private evictForSpace;
|
|
23
17
|
/** Clear all cached results (does not affect in-flight requests). */
|
|
24
18
|
clearCache(): void;
|
|
25
19
|
}
|
|
@@ -6,6 +6,13 @@
|
|
|
6
6
|
* - cached(): adds a TTL layer on top of coalesce so repeat
|
|
7
7
|
* requests within the window serve stale data instantly.
|
|
8
8
|
*/
|
|
9
|
+
/**
|
|
10
|
+
* Hard cap on retained results. Keys embed user input (search terms, filters,
|
|
11
|
+
* pagination), so without a bound a dashboard viewer could grow the heap
|
|
12
|
+
* without limit by walking distinct queries. TTLs are 1-10s, so anything past
|
|
13
|
+
* a few hundred entries is expired garbage anyway.
|
|
14
|
+
*/
|
|
15
|
+
const MAX_RESULT_ENTRIES = 500;
|
|
9
16
|
export class CoalesceCache {
|
|
10
17
|
inflight = new Map();
|
|
11
18
|
resultCache = new Map();
|
|
@@ -33,6 +40,7 @@ export class CoalesceCache {
|
|
|
33
40
|
}
|
|
34
41
|
return this.coalesce(key, async () => {
|
|
35
42
|
const result = await fn();
|
|
43
|
+
this.evictForSpace();
|
|
36
44
|
this.resultCache.set(key, {
|
|
37
45
|
data: result,
|
|
38
46
|
expiresAt: Date.now() + ttlMs,
|
|
@@ -40,6 +48,23 @@ export class CoalesceCache {
|
|
|
40
48
|
return result;
|
|
41
49
|
});
|
|
42
50
|
}
|
|
51
|
+
/** Drop expired entries on write; if still at the cap, drop oldest first. */
|
|
52
|
+
evictForSpace() {
|
|
53
|
+
if (this.resultCache.size < MAX_RESULT_ENTRIES)
|
|
54
|
+
return;
|
|
55
|
+
const now = Date.now();
|
|
56
|
+
for (const [key, entry] of this.resultCache) {
|
|
57
|
+
if (entry.expiresAt <= now)
|
|
58
|
+
this.resultCache.delete(key);
|
|
59
|
+
}
|
|
60
|
+
// Map iteration is insertion-ordered, so the front is the oldest.
|
|
61
|
+
while (this.resultCache.size >= MAX_RESULT_ENTRIES) {
|
|
62
|
+
const oldest = this.resultCache.keys().next().value;
|
|
63
|
+
if (oldest === undefined)
|
|
64
|
+
break;
|
|
65
|
+
this.resultCache.delete(oldest);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
43
68
|
/** Clear all cached results (does not affect in-flight requests). */
|
|
44
69
|
clearCache() {
|
|
45
70
|
this.resultCache.clear();
|
|
@@ -15,6 +15,7 @@ export default class DashboardController {
|
|
|
15
15
|
requestDetail({ params, response }: HttpContext): Promise<void>;
|
|
16
16
|
queriesGrouped({ request, response }: HttpContext): Promise<void>;
|
|
17
17
|
queryExplain(ctx: HttpContext): Promise<unknown>;
|
|
18
|
+
private cachePolicy;
|
|
18
19
|
cacheStats(ctx: HttpContext): Promise<void>;
|
|
19
20
|
cacheKey(ctx: HttpContext): Promise<void>;
|
|
20
21
|
cacheKeyDelete(ctx: HttpContext): Promise<void>;
|
|
@@ -116,14 +116,19 @@ export default class DashboardController {
|
|
|
116
116
|
async queryExplain(ctx) {
|
|
117
117
|
return handleQueryExplain(this.dashboardStore, this.app, ctx);
|
|
118
118
|
}
|
|
119
|
+
cachePolicy() {
|
|
120
|
+
const config = this.app.config.get('server_stats');
|
|
121
|
+
const prefix = config?.advanced?.cacheKeyPrefix ?? process.env.SERVER_STATS_CACHE_KEY_PREFIX ?? '';
|
|
122
|
+
return { prefix: prefix.trim(), inProduction: this.app.inProduction };
|
|
123
|
+
}
|
|
119
124
|
async cacheStats(ctx) {
|
|
120
|
-
return handleCacheStats(this.inspectors, ctx);
|
|
125
|
+
return handleCacheStats(this.inspectors, ctx, this.cachePolicy());
|
|
121
126
|
}
|
|
122
127
|
async cacheKey(ctx) {
|
|
123
|
-
return handleCacheKey(this.inspectors, ctx);
|
|
128
|
+
return handleCacheKey(this.inspectors, ctx, this.cachePolicy());
|
|
124
129
|
}
|
|
125
130
|
async cacheKeyDelete(ctx) {
|
|
126
|
-
return handleCacheKeyDelete(this.inspectors, ctx);
|
|
131
|
+
return handleCacheKeyDelete(this.inspectors, ctx, this.cachePolicy());
|
|
127
132
|
}
|
|
128
133
|
async jobs(ctx) {
|
|
129
134
|
return handleJobs(this.inspectors, ctx);
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { DevToolbarConfig,
|
|
1
|
+
import type { DevToolbarConfig, EmailRecord } from '../debug/types.js';
|
|
2
2
|
import type { StorageStatsResult } from './storage_stats.js';
|
|
3
3
|
import type { Knex } from 'knex';
|
|
4
4
|
export type { RequestInput, PersistRequestInput, RequestFilters, QueryFilters, EventFilters, EmailFilters, LogFilters, TraceFilters, PaginatedResult, PaginateOptions, } from './dashboard_types.js';
|
|
@@ -26,7 +26,6 @@ export declare class DashboardStore {
|
|
|
26
26
|
isReady(): boolean;
|
|
27
27
|
getStorageStats(): Promise<StorageStatsResult>;
|
|
28
28
|
persistRequest(input: PersistRequestInput): Promise<number | null>;
|
|
29
|
-
queueEvents(requestIndex: number, events: EventRecord[]): void;
|
|
30
29
|
recordLog(entry: Record<string, unknown>): void;
|
|
31
30
|
recordEmail(record: EmailRecord): void;
|
|
32
31
|
flushWriteQueue(): Promise<void>;
|
|
@@ -75,6 +74,5 @@ export declare class DashboardStore {
|
|
|
75
74
|
getSavedFilters(section?: string): Promise<Record<string, unknown>[]>;
|
|
76
75
|
createSavedFilter(name: string, section: string, filterConfig: Record<string, unknown>): Promise<Record<string, unknown> | null>;
|
|
77
76
|
deleteSavedFilter(id: number): Promise<boolean>;
|
|
78
|
-
runExplain(queryId: number, appDb: unknown): Promise<Record<string, unknown> | null>;
|
|
79
77
|
private wireEventListeners;
|
|
80
78
|
}
|
|
@@ -4,7 +4,6 @@ import { log } from '../utils/logger.js';
|
|
|
4
4
|
import { ChartAggregator } from './chart_aggregator.js';
|
|
5
5
|
import { CoalesceCache } from './coalesce_cache.js';
|
|
6
6
|
import { buildEmailRecordFromEvent } from './email_event_builder.js';
|
|
7
|
-
import { executeExplain } from './explain_query.js';
|
|
8
7
|
import { FlushManager } from './flush_manager.js';
|
|
9
8
|
import { createKnexConnection, applyPragmas } from './knex_factory.js';
|
|
10
9
|
import { autoMigrate, runRetentionCleanup } from './migrator.js';
|
|
@@ -12,6 +11,7 @@ import { fetchOverviewMetrics, fetchChartData, fetchOverviewWidgets, fetchSparkl
|
|
|
12
11
|
import { queryRequests, queryQueries, queryQueriesGrouped, queryEvents, queryEmails, queryEmailHtml, queryLogs, queryTraces, queryTraceDetail, queryRequestDetail, } from './read_queries.js';
|
|
13
12
|
import { fetchSavedFilters, insertSavedFilter, removeSavedFilter } from './saved_filter_queries.js';
|
|
14
13
|
import { fetchStorageStats } from './storage_stats.js';
|
|
14
|
+
import { resetWriteWarnings } from './write_queue.js';
|
|
15
15
|
const EMPTY_PAGINATED = (p, pp) => ({
|
|
16
16
|
data: [],
|
|
17
17
|
total: 0,
|
|
@@ -77,6 +77,7 @@ export class DashboardStore {
|
|
|
77
77
|
}
|
|
78
78
|
async stop() {
|
|
79
79
|
await this.flushMgr.stop();
|
|
80
|
+
resetWriteWarnings();
|
|
80
81
|
if (this.retentionTimer) {
|
|
81
82
|
clearInterval(this.retentionTimer);
|
|
82
83
|
this.retentionTimer = null;
|
|
@@ -139,13 +140,14 @@ export class DashboardStore {
|
|
|
139
140
|
this.flushMgr.persistRequest(input, this.dashboardPath);
|
|
140
141
|
return Promise.resolve(null);
|
|
141
142
|
}
|
|
142
|
-
queueEvents(requestIndex, events) {
|
|
143
|
-
this.flushMgr.queueEvents(requestIndex, events);
|
|
144
|
-
}
|
|
145
143
|
recordLog(entry) {
|
|
146
144
|
this.flushMgr.recordLog(entry);
|
|
147
145
|
}
|
|
148
146
|
recordEmail(record) {
|
|
147
|
+
// Guarded here as well as in wireEventListeners: the Redis email bridge
|
|
148
|
+
// calls this directly for cross-process mail, bypassing the listeners.
|
|
149
|
+
if (this.config.capture?.emails === false)
|
|
150
|
+
return;
|
|
149
151
|
this.flushMgr.recordEmail(record);
|
|
150
152
|
}
|
|
151
153
|
async flushWriteQueue() {
|
|
@@ -207,12 +209,15 @@ export class DashboardStore {
|
|
|
207
209
|
async deleteSavedFilter(id) {
|
|
208
210
|
return this.db ? removeSavedFilter(this.db, id) : false;
|
|
209
211
|
}
|
|
210
|
-
async runExplain(queryId, appDb) {
|
|
211
|
-
if (!this.db)
|
|
212
|
-
return { error: 'Dashboard store not initialized' };
|
|
213
|
-
return executeExplain(this.db, this.cache, queryId, appDb);
|
|
214
|
-
}
|
|
215
212
|
wireEventListeners() {
|
|
213
|
+
// `capture.emails: false` must stop persistence too, not just the in-memory
|
|
214
|
+
// collector — otherwise production mode stores full mail bodies while the
|
|
215
|
+
// config promises it doesn't. Undefined means an externally-constructed
|
|
216
|
+
// store without resolved capture flags; default on, matching DebugStore.
|
|
217
|
+
if (this.config.capture?.emails === false) {
|
|
218
|
+
log.info('dashboard: email capture disabled — mail listeners not wired');
|
|
219
|
+
return;
|
|
220
|
+
}
|
|
216
221
|
if (!this.emitter || typeof this.emitter.on !== 'function') {
|
|
217
222
|
log.warn('dashboard: emitter not available — email collection disabled');
|
|
218
223
|
return;
|
|
@@ -16,6 +16,8 @@ export interface RequestInput {
|
|
|
16
16
|
}
|
|
17
17
|
export interface PersistRequestInput extends RequestInput {
|
|
18
18
|
queries: import('../debug/types.js').QueryRecord[];
|
|
19
|
+
/** Events emitted since the previous request completed. */
|
|
20
|
+
events?: import('../debug/types.js').EventRecord[];
|
|
19
21
|
trace: import('../debug/types.js').TraceRecord | null;
|
|
20
22
|
httpRequestId?: string | null;
|
|
21
23
|
}
|
|
@@ -14,6 +14,29 @@ export async function handleSavedFilters(dashboardStore) {
|
|
|
14
14
|
})),
|
|
15
15
|
};
|
|
16
16
|
}
|
|
17
|
+
/**
|
|
18
|
+
* Validate saved-filter input. Returns an error message, or null when valid.
|
|
19
|
+
*
|
|
20
|
+
* Size bounds matter because these land in SQLite verbatim and the list
|
|
21
|
+
* endpoint returns every row — one oversized filter would bloat every
|
|
22
|
+
* response.
|
|
23
|
+
*/
|
|
24
|
+
function validateSavedFilterInput(name, section, filterConfig) {
|
|
25
|
+
if (!name || !section || !filterConfig) {
|
|
26
|
+
return 'Missing required fields: name, section, filterConfig';
|
|
27
|
+
}
|
|
28
|
+
if (typeof name !== 'string' || name.length > 100) {
|
|
29
|
+
return 'name must be a string of at most 100 chars';
|
|
30
|
+
}
|
|
31
|
+
if (typeof section !== 'string' || section.length > 50) {
|
|
32
|
+
return 'section must be a string of at most 50 chars';
|
|
33
|
+
}
|
|
34
|
+
const configJson = typeof filterConfig === 'string' ? filterConfig : JSON.stringify(filterConfig);
|
|
35
|
+
if (configJson.length > 4096) {
|
|
36
|
+
return 'filterConfig too large (max 4096 chars)';
|
|
37
|
+
}
|
|
38
|
+
return null;
|
|
39
|
+
}
|
|
17
40
|
/**
|
|
18
41
|
* Handle POST /saved-filters — create a new saved filter.
|
|
19
42
|
* Returns the response payload or null if creation fails.
|
|
@@ -24,10 +47,9 @@ export async function handleCreateSavedFilter(dashboardStore, { request, respons
|
|
|
24
47
|
}
|
|
25
48
|
try {
|
|
26
49
|
const { name, section, filterConfig } = request.body();
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
});
|
|
50
|
+
const validationError = validateSavedFilterInput(name, section, filterConfig);
|
|
51
|
+
if (validationError) {
|
|
52
|
+
return response.badRequest({ error: validationError });
|
|
31
53
|
}
|
|
32
54
|
const result = await dashboardStore.createSavedFilter(name, section, typeof filterConfig === 'string' ? safeParseJson(filterConfig) : filterConfig);
|
|
33
55
|
return result
|
|
@@ -1,21 +1,17 @@
|
|
|
1
|
-
import type {
|
|
1
|
+
import type { EmailRecord } from '../debug/types.js';
|
|
2
2
|
import type { PersistRequestInput } from './dashboard_types.js';
|
|
3
3
|
import type { Knex } from 'knex';
|
|
4
4
|
export declare class FlushManager {
|
|
5
5
|
writeQueue: PersistRequestInput[];
|
|
6
|
-
pendingEvents: {
|
|
7
|
-
requestIndex: number;
|
|
8
|
-
events: EventRecord[];
|
|
9
|
-
}[];
|
|
10
6
|
pendingLogs: Record<string, unknown>[];
|
|
11
7
|
pendingEmails: EmailRecord[];
|
|
12
8
|
private flushTimer;
|
|
13
9
|
private flushing;
|
|
10
|
+
private stopped;
|
|
14
11
|
private inFlight;
|
|
15
12
|
private db;
|
|
16
13
|
constructor(getDb: () => Knex | null);
|
|
17
14
|
persistRequest(input: PersistRequestInput, dashboardPath: string): void;
|
|
18
|
-
queueEvents(requestIndex: number, events: EventRecord[]): void;
|
|
19
15
|
recordLog(entry: Record<string, unknown>): void;
|
|
20
16
|
recordEmail(record: EmailRecord): void;
|
|
21
17
|
stop(): Promise<void>;
|
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
import { log } from '../utils/logger.js';
|
|
2
|
-
import { prepareRequestRows, prepareLogRows, flushRequests,
|
|
2
|
+
import { prepareRequestRows, prepareLogRows, flushRequests, flushEmails, flushLogs, hasWarned, markWarned, } from './write_queue.js';
|
|
3
3
|
const FLUSH_MS = 500;
|
|
4
4
|
const MAX_Q = 200;
|
|
5
5
|
export class FlushManager {
|
|
6
6
|
writeQueue = [];
|
|
7
|
-
pendingEvents = [];
|
|
8
7
|
pendingLogs = [];
|
|
9
8
|
pendingEmails = [];
|
|
10
9
|
flushTimer = null;
|
|
11
10
|
flushing = false;
|
|
11
|
+
stopped = false;
|
|
12
12
|
inFlight = null;
|
|
13
13
|
db;
|
|
14
14
|
constructor(getDb) {
|
|
@@ -21,10 +21,6 @@ export class FlushManager {
|
|
|
21
21
|
this.writeQueue.push(input);
|
|
22
22
|
this.scheduleFlush();
|
|
23
23
|
}
|
|
24
|
-
queueEvents(requestIndex, events) {
|
|
25
|
-
if (events.length > 0)
|
|
26
|
-
this.pendingEvents.push({ requestIndex, events });
|
|
27
|
-
}
|
|
28
24
|
recordLog(entry) {
|
|
29
25
|
if (!this.db())
|
|
30
26
|
return;
|
|
@@ -40,6 +36,10 @@ export class FlushManager {
|
|
|
40
36
|
this.scheduleFlush();
|
|
41
37
|
}
|
|
42
38
|
async stop() {
|
|
39
|
+
// Block rescheduling first: an in-flight runFlush ends with a
|
|
40
|
+
// scheduleFlush() that would otherwise create a fresh, untracked timer
|
|
41
|
+
// after the clearTimeout below — outliving this "clean" shutdown.
|
|
42
|
+
this.stopped = true;
|
|
43
43
|
if (this.flushTimer) {
|
|
44
44
|
clearTimeout(this.flushTimer);
|
|
45
45
|
this.flushTimer = null;
|
|
@@ -55,7 +55,7 @@ export class FlushManager {
|
|
|
55
55
|
q.splice(0, Math.floor(MAX_Q / 4));
|
|
56
56
|
}
|
|
57
57
|
scheduleFlush() {
|
|
58
|
-
if (this.flushTimer)
|
|
58
|
+
if (this.stopped || this.flushTimer)
|
|
59
59
|
return;
|
|
60
60
|
this.flushTimer = setTimeout(() => {
|
|
61
61
|
this.flushTimer = null;
|
|
@@ -94,7 +94,6 @@ export class FlushManager {
|
|
|
94
94
|
const pl = prepareLogRows(snap.logs);
|
|
95
95
|
await db.transaction(async (trx) => {
|
|
96
96
|
await flushRequests(trx, pr);
|
|
97
|
-
await flushEvents(trx, snap.events);
|
|
98
97
|
await flushEmails(trx, snap.emails);
|
|
99
98
|
await flushLogs(trx, pl);
|
|
100
99
|
});
|
|
@@ -115,10 +114,9 @@ export class FlushManager {
|
|
|
115
114
|
takeSnapshot() {
|
|
116
115
|
const requests = this.writeQueue.splice(0);
|
|
117
116
|
const logs = this.pendingLogs.splice(0);
|
|
118
|
-
const events = this.pendingEvents.splice(0);
|
|
119
117
|
const emails = this.pendingEmails.splice(0);
|
|
120
|
-
if (requests.length === 0 && logs.length === 0 &&
|
|
118
|
+
if (requests.length === 0 && logs.length === 0 && emails.length === 0)
|
|
121
119
|
return null;
|
|
122
|
-
return { requests, logs,
|
|
120
|
+
return { requests, logs, emails };
|
|
123
121
|
}
|
|
124
122
|
}
|
|
@@ -120,7 +120,3 @@ export declare function formatGroupedQuery(g: Record<string, unknown>, totalTime
|
|
|
120
120
|
totalDuration: number;
|
|
121
121
|
percentOfTotal: number;
|
|
122
122
|
};
|
|
123
|
-
/** Run EXPLAIN on a query, returning the plan. */
|
|
124
|
-
export declare function runExplain(appDb: {
|
|
125
|
-
raw: (sql: string, bindings: unknown[]) => Promise<Record<string, unknown>>;
|
|
126
|
-
}, query: Record<string, unknown>): Promise<unknown[]>;
|
|
@@ -119,22 +119,3 @@ export function formatGroupedQuery(g, totalTime) {
|
|
|
119
119
|
percentOfTotal: totalTime > 0 ? round((g.total_duration / totalTime) * 100) : 0,
|
|
120
120
|
};
|
|
121
121
|
}
|
|
122
|
-
/** Run EXPLAIN on a query, returning the plan. */
|
|
123
|
-
export async function runExplain(appDb, query) {
|
|
124
|
-
let bindings = [];
|
|
125
|
-
if (query.bindings) {
|
|
126
|
-
try {
|
|
127
|
-
bindings = JSON.parse(query.bindings);
|
|
128
|
-
}
|
|
129
|
-
catch {
|
|
130
|
-
/* skip */
|
|
131
|
-
}
|
|
132
|
-
}
|
|
133
|
-
const explainResult = await appDb.raw(`EXPLAIN (FORMAT JSON) ${query.sql_text}`, bindings);
|
|
134
|
-
const rawRows = explainResult?.rows ??
|
|
135
|
-
(Array.isArray(explainResult) ? explainResult : []);
|
|
136
|
-
if (rawRows.length > 0 && rawRows[0]['QUERY PLAN']) {
|
|
137
|
-
return rawRows[0]['QUERY PLAN'];
|
|
138
|
-
}
|
|
139
|
-
return rawRows;
|
|
140
|
-
}
|