adonisjs-server-stats 1.17.0 → 1.18.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 +11 -6
- package/dist/core/core/define-config-helpers.d.ts +1 -0
- package/dist/core/core/types-diagnostics.d.ts +2 -0
- package/dist/core/debug/types.d.ts +2 -0
- package/dist/core/types.d.ts +31 -3
- package/dist/react/{CacheSection-C-X2Tnca.js → CacheSection-B-lSIN8Q.js} +1 -1
- package/dist/react/{CacheTab-D55mfmeE.js → CacheTab-BN0QgPXz.js} +1 -1
- package/dist/react/{ConfigSection-C-HGnZfd.js → ConfigSection-u_TSfb9f.js} +1 -1
- package/dist/react/{ConfigTab-CNnsOXBh.js → ConfigTab-au26s-ON.js} +1 -1
- package/dist/react/{CustomPaneTab-D-267UVd.js → CustomPaneTab-qvMnh8ss.js} +1 -1
- package/dist/react/{EmailsSection-Bqw4DI4r.js → EmailsSection-HfEMeRex.js} +1 -1
- package/dist/react/{EmailsTab-CkHOhj2F.js → EmailsTab-BWwq-CxB.js} +1 -1
- package/dist/react/{EventsSection-CMeyjzVC.js → EventsSection-CA9b15dJ.js} +1 -1
- package/dist/react/{EventsTab-DgS2v1HX.js → EventsTab-CgSdHbKU.js} +1 -1
- package/dist/react/{InternalsContent-C9lIA92C.js → InternalsContent-CI7HvBtH.js} +51 -47
- package/dist/react/{InternalsSection-lOv_UcSV.js → InternalsSection-IEeWXJqP.js} +1 -1
- package/dist/react/{InternalsTab-BygoSorC.js → InternalsTab-CoVLHQbF.js} +1 -1
- package/dist/react/{JobsSection-oWH6pbrG.js → JobsSection-DgsS_VwU.js} +1 -1
- package/dist/react/{JobsTab-zdxX_as-.js → JobsTab-CzwLSRUC.js} +1 -1
- package/dist/react/{LogEntryRow-Cn8Ee_X0.js → LogEntryRow-VCCzwD1H.js} +1 -1
- package/dist/react/{LogsSection-XmiI2MMs.js → LogsSection-BujNn_cO.js} +2 -2
- package/dist/react/{LogsTab-ixusY4v7.js → LogsTab-BtD1h7DM.js} +2 -2
- package/dist/react/{OverviewSection-CvhObTLP.js → OverviewSection-B28em0gQ.js} +1 -1
- package/dist/react/{QueriesSection-BvledwBI.js → QueriesSection-B0tFn_Dc.js} +1 -1
- package/dist/react/{QueriesTab-smQQJvJa.js → QueriesTab-DJY7aavR.js} +1 -1
- package/dist/react/{RequestsSection-DKuYpViv.js → RequestsSection-DPmYM7tq.js} +2 -2
- package/dist/react/{RoutesSection-5RNVurQ6.js → RoutesSection-Ck-RcFVS.js} +1 -1
- package/dist/react/{RoutesTab-CpXNO-Rx.js → RoutesTab-Ch3PDLzo.js} +1 -1
- package/dist/react/{SplitPaneWrapper-DQvJnsuk.js → SplitPaneWrapper-BZ6VrBr-.js} +1 -1
- package/dist/react/{TimelineTab-LHQHHKkO.js → TimelineTab-_byxh2mJ.js} +2 -2
- package/dist/react/{index-LlFvooUN.js → index-De7nsWBN.js} +2 -2
- package/dist/react/index.js +1 -1
- package/dist/src/dashboard/dashboard_store.js +4 -12
- package/dist/src/dashboard/knex_factory.js +8 -0
- package/dist/src/dashboard/migrator.d.ts +7 -2
- package/dist/src/dashboard/migrator.js +140 -6
- package/dist/src/dashboard/migrator_tables.js +4 -0
- package/dist/src/dashboard/storage_stats.d.ts +5 -0
- package/dist/src/dashboard/storage_stats.js +15 -1
- package/dist/src/debug/types.d.ts +2 -0
- package/dist/src/define_config.js +2 -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-vue/dashboard.js +3 -3
- package/dist/src/edge/client-vue/debug-panel-deferred.js +2 -2
- package/dist/src/provider/dashboard_setup.js +14 -0
- package/dist/src/provider/diagnostics.d.ts +2 -0
- package/dist/src/provider/diagnostics.js +1 -0
- package/dist/src/provider/server_stats_provider.d.ts +1 -0
- package/dist/src/stubs/config.stub +4 -0
- package/dist/src/types.d.ts +31 -3
- package/dist/vue/{CacheSection-Bm0goqX6.js → CacheSection-z2TQRez9.js} +1 -1
- package/dist/vue/{ConfigSection-BrYslIMN.js → ConfigSection-CFEaig69.js} +1 -1
- package/dist/vue/{EmailsSection-B8N5vaWA.js → EmailsSection-CYohBo62.js} +1 -1
- package/dist/vue/{EventsSection-D2Y_xt0m.js → EventsSection-B_TuVrGj.js} +1 -1
- package/dist/vue/{InternalsSection-jhTCuK3j.js → InternalsSection-B1P2kNT-.js} +92 -84
- package/dist/vue/{InternalsTab-Z3c82glB.js → InternalsTab-CRI-IFTo.js} +155 -147
- package/dist/vue/{JobsSection-cEKqELLC.js → JobsSection-AjkEzyiv.js} +1 -1
- package/dist/vue/{LogsSection-C3H2c0S3.js → LogsSection-DZD0CCDi.js} +1 -1
- package/dist/vue/{OverviewSection-I2swuHDa.js → OverviewSection-BEc4Gktf.js} +1 -1
- package/dist/vue/{QueriesSection-CF1IE3SN.js → QueriesSection-BMImGXTn.js} +1 -1
- package/dist/vue/{RequestsSection-CuwgXzmU.js → RequestsSection-DOI2MUFX.js} +1 -1
- package/dist/vue/{RoutesSection-BhJCF7Rn.js → RoutesSection-C1sxt-AW.js} +1 -1
- package/dist/vue/{index-DUsImuIN.js → index-BfPAAjL5.js} +2 -2
- package/dist/vue/index.js +1 -1
- package/package.json +1 -1
|
@@ -2,11 +2,11 @@ import { jsx as e, jsxs as t } from "react/jsx-runtime";
|
|
|
2
2
|
import { useState as i, useMemo as q, useEffect as I, useCallback as O } from "react";
|
|
3
3
|
import { formatDuration as m, durationClassName as j } from "adonisjs-server-stats/core";
|
|
4
4
|
import { u as U } from "./useApiClient-CtEG7rHs.js";
|
|
5
|
-
import { u as W, M as D, S as L } from "./index-
|
|
5
|
+
import { u as W, M as D, S as L } from "./index-De7nsWBN.js";
|
|
6
6
|
import { T as _ } from "./TimeAgoCell-o3KigGfM.js";
|
|
7
7
|
import { u as z } from "./useResizableTable-CNJmACdt.js";
|
|
8
8
|
import { F as G } from "./FilterBar-CQ7bD669.js";
|
|
9
|
-
import { S as K, R as V } from "./SplitPaneWrapper-
|
|
9
|
+
import { S as K, R as V } from "./SplitPaneWrapper-BZ6VrBr-.js";
|
|
10
10
|
const S = {
|
|
11
11
|
request: "#1e3a5f",
|
|
12
12
|
middleware: "rgba(30, 58, 95, 0.7)",
|
|
@@ -437,7 +437,7 @@ function K({
|
|
|
437
437
|
}
|
|
438
438
|
);
|
|
439
439
|
}
|
|
440
|
-
const we = T(() => import("./QueriesTab-
|
|
440
|
+
const we = T(() => import("./QueriesTab-DJY7aavR.js")), ke = T(() => import("./EventsTab-CgSdHbKU.js")), ye = T(() => import("./EmailsTab-BWwq-CxB.js")), Ne = T(() => import("./RoutesTab-Ch3PDLzo.js")), Ce = T(() => import("./LogsTab-BtD1h7DM.js")), Se = T(() => import("./TimelineTab-_byxh2mJ.js")), Le = T(() => import("./CacheTab-BN0QgPXz.js")), Te = T(() => import("./JobsTab-CzwLSRUC.js")), Ee = T(() => import("./ConfigTab-au26s-ON.js")), $e = T(() => import("./InternalsTab-CoVLHQbF.js")), xe = T(() => import("./CustomPaneTab-qvMnh8ss.js"));
|
|
441
441
|
function ss(e) {
|
|
442
442
|
const {
|
|
443
443
|
defaultOpen: r = !1,
|
|
@@ -756,7 +756,7 @@ const je = X(void 0), Pe = [
|
|
|
756
756
|
"jobs",
|
|
757
757
|
"config",
|
|
758
758
|
"internals"
|
|
759
|
-
], He = T(() => import("./OverviewSection-
|
|
759
|
+
], He = T(() => import("./OverviewSection-B28em0gQ.js")), Ue = T(() => import("./RequestsSection-DPmYM7tq.js")), qe = T(() => import("./QueriesSection-B0tFn_Dc.js")), Fe = T(() => import("./EventsSection-CA9b15dJ.js")), ze = T(() => import("./RoutesSection-Ck-RcFVS.js")), Ae = T(() => import("./LogsSection-BujNn_cO.js")), We = T(() => import("./EmailsSection-HfEMeRex.js")), Ke = T(() => import("./CacheSection-B-lSIN8Q.js")), Ve = T(() => import("./JobsSection-DgsS_VwU.js")), Je = T(() => import("./ConfigSection-u_TSfb9f.js")), Ge = T(() => import("./InternalsSection-IEeWXJqP.js"));
|
|
760
760
|
function ts(e) {
|
|
761
761
|
const {
|
|
762
762
|
baseUrl: r = "",
|
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-De7nsWBN.js";
|
|
2
2
|
export {
|
|
3
3
|
s as Badge,
|
|
4
4
|
t as DashboardPage,
|
|
@@ -10,7 +10,7 @@ import { autoMigrate, runRetentionCleanup } from './migrator.js';
|
|
|
10
10
|
import { fetchOverviewMetrics, fetchChartData, fetchOverviewWidgets, fetchSparklineData, } from './overview_store_queries.js';
|
|
11
11
|
import { queryRequests, queryQueries, queryQueriesGrouped, queryEvents, queryEmails, queryEmailHtml, queryLogs, queryTraces, queryTraceDetail, queryRequestDetail, } from './read_queries.js';
|
|
12
12
|
import { fetchSavedFilters, insertSavedFilter, removeSavedFilter } from './saved_filter_queries.js';
|
|
13
|
-
import { fetchStorageStats } from './storage_stats.js';
|
|
13
|
+
import { fetchStorageStats, emptyStorageStats } from './storage_stats.js';
|
|
14
14
|
import { resetWriteWarnings } from './write_queue.js';
|
|
15
15
|
const EMPTY_PAGINATED = (p, pp) => ({
|
|
16
16
|
data: [],
|
|
@@ -63,7 +63,7 @@ export class DashboardStore {
|
|
|
63
63
|
const cleanup = async () => {
|
|
64
64
|
try {
|
|
65
65
|
if (this.db) {
|
|
66
|
-
await runRetentionCleanup(this.db, this.config.retentionDays);
|
|
66
|
+
await runRetentionCleanup(this.db, this.config.retentionDays, this.config.maxDbSizeMb);
|
|
67
67
|
this.lastCleanupAt = Date.now();
|
|
68
68
|
log.info('dashboard: retention cleanup complete');
|
|
69
69
|
}
|
|
@@ -110,17 +110,8 @@ export class DashboardStore {
|
|
|
110
110
|
return this.db !== null;
|
|
111
111
|
}
|
|
112
112
|
async getStorageStats() {
|
|
113
|
-
const empty = {
|
|
114
|
-
ready: false,
|
|
115
|
-
dbPath: this.config.dbPath,
|
|
116
|
-
fileSizeMb: 0,
|
|
117
|
-
walSizeMb: 0,
|
|
118
|
-
retentionDays: this.config.retentionDays,
|
|
119
|
-
tables: [],
|
|
120
|
-
lastCleanupAt: null,
|
|
121
|
-
};
|
|
122
113
|
if (!this.db)
|
|
123
|
-
return
|
|
114
|
+
return emptyStorageStats(this.config.dbPath, this.config.retentionDays, this.config.maxDbSizeMb);
|
|
124
115
|
if (this.cachedStorageStats &&
|
|
125
116
|
Date.now() - this.cachedStorageStats.cachedAt < DashboardStore.STORAGE_TTL)
|
|
126
117
|
return this.cachedStorageStats.data;
|
|
@@ -130,6 +121,7 @@ export class DashboardStore {
|
|
|
130
121
|
dbFilePath: this.dbFilePath,
|
|
131
122
|
dbPath: this.config.dbPath,
|
|
132
123
|
retentionDays: this.config.retentionDays,
|
|
124
|
+
maxDbSizeMb: this.config.maxDbSizeMb,
|
|
133
125
|
lastCleanupAt: this.lastCleanupAt,
|
|
134
126
|
onResult: (stats) => {
|
|
135
127
|
this.cachedStorageStats = { data: stats, cachedAt: Date.now() };
|
|
@@ -8,7 +8,14 @@ import { log } from '../utils/logger.js';
|
|
|
8
8
|
// ---------------------------------------------------------------------------
|
|
9
9
|
// PRAGMA constants
|
|
10
10
|
// ---------------------------------------------------------------------------
|
|
11
|
+
// auto_vacuum must come FIRST: it only takes effect on a database whose
|
|
12
|
+
// header hasn't been written yet, and even `journal_mode=WAL` initializes
|
|
13
|
+
// the header. On existing databases it only takes effect after a VACUUM
|
|
14
|
+
// (which runRetentionCleanup performs, guarded, when the file is mostly
|
|
15
|
+
// dead pages). Incremental mode lets retention cleanup return deleted
|
|
16
|
+
// pages to the OS in small chunks instead of growing the file forever.
|
|
11
17
|
export const SQLITE_PRAGMAS = [
|
|
18
|
+
'auto_vacuum=INCREMENTAL',
|
|
12
19
|
'journal_mode=WAL',
|
|
13
20
|
'foreign_keys=ON',
|
|
14
21
|
'synchronous=NORMAL',
|
|
@@ -17,6 +24,7 @@ export const SQLITE_PRAGMAS = [
|
|
|
17
24
|
'temp_store=MEMORY',
|
|
18
25
|
];
|
|
19
26
|
const PRAGMA_STATEMENTS_FOR_POOL = [
|
|
27
|
+
'auto_vacuum = INCREMENTAL',
|
|
20
28
|
'journal_mode = WAL',
|
|
21
29
|
'foreign_keys = ON',
|
|
22
30
|
'synchronous = NORMAL',
|
|
@@ -11,12 +11,17 @@ import type { Knex } from 'knex';
|
|
|
11
11
|
*/
|
|
12
12
|
export declare function autoMigrate(db: Knex): Promise<void>;
|
|
13
13
|
/**
|
|
14
|
-
* Delete records older than `retentionDays` from all tables
|
|
14
|
+
* Delete records older than `retentionDays` from all tables, then enforce
|
|
15
|
+
* the database size cap.
|
|
15
16
|
*
|
|
16
17
|
* Foreign-key cascades on `server_stats_requests` handle the child
|
|
17
18
|
* tables (queries, events, traces). Standalone tables (logs, emails,
|
|
18
19
|
* metrics, saved_filters) are pruned individually.
|
|
19
20
|
*
|
|
21
|
+
* `maxDbSizeMb` bounds the *live* data size: when exceeded, the globally
|
|
22
|
+
* oldest rows are deleted — regardless of age — until usage drops below
|
|
23
|
+
* ~90% of the cap. `0` disables the cap.
|
|
24
|
+
*
|
|
20
25
|
* Yields between each DELETE so the event loop stays responsive.
|
|
21
26
|
*/
|
|
22
|
-
export declare function runRetentionCleanup(db: Knex, retentionDays: number): Promise<void>;
|
|
27
|
+
export declare function runRetentionCleanup(db: Knex, retentionDays: number, maxDbSizeMb?: number): Promise<void>;
|
|
@@ -27,15 +27,20 @@ export async function autoMigrate(db) {
|
|
|
27
27
|
await migrateSavedFilters(db);
|
|
28
28
|
}
|
|
29
29
|
/**
|
|
30
|
-
* Delete records older than `retentionDays` from all tables
|
|
30
|
+
* Delete records older than `retentionDays` from all tables, then enforce
|
|
31
|
+
* the database size cap.
|
|
31
32
|
*
|
|
32
33
|
* Foreign-key cascades on `server_stats_requests` handle the child
|
|
33
34
|
* tables (queries, events, traces). Standalone tables (logs, emails,
|
|
34
35
|
* metrics, saved_filters) are pruned individually.
|
|
35
36
|
*
|
|
37
|
+
* `maxDbSizeMb` bounds the *live* data size: when exceeded, the globally
|
|
38
|
+
* oldest rows are deleted — regardless of age — until usage drops below
|
|
39
|
+
* ~90% of the cap. `0` disables the cap.
|
|
40
|
+
*
|
|
36
41
|
* Yields between each DELETE so the event loop stays responsive.
|
|
37
42
|
*/
|
|
38
|
-
export async function runRetentionCleanup(db, retentionDays) {
|
|
43
|
+
export async function runRetentionCleanup(db, retentionDays, maxDbSizeMb = 0) {
|
|
39
44
|
// Use string interpolation instead of parameterized bindings.
|
|
40
45
|
// Knex + better-sqlite3 can hang on parameterized db.raw() calls,
|
|
41
46
|
// while non-parameterized queries (used in migrations) work fine.
|
|
@@ -53,7 +58,12 @@ export async function runRetentionCleanup(db, retentionDays) {
|
|
|
53
58
|
await yieldToEventLoop();
|
|
54
59
|
await batchDelete(db, 'server_stats_metrics', cutoff);
|
|
55
60
|
await yieldToEventLoop();
|
|
56
|
-
|
|
61
|
+
if (maxDbSizeMb > 0)
|
|
62
|
+
await enforceSizeCap(db, maxDbSizeMb);
|
|
63
|
+
// Return deleted pages to the OS — PRAGMA optimize alone only updates
|
|
64
|
+
// planner stats, leaving the file at its high-water mark forever.
|
|
65
|
+
await reclaimFreeSpace(db);
|
|
66
|
+
// Update query planner statistics
|
|
57
67
|
await db.raw('PRAGMA optimize');
|
|
58
68
|
}
|
|
59
69
|
catch (err) {
|
|
@@ -72,10 +82,134 @@ async function batchDelete(db, table, cutoff) {
|
|
|
72
82
|
let hasMore = true;
|
|
73
83
|
while (hasMore) {
|
|
74
84
|
await db.raw(`DELETE FROM ${table} WHERE rowid IN (SELECT rowid FROM ${table} WHERE created_at < ${cutoff} LIMIT 1000)`);
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
85
|
+
// changes() reports the last DELETE's row count on this connection
|
|
86
|
+
// (pool is min:1/max:1). Re-counting the remaining backlog here instead
|
|
87
|
+
// made total work quadratic in backlog size.
|
|
88
|
+
const deleted = await pragmaNumber(db, 'SELECT changes() AS n');
|
|
89
|
+
hasMore = deleted === 1000;
|
|
78
90
|
if (hasMore)
|
|
79
91
|
await yieldToEventLoop();
|
|
80
92
|
}
|
|
81
93
|
}
|
|
94
|
+
/** Root tables pruned by the size cap; requests cascades to queries/events/traces. */
|
|
95
|
+
const SIZE_CAP_TABLES = [
|
|
96
|
+
'server_stats_requests',
|
|
97
|
+
'server_stats_logs',
|
|
98
|
+
'server_stats_emails',
|
|
99
|
+
'server_stats_metrics',
|
|
100
|
+
];
|
|
101
|
+
/**
|
|
102
|
+
* Bytes of live data: total pages minus freelist pages. Freed pages are
|
|
103
|
+
* excluded because reclaimFreeSpace returns them to the OS afterwards.
|
|
104
|
+
*/
|
|
105
|
+
async function liveSizeBytes(db, pageSize) {
|
|
106
|
+
const pages = await pragmaNumber(db, 'PRAGMA page_count');
|
|
107
|
+
const freelist = await pragmaNumber(db, 'PRAGMA freelist_count');
|
|
108
|
+
return (pages - freelist) * pageSize;
|
|
109
|
+
}
|
|
110
|
+
/**
|
|
111
|
+
* Keep live data under `maxSizeMb` by deleting the globally oldest rows,
|
|
112
|
+
* whatever their age — a size cap effectively shortens the retention
|
|
113
|
+
* window when write volume outruns `retentionDays`.
|
|
114
|
+
*
|
|
115
|
+
* Prunes down to ~90% of the cap so the hourly cleanup doesn't re-trigger
|
|
116
|
+
* on every pass. Each round deletes 1000 rows from whichever root table
|
|
117
|
+
* currently holds the oldest data, so mixed workloads (trace-heavy vs
|
|
118
|
+
* log-heavy) shed their actual oldest history first.
|
|
119
|
+
*/
|
|
120
|
+
async function enforceSizeCap(db, maxSizeMb) {
|
|
121
|
+
const budget = maxSizeMb * 1024 * 1024;
|
|
122
|
+
const pageSize = await pragmaNumber(db, 'PRAGMA page_size');
|
|
123
|
+
if ((await liveSizeBytes(db, pageSize)) <= budget)
|
|
124
|
+
return;
|
|
125
|
+
const { log } = await import('../utils/logger.js');
|
|
126
|
+
log.info(`dashboard: database over the ${maxSizeMb} MB size cap — pruning oldest records`);
|
|
127
|
+
const target = budget * 0.9;
|
|
128
|
+
// 10k rounds × 1000 rows bounds one pass; the next hourly pass continues.
|
|
129
|
+
let guard = 10_000;
|
|
130
|
+
while (guard-- > 0 && (await liveSizeBytes(db, pageSize)) > target) {
|
|
131
|
+
const table = await findTableWithOldestRow(db);
|
|
132
|
+
if (!table)
|
|
133
|
+
break;
|
|
134
|
+
await db.raw(`DELETE FROM ${table} WHERE rowid IN (SELECT rowid FROM ${table} ORDER BY created_at LIMIT 1000)`);
|
|
135
|
+
const deleted = await pragmaNumber(db, 'SELECT changes() AS n');
|
|
136
|
+
if (deleted === 0)
|
|
137
|
+
break;
|
|
138
|
+
await yieldToEventLoop();
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
/** The root table whose oldest row is globally oldest, or null when all are empty. */
|
|
142
|
+
async function findTableWithOldestRow(db) {
|
|
143
|
+
let best = null;
|
|
144
|
+
let bestTs = '';
|
|
145
|
+
for (const table of SIZE_CAP_TABLES) {
|
|
146
|
+
const rows = (await db.raw(`SELECT MIN(created_at) AS ts FROM ${table}`));
|
|
147
|
+
const ts = rows?.[0]?.ts;
|
|
148
|
+
if (ts && (best === null || ts < bestTs)) {
|
|
149
|
+
best = table;
|
|
150
|
+
bestTs = ts;
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
return best;
|
|
154
|
+
}
|
|
155
|
+
/**
|
|
156
|
+
* Return freelist pages to the OS after a cleanup pass.
|
|
157
|
+
*
|
|
158
|
+
* Databases created with `auto_vacuum=INCREMENTAL` are trimmed in bounded
|
|
159
|
+
* chunks so each synchronous step stays short. Legacy databases (created
|
|
160
|
+
* before the pragma existed) can't be trimmed incrementally, so when dead
|
|
161
|
+
* pages exceed ~30% of the file a one-time VACUUM rewrites it — which also
|
|
162
|
+
* converts it to incremental mode, since `auto_vacuum=INCREMENTAL` is set
|
|
163
|
+
* on this connection.
|
|
164
|
+
*/
|
|
165
|
+
async function reclaimFreeSpace(db) {
|
|
166
|
+
const mode = await pragmaNumber(db, 'PRAGMA auto_vacuum');
|
|
167
|
+
if (mode === 2) {
|
|
168
|
+
// Chunks of ~2000 pages (~8 MB at the 4 KB default) keep each synchronous
|
|
169
|
+
// call short; guard bounds one pass at ~8 GB in case freelist_count
|
|
170
|
+
// misbehaves.
|
|
171
|
+
let guard = 1000;
|
|
172
|
+
while (guard-- > 0) {
|
|
173
|
+
const freelist = await pragmaNumber(db, 'PRAGMA freelist_count');
|
|
174
|
+
if (freelist <= 0)
|
|
175
|
+
break;
|
|
176
|
+
if (!(await incrementalVacuumChunk(db)))
|
|
177
|
+
break;
|
|
178
|
+
await yieldToEventLoop();
|
|
179
|
+
}
|
|
180
|
+
return;
|
|
181
|
+
}
|
|
182
|
+
const pageCount = await pragmaNumber(db, 'PRAGMA page_count');
|
|
183
|
+
const freelistCount = await pragmaNumber(db, 'PRAGMA freelist_count');
|
|
184
|
+
if (pageCount > 0 && freelistCount / pageCount > 0.3) {
|
|
185
|
+
const { log } = await import('../utils/logger.js');
|
|
186
|
+
log.info(`dashboard: reclaiming ${freelistCount} of ${pageCount} pages via one-time VACUUM (may pause briefly)`);
|
|
187
|
+
await db.raw('VACUUM');
|
|
188
|
+
log.info('dashboard: VACUUM complete — database converted to incremental auto-vacuum');
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
/**
|
|
192
|
+
* Free up to 2000 freelist pages. incremental_vacuum frees ONE page per
|
|
193
|
+
* sqlite3_step, and knex's raw() steps no-result pragmas exactly once — so
|
|
194
|
+
* it must run on the underlying better-sqlite3 handle, whose .pragma()
|
|
195
|
+
* steps to completion. Returns false if the handle has no pragma method.
|
|
196
|
+
*/
|
|
197
|
+
async function incrementalVacuumChunk(db) {
|
|
198
|
+
const client = db.client;
|
|
199
|
+
const conn = (await client.acquireConnection());
|
|
200
|
+
try {
|
|
201
|
+
if (typeof conn.pragma !== 'function')
|
|
202
|
+
return false;
|
|
203
|
+
conn.pragma('incremental_vacuum(2000)');
|
|
204
|
+
return true;
|
|
205
|
+
}
|
|
206
|
+
finally {
|
|
207
|
+
client.releaseConnection(conn);
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
/** Run a single-row/single-column statement and return its value as a number. */
|
|
211
|
+
async function pragmaNumber(db, statement) {
|
|
212
|
+
const rows = (await db.raw(statement));
|
|
213
|
+
const value = Object.values(rows?.[0] ?? {})[0];
|
|
214
|
+
return Number(value ?? 0);
|
|
215
|
+
}
|
|
@@ -72,6 +72,10 @@ export async function migrateEvents(db) {
|
|
|
72
72
|
`);
|
|
73
73
|
await db.raw(`CREATE INDEX IF NOT EXISTS idx_ss_events_created ON server_stats_events(created_at)`);
|
|
74
74
|
await db.raw(`CREATE INDEX IF NOT EXISTS idx_ss_events_name ON server_stats_events(event_name)`);
|
|
75
|
+
// Without this index every cascaded delete from server_stats_requests
|
|
76
|
+
// full-scans the events table (synchronously, on the host event loop) —
|
|
77
|
+
// the other FK children (queries, traces, logs) all have one.
|
|
78
|
+
await db.raw(`CREATE INDEX IF NOT EXISTS idx_ss_events_request ON server_stats_events(request_id)`);
|
|
75
79
|
}
|
|
76
80
|
export async function migrateEmails(db) {
|
|
77
81
|
await db.raw(`
|
|
@@ -20,18 +20,23 @@ export interface StorageStatsResult {
|
|
|
20
20
|
fileSizeMb: number;
|
|
21
21
|
walSizeMb: number;
|
|
22
22
|
retentionDays: number;
|
|
23
|
+
/** Configured live-data size cap in MB; `0` means the cap is disabled. */
|
|
24
|
+
maxDbSizeMb: number;
|
|
23
25
|
tables: Array<{
|
|
24
26
|
name: string;
|
|
25
27
|
rowCount: number;
|
|
26
28
|
}>;
|
|
27
29
|
lastCleanupAt: number | null;
|
|
28
30
|
}
|
|
31
|
+
/** The not-ready result returned before the SQLite connection exists. */
|
|
32
|
+
export declare function emptyStorageStats(dbPath: string, retentionDays: number, maxDbSizeMb: number): StorageStatsResult;
|
|
29
33
|
export interface StorageStatsOpts {
|
|
30
34
|
db: Knex;
|
|
31
35
|
cache: CoalesceCache;
|
|
32
36
|
dbFilePath: string;
|
|
33
37
|
dbPath: string;
|
|
34
38
|
retentionDays: number;
|
|
39
|
+
maxDbSizeMb: number;
|
|
35
40
|
lastCleanupAt: number | null;
|
|
36
41
|
onResult: (stats: StorageStatsResult) => void;
|
|
37
42
|
}
|
|
@@ -58,11 +58,24 @@ export async function countAllTables(db) {
|
|
|
58
58
|
return result;
|
|
59
59
|
});
|
|
60
60
|
}
|
|
61
|
+
/** The not-ready result returned before the SQLite connection exists. */
|
|
62
|
+
export function emptyStorageStats(dbPath, retentionDays, maxDbSizeMb) {
|
|
63
|
+
return {
|
|
64
|
+
ready: false,
|
|
65
|
+
dbPath,
|
|
66
|
+
fileSizeMb: 0,
|
|
67
|
+
walSizeMb: 0,
|
|
68
|
+
retentionDays,
|
|
69
|
+
maxDbSizeMb,
|
|
70
|
+
tables: [],
|
|
71
|
+
lastCleanupAt: null,
|
|
72
|
+
};
|
|
73
|
+
}
|
|
61
74
|
/**
|
|
62
75
|
* Build storage stats by querying file sizes and table row counts.
|
|
63
76
|
*/
|
|
64
77
|
export function fetchStorageStats(opts) {
|
|
65
|
-
const { db, cache, dbFilePath, dbPath, retentionDays, lastCleanupAt, onResult } = opts;
|
|
78
|
+
const { db, cache, dbFilePath, dbPath, retentionDays, maxDbSizeMb, lastCleanupAt, onResult } = opts;
|
|
66
79
|
return cache.coalesce('storageStats', async () => {
|
|
67
80
|
const [fileSizeMb, walSizeMb] = await getFileSizes(dbFilePath);
|
|
68
81
|
const tables = await countAllTables(db);
|
|
@@ -72,6 +85,7 @@ export function fetchStorageStats(opts) {
|
|
|
72
85
|
fileSizeMb,
|
|
73
86
|
walSizeMb,
|
|
74
87
|
retentionDays,
|
|
88
|
+
maxDbSizeMb,
|
|
75
89
|
tables,
|
|
76
90
|
lastCleanupAt,
|
|
77
91
|
};
|
|
@@ -284,6 +284,8 @@ export interface DevToolbarConfig {
|
|
|
284
284
|
dashboardPath: string;
|
|
285
285
|
/** Data retention period in days for historical persistence. */
|
|
286
286
|
retentionDays: number;
|
|
287
|
+
/** Size cap in MB for the SQLite database's live data; `0` disables it. */
|
|
288
|
+
maxDbSizeMb: number;
|
|
287
289
|
/** Path to the SQLite database file for historical persistence. */
|
|
288
290
|
dbPath: string;
|
|
289
291
|
/** Base path for the debug toolbar API endpoints. */
|
|
@@ -33,6 +33,8 @@ function applyDashboardConfig(dashboard, result) {
|
|
|
33
33
|
result.dashboardPath = dashboard.path;
|
|
34
34
|
if (dashboard.retentionDays !== undefined)
|
|
35
35
|
result.retentionDays = dashboard.retentionDays;
|
|
36
|
+
if (dashboard.maxDbSizeMb !== undefined)
|
|
37
|
+
result.maxDbSizeMb = dashboard.maxDbSizeMb;
|
|
36
38
|
}
|
|
37
39
|
}
|
|
38
40
|
/** Advanced config field mappings. */
|