@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.
@@ -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(''), review = $state(false);
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 = ''; review = false; error = null; createOpen = true; }
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; review = false;
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, confirmed=false) {
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,protect:confirmed});
86
- protectedIds = result.protectedRouteIds; routeKeyBindings = result.routeKeyBindings; ready = result.ready !== false;
87
- status = message(publicationMessage(result)); routeConfigOpen = false; pendingRouteKey = null;
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
- if (selectedPublic.length) {
114
- try {
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(protectionCount ? 'ui.createdProtected' : 'ui.createdPublic');
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">{#if publicBlockedReason(route.id)}
182
- <Tooltip.Root openDelay={200} closeOnEscape>
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' : (allRoutes || allowed.includes(route.id)) ? 'standby' : 'muted'}>{protectedIds.includes(route.id) ? t('ui.requiresKey') : (allRoutes || allowed.includes(route.id)) ? t('ui.publicToProtected') : t('ui.publicRoute')}</StatusBadge>
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 selectedPublic.length}<p class="text-sm text-amber-400">{t('ui.selectedPublicWarning', { count: selectedPublic.length })}</p>{:else if !allRoutes && !allowed.length}<p class="text-sm text-zinc-400">{t('ui.noRoutesSelected')}</p>{/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
- {#snippet reviewChanges()}
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
- {#if review}{@render reviewChanges()}{:else}
241
- {@render basicFields()}
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()}{#if review}<button class="nx-btn-ghost" disabled={busy} onclick={() => review = false}>{t('ui.back')}</button>{/if}<button class="nx-btn-ghost" disabled={busy} onclick={() => { createOpen = false; review = false; }}>{t('ui.cancel')}</button><button class="nx-btn-primary" form="create-api-key" disabled={busy}>{busy ? t('ui.saving') : !review && selectedPublic.length ? t('ui.next') : t('ui.save')}</button>{/snippet}
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={review ? t('ui.reviewTitle') : t('ui.editKey', { name: target?.name ?? '' })} description={review ? t('ui.editReviewHelp') : t('ui.editHelp')} width="48rem" {busy} scrollBody onOpenChange={open => { if (!open) { guard.invalidate(); } }}>
250
- {#snippet body()}{#if policyLoading}<LoadingIndicator label={t('ui.loadingPolicy')} height="sm" />{:else}<form id="key-route-policy" class="space-y-5" onsubmit={update}>{#if review}{@render reviewChanges()}{:else}{@render basicFields()}
251
- {@render routeFields()}{/if}{#if error}<p role="alert" class="text-sm text-red-400">{displayMessage(error)}</p>{/if}</form>{/if}{/snippet}
252
- {#snippet footer()}{#if review}<button class="nx-btn-ghost" disabled={busy} onclick={() => review = false}>{t('ui.back')}</button>{/if}<button class="nx-btn-ghost" disabled={busy} onclick={() => { policyOpen = false; review = 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') : !review && selectedPublic.length ? t('ui.next') : t('ui.save')}</button>{/snippet}
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 || !!routeKeyBindings[selectedRoute?.id ?? '']?.some(item => item.id === key.id)} onclick={() => void applyRouteKey(key)}>{routeKeyBindings[selectedRoute?.id ?? '']?.some(item => item.id === key.id) ? t('ui.applied') : t('ui.apply')}</button>
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>{#if pendingRouteKey}<button class="nx-btn-primary" disabled={busy} onclick={() => pendingRouteKey && applyRouteKey(pendingRouteKey,true)}>{busy ? t('ui.saving') : t('ui.apply')}</button>{/if}{/snippet}
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: false, error: '', refreshedAt: 0 });
25
- let time: StatsState = $state({ data: null, busy: false, error: '', refreshedAt: 0 });
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, (id, options) => $_(id, options));
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
- {#if models.busy && !models.data}
101
- <LoadingIndicator label={t('ui.loading')} height="sm" />
102
- {:else if models.data}
103
- <div class="grid grid-cols-2 gap-3 xl:grid-cols-4" data-testid="token-stats-page-summary">
104
- <KpiCard class="min-w-0 [container-type:inline-size]" label={t('ui.input')} value={display(usage?.input)} valueTitle={fullCount(usage?.input)} />
105
- <KpiCard class="min-w-0 [container-type:inline-size]" label={t('ui.output')} value={display(usage?.output)} valueTitle={fullCount(usage?.output)} />
106
- <KpiCard class="min-w-0 [container-type:inline-size]" label={t('page.tokens')} value={display(modelTokenTotal(models.data))} valueTitle={fullCount(modelTokenTotal(models.data))} />
107
- <KpiCard class="min-w-0 [container-type:inline-size]" label={t('ui.estimatedCostUsd')} value={displayCost(models.data.estimatedCostUsd)} valueTitle={formatEstimatedUsd(models.data.estimatedCostUsd)} />
108
- </div>
109
- {#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}
110
- {/if}
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
- {#if models.data}
122
- <PanelCard title={t('page.models')} tag={`${rows.length} / ${models.data.data.length}`}>
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
- </PanelCard>
147
- {/if}
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 type OverviewTrend = { metric: OverviewMetric; percent: number | null; change: 'up' | 'down' | 'flat' | null; isNew: boolean };
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
- const valid = snapshot?.groupBy === 'time' && Number.isSafeInteger(asOfMs) && Number.isSafeInteger(bucketMs) && bucketMs! > 0;
13
- const currentStart = valid ? Math.floor(asOfMs! / bucketMs!) * bucketMs! - bucketMs! : NaN;
14
- return OVERVIEW_METRICS.map(metric => {
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(currentStart - bucketMs!), current = amount(currentStart);
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.15.1",
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.1",
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",