@jeffusion/bungee-core 5.1.6 → 5.2.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.
@@ -0,0 +1,136 @@
1
+ <script lang="ts">
2
+ import { isLoading, locale } from 'svelte-i18n';
3
+ import RefreshCw from 'lucide-svelte/icons/refresh-cw';
4
+ import { _ } from '@bungee/plugin-sdk';
5
+ import { getPluginText } from '$utils/plugin-i18n';
6
+ import { KpiCard, PanelCard, MetricBar, BSegmentedControl, LoadingIndicator } from '$components/industrial';
7
+ import { Button } from '$components/ui/button';
8
+ import { Input } from '$components/ui/input';
9
+ import UsageTimeChart from './UsageTimeChart.svelte';
10
+ import { getStatsResource } from './stats';
11
+ import { cacheDetail, formatEstimatedUsd, formatTokenCount, modelTokenTotal, tokenComposition, usagePresentation, type TokenRange } from './labels';
12
+ import type { StatsState, StatsResource } from './stats-resource';
13
+
14
+ let { pluginName = 'token-stats' }: { pluginName?: string } = $props();
15
+ let range: TokenRange = $state('24h');
16
+ let sort = $state('tokens');
17
+ let search = $state('');
18
+ let models: StatsState = $state({ data: null, busy: false, error: '', refreshedAt: 0 });
19
+ let time: StatsState = $state({ data: null, busy: false, error: '', refreshedAt: 0 });
20
+ let modelResource: StatsResource | undefined = $state();
21
+ let timeResource: StatsResource | undefined = $state();
22
+ const t = (key: string) => $isLoading ? '' : getPluginText(key, pluginName, (id, options) => $_(id, options));
23
+ const display = (value: unknown) => formatTokenCount(value, $locale ?? undefined);
24
+ const usage = $derived(models.data ? usagePresentation(models.data) : null);
25
+ const composition = $derived(tokenComposition(models.data ?? {}));
26
+ const modelLabel = (id: string) => id === 'unknown' ? t('ui.unknownModel') : id;
27
+ const parts = (row: Parameters<typeof tokenComposition>[0]) => {
28
+ const c = tokenComposition(row);
29
+ return [
30
+ { key: 'input', label: t('ui.input'), value: c.input, tone: 'accent' as const },
31
+ { key: 'output', label: t('ui.output'), value: c.output, tone: 'ok' as const },
32
+ { key: 'cacheRead', label: t('page.cacheRead'), value: c.cacheRead, tone: 'warn' as const },
33
+ { key: 'cacheWrite', label: t('page.cacheWrite'), value: c.cacheWrite, tone: 'danger' as const },
34
+ ];
35
+ };
36
+ const knownPart = (key: string) => key === 'input' ? usage?.input !== undefined
37
+ : key === 'output' ? usage?.output !== undefined
38
+ : cacheDetail(key === 'cacheRead' ? models.data?.cacheReadTokens : models.data?.cacheWriteTokens) !== undefined;
39
+ const rankAmount = (row: NonNullable<typeof models.data>['data'][number]) => sort === 'cost'
40
+ ? row.estimatedCostUsd ?? -1 : modelTokenTotal(row) ?? -1;
41
+ const rows = $derived.by(() => [...models.data?.data ?? []]
42
+ .filter(row => modelLabel(row.dimension).toLowerCase().includes(search.trim().toLowerCase()))
43
+ .sort((a, b) => rankAmount(b) - rankAmount(a) || a.dimension.localeCompare(b.dimension)));
44
+ const maxRank = $derived(Math.max(0, ...rows.map(rankAmount)));
45
+ const busy = $derived(models.busy || time.busy);
46
+ const refreshedAt = $derived(Math.min(models.refreshedAt, time.refreshedAt));
47
+ const ranges = $derived(['1h', '12h', '24h'].map(value => ({ value, label: t(`page.range.${value}`) })));
48
+ const sorts = $derived(['tokens', 'cost'].map(value => ({ value, label: t(`page.sort.${value}`) })));
49
+ $effect(() => {
50
+ const nextModel = getStatsResource(pluginName, range, 'model');
51
+ const nextTime = getStatsResource(pluginName, range, 'time');
52
+ modelResource = nextModel; timeResource = nextTime;
53
+ const stopModels = nextModel.subscribe(next => { models = next; });
54
+ const stopTime = nextTime.subscribe(next => { time = next; });
55
+ return () => { stopModels(); stopTime(); };
56
+ });
57
+ function refresh() { void modelResource?.refresh(); void timeResource?.refresh(); }
58
+ </script>
59
+
60
+ <div class="min-w-0 space-y-4" data-testid="token-stats-page">
61
+ <div class="flex flex-wrap items-center justify-between gap-3">
62
+ <BSegmentedControl options={ranges} value={range} onchange={value => range = value as TokenRange} ariaLabel={t('page.range')} />
63
+ <div class="flex min-w-0 flex-wrap items-center gap-3">
64
+ <span class="font-mono text-[10px] text-zinc-400" aria-live="polite">{refreshedAt ? `${t('page.updatedAt')} ${new Date(refreshedAt).toLocaleTimeString($locale ?? undefined)}` : ''}</span>
65
+ <Button variant="ghost" size="sm" disabled={busy} onclick={refresh} aria-label={t('ui.refresh')} aria-busy={busy}>
66
+ <RefreshCw class={`mr-1.5 h-3.5 w-3.5 ${busy ? 'motion-safe:animate-spin' : ''}`} />{t('ui.refresh')}
67
+ </Button>
68
+ <a href={`/#/plugins/${pluginName}/pricing`} class="nx-btn nx-btn-ghost nx-btn-sm">{t('settings.title')}</a>
69
+ </div>
70
+ </div>
71
+ {#if models.error || time.error}
72
+ <div role="alert" class="flex flex-wrap items-center justify-between gap-2 border-l-2 border-red-500 bg-carbon-900 px-3 py-2 text-xs text-red-300">
73
+ <span>{t('ui.loadFailed')}</span><Button variant="ghost" size="sm" onclick={refresh} disabled={busy}>{t('page.retry')}</Button>
74
+ </div>
75
+ {/if}
76
+ {#if models.busy && !models.data}
77
+ <LoadingIndicator label={t('ui.loading')} height="sm" />
78
+ {:else if models.data}
79
+ <div class="grid grid-cols-2 gap-3 xl:grid-cols-4" data-testid="token-stats-page-summary">
80
+ <KpiCard class="min-w-0 [container-type:inline-size]" label={t('ui.input')} value={display(usage?.input)} />
81
+ <KpiCard class="min-w-0 [container-type:inline-size]" label={t('ui.output')} value={display(usage?.output)} />
82
+ <KpiCard class="min-w-0 [container-type:inline-size]" label={t('page.tokens')} value={display(modelTokenTotal(models.data))} />
83
+ <KpiCard class="min-w-0 [container-type:inline-size]" label={t('ui.estimatedCostUsd')} value={formatEstimatedUsd(models.data.estimatedCostUsd)} />
84
+ </div>
85
+ {#if usage?.state === 'empty'}<p class="py-8 text-center text-sm text-zinc-400" data-testid="token-stats-page-empty">{t('ui.noData')}</p>{/if}
86
+ {/if}
87
+
88
+ <PanelCard title={t('page.activity')} tag="TOKEN / TIME">
89
+ <div class="h-[240px] min-w-0 sm:h-[300px]">
90
+ {#if time.busy && !time.data}<LoadingIndicator label={t('ui.loading')} />
91
+ {:else if time.data && usagePresentation(time.data).positive}
92
+ <UsageTimeChart stats={time.data} {range} refreshedAt={time.refreshedAt} {pluginName} />
93
+ {:else}<p class="flex h-full items-center justify-center text-sm text-zinc-400">{t(time.data && usagePresentation(time.data).state === 'empty' ? 'ui.noData' : 'ui.noBreakdown')}</p>{/if}
94
+ </div>
95
+ </PanelCard>
96
+
97
+ {#if models.data}
98
+ <PanelCard title={t('page.composition')} tag="TOKEN">
99
+ <MetricBar label={t('page.tokens')} value={composition.total} max={composition.total} valueLabel={display(modelTokenTotal(models.data))}
100
+ segments={parts(models.data)} tone="neutral" />
101
+ <dl class="mt-3 grid grid-cols-2 gap-x-6 gap-y-3 sm:grid-cols-4" data-testid="token-stats-composition">
102
+ {#each parts(models.data) as part}
103
+ <div><dt class="nx-label">{part.label}</dt><dd class="mt-1 font-mono text-xs text-zinc-200">{knownPart(part.key) ? display(part.value) : '—'}
104
+ <span class="ml-2 text-zinc-400">{knownPart(part.key) && composition.total > 0 ? `${(part.value / composition.total * 100).toFixed(1)}%` : '—'}</span></dd></div>
105
+ {/each}
106
+ </dl>
107
+ </PanelCard>
108
+
109
+ <PanelCard title={t('dimension.model')} tag={`${rows.length} / ${models.data.data.length}`}>
110
+ <div class="mb-3 flex flex-wrap items-center justify-between gap-3">
111
+ <Input bind:value={search} aria-label={t('page.searchModels')} placeholder={t('page.searchModels')} class="w-full sm:w-64" />
112
+ <BSegmentedControl options={sorts} bind:value={sort} ariaLabel={t('page.sort')} />
113
+ </div>
114
+ <ol class="space-y-4" data-testid="token-stats-model-list">
115
+ {#each rows as row (row.dimension)}
116
+ {@const total = modelTokenTotal(row)}
117
+ <li class="min-w-0 border-b border-carbon-600 pb-4 last:border-0 last:pb-0" data-testid="token-stats-model-row">
118
+ <div class="mb-1.5 flex flex-wrap items-baseline justify-between gap-2">
119
+ <strong class="min-w-0 break-all font-mono text-xs font-normal text-zinc-200">{modelLabel(row.dimension)}</strong>
120
+ <span class="font-mono text-[11px] text-zinc-400">{display(total)} Token · {formatEstimatedUsd(row.estimatedCostUsd)}</span>
121
+ </div>
122
+ <MetricBar label={`${modelLabel(row.dimension)} · ${t(`page.sort.${sort}`)}`} value={Math.max(0, rankAmount(row))} max={maxRank}
123
+ valueLabel={sort === 'cost' ? formatEstimatedUsd(row.estimatedCostUsd) : display(total)}
124
+ tone={sort === 'tokens' ? 'neutral' : 'accent'} segments={sort === 'tokens' ? parts(row) : []} headless />
125
+ <dl class="mt-2 flex flex-wrap gap-x-5 gap-y-1 font-mono text-[10px] text-zinc-400">
126
+ <div>{t('ui.input')} <span class="text-zinc-200">{display(usagePresentation(row).input)}</span></div>
127
+ <div>{t('ui.output')} <span class="text-zinc-200">{display(usagePresentation(row).output)}</span></div>
128
+ <div>{t('page.cacheRead')} <span class="text-zinc-200">{display(row.cacheReadTokens)}</span></div>
129
+ <div>{t('page.cacheWrite')} <span class="text-zinc-200">{display(row.cacheWriteTokens)}</span></div>
130
+ </dl>
131
+ </li>
132
+ {:else}<li class="py-6 text-center text-xs text-zinc-400">{t(search ? 'page.noModels' : 'ui.noData')}</li>{/each}
133
+ </ol>
134
+ </PanelCard>
135
+ {/if}
136
+ </div>
@@ -0,0 +1,181 @@
1
+ <script lang="ts">
2
+ import { onMount } from 'svelte';
3
+ import { isLoading, locale } from 'svelte-i18n';
4
+ import { getPluginText } from '$utils/plugin-i18n';
5
+ import * as Tooltip from '$components/ui/tooltip';
6
+ import { Bar, ChartJS, BarElement, CategoryScale, LinearScale, _, type ChartData, type ChartOptions } from '@bungee/plugin-sdk';
7
+ import type { Plugin } from 'chart.js';
8
+ import { buildTimeSeries, formatTokenCount, modelColorIndex, OTHER_MODEL, timeAxisLabels, type TimeBucket, type TokenRange } from './labels';
9
+ import type { StatsResponse } from './stats-resource';
10
+
11
+ ChartJS.register(BarElement, CategoryScale, LinearScale);
12
+ let { stats, range, refreshedAt, pluginName = 'token-stats' }:
13
+ { stats: StatsResponse; range: TokenRange; refreshedAt: number; pluginName?: string } = $props();
14
+ const t = (key: string) => $isLoading ? '' : getPluginText(key, pluginName, (id, options) => $_(id, options));
15
+ const display = (value: unknown) => formatTokenCount(value, $locale ?? undefined);
16
+ const trend = $derived(buildTimeSeries(stats.data, range, stats.bucketMs ?? 0, refreshedAt));
17
+ const axisTicks = $derived(trend.buckets.length ? timeAxisLabels([
18
+ trend.buckets[0].startMs, trend.buckets[Math.floor(trend.buckets.length / 2)].startMs,
19
+ trend.buckets[trend.buckets.length - 1].startMs,
20
+ ], $locale ?? undefined) : ['', '', '']);
21
+ const denseAxis = $derived(axisTicks.join('').length > 42);
22
+ const tones = ['bg-nexus-500', 'bg-sky-400', 'bg-emerald-400', 'bg-amber-400', 'bg-red-400'] as const;
23
+ // Match the original Tailwind palette, including carbon-500 and zinc-500.
24
+ const palette = ['#f97316', '#38bdf8', '#34d399', '#fbbf24', '#f87171'];
25
+ const data: ChartData<'bar'> = $derived({
26
+ labels: trend.buckets.map(bucket => bucket.startMs),
27
+ datasets: trend.models.map(id => ({
28
+ label: modelLabel(id),
29
+ data: trend.buckets.map(bucket => bucket.parts.find(part => part.id === id)?.tokens ?? 0),
30
+ backgroundColor: id === OTHER_MODEL ? '#373d4a' : id === 'unknown' ? '#71717a' : palette[modelColorIndex(id)],
31
+ borderWidth: 0, borderRadius: 0, inflateAmount: 0,
32
+ })),
33
+ });
34
+ const options: ChartOptions<'bar'> = $derived({
35
+ responsive: true, maintainAspectRatio: false, animation: false, events: [],
36
+ layout: { padding: 0 },
37
+ scales: {
38
+ x: { stacked: true, display: false, offset: true },
39
+ y: { stacked: true, display: false, min: 0, max: Math.max(1, trend.maxTokens) },
40
+ },
41
+ plugins: { legend: { display: false }, tooltip: { enabled: false } },
42
+ });
43
+ const chartPlugins: Plugin<'bar'>[] = [{
44
+ id: 'token-stats-bar-style',
45
+ beforeUpdate(chart) {
46
+ // Fill each time slot, keeping the same 12px gutter at every viewport width.
47
+ const width = Math.max(0, chart.width / Math.max(1, chart.data.labels?.length ?? 0) - 12);
48
+ for (const dataset of chart.data.datasets) dataset.barThickness = width;
49
+ },
50
+ }];
51
+ let selectedBucketStart: number | null = $state(null);
52
+ const tooltipId = $props.id();
53
+ let tooltipAnchor: HTMLElement | null = $state(null);
54
+ let tooltipBucket: TimeBucket | undefined = $state();
55
+ let tooltipMode: 'hover' | 'focus' | 'touch' = 'hover';
56
+ let closeTimer: ReturnType<typeof setTimeout> | undefined;
57
+ let touchBucketWasOpen = false;
58
+ const selectedBucket = $derived(trend.buckets.find(bucket => bucket.startMs === selectedBucketStart));
59
+ $effect(() => { if (selectedBucket) tooltipBucket = selectedBucket; });
60
+ function modelLabel(id: string): string {
61
+ return id === OTHER_MODEL ? t('ui.otherModels') : id === 'unknown' ? t('ui.unknownModel') : id;
62
+ }
63
+ function modelTone(id: string): string {
64
+ return id === OTHER_MODEL ? 'bg-carbon-500' : id === 'unknown' ? 'bg-zinc-500' : tones[modelColorIndex(id)];
65
+ }
66
+ function timeLabel(startMs: number, full = false): string {
67
+ return new Intl.DateTimeFormat($locale ?? undefined, {
68
+ ...(full ? { month: '2-digit' as const, day: '2-digit' as const, timeZoneName: 'shortOffset' as const } : {}),
69
+ hour: '2-digit', minute: '2-digit', hourCycle: 'h23',
70
+ }).format(startMs);
71
+ }
72
+ function bucketDescription(bucket: TimeBucket): string {
73
+ return `${timeLabel(bucket.startMs, true)} · ${bucket.details.length
74
+ ? `${display(bucket.total)} Token · ${bucket.details.map(part => `${modelLabel(part.id)} ${display(part.tokens)}`).join(' · ')}`
75
+ : t('ui.noCountedTokens')}`;
76
+ }
77
+
78
+ function cancelClose() {
79
+ clearTimeout(closeTimer);
80
+ closeTimer = undefined;
81
+ }
82
+ function closeBucket() {
83
+ cancelClose();
84
+ selectedBucketStart = null;
85
+ tooltipAnchor = null;
86
+ }
87
+ function showBucket(startMs: number, element: HTMLElement, mode: 'hover' | 'focus' | 'touch') {
88
+ cancelClose();
89
+ tooltipMode = mode;
90
+ tooltipAnchor = element;
91
+ selectedBucketStart = startMs;
92
+ }
93
+ function leaveBucket() {
94
+ if (tooltipMode !== 'hover') return;
95
+ cancelClose();
96
+ // Allow the pointer to cross the small gap into the shared tooltip to read or scroll it.
97
+ closeTimer = setTimeout(closeBucket, 100);
98
+ }
99
+
100
+ onMount(() => () => cancelClose());
101
+ </script>
102
+
103
+ <div class="relative flex min-h-0 min-w-0 h-full flex-col overflow-hidden" data-testid="token-stats-time-chart">
104
+ <div class="relative min-h-0 flex-1 border-b border-carbon-500">
105
+ <div class="pointer-events-none absolute -left-[6px] -right-[6px] inset-y-0 z-10">
106
+ <Bar {data} {options} plugins={chartPlugins} aria-hidden="true" />
107
+ </div>
108
+ <div class="flex h-full items-end gap-[12px]" role="group" aria-label={t('ui.timeChart')}>
109
+ {#each trend.buckets as bucket, index (bucket.startMs)}
110
+ <button type="button" aria-pressed={selectedBucketStart === bucket.startMs}
111
+ aria-label={bucketDescription(bucket)} aria-describedby={selectedBucketStart === bucket.startMs ? tooltipId : undefined} data-token-stats-bucket-trigger
112
+ class="flex min-w-0 flex-1 self-stretch flex-col justify-end transition-colors hover:bg-carbon-700/40 focus-visible:outline focus-visible:outline-2 focus-visible:outline-nexus-500 {selectedBucketStart === bucket.startMs ? 'bg-carbon-700/40' : ''}"
113
+ onpointerenter={(event) => {
114
+ if (event.pointerType !== 'touch') showBucket(bucket.startMs, event.currentTarget, 'hover');
115
+ }}
116
+ onpointerleave={leaveBucket}
117
+ onfocus={(event) => showBucket(bucket.startMs, event.currentTarget, 'focus')}
118
+ onpointerdown={(event) => {
119
+ touchBucketWasOpen = event.pointerType === 'touch' && selectedBucketStart === bucket.startMs;
120
+ }}
121
+ onclick={(event) => {
122
+ if ('pointerType' in event && event.pointerType === 'touch') {
123
+ if (touchBucketWasOpen) closeBucket();
124
+ else showBucket(bucket.startMs, event.currentTarget, 'touch');
125
+ } else showBucket(bucket.startMs, event.currentTarget, event.detail === 0 ? 'focus' : 'hover');
126
+ }}
127
+ onblur={(event) => {
128
+ if (selectedBucketStart === bucket.startMs && !(event.relatedTarget instanceof HTMLElement && event.currentTarget.parentElement?.contains(event.relatedTarget))) closeBucket();
129
+ }}
130
+ onkeydown={(event) => {
131
+ if (event.key === 'Escape') closeBucket();
132
+ if (['ArrowLeft', 'ArrowRight', 'Home', 'End'].includes(event.key)) {
133
+ event.preventDefault();
134
+ const nextIndex = event.key === 'Home' ? 0 : event.key === 'End' ? trend.buckets.length - 1
135
+ : index + (event.key === 'ArrowRight' ? 1 : -1);
136
+ const next = event.currentTarget.parentElement?.children[nextIndex] as HTMLElement | undefined;
137
+ next?.focus();
138
+ }
139
+ }}>
140
+ </button>
141
+ {/each}
142
+ </div>
143
+ </div>
144
+ <Tooltip.SharedContent anchor={tooltipAnchor} open={selectedBucket !== undefined} id={tooltipId}
145
+ onclose={closeBucket} onpointerenter={cancelClose} onpointerleave={leaveBucket}
146
+ class="w-72 font-mono text-[11px]"
147
+ data-testid="token-stats-bucket-detail">
148
+ {#if tooltipBucket}
149
+ <div class="border-b border-carbon-600 pb-2 text-zinc-400">{timeLabel(tooltipBucket.startMs, true)}</div>
150
+ <div class="mt-2 flex items-baseline justify-between gap-3">
151
+ <span>Token</span><strong class="font-display text-sm text-zinc-50">{display(tooltipBucket.total)}</strong>
152
+ </div>
153
+ <div class="mt-2 space-y-1.5">
154
+ {#each tooltipBucket.details as part (part.id)}
155
+ <div class="flex items-start justify-between gap-3">
156
+ <span class="flex min-w-0 items-start gap-1.5">
157
+ <span class="mt-1 h-2 w-2 shrink-0 {modelTone(part.id)}" aria-hidden="true"></span>
158
+ <span class="break-all">{modelLabel(part.id)}</span>
159
+ </span>
160
+ <strong class="shrink-0 font-normal text-zinc-50">{display(part.tokens)}</strong>
161
+ </div>
162
+ {:else}
163
+ <span class="text-zinc-400">{t('ui.noCountedTokens')}</span>
164
+ {/each}
165
+ </div>
166
+ {/if}
167
+ </Tooltip.SharedContent>
168
+ <div class="mt-1 flex shrink-0 items-start justify-between gap-1 font-mono text-[10px] leading-tight text-zinc-400" aria-hidden="true">
169
+ <span class="shrink-0 whitespace-nowrap">{axisTicks[0]}</span>
170
+ <span class="min-w-0 whitespace-nowrap text-center {denseAxis ? 'hidden sm:inline' : ''}">{axisTicks[1]}</span>
171
+ <span class="shrink-0 whitespace-nowrap text-right">{axisTicks[2]}</span>
172
+ </div>
173
+ <div class="mt-2 flex shrink-0 flex-wrap gap-x-3 gap-y-1 font-mono text-[10px] text-zinc-300">
174
+ {#each trend.models as id (id)}
175
+ <span class="inline-flex min-w-0 items-center gap-1" title={modelLabel(id)}>
176
+ <span class="h-2 w-2 shrink-0 {modelTone(id)}" aria-hidden="true"></span>
177
+ <span class="max-w-28 truncate">{modelLabel(id)}</span>
178
+ </span>
179
+ {/each}
180
+ </div>
181
+ </div>
@@ -148,3 +148,31 @@ export function buildTimeSeries(rows: readonly ModelUsageRow[], range: TokenRang
148
148
  }
149
149
  return { buckets, models, maxTokens: Math.max(0, ...buckets.map(bucket => bucket.total)) };
150
150
  }
151
+
152
+ export function formatTokenCount(value: unknown, locale?: string): string {
153
+ const amount = tokenAmount(value);
154
+ return amount === undefined ? '—' : new Intl.NumberFormat(locale, { maximumFractionDigits: 0 }).format(amount);
155
+ }
156
+
157
+ /** Cache tokens are subsets of input; clamping prevents double counting inconsistent partial reports. */
158
+ export function tokenComposition(row: UsageSnapshot): { input: number; output: number; cacheRead: number; cacheWrite: number; total: number } {
159
+ const usage = usagePresentation(row);
160
+ const input = usage.input ?? 0;
161
+ const cacheRead = Math.min(input, tokenAmount(row.cacheReadTokens) ?? 0);
162
+ const cacheWrite = Math.min(input - cacheRead, tokenAmount(row.cacheWriteTokens) ?? 0);
163
+ const output = usage.output ?? 0;
164
+ return { input: input - cacheRead - cacheWrite, output, cacheRead, cacheWrite, total: input + output };
165
+ }
166
+
167
+ /** Dates and UTC offsets disambiguate midnight and repeated DST clock hours. */
168
+ export function timeAxisLabels(starts: readonly number[], locale?: string, timeZone?: string): string[] {
169
+ const zone = timeZone ? { timeZone } : {};
170
+ const days = new Intl.DateTimeFormat(locale, { ...zone, year: 'numeric', month: '2-digit', day: '2-digit' });
171
+ const dates = new Intl.DateTimeFormat(locale, { ...zone, month: 'numeric', day: 'numeric' });
172
+ const clock = new Intl.DateTimeFormat(locale, { ...zone, hour: '2-digit', minute: '2-digit', hourCycle: 'h23' });
173
+ const offsets = new Intl.DateTimeFormat(locale, { ...zone, timeZoneName: 'shortOffset' });
174
+ const crossesDay = new Set(starts.map(start => days.format(start))).size > 1;
175
+ const zoneLabels = starts.map(start => offsets.formatToParts(start).find(part => part.type === 'timeZoneName')?.value ?? '');
176
+ const changesOffset = new Set(zoneLabels).size > 1;
177
+ return starts.map((start, index) => `${crossesDay ? `${dates.format(start)} ` : ''}${clock.format(start)}${changesOffset ? ` ${zoneLabels[index]}` : ''}`);
178
+ }
@@ -0,0 +1,52 @@
1
+ import { writable } from 'svelte/store';
2
+ import type { ModelUsageRow, TokenRange, UsageSnapshot } from './labels';
3
+
4
+ export type StatsResponse = UsageSnapshot & {
5
+ groupBy: 'model' | 'time';
6
+ estimatedCostUsd: number | null;
7
+ bucketMs?: number;
8
+ data: ModelUsageRow[];
9
+ };
10
+ export type StatsState = { data: StatsResponse | null; busy: boolean; error: string; refreshedAt: number };
11
+
12
+ /** One request and timer for all subscribers; the last unsubscribe aborts pending work. */
13
+ export function createStatsResource(
14
+ load: (signal: AbortSignal) => Promise<StatsResponse>,
15
+ onStop: () => void = () => {},
16
+ ) {
17
+ let current: StatsState = { data: null, busy: false, error: '', refreshedAt: 0 };
18
+ let controller: AbortController | undefined;
19
+ let pending: Promise<void> | undefined;
20
+ let generation = 0;
21
+ let active = false;
22
+ const state = writable<StatsState>(current, () => {
23
+ active = true;
24
+ void refresh();
25
+ const timer = setInterval(() => void refresh(), 60_000);
26
+ return () => {
27
+ active = false; generation++; controller?.abort(); pending = undefined;
28
+ clearInterval(timer); onStop();
29
+ };
30
+ });
31
+ function publish(next: StatsState) { current = next; state.set(next); }
32
+ function refresh(): Promise<void> {
33
+ if (!active) return Promise.resolve();
34
+ if (pending) return pending;
35
+ const request = new AbortController();
36
+ controller = request;
37
+ const version = ++generation;
38
+ publish({ ...current, busy: true });
39
+ pending = Promise.resolve().then(() => load(request.signal)).then(data => {
40
+ if (version === generation) publish({ data, busy: false, error: '', refreshedAt: Date.now() });
41
+ }).catch(error => {
42
+ if (version === generation && !request.signal.aborted) {
43
+ publish({ ...current, busy: false, error: error instanceof Error ? error.message : String(error) });
44
+ }
45
+ }).finally(() => { if (version === generation) pending = undefined; });
46
+ return pending;
47
+ }
48
+ return { subscribe: state.subscribe, refresh };
49
+ }
50
+
51
+ export type StatsResource = ReturnType<typeof createStatsResource>;
52
+ export type StatsRange = TokenRange;
@@ -0,0 +1,15 @@
1
+ import { requestPluginControl } from '@bungee/plugin-sdk';
2
+ import { createStatsResource, type StatsRange, type StatsResource, type StatsResponse } from './stats-resource';
3
+
4
+ const resources = new Map<string, StatsResource>();
5
+ export function getStatsResource(pluginName: string, range: StatsRange, groupBy: 'model' | 'time'): StatsResource {
6
+ const key = `${pluginName}:${range}:${groupBy}`;
7
+ let resource = resources.get(key);
8
+ if (!resource) {
9
+ resource = createStatsResource(signal => requestPluginControl<StatsResponse>(pluginName,
10
+ `/stats?range=${encodeURIComponent(range)}&groupBy=${groupBy}`, 'GET', undefined, signal),
11
+ () => { if (resources.get(key) === resource) resources.delete(key); });
12
+ resources.set(key, resource);
13
+ }
14
+ return resource;
15
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jeffusion/bungee-core",
3
- "version": "5.1.6",
3
+ "version": "5.2.0",
4
4
  "description": "Bungee reverse proxy core engine",
5
5
  "main": "./dist/main.js",
6
6
  "types": "./dist/main.d.ts",
@@ -12,7 +12,7 @@
12
12
  "test": "bun test"
13
13
  },
14
14
  "dependencies": {
15
- "@jeffusion/bungee-types": "^5.1.6",
15
+ "@jeffusion/bungee-types": "^5.2.0",
16
16
  "@types/lodash-es": "^4.17.12",
17
17
  "acorn": "^8.15.0",
18
18
  "canonicalize": "3.0.0",