@oh-my-pi/omp-stats 17.1.0 → 17.1.2
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/CHANGELOG.md +6 -0
- package/dist/client/index.css +1 -1
- package/dist/client/index.js +91 -91
- package/dist/client/styles.css +6 -0
- package/dist/types/aggregator.d.ts +7 -1
- package/dist/types/client/api.d.ts +2 -1
- package/dist/types/client/app/routes.d.ts +1 -1
- package/dist/types/client/routes/ProvidersRoute.d.ts +7 -0
- package/dist/types/client/routes/index.d.ts +1 -0
- package/dist/types/db.d.ts +15 -1
- package/dist/types/shared-types.d.ts +102 -0
- package/dist/types/usage-windows.d.ts +45 -0
- package/package.json +4 -4
- package/src/aggregator.ts +33 -1
- package/src/client/App.tsx +3 -0
- package/src/client/api.ts +10 -0
- package/src/client/app/routes.ts +18 -1
- package/src/client/data/useHashRoute.ts +1 -0
- package/src/client/routes/ProvidersRoute.tsx +492 -0
- package/src/client/routes/index.ts +1 -0
- package/src/client/styles.css +1 -0
- package/src/db.ts +141 -0
- package/src/server.ts +6 -0
- package/src/shared-types.ts +109 -0
- package/src/usage-windows.ts +303 -0
package/src/shared-types.ts
CHANGED
|
@@ -321,3 +321,112 @@ export interface ToolDashboardStats {
|
|
|
321
321
|
byToolModel: ToolModelStats[];
|
|
322
322
|
series: ToolTimeSeriesPoint[];
|
|
323
323
|
}
|
|
324
|
+
|
|
325
|
+
/**
|
|
326
|
+
* Aggregated request/token/cost totals for one provider over the active range.
|
|
327
|
+
*/
|
|
328
|
+
export interface ProviderAggregate {
|
|
329
|
+
provider: string;
|
|
330
|
+
totalRequests: number;
|
|
331
|
+
failedRequests: number;
|
|
332
|
+
/** Distinct models used through this provider in the range. */
|
|
333
|
+
models: number;
|
|
334
|
+
totalInputTokens: number;
|
|
335
|
+
totalOutputTokens: number;
|
|
336
|
+
totalCacheReadTokens: number;
|
|
337
|
+
totalCacheWriteTokens: number;
|
|
338
|
+
/** Uncached input + cache reads + cache writes + output. */
|
|
339
|
+
totalTokens: number;
|
|
340
|
+
totalCost: number;
|
|
341
|
+
totalPremiumRequests: number;
|
|
342
|
+
avgTokensPerSecond: number | null;
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
/**
|
|
346
|
+
* Token burn attributed to one local hour-of-day (0-23) for one provider.
|
|
347
|
+
* Powers the "peak burn hours" histogram.
|
|
348
|
+
*/
|
|
349
|
+
export interface ProviderHourlyPoint {
|
|
350
|
+
provider: string;
|
|
351
|
+
/** Local hour of day, 0-23. */
|
|
352
|
+
hour: number;
|
|
353
|
+
totalTokens: number;
|
|
354
|
+
outputTokens: number;
|
|
355
|
+
requests: number;
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
/** Provider token/cost time-series point (bucketed like the model series). */
|
|
359
|
+
export interface ProviderTimeSeriesPoint {
|
|
360
|
+
timestamp: number;
|
|
361
|
+
provider: string;
|
|
362
|
+
totalTokens: number;
|
|
363
|
+
cost: number;
|
|
364
|
+
requests: number;
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
/** One recorded usage-limit snapshot for an (account, window) series. */
|
|
368
|
+
export interface UsageWindowPoint {
|
|
369
|
+
timestamp: number;
|
|
370
|
+
/** Used fraction 0..1 (>1 = overage) when the provider reported one. */
|
|
371
|
+
usedFraction: number | null;
|
|
372
|
+
exhausted: boolean;
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
/**
|
|
376
|
+
* Utilization history for one (account, limit window) pair of a provider,
|
|
377
|
+
* sourced from the auth store's recorded usage-limit snapshots.
|
|
378
|
+
*/
|
|
379
|
+
export interface UsageWindowSeries {
|
|
380
|
+
provider: string;
|
|
381
|
+
accountKey: string;
|
|
382
|
+
/** Email/account id when known, else the stable account key. */
|
|
383
|
+
accountLabel: string;
|
|
384
|
+
/** Groups the same limit window across accounts (window label or limit id). */
|
|
385
|
+
windowKey: string;
|
|
386
|
+
windowLabel: string;
|
|
387
|
+
points: UsageWindowPoint[];
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
/**
|
|
391
|
+
* Derived subscription insight for one provider limit window across all
|
|
392
|
+
* accounts: how much of the window was consumed, what one window is worth in
|
|
393
|
+
* tokens, and how many accounts peak demand would have needed.
|
|
394
|
+
*/
|
|
395
|
+
export interface ProviderWindowInsight {
|
|
396
|
+
provider: string;
|
|
397
|
+
windowKey: string;
|
|
398
|
+
windowLabel: string;
|
|
399
|
+
/** Accounts with at least one snapshot for this window in range. */
|
|
400
|
+
accounts: number;
|
|
401
|
+
/** Window resets observed (drops in used fraction). */
|
|
402
|
+
cycles: number;
|
|
403
|
+
/**
|
|
404
|
+
* Subscription-window equivalents consumed in range: sum of positive
|
|
405
|
+
* used-fraction deltas across accounts (1.0 = one full window burned).
|
|
406
|
+
*/
|
|
407
|
+
fractionConsumed: number;
|
|
408
|
+
/**
|
|
409
|
+
* Estimated tokens one full window buys: provider tokens burned in range
|
|
410
|
+
* divided by {@link fractionConsumed}. Null when too little of the window
|
|
411
|
+
* was consumed to extrapolate.
|
|
412
|
+
*/
|
|
413
|
+
estTokensPerWindow: number | null;
|
|
414
|
+
/** Peak of sum-across-accounts used fraction at any sampled instant. */
|
|
415
|
+
peakConcurrentFraction: number;
|
|
416
|
+
/**
|
|
417
|
+
* Accounts needed to keep peak demand under 90% of fleet capacity:
|
|
418
|
+
* max(1, ceil(peakConcurrentFraction / 0.9)).
|
|
419
|
+
*/
|
|
420
|
+
idealAccounts: number;
|
|
421
|
+
/** Transitions into an exhausted state observed in range. */
|
|
422
|
+
exhaustedEvents: number;
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
/** Complete providers dashboard payload. */
|
|
426
|
+
export interface ProviderDashboardStats {
|
|
427
|
+
providers: ProviderAggregate[];
|
|
428
|
+
hourly: ProviderHourlyPoint[];
|
|
429
|
+
series: ProviderTimeSeriesPoint[];
|
|
430
|
+
usageSeries: UsageWindowSeries[];
|
|
431
|
+
windowInsights: ProviderWindowInsight[];
|
|
432
|
+
}
|
|
@@ -0,0 +1,303 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Provider subscription-window analytics for the stats dashboard.
|
|
3
|
+
*
|
|
4
|
+
* The auth layer appends one row to `usage_history` in agent.db every time a
|
|
5
|
+
* provider usage report is fetched (see AuthStorage's usage recording). This
|
|
6
|
+
* module reads those snapshots read-only and derives:
|
|
7
|
+
* - utilization series (used fraction over time per account and limit window),
|
|
8
|
+
* - per-window subscription insights: window-equivalents consumed, an
|
|
9
|
+
* estimate of how many tokens one full window buys, peak concurrent
|
|
10
|
+
* utilization across accounts, and the account count that peak implies.
|
|
11
|
+
*
|
|
12
|
+
* A missing agent DB or `usage_history` table yields empty results — the
|
|
13
|
+
* dashboard must keep working for API-key-only setups that never record usage.
|
|
14
|
+
*/
|
|
15
|
+
import { Database } from "bun:sqlite";
|
|
16
|
+
import { AuthBrokerClient, resolveAuthBrokerConfig } from "@oh-my-pi/pi-ai/auth-broker";
|
|
17
|
+
import { getAgentDbPath, logger } from "@oh-my-pi/pi-utils";
|
|
18
|
+
import type { ProviderWindowInsight, UsageWindowPoint, UsageWindowSeries } from "./shared-types";
|
|
19
|
+
|
|
20
|
+
/** Subset of a `usage_history` row consumed by the window analytics. */
|
|
21
|
+
export interface UsageSnapshotRow {
|
|
22
|
+
/** Epoch ms the report was fetched. */
|
|
23
|
+
recordedAt: number;
|
|
24
|
+
provider: string;
|
|
25
|
+
/** Stable credential identity key. */
|
|
26
|
+
accountKey: string;
|
|
27
|
+
email: string | null;
|
|
28
|
+
accountId: string | null;
|
|
29
|
+
limitId: string;
|
|
30
|
+
label: string;
|
|
31
|
+
windowLabel: string | null;
|
|
32
|
+
/** Used fraction (0..1, >1 = overage) when the provider reported one. */
|
|
33
|
+
usedFraction: number | null;
|
|
34
|
+
status: string | null;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Utilization series + derived insights for every provider window in range. */
|
|
38
|
+
export interface UsageWindowStats {
|
|
39
|
+
usageSeries: UsageWindowSeries[];
|
|
40
|
+
windowInsights: ProviderWindowInsight[];
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** A used-fraction drop smaller than this is jitter, not a window reset. */
|
|
44
|
+
const RESET_DROP_THRESHOLD = 0.05;
|
|
45
|
+
/** Minimum window-equivalents consumed before extrapolating tokens/window. */
|
|
46
|
+
const MIN_EXTRAPOLATION_FRACTION = 0.1;
|
|
47
|
+
/** Fleet-capacity headroom target: peak demand should stay under 90%. */
|
|
48
|
+
const TARGET_PEAK_UTILIZATION = 0.9;
|
|
49
|
+
/** Used fraction at or above this counts as exhausted even without a status. */
|
|
50
|
+
const EXHAUSTED_FRACTION = 0.999;
|
|
51
|
+
/** Utilization series are downsampled (peak per bucket) to at most this many points. */
|
|
52
|
+
const MAX_SERIES_POINTS = 400;
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Read usage-limit snapshots recorded at or after `sinceMs`, oldest first.
|
|
56
|
+
* Opens the agent DB read-only; returns `[]` when the DB or table is absent.
|
|
57
|
+
*/
|
|
58
|
+
export function readUsageSnapshots(sinceMs: number, dbPath = getAgentDbPath()): UsageSnapshotRow[] {
|
|
59
|
+
let db: Database | null = null;
|
|
60
|
+
try {
|
|
61
|
+
db = new Database(dbPath, { readonly: true });
|
|
62
|
+
const rows = db
|
|
63
|
+
.prepare(
|
|
64
|
+
`SELECT recorded_at, provider, account_key, email, account_id, limit_id, label, window_label, used_fraction, status
|
|
65
|
+
FROM usage_history
|
|
66
|
+
WHERE recorded_at >= ?
|
|
67
|
+
ORDER BY recorded_at ASC`,
|
|
68
|
+
)
|
|
69
|
+
.all(sinceMs) as Array<{
|
|
70
|
+
recorded_at: number;
|
|
71
|
+
provider: string;
|
|
72
|
+
account_key: string;
|
|
73
|
+
email: string | null;
|
|
74
|
+
account_id: string | null;
|
|
75
|
+
limit_id: string;
|
|
76
|
+
label: string;
|
|
77
|
+
window_label: string | null;
|
|
78
|
+
used_fraction: number | null;
|
|
79
|
+
status: string | null;
|
|
80
|
+
}>;
|
|
81
|
+
return rows.map(row => ({
|
|
82
|
+
recordedAt: row.recorded_at,
|
|
83
|
+
provider: row.provider,
|
|
84
|
+
accountKey: row.account_key,
|
|
85
|
+
email: row.email,
|
|
86
|
+
accountId: row.account_id,
|
|
87
|
+
limitId: row.limit_id,
|
|
88
|
+
label: row.label,
|
|
89
|
+
windowLabel: row.window_label,
|
|
90
|
+
usedFraction: row.used_fraction,
|
|
91
|
+
status: row.status,
|
|
92
|
+
}));
|
|
93
|
+
} catch (err) {
|
|
94
|
+
// Expected for fresh installs (no agent.db) or pre-usage-history schemas.
|
|
95
|
+
logger.debug("usage_history unavailable for provider stats", { dbPath, error: String(err) });
|
|
96
|
+
return [];
|
|
97
|
+
} finally {
|
|
98
|
+
db?.close();
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Fetch usage snapshots from wherever they actually accumulate: the auth
|
|
104
|
+
* broker's durable history when a broker is configured (the broker performs
|
|
105
|
+
* every upstream usage fetch in that mode, so the local `usage_history` stays
|
|
106
|
+
* frozen), else the local agent DB. Broker errors fall back to the local read
|
|
107
|
+
* so the dashboard degrades to stale-but-present data instead of failing.
|
|
108
|
+
*/
|
|
109
|
+
export async function fetchUsageSnapshots(sinceMs: number): Promise<UsageSnapshotRow[]> {
|
|
110
|
+
try {
|
|
111
|
+
const brokerConfig = await resolveAuthBrokerConfig();
|
|
112
|
+
if (brokerConfig) {
|
|
113
|
+
const client = new AuthBrokerClient({ url: brokerConfig.url, token: brokerConfig.token });
|
|
114
|
+
const response = await client.fetchUsageHistory({ sinceMs });
|
|
115
|
+
return response.entries.map(entry => ({
|
|
116
|
+
recordedAt: entry.recordedAt,
|
|
117
|
+
provider: entry.provider,
|
|
118
|
+
accountKey: entry.accountKey,
|
|
119
|
+
email: entry.email ?? null,
|
|
120
|
+
accountId: entry.accountId ?? null,
|
|
121
|
+
limitId: entry.limitId,
|
|
122
|
+
label: entry.label,
|
|
123
|
+
windowLabel: entry.windowLabel ?? null,
|
|
124
|
+
usedFraction: entry.usedFraction ?? null,
|
|
125
|
+
status: entry.status ?? null,
|
|
126
|
+
}));
|
|
127
|
+
}
|
|
128
|
+
} catch (err) {
|
|
129
|
+
logger.debug("broker usage history unavailable, falling back to local", { error: String(err) });
|
|
130
|
+
}
|
|
131
|
+
return readUsageSnapshots(sinceMs);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** True when a snapshot reports an exhausted window, by status or by fraction. */
|
|
135
|
+
function isExhausted(fraction: number | null, status: string | null): boolean {
|
|
136
|
+
if (status === "exhausted") return true;
|
|
137
|
+
return fraction !== null && fraction >= EXHAUSTED_FRACTION;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Reduce a point list to at most {@link MAX_SERIES_POINTS} by keeping the
|
|
142
|
+
* peak-fraction point per time bucket, so utilization peaks survive downsampling.
|
|
143
|
+
*/
|
|
144
|
+
function downsamplePoints(points: UsageWindowPoint[]): UsageWindowPoint[] {
|
|
145
|
+
if (points.length <= MAX_SERIES_POINTS) return points;
|
|
146
|
+
const first = points[0].timestamp;
|
|
147
|
+
const span = points[points.length - 1].timestamp - first;
|
|
148
|
+
const bucketMs = Math.max(1, Math.ceil(span / MAX_SERIES_POINTS));
|
|
149
|
+
const out: UsageWindowPoint[] = [];
|
|
150
|
+
let bucket = -1;
|
|
151
|
+
for (const point of points) {
|
|
152
|
+
const b = Math.floor((point.timestamp - first) / bucketMs);
|
|
153
|
+
if (b !== bucket) {
|
|
154
|
+
out.push(point);
|
|
155
|
+
bucket = b;
|
|
156
|
+
continue;
|
|
157
|
+
}
|
|
158
|
+
const last = out[out.length - 1];
|
|
159
|
+
if ((point.usedFraction ?? -1) >= (last.usedFraction ?? -1)) out[out.length - 1] = point;
|
|
160
|
+
}
|
|
161
|
+
return out;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
interface AccountSeries {
|
|
165
|
+
accountKey: string;
|
|
166
|
+
accountLabel: string;
|
|
167
|
+
rows: UsageSnapshotRow[];
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
interface WindowGroup {
|
|
171
|
+
provider: string;
|
|
172
|
+
windowKey: string;
|
|
173
|
+
windowLabel: string;
|
|
174
|
+
accounts: Map<string, AccountSeries>;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* Derive utilization series and per-window insights from raw snapshots.
|
|
179
|
+
*
|
|
180
|
+
* `tokensByProvider` supplies each provider's token burn over the same time
|
|
181
|
+
* range (from the local message stats); it converts consumed window fraction
|
|
182
|
+
* into an estimated token capacity per window. Attribution note: tokens are
|
|
183
|
+
* per provider, not per account, so the estimate treats the account fleet as
|
|
184
|
+
* one pooled subscription — which is exactly how round-robin auth uses it.
|
|
185
|
+
*/
|
|
186
|
+
export function computeUsageWindowStats(
|
|
187
|
+
rows: UsageSnapshotRow[],
|
|
188
|
+
tokensByProvider: ReadonlyMap<string, number>,
|
|
189
|
+
): UsageWindowStats {
|
|
190
|
+
const groups = new Map<string, WindowGroup>();
|
|
191
|
+
for (const row of rows) {
|
|
192
|
+
const windowKey = row.windowLabel ?? row.limitId;
|
|
193
|
+
const groupKey = `${row.provider}\u0000${windowKey}`;
|
|
194
|
+
let group = groups.get(groupKey);
|
|
195
|
+
if (!group) {
|
|
196
|
+
group = { provider: row.provider, windowKey, windowLabel: row.windowLabel ?? row.label, accounts: new Map() };
|
|
197
|
+
groups.set(groupKey, group);
|
|
198
|
+
}
|
|
199
|
+
// Labels can change across snapshots (provider renames); latest wins.
|
|
200
|
+
group.windowLabel = row.windowLabel ?? row.label;
|
|
201
|
+
let account = group.accounts.get(row.accountKey);
|
|
202
|
+
if (!account) {
|
|
203
|
+
account = { accountKey: row.accountKey, accountLabel: row.email ?? row.accountId ?? row.accountKey, rows: [] };
|
|
204
|
+
group.accounts.set(row.accountKey, account);
|
|
205
|
+
}
|
|
206
|
+
if (row.email || row.accountId) account.accountLabel = row.email ?? row.accountId ?? row.accountKey;
|
|
207
|
+
account.rows.push(row);
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
const usageSeries: UsageWindowSeries[] = [];
|
|
211
|
+
const windowInsights: ProviderWindowInsight[] = [];
|
|
212
|
+
|
|
213
|
+
for (const group of groups.values()) {
|
|
214
|
+
let fractionConsumed = 0;
|
|
215
|
+
let cycles = 0;
|
|
216
|
+
let exhaustedEvents = 0;
|
|
217
|
+
|
|
218
|
+
for (const account of group.accounts.values()) {
|
|
219
|
+
const points: UsageWindowPoint[] = account.rows.map(row => ({
|
|
220
|
+
timestamp: row.recordedAt,
|
|
221
|
+
usedFraction: row.usedFraction,
|
|
222
|
+
exhausted: isExhausted(row.usedFraction, row.status),
|
|
223
|
+
}));
|
|
224
|
+
usageSeries.push({
|
|
225
|
+
provider: group.provider,
|
|
226
|
+
accountKey: account.accountKey,
|
|
227
|
+
accountLabel: account.accountLabel,
|
|
228
|
+
windowKey: group.windowKey,
|
|
229
|
+
windowLabel: group.windowLabel,
|
|
230
|
+
points: downsamplePoints(points),
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
let prevFraction: number | null = null;
|
|
234
|
+
let prevExhausted = false;
|
|
235
|
+
for (const row of account.rows) {
|
|
236
|
+
const exhausted = isExhausted(row.usedFraction, row.status);
|
|
237
|
+
if (exhausted && !prevExhausted) exhaustedEvents++;
|
|
238
|
+
prevExhausted = exhausted;
|
|
239
|
+
if (row.usedFraction === null) continue;
|
|
240
|
+
if (prevFraction !== null) {
|
|
241
|
+
const delta = row.usedFraction - prevFraction;
|
|
242
|
+
if (delta > 0) fractionConsumed += delta;
|
|
243
|
+
else if (delta < -RESET_DROP_THRESHOLD) cycles++;
|
|
244
|
+
}
|
|
245
|
+
prevFraction = row.usedFraction;
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
const providerTokens = tokensByProvider.get(group.provider) ?? 0;
|
|
250
|
+
const peak = peakConcurrentFraction(group);
|
|
251
|
+
windowInsights.push({
|
|
252
|
+
provider: group.provider,
|
|
253
|
+
windowKey: group.windowKey,
|
|
254
|
+
windowLabel: group.windowLabel,
|
|
255
|
+
accounts: group.accounts.size,
|
|
256
|
+
cycles,
|
|
257
|
+
fractionConsumed,
|
|
258
|
+
estTokensPerWindow:
|
|
259
|
+
providerTokens > 0 && fractionConsumed >= MIN_EXTRAPOLATION_FRACTION
|
|
260
|
+
? Math.round(providerTokens / fractionConsumed)
|
|
261
|
+
: null,
|
|
262
|
+
peakConcurrentFraction: peak,
|
|
263
|
+
idealAccounts: Math.max(1, Math.ceil(peak / TARGET_PEAK_UTILIZATION)),
|
|
264
|
+
exhaustedEvents,
|
|
265
|
+
});
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
usageSeries.sort(
|
|
269
|
+
(a, b) =>
|
|
270
|
+
a.provider.localeCompare(b.provider) ||
|
|
271
|
+
a.windowKey.localeCompare(b.windowKey) ||
|
|
272
|
+
a.accountLabel.localeCompare(b.accountLabel),
|
|
273
|
+
);
|
|
274
|
+
windowInsights.sort((a, b) => a.provider.localeCompare(b.provider) || b.fractionConsumed - a.fractionConsumed);
|
|
275
|
+
return { usageSeries, windowInsights };
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
/**
|
|
279
|
+
* Peak of sum-across-accounts used fraction at any sampled instant: sweep all
|
|
280
|
+
* snapshot times, forward-filling each account's last known fraction. A peak
|
|
281
|
+
* of 1.7 means demand simultaneously held 1.7 windows' worth of quota.
|
|
282
|
+
*/
|
|
283
|
+
function peakConcurrentFraction(group: WindowGroup): number {
|
|
284
|
+
type Event = { timestamp: number; account: string; fraction: number };
|
|
285
|
+
const events: Event[] = [];
|
|
286
|
+
for (const account of group.accounts.values()) {
|
|
287
|
+
for (const row of account.rows) {
|
|
288
|
+
if (row.usedFraction === null) continue;
|
|
289
|
+
events.push({ timestamp: row.recordedAt, account: account.accountKey, fraction: row.usedFraction });
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
events.sort((a, b) => a.timestamp - b.timestamp);
|
|
293
|
+
|
|
294
|
+
const current = new Map<string, number>();
|
|
295
|
+
let sum = 0;
|
|
296
|
+
let peak = 0;
|
|
297
|
+
for (const event of events) {
|
|
298
|
+
sum += event.fraction - (current.get(event.account) ?? 0);
|
|
299
|
+
current.set(event.account, event.fraction);
|
|
300
|
+
if (sum > peak) peak = sum;
|
|
301
|
+
}
|
|
302
|
+
return peak;
|
|
303
|
+
}
|