mirror of
https://github.com/teamchong/pxpipe.git
synced 2026-07-22 02:02:51 +02:00
feat(dashboard): per-session dollar-weighted savings panel with bucket + passthrough breakdown
This commit is contained in:
+36
-36
File diff suppressed because one or more lines are too long
+162
-1
@@ -142,6 +142,31 @@ export interface RecentRow {
|
||||
* output×5 in input-token-equivalents. The dashboard headline matches it
|
||||
* so a "20% saved" number means weekly-limit consumption dropped by 20%,
|
||||
* not "20% off the slice we touched while the other half stayed full." */
|
||||
/**
|
||||
* Per-session aggregate. Same dollar-weighted savings math as the global
|
||||
* `Totals` block, but partitioned by `info.firstUserSha8` so the dashboard
|
||||
* can show "what's happening RIGHT NOW" instead of stale lifetime numbers.
|
||||
* Field names mirror the JSON wire shape served by `serveCurrentSessionJson`.
|
||||
*/
|
||||
interface SessionTotals {
|
||||
sessionId: string;
|
||||
firstSeen: number; // unix seconds
|
||||
lastSeen: number; // unix seconds
|
||||
requests: number;
|
||||
// Dollar-weighted accumulators. baseline/actual are the SAME math as the
|
||||
// global `Totals.allBaselineEquivalentWeighted` / `allActualInputWeighted`
|
||||
// + `allOutputWeighted`, but scoped to a single session.
|
||||
baselineInputWeighted: number;
|
||||
actualInputWeighted: number;
|
||||
outputWeighted: number;
|
||||
// Per-bucket char attribution from ev.info.bucketChars.
|
||||
bucketChars: { [bucket: string]: number };
|
||||
// Passthrough reason histogram from ev.info.passthroughReasons.
|
||||
passthroughReasons: Record<string, number>;
|
||||
passthroughRequests: number; // count of requests where !ev.info.compressed
|
||||
compressedRequests: number;
|
||||
}
|
||||
|
||||
interface Totals {
|
||||
requests: number;
|
||||
compressedRequests: number;
|
||||
@@ -258,10 +283,24 @@ const OUTPUT_TOKEN_RATE = 5.0;
|
||||
* actual target model; do not trust hardcoded chars-per-token or
|
||||
* tokens-per-image constants on 4.7 without verifying against the
|
||||
* upstream probe. */
|
||||
const ASSUMED_INPUT_USD_PER_MTOK = 5.0;
|
||||
export const ASSUMED_INPUT_USD_PER_MTOK = 5.0;
|
||||
|
||||
export class DashboardState {
|
||||
private recent: RecentRow[] = [];
|
||||
/** Per-session dollar-weighted totals, keyed by `info.firstUserSha8`. The
|
||||
* dashboard surfaces ONLY the most-recently-active session via the
|
||||
* `serveCurrentSessionJson` endpoint — older sessions linger in the Map
|
||||
* so a tab refresh during a brief lull still finds the previous session,
|
||||
* but get evicted at `SESSION_CAP` to bound memory in long-running hosts. */
|
||||
private sessions: Map<string, SessionTotals> = new Map();
|
||||
/** sha8 of the most-recently-active session id. null when no events have
|
||||
* ever carried a `firstUserSha8` (e.g. a cold start with only passthrough
|
||||
* hits that the upstream probe never tagged). */
|
||||
private currentSessionId: string | null = null;
|
||||
/** Hard cap on `sessions` Map entries. Keeps memory bounded in
|
||||
* long-running deployments. 50 sessions × ~13 numeric fields each is
|
||||
* comfortably under a MB even with fat bucket/passthrough histograms. */
|
||||
private static readonly SESSION_CAP = 50;
|
||||
private totals: Totals = {
|
||||
requests: 0,
|
||||
compressedRequests: 0,
|
||||
@@ -460,6 +499,75 @@ export class DashboardState {
|
||||
}
|
||||
}
|
||||
|
||||
// Per-session aggregation. Uses the SAME baseline/actual/output math as
|
||||
// the global accumulators above, partitioned by `info.firstUserSha8`
|
||||
// so the dashboard's "current session" panel can show what's happening
|
||||
// RIGHT NOW instead of stale lifetime numbers. Untagged events (no
|
||||
// firstUserSha8 — cold start, passthrough probe failures) are skipped
|
||||
// rather than bucketed into a synthetic "unknown" session.
|
||||
const sid = info?.firstUserSha8;
|
||||
if (typeof sid === 'string' && sid.length > 0) {
|
||||
this.currentSessionId = sid;
|
||||
let s = this.sessions.get(sid);
|
||||
if (!s) {
|
||||
s = {
|
||||
sessionId: sid,
|
||||
firstSeen: Date.now() / 1000,
|
||||
lastSeen: Date.now() / 1000,
|
||||
requests: 0,
|
||||
baselineInputWeighted: 0,
|
||||
actualInputWeighted: 0,
|
||||
outputWeighted: 0,
|
||||
bucketChars: {},
|
||||
passthroughReasons: {},
|
||||
passthroughRequests: 0,
|
||||
compressedRequests: 0,
|
||||
};
|
||||
this.sessions.set(sid, s);
|
||||
// Cap memory — drop the oldest session by lastSeen when over budget.
|
||||
if (this.sessions.size > DashboardState.SESSION_CAP) {
|
||||
const oldest = [...this.sessions.values()].sort(
|
||||
(a, b) => a.lastSeen - b.lastSeen,
|
||||
)[0];
|
||||
if (oldest) this.sessions.delete(oldest.sessionId);
|
||||
}
|
||||
}
|
||||
s.lastSeen = Date.now() / 1000;
|
||||
s.requests += 1;
|
||||
// Reuse the same haveUsage / haveBaseline guards + the
|
||||
// baselineInputEff / actualInputEff / outputEquiv locals computed
|
||||
// earlier in update() so the lifetime totals block (above) and the
|
||||
// per-session block (here) read the same values. Re-deriving them
|
||||
// here would duplicate the cache-aware-baseline math and invite drift.
|
||||
if (haveBaseline && haveUsage) {
|
||||
s.baselineInputWeighted += baselineInputEff;
|
||||
s.actualInputWeighted += actualInputEff;
|
||||
s.outputWeighted += outputEquiv;
|
||||
}
|
||||
if (compressed) s.compressedRequests += 1;
|
||||
else s.passthroughRequests += 1;
|
||||
// Per-bucket char attribution from `info.bucketChars` (flat
|
||||
// `Partial<Record<BucketName, number>>` — see src/core/tracker.ts).
|
||||
// Matches the shape the Svelte panel iterates over.
|
||||
const bc = info?.bucketChars;
|
||||
if (bc && typeof bc === 'object') {
|
||||
for (const [key, val] of Object.entries(bc) as [string, unknown][]) {
|
||||
if (typeof val === 'number' && Number.isFinite(val)) {
|
||||
s.bucketChars[key] = (s.bucketChars[key] ?? 0) + val;
|
||||
}
|
||||
}
|
||||
}
|
||||
// Passthrough reason histogram from `info.passthroughReasons`.
|
||||
const pr = info?.passthroughReasons;
|
||||
if (pr && typeof pr === 'object') {
|
||||
for (const [reason, count] of Object.entries(pr as Record<string, unknown>)) {
|
||||
if (typeof count === 'number' && Number.isFinite(count) && count > 0) {
|
||||
s.passthroughReasons[reason] = (s.passthroughReasons[reason] ?? 0) + count;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Measurement totals are independent of usage/baseline gating — they
|
||||
// accumulate whenever the scanner produced numbers. The scanner sets
|
||||
// measurement to undefined on 5xx (no body to scan) and on unknown
|
||||
@@ -561,6 +669,57 @@ export class DashboardState {
|
||||
|
||||
// ---- HTTP handlers ------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Per-session "what's happening right now" payload backing the
|
||||
* `SessionSummary` panel. Scopes the dollar-weighted savings ratio + the
|
||||
* per-bucket char attribution + the passthrough-reason histogram to the
|
||||
* most-recently-active session (tracked via `info.firstUserSha8`) so the
|
||||
* top-of-dashboard headline reflects the live session rather than stale
|
||||
* lifetime aggregates from a previous run.
|
||||
*
|
||||
* Returns `{ sessionId: null, message: 'no active session yet' }` when no
|
||||
* events have been received yet (or the first events were all untagged —
|
||||
* cold start, probe failures) — `update()` only sets `currentSessionId`
|
||||
* when a `firstUserSha8`-tagged event lands. The client renders a stale
|
||||
* panel rather than zeroes when a session goes idle (NO `lastSeen >
|
||||
* threshold` check here; see comment in `update()` for the rationale).
|
||||
*/
|
||||
serveCurrentSessionJson(): Response {
|
||||
if (!this.currentSessionId) {
|
||||
return jsonResponse({
|
||||
sessionId: null,
|
||||
message: 'no active session yet',
|
||||
});
|
||||
}
|
||||
const s = this.sessions.get(this.currentSessionId);
|
||||
if (!s) {
|
||||
return jsonResponse({ sessionId: null, message: 'no active session yet' });
|
||||
}
|
||||
// Dollar-weighted savings ratio. Same math as the global `saved_pct`
|
||||
// (compressed AND passthrough requests — passthrough has baseline=actual,
|
||||
// so it correctly drags the ratio down when we leak). Honest denominator:
|
||||
// includes both compressed and uncompressed flows that the proxy saw.
|
||||
const baselineUsd = (s.baselineInputWeighted * ASSUMED_INPUT_USD_PER_MTOK) / 1e6;
|
||||
const actualUsd = (s.actualInputWeighted * ASSUMED_INPUT_USD_PER_MTOK) / 1e6;
|
||||
const savedUsd = baselineUsd - actualUsd;
|
||||
const savedPct = baselineUsd > 0 ? (savedUsd / baselineUsd) * 100 : 0;
|
||||
return jsonResponse({
|
||||
sessionId: s.sessionId,
|
||||
firstSeen: s.firstSeen,
|
||||
lastSeen: s.lastSeen,
|
||||
uptimeSec: Math.max(0, s.lastSeen - s.firstSeen),
|
||||
requests: s.requests,
|
||||
compressedRequests: s.compressedRequests,
|
||||
passthroughRequests: s.passthroughRequests,
|
||||
baselineUsd: round4(baselineUsd),
|
||||
actualUsd: round4(actualUsd),
|
||||
savedUsd: round4(savedUsd),
|
||||
savedPct: round1(savedPct),
|
||||
bucketChars: s.bucketChars,
|
||||
passthroughReasons: s.passthroughReasons,
|
||||
});
|
||||
}
|
||||
|
||||
serveStats(): Response {
|
||||
// Two headline numbers, derived from the same per-event accumulators:
|
||||
//
|
||||
@@ -836,6 +995,7 @@ export type DashboardRoute =
|
||||
| { kind: 'png' } // /proxy-latest-png
|
||||
| { kind: 'api-sessions' } // /api/sessions.json
|
||||
| { kind: 'api-stats' } // /api/stats.json
|
||||
| { kind: 'current-session' } // /api/current-session.json
|
||||
| { kind: 'api-compression' }; // /api/compression (POST {enabled}) — runtime kill switch
|
||||
|
||||
/** Match dashboard paths (handle query strings on /proxy-latest-png). */
|
||||
@@ -846,6 +1006,7 @@ export function dashboardPath(pathname: string): DashboardRoute | null {
|
||||
if (pathname === '/proxy-latest-png') return { kind: 'png' };
|
||||
if (pathname === '/api/sessions.json') return { kind: 'api-sessions' };
|
||||
if (pathname === '/api/stats.json') return { kind: 'api-stats' };
|
||||
if (pathname === '/api/current-session.json') return { kind: 'current-session' };
|
||||
if (pathname === '/api/compression') return { kind: 'api-compression' };
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
import Sessions from './components/Sessions.svelte';
|
||||
import StatsTable from './components/StatsTable.svelte';
|
||||
import CompressionToggle from './components/CompressionToggle.svelte';
|
||||
import SessionSummary from './components/SessionSummary.svelte';
|
||||
import ToastTray from './components/ToastTray.svelte';
|
||||
import { stats } from './stores/index.js';
|
||||
|
||||
@@ -34,6 +35,8 @@
|
||||
|
||||
<CompressionToggle />
|
||||
|
||||
<SessionSummary />
|
||||
|
||||
<StatsHeader />
|
||||
|
||||
<div class="row">
|
||||
|
||||
@@ -0,0 +1,263 @@
|
||||
<script lang="ts">
|
||||
// Top-of-dashboard headline. Dollar-weighted savings ratio scoped to the
|
||||
// most-recently-active session, with per-bucket breakdown of where the
|
||||
// savings came from and a list of passthrough reasons for requests we
|
||||
// didn't compress. Polls 2s via the `currentSession` store.
|
||||
import { currentSession } from '../stores/index.js';
|
||||
|
||||
$: data = $currentSession.data;
|
||||
$: err = $currentSession.error;
|
||||
$: hasSession = data && data.sessionId != null;
|
||||
|
||||
function fmtUsd(n: number): string {
|
||||
return '$' + n.toFixed(2);
|
||||
}
|
||||
function fmtPct(n: number): string {
|
||||
return n.toFixed(1) + '%';
|
||||
}
|
||||
function fmtDuration(sec: number): string {
|
||||
if (sec < 60) return Math.round(sec) + 's';
|
||||
const m = Math.floor(sec / 60);
|
||||
if (m < 60) return m + 'm';
|
||||
const h = Math.floor(m / 60);
|
||||
return h + 'h ' + (m % 60) + 'm';
|
||||
}
|
||||
function fmtChars(n: number): string {
|
||||
if (n < 1000) return String(n);
|
||||
if (n < 1_000_000) return (n / 1000).toFixed(1) + 'k';
|
||||
return (n / 1_000_000).toFixed(1) + 'M';
|
||||
}
|
||||
|
||||
$: bucketTotal = data?.bucketChars
|
||||
? Object.values(data.bucketChars).reduce((a, b) => a + b, 0)
|
||||
: 0;
|
||||
$: bucketEntries = data?.bucketChars
|
||||
? (Object.entries(data.bucketChars) as [string, number][])
|
||||
.filter(([, v]) => v > 0)
|
||||
.sort((a, b) => b[1] - a[1])
|
||||
: [];
|
||||
$: passthroughEntries = data?.passthroughReasons
|
||||
? (Object.entries(data.passthroughReasons) as [string, number][])
|
||||
.sort((a, b) => b[1] - a[1])
|
||||
: [];
|
||||
|
||||
// Static key→label / key→color maps. The Record type matches the spec's
|
||||
// `bucketChars` shape so any new bucket added in tracker.ts will show up
|
||||
// in legend order. Hex codes chosen to match the legacy panel palette.
|
||||
const BUCKET_LABELS: Record<string, string> = {
|
||||
static_slab: 'slab',
|
||||
reminder: 'reminder',
|
||||
tool_result: 'tool_result',
|
||||
history: 'history',
|
||||
billing: 'billing',
|
||||
dynamic: 'dynamic',
|
||||
};
|
||||
const BUCKET_COLORS: Record<string, string> = {
|
||||
static_slab: '#b9be0',
|
||||
reminder: '#85c5f6',
|
||||
tool_result: '#10b981',
|
||||
history: '#f59e0b',
|
||||
billing: '#8e7681',
|
||||
dynamic: '#ef4444',
|
||||
};
|
||||
</script>
|
||||
|
||||
<div class="panel session-summary">
|
||||
{#if err}
|
||||
<div class="error">error: {err}</div>
|
||||
{:else if !data}
|
||||
<div class="loading">loading…</div>
|
||||
{:else if !hasSession}
|
||||
<h2>This session</h2>
|
||||
<div class="empty">{data.message ?? 'no active session yet'}</div>
|
||||
{:else}
|
||||
<div class="header">
|
||||
<h2>This session</h2>
|
||||
<div class="sub">
|
||||
started {fmtDuration(data.uptimeSec ?? 0)} ago · {data.requests} requests
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="headline">
|
||||
<div class="big">
|
||||
Saved <span class="pct">{fmtPct(data.savedPct ?? 0)}</span>
|
||||
</div>
|
||||
<div class="usd">
|
||||
{fmtUsd(data.savedUsd ?? 0)} of {fmtUsd(data.baselineUsd ?? 0)} baseline
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="bar">
|
||||
<div class="bar-fill" style="width: {Math.max(0, Math.min(100, data.savedPct ?? 0))}%"></div>
|
||||
</div>
|
||||
|
||||
{#if bucketEntries.length > 0}
|
||||
<div class="section">
|
||||
<div class="section-label">Where it came from</div>
|
||||
<div class="bucket-bar">
|
||||
{#each bucketEntries as [key, val]}
|
||||
{@const w = bucketTotal > 0 ? (val / bucketTotal) * 100 : 0}
|
||||
<div
|
||||
class="bucket-seg"
|
||||
style="width: {w}%; background: {BUCKET_COLORS[key] ?? '#8e7681'}"
|
||||
title="{BUCKET_LABELS[key] ?? key}: {fmtChars(val)} chars ({w.toFixed(1)}%)"
|
||||
></div>
|
||||
{/each}
|
||||
</div>
|
||||
<div class="bucket-legend">
|
||||
{#each bucketEntries as [key, val]}
|
||||
{@const w = bucketTotal > 0 ? (val / bucketTotal) * 100 : 0}
|
||||
<span class="legend-item">
|
||||
<span class="swatch" style="background: {BUCKET_COLORS[key] ?? '#8e7681'}"></span>
|
||||
{BUCKET_LABELS[key] ?? key} {w.toFixed(0)}%
|
||||
</span>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if passthroughEntries.length > 0}
|
||||
<div class="section">
|
||||
<div class="section-label">
|
||||
Where it didn't · {data.passthroughRequests ?? 0} of {data.requests} requests passed through
|
||||
</div>
|
||||
<ul class="passthrough-list">
|
||||
{#each passthroughEntries as [reason, count]}
|
||||
<li><span class="reason">{reason}</span> <span class="count">{count}</span></li>
|
||||
{/each}
|
||||
</ul>
|
||||
</div>
|
||||
{:else if (data.passthroughRequests ?? 0) === 0}
|
||||
<div class="section">
|
||||
<div class="section-label-good">✓ Every request compressed</div>
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.session-summary {
|
||||
margin-bottom: 22px;
|
||||
}
|
||||
.header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: baseline;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
h2 {
|
||||
margin: 0;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
color: #8e7681;
|
||||
}
|
||||
.sub {
|
||||
font-size: 12px;
|
||||
color: #8e7681;
|
||||
}
|
||||
.headline {
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
.big {
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
color: #c9d1d9;
|
||||
}
|
||||
.pct {
|
||||
color: #3fb950;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.usd {
|
||||
font-size: 12px;
|
||||
color: #8e7681;
|
||||
margin-top: 2px;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.bar {
|
||||
margin-top: 8px;
|
||||
height: 6px;
|
||||
background: #21262d;
|
||||
border-radius: 3px;
|
||||
overflow: hidden;
|
||||
}
|
||||
.bar-fill {
|
||||
height: 100%;
|
||||
background: #3fb950;
|
||||
transition: width 1s ease;
|
||||
}
|
||||
.section {
|
||||
margin-top: 16px;
|
||||
}
|
||||
.section-label {
|
||||
font-size: 11px;
|
||||
color: #8e7681;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
.section-label-good {
|
||||
color: #3fb950;
|
||||
text-transform: none;
|
||||
letter-spacing: 0;
|
||||
font-size: 12px;
|
||||
}
|
||||
.bucket-bar {
|
||||
display: flex;
|
||||
height: 8px;
|
||||
border-radius: 4px;
|
||||
overflow: hidden;
|
||||
background: #21262d;
|
||||
}
|
||||
.bucket-seg {
|
||||
height: 100%;
|
||||
transition: width 1s ease;
|
||||
}
|
||||
.bucket-legend {
|
||||
margin-top: 6px;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 12px;
|
||||
font-size: 11px;
|
||||
color: #c9d1d9;
|
||||
}
|
||||
.legend-item {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.swatch {
|
||||
display: inline-block;
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 2px;
|
||||
}
|
||||
.passthrough-list {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
font-size: 12px;
|
||||
color: #c9d1d9;
|
||||
}
|
||||
.passthrough-list li {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
padding: 2px 0;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.reason {
|
||||
color: #8e7681;
|
||||
}
|
||||
.count {
|
||||
color: #8e7681;
|
||||
}
|
||||
.loading, .empty, .error {
|
||||
color: #8e7681;
|
||||
font-size: 12px;
|
||||
}
|
||||
.error {
|
||||
color: #f85149;
|
||||
}
|
||||
</style>
|
||||
@@ -14,6 +14,7 @@ export const API = {
|
||||
latestPng: '/proxy-latest-png',
|
||||
sessions: '/api/sessions.json',
|
||||
fullStats: '/api/stats.json',
|
||||
currentSession: '/api/current-session.json',
|
||||
compressionToggle: '/api/compression',
|
||||
} as const;
|
||||
|
||||
|
||||
@@ -11,6 +11,7 @@ import type {
|
||||
RecentPayload,
|
||||
SessionsPayload,
|
||||
FullStatsPayload,
|
||||
CurrentSessionPayload,
|
||||
} from '../types.js';
|
||||
|
||||
// Live counters + recent table (legacy poll cadence: 2s).
|
||||
@@ -20,6 +21,7 @@ export const recent = pollJson<RecentPayload>('/proxy-recent', 2000);
|
||||
// Slower endpoints (legacy: 5s).
|
||||
export const sessions = pollJson<SessionsPayload>('/api/sessions.json', 5000);
|
||||
export const fullStats = pollJson<FullStatsPayload>('/api/stats.json', 5000);
|
||||
export const currentSession = pollJson<CurrentSessionPayload>('/api/current-session.json', 2000);
|
||||
|
||||
// when null the image viewer follows the latest render; when set it pins that image id. ui-only state.
|
||||
export const selectedImageId = writable<number | null>(null);
|
||||
|
||||
@@ -152,3 +152,28 @@ export interface FullStatsSummary {
|
||||
export interface CompressionToggleResponse {
|
||||
compression_enabled: boolean;
|
||||
}
|
||||
|
||||
/** /api/current-session.json payload — per-session aggregates for the most-recently-active Claude Code session. */
|
||||
export interface CurrentSessionPayload {
|
||||
sessionId: string | null;
|
||||
message?: string;
|
||||
firstSeen?: number;
|
||||
lastSeen?: number;
|
||||
uptimeSec?: number;
|
||||
requests?: number;
|
||||
compressedRequests?: number;
|
||||
passthroughRequests?: number;
|
||||
baselineUsd?: number;
|
||||
actualUsd?: number;
|
||||
savedUsd?: number;
|
||||
savedPct?: number;
|
||||
bucketChars?: {
|
||||
static_slab: number;
|
||||
reminder: number;
|
||||
tool_result: number;
|
||||
history: number;
|
||||
billing: number;
|
||||
dynamic: number;
|
||||
};
|
||||
passthroughReasons?: Record<string, number>;
|
||||
}
|
||||
|
||||
@@ -202,6 +202,9 @@ async function dispatchDashboard(
|
||||
case 'api-stats':
|
||||
if (method !== 'GET') return undefined;
|
||||
return dashboard.serveApiStats();
|
||||
case 'current-session':
|
||||
if (method !== 'GET') return undefined;
|
||||
return dashboard.serveCurrentSessionJson();
|
||||
case 'api-compression': {
|
||||
if (method !== 'POST') {
|
||||
return new Response(
|
||||
|
||||
Reference in New Issue
Block a user