@jeffusion/bungee-core 5.15.1 → 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.
- package/dist/main.js +25 -25
- package/dist/plugins/key-access/control.js +15 -14
- package/dist/plugins/key-access/control.js.map +3 -3
- package/dist/plugins/key-access/manifest.json +12 -54
- package/dist/plugins/key-access/ui/KeyPolicy.svelte +30 -80
- package/dist/plugins/token-stats/manifest.json +4 -0
- package/dist/plugins/token-stats/ui/TokenStatsPage.svelte +49 -19
- package/dist/plugins/token-stats/ui/overview-trends.ts +21 -8
- package/package.json +2 -2
|
@@ -2,7 +2,6 @@
|
|
|
2
2
|
import { onMount } from 'svelte';
|
|
3
3
|
import { isLoading, _, locale } from 'svelte-i18n';
|
|
4
4
|
import { getPluginText } from '$utils/plugin-i18n';
|
|
5
|
-
import * as Tooltip from '$components/ui/tooltip';
|
|
6
5
|
import { requestPluginControl, ApiError } from '$api/client';
|
|
7
6
|
import { getConfigSnapshot } from '$api/config';
|
|
8
7
|
import { PluginsAPI } from '$api/plugins';
|
|
@@ -14,11 +13,12 @@
|
|
|
14
13
|
let tab = $state('keys'), keys = $state<ApiKey[]>([]), routes = $state<{id:string;path:string}[]>([]), protectedIds = $state<string[]>([]);
|
|
15
14
|
let loading = $state(true), busy = $state(false), error = $state<Message | null>(null), status = $state<Message | null>(null), ready = $state(false), inactive = $state(false);
|
|
16
15
|
let createOpen = $state(false), policyOpen = $state(false), secretOpen = $state(false), deleteOpen = $state(false);
|
|
17
|
-
let target = $state<ApiKey | null>(null), name = $state(''), expires = $state(''), allRoutes = $state(false), allowed = $state<string[]>([]), models = $state('')
|
|
16
|
+
let target = $state<ApiKey | null>(null), name = $state(''), expires = $state(''), allRoutes = $state(false), allowed = $state<string[]>([]), models = $state('');
|
|
18
17
|
let secret = $state(''), copied = $state(false), secretStatus = $state<Message | null>(null), policyLoading = $state(false), policyReady = $state(false);
|
|
19
18
|
let routeConfigOpen = $state(false), selectedRoute = $state<{id:string;path:string}|null>(null);
|
|
20
|
-
let pendingRouteKey = $state<ApiKey|null>(null);
|
|
21
19
|
let routeKeyBindings = $state<Record<string,{id:string;name:string}[]>>({});
|
|
20
|
+
let unrestrictedKeyIds = $state<string[]>([]);
|
|
21
|
+
const isKeyApplied = (routeId:string, keyId:string) => unrestrictedKeyIds.includes(keyId) || !!routeKeyBindings[routeId]?.some(key => key.id === keyId);
|
|
22
22
|
type Message = { key: string; values?: Record<string, string | number | string[] | Message>; append?: Message };
|
|
23
23
|
const message = (key: string, values?: Message['values']): Message => ({ key, values });
|
|
24
24
|
const t = (key: string, values?: Record<string, string | number>) => $isLoading ? '' : getPluginText(key, pluginName, (id, options) => $_(id, { ...options, values }));
|
|
@@ -32,11 +32,9 @@
|
|
|
32
32
|
constructor(content: Message) { super(content.key); this.content = content; }
|
|
33
33
|
}
|
|
34
34
|
const keyStatusText = (key: ApiKey) => t(keyStatus(key) === '有效' ? 'ui.keyActive' : keyStatus(key) === '已过期' ? 'ui.keyExpired' : 'ui.keyRevoked');
|
|
35
|
-
function publicBlockedReason(id:string) { const bindings = routeKeyBindings[id] ?? []; return protectedIds.includes(id) && bindings.length ? message('ui.publicBlockedReason', { keys: bindings.map(key => key.name) }) : null; }
|
|
36
35
|
function errorMessage(e: unknown): Message {
|
|
37
36
|
if (e instanceof LocalizedError) return e.content;
|
|
38
37
|
if (e instanceof ApiError && (e.body as any)?.error === 'key_name_exists') return message('ui.keyNameExists');
|
|
39
|
-
if (e instanceof ApiError && (e.body as any)?.error === 'route_has_api_keys') return message('ui.routeHasKeys');
|
|
40
38
|
if (e instanceof ApiError && (e.body as any)?.persisted) return message('ui.persistedPending');
|
|
41
39
|
if (e instanceof ApiError) {
|
|
42
40
|
const code = (e.body as any)?.error;
|
|
@@ -51,7 +49,6 @@
|
|
|
51
49
|
return message('ui.operationFailed');
|
|
52
50
|
}
|
|
53
51
|
const guard = latestRequest();
|
|
54
|
-
let selectedPublic = $derived(routes.filter(route => (allRoutes || allowed.includes(route.id)) && !protectedIds.includes(route.id)));
|
|
55
52
|
const control = <T,>(path:string,method:'GET'|'PUT',body?:unknown) => requestPluginControl<T>(pluginName,path,method,body);
|
|
56
53
|
function failure(e:unknown) { if (e instanceof ApiError && (e.body as any)?.ready === false) ready = false; error = errorMessage(e); }
|
|
57
54
|
async function refresh() {
|
|
@@ -61,15 +58,15 @@
|
|
|
61
58
|
keys = credentials.keys; routes = config.config.logical_configuration.routes.map(route => ({id:route.id,path:route.path}));
|
|
62
59
|
inactive = !plugins.find(plugin => plugin.name === pluginName)?.enabled;
|
|
63
60
|
if (inactive) { ready = false; status = message('ui.inactive'); return; }
|
|
64
|
-
const protection = await control<{protectedRouteIds:string[];routeKeyBindings:Record<string,{id:string;name:string}[]>;ready?:boolean}>('/routes','GET');
|
|
65
|
-
protectedIds = protection.protectedRouteIds; routeKeyBindings = protection.routeKeyBindings; ready = protection.ready !== false;
|
|
61
|
+
const protection = await control<{protectedRouteIds:string[];routeKeyBindings:Record<string,{id:string;name:string}[]>;unrestrictedKeyIds?:string[];ready?:boolean}>('/routes','GET');
|
|
62
|
+
protectedIds = protection.protectedRouteIds; routeKeyBindings = protection.routeKeyBindings; unrestrictedKeyIds = protection.unrestrictedKeyIds ?? []; ready = protection.ready !== false;
|
|
66
63
|
status = ready ? null : message('ui.configurationPending');
|
|
67
64
|
} catch(e) { ready = false; failure(e); } finally { loading = false; }
|
|
68
65
|
}
|
|
69
66
|
onMount(() => { void refresh(); return () => guard.invalidate(); });
|
|
70
|
-
function newKey(routeId?:string) { if (secretOpen) return; name = ''; expires = ''; allRoutes = false; allowed = routeId ? [routeId] : []; models = '';
|
|
67
|
+
function newKey(routeId?:string) { if (secretOpen) return; name = ''; expires = ''; allRoutes = false; allowed = routeId ? [routeId] : []; models = ''; error = null; createOpen = true; }
|
|
71
68
|
async function edit(key:ApiKey) {
|
|
72
|
-
target = key; name = key.name; expires = localDate(key.expiresAt); copied = false; policyOpen = true; policyLoading = true; policyReady = false; error = null;
|
|
69
|
+
target = key; name = key.name; expires = localDate(key.expiresAt); copied = false; policyOpen = true; policyLoading = true; policyReady = false; error = null;
|
|
73
70
|
const generation = guard.begin();
|
|
74
71
|
try {
|
|
75
72
|
const result = await control<{value:{routes:string[]|null;models:string[]|null}|null}>(`/keys/${encodeURIComponent(key.id)}`,'GET');
|
|
@@ -77,18 +74,14 @@
|
|
|
77
74
|
allRoutes = result.value?.routes == null; allowed = result.value?.routes ?? []; models = result.value?.models?.join('\n') ?? ''; policyReady = true;
|
|
78
75
|
} catch(e) { if (guard.current(generation)) failure(e); } finally { if (guard.current(generation)) policyLoading = false; }
|
|
79
76
|
}
|
|
80
|
-
async function applyRouteKey(key:ApiKey
|
|
77
|
+
async function applyRouteKey(key:ApiKey) {
|
|
81
78
|
if (busy || !selectedRoute) return;
|
|
82
|
-
if (!protectedIds.includes(selectedRoute.id) && !confirmed) { pendingRouteKey = key; return; }
|
|
83
79
|
busy = true; error = null;
|
|
84
80
|
try {
|
|
85
|
-
const result = await control<{protectedRouteIds:string[];routeKeyBindings:Record<string,{id:string;name:string}[]>;ready?:boolean;published?:boolean}>('/route-key','PUT',{routeId:selectedRoute.id,keyId:key.id
|
|
86
|
-
protectedIds = result.protectedRouteIds; routeKeyBindings = result.routeKeyBindings; ready = result.ready !== false;
|
|
87
|
-
status = message(publicationMessage(result)); routeConfigOpen = false;
|
|
88
|
-
} catch(e) {
|
|
89
|
-
if (e instanceof ApiError && (e.body as any)?.error === 'route_protection_confirmation_required') pendingRouteKey = key;
|
|
90
|
-
else failure(e);
|
|
91
|
-
} finally { busy = false; }
|
|
81
|
+
const result = await control<{protectedRouteIds:string[];routeKeyBindings:Record<string,{id:string;name:string}[]>;unrestrictedKeyIds?:string[];ready?:boolean;published?:boolean}>('/route-key','PUT',{routeId:selectedRoute.id,keyId:key.id});
|
|
82
|
+
protectedIds = result.protectedRouteIds; routeKeyBindings = result.routeKeyBindings; unrestrictedKeyIds = result.unrestrictedKeyIds ?? []; ready = result.ready !== false;
|
|
83
|
+
status = message(publicationMessage(result)); routeConfigOpen = false;
|
|
84
|
+
} catch(e) { failure(e); } finally { busy = false; }
|
|
92
85
|
}
|
|
93
86
|
function toggleRoute(id:string,checked:boolean) { allowed = checked ? [...allowed,id] : allowed.filter(value => value !== id); }
|
|
94
87
|
function localDate(value:number|null) { return value === null ? '' : new Date(value - new Date(value).getTimezoneOffset()*60000).toISOString().slice(0,16); }
|
|
@@ -110,22 +103,13 @@
|
|
|
110
103
|
const result = includeDetails ? await keysApi.update(id,{name,expiresAt:expiration(),...scope}) : await control<{ready?:boolean;published?:boolean}>(`/keys/${encodeURIComponent(id)}`,'PUT',scope);
|
|
111
104
|
if ('key' in result) { const key = result.key as ApiKey; keys = keys.map(item => item.id === id ? key : item); target = key; }
|
|
112
105
|
if (result.ready === false || result.published === false) throw new LocalizedError(message('ui.keyRoutesPending'));
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
const result = await control<{protectedRouteIds:string[];ready?:boolean;published?:boolean}>('/routes','PUT',{protectedRouteIds:[...new Set([...protectedIds,...selectedPublic.map(route => route.id)])]});
|
|
116
|
-
protectedIds = result.protectedRouteIds;
|
|
117
|
-
if (result.ready === false || result.published === false) throw new LocalizedError(message('ui.protectionPending'));
|
|
118
|
-
} catch (e) { throw new LocalizedError(message('ui.keySavedProtectionIncomplete', { reason: errorMessage(e) })); }
|
|
119
|
-
}
|
|
120
|
-
const protection = await control<{protectedRouteIds:string[];routeKeyBindings:Record<string,{id:string;name:string}[]>}>('/routes','GET');
|
|
121
|
-
protectedIds = protection.protectedRouteIds; routeKeyBindings = protection.routeKeyBindings;
|
|
106
|
+
const protection = await control<{protectedRouteIds:string[];routeKeyBindings:Record<string,{id:string;name:string}[]>;unrestrictedKeyIds?:string[]}>('/routes','GET');
|
|
107
|
+
protectedIds = protection.protectedRouteIds; routeKeyBindings = protection.routeKeyBindings; unrestrictedKeyIds = protection.unrestrictedKeyIds ?? [];
|
|
122
108
|
}
|
|
123
109
|
async function create(event:SubmitEvent) {
|
|
124
110
|
event.preventDefault(); if (busy) return;
|
|
125
111
|
const expiresAt = expires ? new Date(expires).getTime() : null;
|
|
126
112
|
if (expiresAt !== null && (!Number.isFinite(expiresAt) || expiresAt <= Date.now())) { error = message('ui.expirationFuture'); return; }
|
|
127
|
-
if (selectedPublic.length && !review) { review = true; error = null; return; }
|
|
128
|
-
const protectionCount = selectedPublic.length;
|
|
129
113
|
busy = true; error = null; status = null; secretStatus = null;
|
|
130
114
|
let stage = 'ui.stageCreate';
|
|
131
115
|
try {
|
|
@@ -138,7 +122,7 @@
|
|
|
138
122
|
if (created.ready === false || created.published === false) { ready = false; secretStatus = message('ui.createdPending'); return; }
|
|
139
123
|
stage = 'ui.stageSavePolicy';
|
|
140
124
|
await savePolicy(created.key.id);
|
|
141
|
-
secretStatus = message(
|
|
125
|
+
secretStatus = message('ui.createdPublic');
|
|
142
126
|
status = secretStatus;
|
|
143
127
|
} catch(e) { failure(e); if (secret) secretStatus = message('ui.createdIncomplete', { stage: message(stage), reason: error ?? message('ui.operationFailed') }); }
|
|
144
128
|
finally { busy = false; }
|
|
@@ -146,16 +130,14 @@
|
|
|
146
130
|
async function update(event:SubmitEvent) {
|
|
147
131
|
event.preventDefault(); if (busy || policyLoading || !policyReady || !target) return;
|
|
148
132
|
try { expiration(); } catch(e) { failure(e); return; }
|
|
149
|
-
if (selectedPublic.length && !review) { review = true; error = null; return; }
|
|
150
133
|
busy = true; error = null; status = null;
|
|
151
134
|
try { await savePolicy(target.id,true); policyOpen = false; status = message('ui.policyPublished'); }
|
|
152
135
|
catch(e) { failure(e); } finally { busy = false; }
|
|
153
136
|
}
|
|
154
137
|
async function protect(id:string,checked:boolean) {
|
|
155
138
|
if (busy) return;
|
|
156
|
-
if (!checked && publicBlockedReason(id)) { error = publicBlockedReason(id); return; }
|
|
157
139
|
busy = true; error = null; status = null;
|
|
158
|
-
try { const result = await control<{protectedRouteIds:string[];routeKeyBindings:Record<string,{id:string;name:string}[]>;ready?:boolean;published?:boolean}>('/routes','PUT',{protectedRouteIds:checked ? [...new Set([...protectedIds,id])] : protectedIds.filter(value => value !== id)}); protectedIds = result.protectedRouteIds; routeKeyBindings = result.routeKeyBindings; status = message(publicationMessage(result)); ready = result.ready !== false; }
|
|
140
|
+
try { const result = await control<{protectedRouteIds:string[];routeKeyBindings:Record<string,{id:string;name:string}[]>;unrestrictedKeyIds?:string[];ready?:boolean;published?:boolean}>('/routes','PUT',{protectedRouteIds:checked ? [...new Set([...protectedIds,id])] : protectedIds.filter(value => value !== id)}); protectedIds = result.protectedRouteIds; routeKeyBindings = result.routeKeyBindings; unrestrictedKeyIds = result.unrestrictedKeyIds ?? []; status = message(publicationMessage(result)); ready = result.ready !== false; }
|
|
159
141
|
catch(e) { await refresh(); failure(e); } finally { busy = false; }
|
|
160
142
|
}
|
|
161
143
|
async function removeKey() {
|
|
@@ -178,26 +160,8 @@
|
|
|
178
160
|
{:else}
|
|
179
161
|
<PanelCard title={t('ui.routeAccess')} tag={t('ui.routeCount', { count: routes.length })}>
|
|
180
162
|
<p class="text-sm text-zinc-400 mb-4">{t('ui.routeHelp')}</p>
|
|
181
|
-
{#if loading}<LoadingIndicator label={t('ui.loadingRoutes')} height="sm" />{:else if !routes.length}<p class="text-sm text-zinc-400">{t('ui.noRoutes')}</p>{:else}<div class="overflow-x-auto"><table class="w-full text-left text-sm"><thead class="text-zinc-400"><tr><th class="px-2 py-3">{t('ui.route')}</th><th class="px-2 py-3">{t('ui.public')}</th><th class="px-2 py-3">{t('ui.actions')}</th></tr></thead><tbody>{#each routes as route (route.id)}<tr class="border-t border-carbon-600"><td class="px-2 py-3 font-mono">{route.path}</td><td class="px-2 py-3"
|
|
182
|
-
|
|
183
|
-
<Tooltip.Trigger asChild let:builder>
|
|
184
|
-
<!-- Disabled controls cannot receive focus; the group exposes their explanation to keyboard users. -->
|
|
185
|
-
<!-- svelte-ignore a11y_no_noninteractive_tabindex -->
|
|
186
|
-
<span {...builder} use:builder.action tabindex="0" role="group" aria-label={t('ui.cannotMakePublic', { path: route.path })} class="inline-flex outline-none focus-visible:ring-2 focus-visible:ring-nexus-500">
|
|
187
|
-
<BSwitch checked={false} description={t('ui.isRoutePublic', { path: route.path })} disabled />
|
|
188
|
-
</span>
|
|
189
|
-
</Tooltip.Trigger>
|
|
190
|
-
<Tooltip.Content class="max-w-xs break-words" side="top">
|
|
191
|
-
<div class="space-y-2">
|
|
192
|
-
<p class="font-semibold">{t('ui.publicBlockedTitle')}</p>
|
|
193
|
-
<ul class="list-disc pl-4 space-y-1">{#each routeKeyBindings[route.id] ?? [] as key (key.id)}<li>{key.name}</li>{/each}</ul>
|
|
194
|
-
<p class="text-carbon-300">{t('ui.publicBlockedHelp')}</p>
|
|
195
|
-
</div>
|
|
196
|
-
</Tooltip.Content>
|
|
197
|
-
</Tooltip.Root>
|
|
198
|
-
{:else}
|
|
199
|
-
<BSwitch checked={!protectedIds.includes(route.id)} description={t('ui.isRoutePublic', { path: route.path })} disabled={busy || loading || !ready} onchange={isPublic => protect(route.id,!isPublic)} />
|
|
200
|
-
{/if}</td><td class="px-2 py-3"><button class="nx-btn-ghost nx-btn-sm" disabled={busy || loading || !ready || secretOpen} onclick={() => { selectedRoute = route; pendingRouteKey = null; error = null; routeConfigOpen = true; }}>{t('ui.configureKey')}</button></td></tr>{/each}</tbody></table></div>{/if}
|
|
163
|
+
{#if loading}<LoadingIndicator label={t('ui.loadingRoutes')} height="sm" />{:else if !routes.length}<p class="text-sm text-zinc-400">{t('ui.noRoutes')}</p>{:else}<div class="overflow-x-auto"><table class="w-full text-left text-sm"><thead class="text-zinc-400"><tr><th class="px-2 py-3">{t('ui.route')}</th><th class="px-2 py-3">{t('ui.public')}</th><th class="px-2 py-3">{t('ui.actions')}</th></tr></thead><tbody>{#each routes as route (route.id)}<tr class="border-t border-carbon-600"><td class="px-2 py-3 font-mono">{route.path}</td><td class="px-2 py-3"><BSwitch checked={!protectedIds.includes(route.id)} description={t('ui.isRoutePublic', { path: route.path })} disabled={busy || loading || !ready} onchange={isPublic => protect(route.id,!isPublic)} />
|
|
164
|
+
</td><td class="px-2 py-3"><button class="nx-btn-ghost nx-btn-sm" disabled={busy || loading || !ready || secretOpen} onclick={() => { selectedRoute = route; error = null; routeConfigOpen = true; }}>{t('ui.configureKey')}</button></td></tr>{/each}</tbody></table></div>{/if}
|
|
201
165
|
</PanelCard>
|
|
202
166
|
{/if}
|
|
203
167
|
</div>
|
|
@@ -213,12 +177,12 @@
|
|
|
213
177
|
{#each routes as route (route.id)}
|
|
214
178
|
<label class="flex min-h-11 cursor-pointer items-center justify-between gap-3 px-3 py-2 hover:bg-carbon-700">
|
|
215
179
|
<span class="flex min-w-0 items-center gap-3"><input type="checkbox" class="accent-nexus-500" checked={allRoutes || allowed.includes(route.id)} disabled={busy || policyLoading || allRoutes} onchange={event => toggleRoute(route.id,event.currentTarget.checked)} /><span class="font-mono text-sm text-zinc-200 break-all">{route.path}</span></span>
|
|
216
|
-
<StatusBadge variant={protectedIds.includes(route.id) ? 'active' :
|
|
180
|
+
<StatusBadge variant={protectedIds.includes(route.id) ? 'active' : 'muted'}>{protectedIds.includes(route.id) ? t('ui.requiresKey') : t('ui.publicRoute')}</StatusBadge>
|
|
217
181
|
</label>
|
|
218
182
|
{:else}<p class="p-3 text-sm text-zinc-400">{t('ui.createRoutesFirst')}</p>{/each}
|
|
219
183
|
{#each allowed.filter(id => !routes.some(route => route.id === id)) as id}<label class="flex gap-2 p-3 text-sm text-amber-400"><input type="checkbox" checked disabled={busy} onchange={() => allowed = allowed.filter(value => value !== id)} />{t('ui.deletedRoute', { id })}</label>{/each}
|
|
220
184
|
</div>{/if}
|
|
221
|
-
{#if
|
|
185
|
+
{#if !allRoutes && !allowed.length}<p class="text-sm text-zinc-400">{t('ui.noRoutesSelected')}</p>{/if}
|
|
222
186
|
</fieldset>
|
|
223
187
|
<fieldset class="space-y-2 border-t border-carbon-600 pt-4">
|
|
224
188
|
<legend class="nx-field-label">{t('ui.modelScopeOptional')}</legend>
|
|
@@ -226,30 +190,20 @@
|
|
|
226
190
|
<p class="text-sm text-zinc-400">{t('ui.modelsHelp')}</p>
|
|
227
191
|
</fieldset>
|
|
228
192
|
{/snippet}
|
|
229
|
-
{
|
|
230
|
-
<div class="space-y-4" role="region" aria-label={t('ui.reviewTitle')}>
|
|
231
|
-
<p class="text-sm text-zinc-200">{t('ui.reviewWarning', { count: selectedPublic.length })}</p>
|
|
232
|
-
<div class="border border-carbon-600 divide-y divide-carbon-600">{#each selectedPublic as route (route.id)}<div class="flex flex-wrap items-center justify-between gap-3 p-3"><span class="font-mono text-sm text-zinc-200 break-all">{route.path}</span><StatusBadge variant="standby">{t('ui.publicToProtected')}</StatusBadge></div>{/each}</div>
|
|
233
|
-
<p class="text-sm text-zinc-400">{t('ui.reviewHelp')}</p>
|
|
234
|
-
<dl class="grid grid-cols-[auto_1fr] gap-x-4 gap-y-2 text-sm"><dt class="text-zinc-400">{t('ui.keyName')}</dt><dd class="text-zinc-200 break-all">{name}</dd><dt class="text-zinc-400">{t('ui.modelScope')}</dt><dd class="text-zinc-200 whitespace-pre-wrap break-all">{models.trim() || t('ui.allModels')}</dd></dl>
|
|
235
|
-
</div>
|
|
236
|
-
{/snippet}
|
|
237
|
-
<IndustrialDialog bind:open={createOpen} title={review ? t('ui.reviewTitle') : t('ui.createKey')} description={review ? t('ui.createReviewHelp') : t('ui.createHelp')} width="48rem" {busy} scrollBody>
|
|
193
|
+
<IndustrialDialog bind:open={createOpen} title={t('ui.createKey')} description={t('ui.createHelp')} width="48rem" {busy} scrollBody>
|
|
238
194
|
{#snippet body()}
|
|
239
195
|
<form id="create-api-key" class="space-y-5" onsubmit={create}>
|
|
240
|
-
{
|
|
241
|
-
|
|
242
|
-
{@render routeFields()}
|
|
243
|
-
{/if}
|
|
196
|
+
{@render basicFields()}
|
|
197
|
+
{@render routeFields()}
|
|
244
198
|
{#if error}<p role="alert" class="text-sm text-red-400">{displayMessage(error)}</p>{/if}
|
|
245
199
|
</form>
|
|
246
200
|
{/snippet}
|
|
247
|
-
{#snippet footer()}
|
|
201
|
+
{#snippet footer()}<button class="nx-btn-ghost" disabled={busy} onclick={() => { createOpen = false; }}>{t('ui.cancel')}</button><button class="nx-btn-primary" form="create-api-key" disabled={busy}>{busy ? t('ui.saving') : t('ui.save')}</button>{/snippet}
|
|
248
202
|
</IndustrialDialog>
|
|
249
|
-
<IndustrialDialog bind:open={policyOpen} title={
|
|
250
|
-
{#snippet body()}{#if policyLoading}<LoadingIndicator label={t('ui.loadingPolicy')} height="sm" />{:else}<form id="key-route-policy" class="space-y-5" onsubmit={update}>{
|
|
251
|
-
{@render routeFields()}{
|
|
252
|
-
{#snippet footer()}
|
|
203
|
+
<IndustrialDialog bind:open={policyOpen} title={t('ui.editKey', { name: target?.name ?? '' })} description={t('ui.editHelp')} width="48rem" {busy} scrollBody onOpenChange={open => { if (!open) { guard.invalidate(); } }}>
|
|
204
|
+
{#snippet body()}{#if policyLoading}<LoadingIndicator label={t('ui.loadingPolicy')} height="sm" />{:else}<form id="key-route-policy" class="space-y-5" onsubmit={update}>{@render basicFields()}
|
|
205
|
+
{@render routeFields()}{#if error}<p role="alert" class="text-sm text-red-400">{displayMessage(error)}</p>{/if}</form>{/if}{/snippet}
|
|
206
|
+
{#snippet footer()}<button class="nx-btn-ghost" disabled={busy} onclick={() => { policyOpen = false; guard.invalidate(); }}>{t('ui.cancel')}</button><button class="nx-btn-primary" form="key-route-policy" disabled={busy || policyLoading || !policyReady || !target || target.revokedAt !== null}>{busy ? t('ui.saving') : t('ui.save')}</button>{/snippet}
|
|
253
207
|
</IndustrialDialog>
|
|
254
208
|
<IndustrialDialog bind:open={secretOpen} title={t('ui.viewKey')} description={t('ui.viewHelp')} busy={busy} onOpenChange={open => { if (!open) { secret = ''; secretStatus = null; guard.invalidate(); } }}>
|
|
255
209
|
{#snippet body()}{#if secret}<code class="block select-all break-all border border-carbon-600 bg-carbon-950 p-3 font-mono text-zinc-200">{secret}</code>{:else if busy}<LoadingIndicator label={t('ui.loadingKeys')} height="sm" />{/if}{#if secretStatus}<p role="status" class="text-sm text-zinc-300 mt-3">{displayMessage(secretStatus)}</p>{/if}{#if copied}<p class="text-sm text-emerald-400 mt-3">{t('ui.copied')}</p>{/if}{/snippet}
|
|
@@ -262,9 +216,6 @@
|
|
|
262
216
|
|
|
263
217
|
<IndustrialDialog bind:open={routeConfigOpen} title={t('ui.configureKey')} description={t('ui.configureDescription', { path: selectedRoute?.path ?? '' })} {busy} scrollBody>
|
|
264
218
|
{#snippet body()}
|
|
265
|
-
{#if pendingRouteKey}
|
|
266
|
-
<p class="text-sm text-zinc-200">{#each t('ui.applyRouteWarning', { path: '\uE000', name: '\uE001' }).split(/(\uE000|\uE001)/) as part}{#if part === '\uE000'}<strong>{selectedRoute?.path}</strong>{:else if part === '\uE001'}<strong>{pendingRouteKey.name}</strong>{:else}{part}{/if}{/each}</p>
|
|
267
|
-
{:else}
|
|
268
219
|
<div class="space-y-4">
|
|
269
220
|
<button class="nx-btn-primary" disabled={busy} onclick={() => { routeConfigOpen = false; newKey(selectedRoute?.id); }}>{t('ui.newKey')}</button>
|
|
270
221
|
<div class="space-y-2">
|
|
@@ -274,14 +225,13 @@
|
|
|
274
225
|
{#each keys.filter(key => key.revokedAt === null) as key (key.id)}
|
|
275
226
|
<div class="flex items-center justify-between gap-3 py-3">
|
|
276
227
|
<div class="min-w-0"><p class="text-sm text-zinc-200 break-all">{key.name}</p><p class="font-mono text-xs text-zinc-400">{key.prefix}… · {keyStatusText(key)}</p></div>
|
|
277
|
-
<button class="nx-btn-ghost nx-btn-sm" disabled={busy ||
|
|
228
|
+
<button class="nx-btn-ghost nx-btn-sm" disabled={busy || isKeyApplied(selectedRoute?.id ?? '',key.id)} onclick={() => void applyRouteKey(key)}>{isKeyApplied(selectedRoute?.id ?? '',key.id) ? t('ui.applied') : t('ui.apply')}</button>
|
|
278
229
|
</div>
|
|
279
230
|
{:else}<p class="py-3 text-sm text-zinc-400">{t('ui.noUsableKeys')}</p>{/each}
|
|
280
231
|
</div>
|
|
281
232
|
</div>
|
|
282
233
|
</div>
|
|
283
|
-
{/if}
|
|
284
234
|
{#if error}<p role="alert" class="text-sm text-red-400 mt-3">{displayMessage(error)}</p>{/if}
|
|
285
235
|
{/snippet}
|
|
286
|
-
{#snippet footer()}<button class="nx-btn-ghost" disabled={busy} onclick={() => routeConfigOpen = false}>{t('ui.cancel')}</button>{
|
|
236
|
+
{#snippet footer()}<button class="nx-btn-ghost" disabled={busy} onclick={() => routeConfigOpen = false}>{t('ui.cancel')}</button>{/snippet}
|
|
287
237
|
</IndustrialDialog>
|
|
@@ -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",
|