adonisjs-pulse 0.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (71) hide show
  1. package/LICENSE.md +9 -0
  2. package/README.md +119 -0
  3. package/build/configure.d.ts +2 -0
  4. package/build/configure.js +47 -0
  5. package/build/index.d.ts +5 -0
  6. package/build/index.js +5 -0
  7. package/build/providers/pulse_provider.d.ts +16 -0
  8. package/build/providers/pulse_provider.js +64 -0
  9. package/build/resources/views/components/card-header.edge +30 -0
  10. package/build/resources/views/components/card.edge +8 -0
  11. package/build/resources/views/components/no-results.edge +3 -0
  12. package/build/resources/views/components/pulse.edge +79 -0
  13. package/build/resources/views/components/scroll.edge +3 -0
  14. package/build/resources/views/components/table.edge +3 -0
  15. package/build/resources/views/components/td.edge +4 -0
  16. package/build/resources/views/components/th.edge +4 -0
  17. package/build/resources/views/components/thead.edge +3 -0
  18. package/build/resources/views/components/theme-switcher.edge +48 -0
  19. package/build/resources/views/dashboard.edge +7 -0
  20. package/build/resources/views/livewire/cache.edge +111 -0
  21. package/build/resources/views/livewire/exceptions.edge +48 -0
  22. package/build/resources/views/livewire/period-selector.edge +11 -0
  23. package/build/resources/views/livewire/servers.edge +86 -0
  24. package/build/resources/views/livewire/slow-queries.edge +50 -0
  25. package/build/resources/views/livewire/slow-requests.edge +62 -0
  26. package/build/resources/views/livewire/usage.edge +63 -0
  27. package/build/services/pulse.d.ts +3 -0
  28. package/build/services/pulse.js +6 -0
  29. package/build/src/entry.d.ts +24 -0
  30. package/build/src/entry.js +67 -0
  31. package/build/src/livewire/cache.d.ts +4 -0
  32. package/build/src/livewire/cache.js +33 -0
  33. package/build/src/livewire/card.d.ts +22 -0
  34. package/build/src/livewire/card.js +96 -0
  35. package/build/src/livewire/exceptions.d.ts +4 -0
  36. package/build/src/livewire/exceptions.js +29 -0
  37. package/build/src/livewire/period_selector.d.ts +5 -0
  38. package/build/src/livewire/period_selector.js +16 -0
  39. package/build/src/livewire/servers.d.ts +4 -0
  40. package/build/src/livewire/servers.js +36 -0
  41. package/build/src/livewire/slow_queries.d.ts +5 -0
  42. package/build/src/livewire/slow_queries.js +37 -0
  43. package/build/src/livewire/slow_requests.d.ts +5 -0
  44. package/build/src/livewire/slow_requests.js +37 -0
  45. package/build/src/pulse.d.ts +44 -0
  46. package/build/src/pulse.js +224 -0
  47. package/build/src/recorders/cache_interactions.d.ts +34 -0
  48. package/build/src/recorders/cache_interactions.js +80 -0
  49. package/build/src/recorders/exceptions.d.ts +8 -0
  50. package/build/src/recorders/exceptions.js +81 -0
  51. package/build/src/recorders/index.d.ts +7 -0
  52. package/build/src/recorders/index.js +6 -0
  53. package/build/src/recorders/recorder.d.ts +24 -0
  54. package/build/src/recorders/recorder.js +45 -0
  55. package/build/src/recorders/servers.d.ts +24 -0
  56. package/build/src/recorders/servers.js +133 -0
  57. package/build/src/recorders/slow_queries.d.ts +8 -0
  58. package/build/src/recorders/slow_queries.js +27 -0
  59. package/build/src/recorders/slow_requests.d.ts +11 -0
  60. package/build/src/recorders/slow_requests.js +80 -0
  61. package/build/src/storage/database_storage.d.ts +20 -0
  62. package/build/src/storage/database_storage.js +357 -0
  63. package/build/src/types.d.ts +91 -0
  64. package/build/src/types.js +3 -0
  65. package/build/src/value.d.ts +9 -0
  66. package/build/src/value.js +20 -0
  67. package/build/stubs/config.stub +58 -0
  68. package/build/stubs/main.d.ts +5 -0
  69. package/build/stubs/main.js +7 -0
  70. package/build/stubs/migration.stub +58 -0
  71. package/package.json +100 -0
