@jeffusion/bungee-core 5.15.2 → 5.16.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.
|
@@ -242,6 +242,8 @@
|
|
|
242
242
|
"ui.previousInterval": "vs previous {interval}",
|
|
243
243
|
"ui.minutes": "min",
|
|
244
244
|
"ui.hours": "h",
|
|
245
|
+
"ui.days": "d",
|
|
246
|
+
"page.trendHint": "Compares the two latest complete time buckets in the selected range; incomplete buckets are excluded.",
|
|
245
247
|
"ui.newActivity": "New",
|
|
246
248
|
"page.range": "Time range",
|
|
247
249
|
"page.range.1h": "1 hour",
|
|
@@ -355,6 +357,8 @@
|
|
|
355
357
|
"ui.previousInterval": "较上个{interval}",
|
|
356
358
|
"ui.minutes": "分钟",
|
|
357
359
|
"ui.hours": "小时",
|
|
360
|
+
"ui.days": "天",
|
|
361
|
+
"page.trendHint": "对比所选范围内最近两个完整统计时段,未完成时段不参与。",
|
|
358
362
|
"ui.newActivity": "新增",
|
|
359
363
|
"page.range": "时间范围",
|
|
360
364
|
"page.range.1h": "1 小时",
|
|
@@ -11,6 +11,8 @@
|
|
|
11
11
|
import { Input } from '$components/ui/input';
|
|
12
12
|
import UsageTimeChart from './UsageTimeChart.svelte';
|
|
13
13
|
import { getStatsResource } from './stats';
|
|
14
|
+
import KpiTrendValue from '$components/industrial/KpiTrendValue.svelte';
|
|
15
|
+
import { overviewTrends, PAGE_METRICS } from './overview-trends';
|
|
14
16
|
import { formatCacheInputPercentage, formatEstimatedUsd, formatTokenCount, modelTokenTotal, tokenComposition, usagePresentation, type TokenPageRange } from './labels';
|
|
15
17
|
import type { StatsState, StatsResource } from './stats-resource';
|
|
16
18
|
|
|
@@ -21,11 +23,12 @@
|
|
|
21
23
|
let keysFailed = $state(false);
|
|
22
24
|
let sort = $state('tokens');
|
|
23
25
|
let search = $state('');
|
|
24
|
-
let models: StatsState = $state({ data: null, busy:
|
|
25
|
-
let time: StatsState = $state({ data: null, busy:
|
|
26
|
+
let models: StatsState = $state({ data: null, busy: true, error: '', refreshedAt: 0 });
|
|
27
|
+
let time: StatsState = $state({ data: null, busy: true, error: '', refreshedAt: 0 });
|
|
26
28
|
let modelResource: StatsResource | undefined = $state();
|
|
27
29
|
let timeResource: StatsResource | undefined = $state();
|
|
28
|
-
const t = (key: string) => $isLoading ? '' : getPluginText(key, pluginName,
|
|
30
|
+
const t = (key: string, values?: Record<string, string>) => $isLoading ? '' : getPluginText(key, pluginName,
|
|
31
|
+
(id, options) => $_(id, { ...options, values }));
|
|
29
32
|
const display = formatCompactNumber;
|
|
30
33
|
const fullCount = (value: unknown) => formatTokenCount(value, $locale ?? undefined);
|
|
31
34
|
const displayCost = (value: unknown) => typeof value === 'number' && value >= 1000
|
|
@@ -47,6 +50,16 @@
|
|
|
47
50
|
.filter(row => modelLabel(row.dimension).toLowerCase().includes(search.trim().toLowerCase()))
|
|
48
51
|
.sort((a, b) => rankAmount(b) - rankAmount(a) || a.dimension.localeCompare(b.dimension)));
|
|
49
52
|
const maxTokens = $derived(Math.max(0, ...rows.map(row => modelTokenTotal(row) ?? 0)));
|
|
53
|
+
const modelsLoading = $derived(models.busy && !models.data);
|
|
54
|
+
const trends = $derived(overviewTrends(time.error || time.data?.reportingIncomplete ? null : time.data, PAGE_METRICS));
|
|
55
|
+
const comparisonCaption = $derived(t('ui.previousInterval', { interval: ['day', '1d'].includes(range)
|
|
56
|
+
? `1${t('ui.hours')}` : `1${t('ui.days')}` }));
|
|
57
|
+
const summaryMetrics = $derived([
|
|
58
|
+
{ metric: 'input', label: t('ui.input'), value: display(usage?.input), title: fullCount(usage?.input) },
|
|
59
|
+
{ metric: 'output', label: t('ui.output'), value: display(usage?.output), title: fullCount(usage?.output) },
|
|
60
|
+
{ metric: 'tokens', label: t('page.tokens'), value: display(models.data ? modelTokenTotal(models.data) : null), title: fullCount(models.data ? modelTokenTotal(models.data) : null) },
|
|
61
|
+
{ metric: 'cost', label: t('ui.estimatedCostUsd'), value: displayCost(models.data?.estimatedCostUsd), title: formatEstimatedUsd(models.data?.estimatedCostUsd) },
|
|
62
|
+
] as const);
|
|
50
63
|
const busy = $derived(models.busy || time.busy);
|
|
51
64
|
const refreshedAt = $derived(Math.min(models.refreshedAt, time.refreshedAt));
|
|
52
65
|
const ranges = $derived(['day', 'week', 'month', '1d', '7d', '30d'].map(value => ({ value, label: t(`page.range.${value}`) })));
|
|
@@ -76,6 +89,10 @@
|
|
|
76
89
|
function refresh() { void modelResource?.refresh(); void timeResource?.refresh(); }
|
|
77
90
|
</script>
|
|
78
91
|
|
|
92
|
+
{#snippet loadingKpi()}
|
|
93
|
+
<LoadingIndicator label={t('ui.loading')} size="sm" height="none" class="h-9 w-full" />
|
|
94
|
+
{/snippet}
|
|
95
|
+
|
|
79
96
|
<div class="min-w-0 space-y-4" data-testid="token-stats-page">
|
|
80
97
|
<div class="flex flex-wrap items-center justify-between gap-3">
|
|
81
98
|
<div class="flex flex-wrap items-center gap-3">
|
|
@@ -97,29 +114,40 @@
|
|
|
97
114
|
<span>{t('ui.loadFailed')}</span><Button variant="ghost" size="sm" onclick={refresh} disabled={busy}>{t('page.retry')}</Button>
|
|
98
115
|
</div>
|
|
99
116
|
{/if}
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
117
|
+
<div class="grid grid-cols-2 gap-3 xl:grid-cols-4" data-testid="token-stats-page-summary" aria-busy={modelsLoading}>
|
|
118
|
+
{#each summaryMetrics as item, index (item.metric)}
|
|
119
|
+
{@const trend = trends[index]!}
|
|
120
|
+
<KpiCard fillHeight reserveFooter class="h-[144px] min-w-0 [container-type:inline-size] xl:h-[168px]"
|
|
121
|
+
label={item.label} value={item.value} valueTitle={item.title} children={modelsLoading ? loadingKpi : undefined}>
|
|
122
|
+
{#snippet foot()}
|
|
123
|
+
<div class="flex h-full min-w-0 flex-wrap items-center gap-x-1.5 gap-y-0.5" data-testid={`token-stats-page-trend-${item.metric}`} title={t('page.trendHint')}>
|
|
124
|
+
{#if time.busy && !time.data}
|
|
125
|
+
<LoadingIndicator size="xs" centered={false} height="none" label={t('ui.loading')} />
|
|
126
|
+
{:else}
|
|
127
|
+
<KpiTrendValue value={trend.isNew ? t('ui.newActivity') : trend.percent == null ? '—' : `${trend.percent >= 0 ? '+' : ''}${trend.percent.toFixed(1)}%`}
|
|
128
|
+
change={trend.change} direction={item.metric === 'cost' ? 'down' : 'up'} clamp />
|
|
129
|
+
<span class="nx-label-sm">{comparisonCaption}</span>
|
|
130
|
+
{/if}
|
|
131
|
+
</div>
|
|
132
|
+
{/snippet}
|
|
133
|
+
</KpiCard>
|
|
134
|
+
{/each}
|
|
135
|
+
</div>
|
|
136
|
+
{#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}
|
|
111
137
|
|
|
112
138
|
<PanelCard title={t('page.activity')} tag="TOKEN / TIME">
|
|
113
139
|
<div class="h-[240px] min-w-0 sm:h-[300px]">
|
|
114
|
-
{#if time.busy && !time.data}<LoadingIndicator label={t('ui.loading')} />
|
|
140
|
+
{#if time.busy && !time.data}<LoadingIndicator label={t('ui.loading')} height="none" class="h-full" />
|
|
115
141
|
{:else if time.data && usagePresentation(time.data).positive}
|
|
116
142
|
<UsageTimeChart stats={time.data} {range} refreshedAt={time.refreshedAt} {pluginName} presentation="page" />
|
|
117
143
|
{: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}
|
|
118
144
|
</div>
|
|
119
145
|
</PanelCard>
|
|
120
146
|
|
|
121
|
-
{
|
|
122
|
-
|
|
147
|
+
<PanelCard title={t('page.models')} tag={models.data ? `${rows.length} / ${models.data.data.length}` : ''}>
|
|
148
|
+
{#if modelsLoading}
|
|
149
|
+
<LoadingIndicator label={t('ui.loading')} />
|
|
150
|
+
{:else if models.data}
|
|
123
151
|
<div class="mb-3 flex flex-wrap items-center gap-3">
|
|
124
152
|
<BSegmentedControl options={sorts} bind:value={sort} ariaLabel={t('page.sort')} class="shrink-0" />
|
|
125
153
|
<Input bind:value={search} aria-label={t('page.searchModels')} placeholder={t('page.searchModels')} class="min-w-[140px] flex-1 sm:max-w-64" />
|
|
@@ -143,6 +171,8 @@
|
|
|
143
171
|
</li>
|
|
144
172
|
{:else}<li class="py-6 text-center text-xs text-zinc-400">{t(search ? 'page.noModels' : 'ui.noData')}</li>{/each}
|
|
145
173
|
</ol>
|
|
146
|
-
|
|
147
|
-
|
|
174
|
+
{:else}
|
|
175
|
+
<p class="py-6 text-center text-xs text-zinc-400">{t(models.error ? 'ui.loadFailed' : 'ui.noData')}</p>
|
|
176
|
+
{/if}
|
|
177
|
+
</PanelCard>
|
|
148
178
|
</div>
|
|
@@ -1,29 +1,42 @@
|
|
|
1
1
|
import { bucketTrend } from '../../../packages/ui/src/components/dashboard/trends';
|
|
2
|
-
import { usagePresentation } from './labels';
|
|
2
|
+
import { modelTokenTotal, usagePresentation } from './labels';
|
|
3
3
|
import type { StatsResponse } from './stats-resource';
|
|
4
4
|
|
|
5
5
|
export const OVERVIEW_METRICS = ['input', 'output', 'cost'] as const;
|
|
6
6
|
export type OverviewMetric = typeof OVERVIEW_METRICS[number];
|
|
7
|
-
export
|
|
7
|
+
export const PAGE_METRICS = ['input', 'output', 'tokens', 'cost'] as const;
|
|
8
|
+
export type SummaryMetric = typeof PAGE_METRICS[number];
|
|
9
|
+
export type OverviewTrend = { metric: SummaryMetric; percent: number | null; change: 'up' | 'down' | 'flat' | null; isNew: boolean };
|
|
8
10
|
|
|
9
11
|
/** Compare adjacent completed server buckets. Missing traffic is zero; missing metering stays unknown. */
|
|
10
|
-
export function overviewTrends(snapshot: StatsResponse | null): OverviewTrend[] {
|
|
12
|
+
export function overviewTrends(snapshot: StatsResponse | null, metrics: readonly SummaryMetric[] = OVERVIEW_METRICS): OverviewTrend[] {
|
|
11
13
|
const { asOfMs, bucketMs } = snapshot ?? {};
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
14
|
+
let valid = snapshot?.groupBy === 'time' && Number.isSafeInteger(asOfMs) && Number.isSafeInteger(bucketMs) && bucketMs! > 0;
|
|
15
|
+
let currentStart = valid ? Math.floor(asOfMs! / bucketMs!) * bucketMs! - bucketMs! : NaN;
|
|
16
|
+
let previousStart = currentStart - bucketMs!;
|
|
17
|
+
if (valid && snapshot?.bucketStarts) {
|
|
18
|
+
const starts = snapshot.bucketStarts;
|
|
19
|
+
valid = starts.every((start, index) => Number.isSafeInteger(start) && (index === 0 || start > starts[index - 1]!));
|
|
20
|
+
// Calendar days can span 23/25 hours; rolling page windows need not align with the UTC clock.
|
|
21
|
+
const current = valid ? starts.findLastIndex((start, index) =>
|
|
22
|
+
(starts[index + 1] ?? snapshot.bucketEndMs ?? start + bucketMs!) <= asOfMs!) : -1;
|
|
23
|
+
valid = current >= 1;
|
|
24
|
+
currentStart = valid ? starts[current]! : NaN;
|
|
25
|
+
previousStart = valid ? starts[current - 1]! : NaN;
|
|
26
|
+
}
|
|
27
|
+
return metrics.map(metric => {
|
|
15
28
|
const amount = (start: number): number | null => {
|
|
16
29
|
if (!valid) return null;
|
|
17
30
|
const rows = snapshot!.data.filter(row => row.bucketStartMs === start);
|
|
18
31
|
let total = 0;
|
|
19
32
|
for (const row of rows) {
|
|
20
|
-
const value = metric === 'cost' ? row.estimatedCostUsd : usagePresentation(row)[metric];
|
|
33
|
+
const value = metric === 'cost' ? row.estimatedCostUsd : metric === 'tokens' ? modelTokenTotal(row) : usagePresentation(row)[metric];
|
|
21
34
|
if (value == null || !Number.isFinite(value) || value < 0) return null;
|
|
22
35
|
total += value;
|
|
23
36
|
}
|
|
24
37
|
return total;
|
|
25
38
|
};
|
|
26
|
-
const previous = amount(
|
|
39
|
+
const previous = amount(previousStart), current = amount(currentStart);
|
|
27
40
|
const known = previous !== null && current !== null;
|
|
28
41
|
const percent = known ? bucketTrend([previous, current]) : null;
|
|
29
42
|
return { metric, percent: Number.isFinite(percent) ? percent : null,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@jeffusion/bungee-core",
|
|
3
|
-
"version": "5.
|
|
3
|
+
"version": "5.16.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.
|
|
15
|
+
"@jeffusion/bungee-types": "^5.16.0",
|
|
16
16
|
"@types/lodash-es": "^4.17.12",
|
|
17
17
|
"acorn": "^8.15.0",
|
|
18
18
|
"canonicalize": "3.0.0",
|