@@ -0,0 +1,133 @@
1
+ import { Recorder } from './recorder.js';
2
+ import { Value } from '../value.js';
3
+ import { execSync } from 'node:child_process';
4
+ import { hostname } from 'node:os';
5
+ export default class Servers extends Recorder {
6
+ serverName;
7
+ directories;
8
+ interval = null;
9
+ constructor(options = {}) {
10
+ super(options);
11
+ this.serverName = options.serverName || hostname();
12
+ this.directories = options.directories || ['/'];
13
+ }
14
+ register() {
15
+ if (!this.enabled) {
16
+ return;
17
+ }
18
+ this.record();
19
+ this.interval = setInterval(() => {
20
+ this.record();
21
+ }, 15000);
22
+ }
23
+ stop() {
24
+ if (this.interval) {
25
+ clearInterval(this.interval);
26
+ this.interval = null;
27
+ }
28
+ }
29
+ record() {
30
+ if (!this.enabled) {
31
+ return;
32
+ }
33
+ const cpu = this.getCpuUsage();
34
+ const memory = this.getMemoryUsage();
35
+ const storage = this.getStorageUsage();
36
+ this.pulse.set(new Value(Date.now(), 'system', this.serverName, JSON.stringify({
37
+ name: this.serverName,
38
+ cpu,
39
+ memory_used: memory.used,
40
+ memory_total: memory.total,
41
+ storage: storage,
42
+ })));
43
+ }
44
+ getCpuUsage() {
45
+ try {
46
+ if (process.platform === 'linux') {
47
+ const output = execSync("grep 'cpu ' /proc/stat").toString();
48
+ const parts = output.trim().split(/\s+/);
49
+ const idle = Number.parseInt(parts[4], 10);
50
+ const total = parts.slice(1).reduce((sum, val) => sum + Number.parseInt(val, 10), 0);
51
+ return Math.round(((total - idle) / total) * 100);
52
+ }
53
+ else if (process.platform === 'darwin') {
54
+ const output = execSync("top -l 1 | grep 'CPU usage'").toString();
55
+ const match = output.match(/(\d+\.\d+)%\s+user/);
56
+ if (match) {
57
+ return Math.round(Number.parseFloat(match[1]));
58
+ }
59
+ }
60
+ }
61
+ catch {
62
+ // ignore
63
+ }
64
+ return 0;
65
+ }
66
+ getMemoryUsage() {
67
+ try {
68
+ if (process.platform === 'linux') {
69
+ const output = execSync('free -b').toString();
70
+ const lines = output.trim().split('\n');
71
+ const memLine = lines[1].split(/\s+/);
72
+ return {
73
+ total: Number.parseInt(memLine[1], 10),
74
+ used: Number.parseInt(memLine[2], 10),
75
+ };
76
+ }
77
+ else if (process.platform === 'darwin') {
78
+ const pageSize = Number.parseInt(execSync('pagesize').toString().trim(), 10);
79
+ const vmStat = execSync('vm_stat').toString();
80
+ const matches = {
81
+ free: vmStat.match(/Pages free:\s+(\d+)/),
82
+ active: vmStat.match(/Pages active:\s+(\d+)/),
83
+ inactive: vmStat.match(/Pages inactive:\s+(\d+)/),
84
+ speculative: vmStat.match(/Pages speculative:\s+(\d+)/),
85
+ wired: vmStat.match(/Pages wired down:\s+(\d+)/),
86
+ compressed: vmStat.match(/Pages occupied by compressor:\s+(\d+)/),
87
+ };
88
+ const free = (Number.parseInt(matches.free?.[1] || '0', 10) +
89
+ Number.parseInt(matches.speculative?.[1] || '0', 10)) *
90
+ pageSize;
91
+ const used = (Number.parseInt(matches.active?.[1] || '0', 10) +
92
+ Number.parseInt(matches.inactive?.[1] || '0', 10) +
93
+ Number.parseInt(matches.wired?.[1] || '0', 10) +
94
+ Number.parseInt(matches.compressed?.[1] || '0', 10)) *
95
+ pageSize;
96
+ const total = free + used;
97
+ return { total, used };
98
+ }
99
+ }
100
+ catch {
101
+ // ignore
102
+ }
103
+ return { total: 0, used: 0 };
104
+ }
105
+ getStorageUsage() {
106
+ const storage = [];
107
+ for (const directory of this.directories) {
108
+ try {
109
+ const output = execSync(`df -B1 "${directory}" 2>/dev/null || df -k "${directory}"`).toString();
110
+ const lines = output.trim().split('\n');
111
+ const parts = lines[1].split(/\s+/);
112
+ if (output.includes('-B1')) {
113
+ storage.push({
114
+ directory,
115
+ total: Number.parseInt(parts[1], 10),
116
+ used: Number.parseInt(parts[2], 10),
117
+ });
118
+ }
119
+ else {
120
+ storage.push({
121
+ directory,
122
+ total: Number.parseInt(parts[1], 10) * 1024,
123
+ used: Number.parseInt(parts[2], 10) * 1024,
124
+ });
125
+ }
126
+ }
127
+ catch {
128
+ // ignore
129
+ }
130
+ }
131
+ return storage;
132
+ }
133
+ }
@@ -0,0 +1,8 @@
1
+ import { Recorder } from './recorder.js';
2
+ export default class SlowQueries extends Recorder {
3
+ register(): Promise<void>;
4
+ protected record(payload: {
5
+ sql: string;
6
+ duration?: [number, number];
7
+ }): void;
8
+ }
@@ -0,0 +1,27 @@
1
+ import { Recorder } from './recorder.js';
2
+ import { Entry } from '../entry.js';
3
+ export default class SlowQueries extends Recorder {
4
+ async register() {
5
+ if (!this.enabled) {
6
+ return;
7
+ }
8
+ const emitter = await this.app.container.make('emitter');
9
+ emitter.on('db:query', (payload) => {
10
+ this.record(payload);
11
+ });
12
+ }
13
+ record(payload) {
14
+ if (!this.shouldSample()) {
15
+ return;
16
+ }
17
+ const { sql, duration } = payload;
18
+ if (this.shouldIgnore(sql)) {
19
+ return;
20
+ }
21
+ const time = Math.round(Number(duration?.[1] ?? 0) / 1_000_000);
22
+ if (time < this.threshold) {
23
+ return;
24
+ }
25
+ this.pulse.record(new Entry(Date.now(), 'slow_query', JSON.stringify([sql]), time).max().count());
26
+ }
27
+ }
@@ -0,0 +1,11 @@
1
+ import type { HttpContext } from '@adonisjs/core/http';
2
+ import { Recorder } from './recorder.js';
3
+ export default class SlowRequests extends Recorder {
4
+ register(): Promise<void>;
5
+ protected record(payload: {
6
+ ctx: HttpContext;
7
+ duration: [number, number];
8
+ }): void;
9
+ protected resolveHandler(ctx: HttpContext): string;
10
+ protected recordUser(ctx: HttpContext, time: number): void;
11
+ }
@@ -0,0 +1,80 @@
1
+ import { Recorder } from './recorder.js';
2
+ import { Entry } from '../entry.js';
3
+ import { Value } from '../value.js';
4
+ export default class SlowRequests extends Recorder {
5
+ async register() {
6
+ if (!this.enabled) {
7
+ return;
8
+ }
9
+ const emitter = await this.app.container.make('emitter');
10
+ emitter.on('http:request_completed', (payload) => {
11
+ this.record(payload);
12
+ });
13
+ }
14
+ record(payload) {
15
+ const { ctx, duration } = payload;
16
+ if (!ctx.route) {
17
+ return;
18
+ }
19
+ if (!this.shouldSample()) {
20
+ return;
21
+ }
22
+ if (this.shouldIgnore(ctx.route.pattern)) {
23
+ return;
24
+ }
25
+ const handler = this.resolveHandler(ctx);
26
+ const time = Math.round(Number(duration?.[1] ?? 0) / 1_000_000);
27
+ if (time < this.threshold) {
28
+ return;
29
+ }
30
+ this.pulse.record(new Entry(Date.now(), 'slow_request', JSON.stringify([ctx.request.method(), ctx.route.pattern, handler]), time)
31
+ .max()
32
+ .count());
33
+ this.recordUser(ctx, time);
34
+ }
35
+ resolveHandler(ctx) {
36
+ let handler = 'Closure';
37
+ if (typeof ctx.route.handler === 'string') {
38
+ handler = ctx.route.handler;
39
+ }
40
+ else if (Array.isArray(ctx.route.handler)) {
41
+ if (ctx.route.handler.length === 1) {
42
+ handler = ctx.route.handler[0];
43
+ }
44
+ else if (ctx.route.handler.length === 2) {
45
+ handler = `${ctx.route.handler[0]}@${ctx.route.handler[1]}`;
46
+ }
47
+ }
48
+ else if (ctx.route.handler &&
49
+ 'reference' in ctx.route.handler &&
50
+ 'name' in ctx.route.handler) {
51
+ const routeHandler = ctx.route.handler;
52
+ if (typeof routeHandler.reference === 'string') {
53
+ handler = `${routeHandler.reference}`;
54
+ }
55
+ else if (Array.isArray(routeHandler.reference)) {
56
+ let fun = 'unknown';
57
+ if (routeHandler.reference.length === 1) {
58
+ fun = String(routeHandler.reference[0]);
59
+ }
60
+ else if (routeHandler.reference.length === 2) {
61
+ fun = `${routeHandler.reference[1]}`;
62
+ }
63
+ handler = `${routeHandler.name}.${fun}`;
64
+ }
65
+ }
66
+ return handler;
67
+ }
68
+ recordUser(ctx, time) {
69
+ const auth = ctx.auth;
70
+ if (!auth?.user) {
71
+ return;
72
+ }
73
+ this.pulse.set(new Value(Date.now(), 'user', String(auth.user.id), JSON.stringify({
74
+ id: auth.user.id,
75
+ name: auth.user.fullName ?? auth.user.name ?? auth.user.email,
76
+ email: auth.user.email,
77
+ })));
78
+ this.pulse.record(new Entry(Date.now(), 'slow_user_request', String(auth.user.id), time).count());
79
+ }
80
+ }
@@ -0,0 +1,20 @@
1
+ import type { Database } from '@adonisjs/lucid/database';
2
+ import type { Entry } from '../entry.js';
3
+ import type { Value } from '../value.js';
4
+ import type { AggregationType, PulseStorage, StoredValue, AggregateResult, AggregateTypesResult, ResolvedPulseConfig } from '../types.js';
5
+ export declare class DatabaseStorage implements PulseStorage {
6
+ protected db: Database;
7
+ protected config: ResolvedPulseConfig;
8
+ constructor(db: Database, config: ResolvedPulseConfig);
9
+ protected hash(key: string): string;
10
+ protected bucket(timestamp: number, period: number): number;
11
+ store(entries: Entry[], values: Value[]): Promise<void>;
12
+ trim(): Promise<void>;
13
+ purge(types?: string[]): Promise<void>;
14
+ values(type: string, keys?: string[]): Promise<StoredValue[]>;
15
+ aggregate(type: string, aggregates: AggregationType[], interval: number, orderBy?: AggregationType, direction?: 'asc' | 'desc', limit?: number): Promise<Map<string, AggregateResult>>;
16
+ graph(types: string[], aggregate: AggregationType, interval: number): Promise<Map<number, Map<string, number>>>;
17
+ protected findPeriod(interval: number): number;
18
+ aggregateTypes(types: string[], aggregate: AggregationType, interval: number, orderBy?: string, direction?: 'asc' | 'desc', limit?: number): Promise<AggregateTypesResult[]>;
19
+ aggregateTotal(types: string[], aggregate: AggregationType, interval: number): Promise<Record<string, number>>;
20
+ }
@@ -0,0 +1,357 @@
1
+ import { createHash } from 'node:crypto';
2
+ export class DatabaseStorage {
3
+ db;
4
+ config;
5
+ constructor(db, config) {
6
+ this.db = db;
7
+ this.config = config;
8
+ }
9
+ hash(key) {
10
+ return createHash('sha256').update(key).digest('hex');
11
+ }
12
+ bucket(timestamp, period) {
13
+ return Math.floor(timestamp / 1000 / period) * period;
14
+ }
15
+ async store(entries, values) {
16
+ await this.db.transaction(async (trx) => {
17
+ if (values.length > 0) {
18
+ for (const value of values) {
19
+ const attrs = value.attributes();
20
+ const keyHash = this.hash(attrs.key);
21
+ const timestamp = Math.floor(attrs.timestamp / 1000);
22
+ const existing = await trx
23
+ .from('pulse_values')
24
+ .where('type', attrs.type)
25
+ .where('key_hash', keyHash)
26
+ .first();
27
+ if (existing) {
28
+ await trx
29
+ .from('pulse_values')
30
+ .where('type', attrs.type)
31
+ .where('key_hash', keyHash)
32
+ .update({
33
+ timestamp,
34
+ value: attrs.value,
35
+ });
36
+ }
37
+ else {
38
+ await trx.insertQuery().table('pulse_values').insert({
39
+ timestamp,
40
+ type: attrs.type,
41
+ key: attrs.key,
42
+ key_hash: keyHash,
43
+ value: attrs.value,
44
+ });
45
+ }
46
+ }
47
+ }
48
+ const entriesToInsert = [];
49
+ const aggregates = new Map();
50
+ for (const entry of entries) {
51
+ const attrs = entry.attributes();
52
+ const keyHash = this.hash(attrs.key);
53
+ if (!entry.isOnlyBuckets()) {
54
+ entriesToInsert.push(entry);
55
+ }
56
+ for (const aggregate of entry.getAggregations()) {
57
+ const periods = [60, 360, 1440, 10080];
58
+ for (const period of periods) {
59
+ const bucket = this.bucket(attrs.timestamp, period);
60
+ const compositeKey = `${bucket}:${period}:${attrs.type}:${aggregate}:${keyHash}`;
61
+ const existing = aggregates.get(compositeKey);
62
+ const value = attrs.value ?? 0;
63
+ if (existing) {
64
+ switch (aggregate) {
65
+ case 'count':
66
+ existing.count += 1;
67
+ break;
68
+ case 'min':
69
+ existing.value = Math.min(existing.value, value);
70
+ break;
71
+ case 'max':
72
+ existing.value = Math.max(existing.value, value);
73
+ break;
74
+ case 'sum':
75
+ case 'avg':
76
+ existing.value += value;
77
+ existing.count += 1;
78
+ break;
79
+ }
80
+ }
81
+ else {
82
+ aggregates.set(compositeKey, {
83
+ entry,
84
+ value: aggregate === 'count' ? 0 : value,
85
+ count: 1,
86
+ });
87
+ }
88
+ }
89
+ }
90
+ }
91
+ if (entriesToInsert.length > 0) {
92
+ const rows = entriesToInsert.map((entry) => {
93
+ const attrs = entry.attributes();
94
+ return {
95
+ timestamp: Math.floor(attrs.timestamp / 1000),
96
+ type: attrs.type,
97
+ key: attrs.key,
98
+ key_hash: this.hash(attrs.key),
99
+ value: attrs.value,
100
+ };
101
+ });
102
+ await trx.insertQuery().table('pulse_entries').multiInsert(rows);
103
+ }
104
+ for (const [compositeKey, data] of aggregates) {
105
+ const [bucket, period, type, aggregate, keyHash] = compositeKey.split(':');
106
+ const existingAggregate = await trx
107
+ .from('pulse_aggregates')
108
+ .where('bucket', Number(bucket))
109
+ .where('period', Number(period))
110
+ .where('type', type)
111
+ .where('aggregate', aggregate)
112
+ .where('key_hash', keyHash)
113
+ .first();
114
+ if (existingAggregate) {
115
+ let newValue;
116
+ let newCount = null;
117
+ switch (aggregate) {
118
+ case 'count':
119
+ newValue = Number(existingAggregate.value) + 1;
120
+ break;
121
+ case 'min':
122
+ newValue = Math.min(Number(existingAggregate.value), data.value);
123
+ break;
124
+ case 'max':
125
+ newValue = Math.max(Number(existingAggregate.value), data.value);
126
+ break;
127
+ case 'sum':
128
+ newValue = Number(existingAggregate.value) + data.value;
129
+ break;
130
+ case 'avg':
131
+ newValue = Number(existingAggregate.value) + data.value;
132
+ newCount = Number(existingAggregate.count) + data.count;
133
+ break;
134
+ default:
135
+ newValue = data.value;
136
+ }
137
+ const updateData = { value: newValue };
138
+ if (newCount !== null) {
139
+ updateData.count = newCount;
140
+ }
141
+ await trx
142
+ .from('pulse_aggregates')
143
+ .where('bucket', Number(bucket))
144
+ .where('period', Number(period))
145
+ .where('type', type)
146
+ .where('aggregate', aggregate)
147
+ .where('key_hash', keyHash)
148
+ .update(updateData);
149
+ }
150
+ else {
151
+ await trx
152
+ .insertQuery()
153
+ .table('pulse_aggregates')
154
+ .insert({
155
+ bucket: Number(bucket),
156
+ period: Number(period),
157
+ type,
158
+ key: data.entry.key,
159
+ key_hash: keyHash,
160
+ aggregate,
161
+ value: aggregate === 'count' ? data.count : data.value,
162
+ count: aggregate === 'avg' ? data.count : null,
163
+ });
164
+ }
165
+ }
166
+ });
167
+ }
168
+ async trim() {
169
+ const now = Math.floor(Date.now() / 1000);
170
+ await this.db
171
+ .from('pulse_entries')
172
+ .where('timestamp', '<', now - this.config.retain.entries)
173
+ .delete();
174
+ await this.db
175
+ .from('pulse_values')
176
+ .where('timestamp', '<', now - this.config.retain.values)
177
+ .delete();
178
+ const periods = [
179
+ { period: 60, retain: this.config.retain.aggregates },
180
+ { period: 360, retain: this.config.retain.aggregates },
181
+ { period: 1440, retain: this.config.retain.aggregates },
182
+ { period: 10080, retain: this.config.retain.aggregates },
183
+ ];
184
+ for (const { period, retain } of periods) {
185
+ const oldestBucket = this.bucket((now - retain) * 1000, period);
186
+ await this.db
187
+ .from('pulse_aggregates')
188
+ .where('period', period)
189
+ .where('bucket', '<', oldestBucket)
190
+ .delete();
191
+ }
192
+ }
193
+ async purge(types) {
194
+ const tables = ['pulse_entries', 'pulse_values', 'pulse_aggregates'];
195
+ for (const table of tables) {
196
+ if (types && types.length > 0) {
197
+ await this.db.from(table).whereIn('type', types).delete();
198
+ }
199
+ else {
200
+ await this.db.from(table).delete();
201
+ }
202
+ }
203
+ }
204
+ async values(type, keys) {
205
+ const query = this.db.from('pulse_values').where('type', type);
206
+ if (keys && keys.length > 0) {
207
+ const hashes = keys.map((k) => this.hash(k));
208
+ query.whereIn('key_hash', hashes);
209
+ }
210
+ return query.select('*');
211
+ }
212
+ async aggregate(type, aggregates, interval, orderBy, direction = 'desc', limit) {
213
+ const now = Math.floor(Date.now() / 1000);
214
+ const period = this.findPeriod(interval);
215
+ const oldestBucket = this.bucket((now - interval) * 1000, period);
216
+ const results = new Map();
217
+ const rows = await this.db
218
+ .from('pulse_aggregates')
219
+ .where('type', type)
220
+ .where('period', period)
221
+ .where('bucket', '>=', oldestBucket)
222
+ .whereIn('aggregate', aggregates)
223
+ .select('key', 'aggregate', 'value', 'count');
224
+ for (const row of rows) {
225
+ const existing = results.get(row.key) || { key: row.key };
226
+ switch (row.aggregate) {
227
+ case 'count':
228
+ existing.count = (existing.count ?? 0) + Number(row.value);
229
+ break;
230
+ case 'min':
231
+ existing.min =
232
+ existing.min !== undefined
233
+ ? Math.min(existing.min, Number(row.value))
234
+ : Number(row.value);
235
+ break;
236
+ case 'max':
237
+ existing.max =
238
+ existing.max !== undefined
239
+ ? Math.max(existing.max, Number(row.value))
240
+ : Number(row.value);
241
+ break;
242
+ case 'sum':
243
+ existing.sum = (existing.sum ?? 0) + Number(row.value);
244
+ break;
245
+ case 'avg':
246
+ const prevTotal = (existing.avg ?? 0) * (existing.count ?? 0);
247
+ const newCount = (existing.count ?? 0) + Number(row.count ?? 1);
248
+ existing.avg = (prevTotal + Number(row.value)) / newCount;
249
+ existing.count = newCount;
250
+ break;
251
+ }
252
+ results.set(row.key, existing);
253
+ }
254
+ if (orderBy && results.size > 0) {
255
+ const sorted = [...results.entries()].sort((a, b) => {
256
+ const aVal = a[1][orderBy] ?? 0;
257
+ const bVal = b[1][orderBy] ?? 0;
258
+ return direction === 'desc' ? bVal - aVal : aVal - bVal;
259
+ });
260
+ if (limit) {
261
+ return new Map(sorted.slice(0, limit));
262
+ }
263
+ return new Map(sorted);
264
+ }
265
+ return results;
266
+ }
267
+ async graph(types, aggregate, interval) {
268
+ const now = Math.floor(Date.now() / 1000);
269
+ const period = this.findPeriod(interval);
270
+ const oldestBucket = this.bucket((now - interval) * 1000, period);
271
+ const rows = await this.db
272
+ .from('pulse_aggregates')
273
+ .whereIn('type', types)
274
+ .where('period', period)
275
+ .where('bucket', '>=', oldestBucket)
276
+ .where('aggregate', aggregate)
277
+ .select('bucket', 'type', 'value')
278
+ .orderBy('bucket', 'asc');
279
+ const results = new Map();
280
+ for (const row of rows) {
281
+ if (!results.has(row.bucket)) {
282
+ results.set(row.bucket, new Map());
283
+ }
284
+ const bucketMap = results.get(row.bucket);
285
+ bucketMap.set(row.type, (bucketMap.get(row.type) ?? 0) + Number(row.value));
286
+ }
287
+ return results;
288
+ }
289
+ findPeriod(interval) {
290
+ const periods = [60, 360, 1440, 10080];
291
+ for (const period of periods) {
292
+ if (interval <= period * 60) {
293
+ return period;
294
+ }
295
+ }
296
+ return periods[periods.length - 1];
297
+ }
298
+ async aggregateTypes(types, aggregate, interval, orderBy, direction = 'desc', limit) {
299
+ const now = Math.floor(Date.now() / 1000);
300
+ const period = this.findPeriod(interval);
301
+ const oldestBucket = this.bucket((now - interval) * 1000, period);
302
+ const rows = await this.db
303
+ .from('pulse_aggregates')
304
+ .whereIn('type', types)
305
+ .where('period', period)
306
+ .where('bucket', '>=', oldestBucket)
307
+ .where('aggregate', aggregate)
308
+ .select('key', 'type', 'value', 'count');
309
+ const results = new Map();
310
+ for (const row of rows) {
311
+ const existing = results.get(row.key) || { key: row.key };
312
+ const rowType = row.type;
313
+ const countKey = `${rowType}_count`;
314
+ if (aggregate === 'avg') {
315
+ const prevTotal = (existing[rowType] ?? 0) * (existing[countKey] ?? 0);
316
+ const newCount = (existing[countKey] ?? 0) + Number(row.count ?? 1);
317
+ existing[rowType] = (prevTotal + Number(row.value)) / newCount;
318
+ existing[countKey] = newCount;
319
+ }
320
+ else {
321
+ existing[rowType] = (existing[rowType] ?? 0) + Number(row.value);
322
+ }
323
+ results.set(row.key, existing);
324
+ }
325
+ let sorted = [...results.values()];
326
+ if (orderBy) {
327
+ sorted.sort((a, b) => {
328
+ const aVal = (a[orderBy] ?? 0);
329
+ const bVal = (b[orderBy] ?? 0);
330
+ return direction === 'desc' ? bVal - aVal : aVal - bVal;
331
+ });
332
+ }
333
+ if (limit) {
334
+ sorted = sorted.slice(0, limit);
335
+ }
336
+ return sorted;
337
+ }
338
+ async aggregateTotal(types, aggregate, interval) {
339
+ const now = Math.floor(Date.now() / 1000);
340
+ const period = this.findPeriod(interval);
341
+ const oldestBucket = this.bucket((now - interval) * 1000, period);
342
+ const rows = await this.db
343
+ .from('pulse_aggregates')
344
+ .whereIn('type', types)
345
+ .where('period', period)
346
+ .where('bucket', '>=', oldestBucket)
347
+ .where('aggregate', aggregate)
348
+ .select('type')
349
+ .sum('value as total')
350
+ .groupBy('type');
351
+ const results = {};
352
+ for (const row of rows) {
353
+ results[row.type] = Number(row.total ?? 0);
354
+ }
355
+ return results;
356
+ }
357
+ }