@jeffusion/bungee-core 5.15.0 → 5.15.2

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>
@@ -76,9 +76,15 @@
76
76
  "capability": "self.password"
77
77
  }
78
78
  ],
79
- "navigation": [],
80
- "settings": "/settings",
81
- "nativeSettingsComponent": "LocalAccountsSettings"
79
+ "navigation": [
80
+ {
81
+ "label": "metadata.name",
82
+ "path": "/settings",
83
+ "component": "LocalAccountsSettings",
84
+ "icon": "manage_accounts",
85
+ "target": "header"
86
+ }
87
+ ]
82
88
  },
83
89
  "metadata": {
84
90
  "name": "metadata.name",
@@ -88,10 +94,10 @@
88
94
  "configSchema": [],
89
95
  "translations": {
90
96
  "zh-CN": {
91
- "metadata.name": "管理认证",
97
+ "metadata.name": "认证管理",
92
98
  "metadata.description": "可选的单管理员账号密码认证",
93
99
  "login.title": "账号登录",
94
- "login.description": "使用管理认证插件中设置的唯一管理员账号。",
100
+ "login.description": "使用认证管理插件中设置的唯一管理员账号。",
95
101
  "login.username": "账号",
96
102
  "login.password": "密码",
97
103
  "login.busy": "正在建立登录会话…",
@@ -122,7 +128,7 @@
122
128
  "settings.accountTag": "账号",
123
129
  "settings.passwordTag": "密码",
124
130
  "settings.loading": "正在读取管理员账号",
125
- "settings.accountDescription": "管理认证使用此唯一管理员账号。停用插件后恢复匿名管理访问。",
131
+ "settings.accountDescription": "认证管理使用此唯一管理员账号。停用插件后恢复匿名管理访问。",
126
132
  "settings.passwordTitle": "修改登录密码",
127
133
  "settings.passwordDescription": "修改后当前登录会话失效,需要重新登录。",
128
134
  "settings.currentPassword": "当前密码",
@@ -151,7 +157,7 @@
151
157
  "session.reload": "重新读取"
152
158
  },
153
159
  "en": {
154
- "metadata.name": "Management authentication",
160
+ "metadata.name": "Authentication Management",
155
161
  "metadata.description": "Optional username and password authentication for a single administrator",
156
162
  "login.title": "Account sign-in",
157
163
  "login.description": "Use the sole administrator account configured in the management authentication plugin.",
@@ -91,8 +91,15 @@
91
91
  }
92
92
  }
93
93
  ],
94
- "settings": "/settings",
95
- "nativeSettingsComponent": "TokenBudgetKeyPolicy"
94
+ "navigation": [
95
+ {
96
+ "label": "metadata.name",
97
+ "path": "/settings",
98
+ "component": "TokenBudgetKeyPolicy",
99
+ "icon": "shield",
100
+ "target": "header"
101
+ }
102
+ ]
96
103
  },
97
104
  "metadata": {
98
105
  "name": "metadata.name",
@@ -191,7 +198,7 @@
191
198
  "status.exhausted": "Budget exhausted"
192
199
  },
193
200
  "zh-CN": {
194
- "metadata.name": "Token 预算",
201
+ "metadata.name": "Token预算",
195
202
  "plugin.description": "按 Key 设置 UTC 每日、每周、每月或累计 Token 与美元预算,并查看计量账本。",
196
203
  "ui.key": "API Key",
197
204
  "ui.search": "搜索 API Key",
@@ -218,7 +225,7 @@
218
225
  "status.unlimited": "不限制",
219
226
  "reason.notReady": "插件尚未就绪,暂不可修改。",
220
227
  "ui.title": "API Key 预算",
221
- "ui.tag": "Token 预算",
228
+ "ui.tag": "Token预算",
222
229
  "ui.description": "为每个 API Key 设置 Token 或 USD 预算,按每日、每周、每月或累计限制。未配置的 Key 不受预算限制。",
223
230
  "ui.behavior": "额度用尽后拒绝新请求;已接纳请求继续,最终用量可能超过额度。",
224
231
  "ui.loading": "正在读取预算",
@@ -82,7 +82,7 @@
82
82
  <div class="flex flex-wrap items-center gap-3">
83
83
  <label class="flex-1 min-w-0"><span class="sr-only">{t('ui.search')}</span><Input placeholder={t('ui.searchPlaceholder')} bind:value={search} /></label>
84
84
  <button class="nx-btn-ghost" disabled={loading || saving} onclick={refresh}>{t('ui.refresh')}</button>
85
- <a class="nx-btn-ghost" href="/#/plugins/key-access/settings">{t('ui.manageKeys')}</a>
85
+ <a class="nx-btn-ghost" href="/#/extensions/key-access/settings">{t('ui.manageKeys')}</a>
86
86
  </div>
87
87
  </div>
88
88
  {#if feedback}<p role="status" class="text-sm text-emerald-400 mb-3">{t(feedback)}</p>{/if}
@@ -178,8 +178,6 @@
178
178
  "ui.input": "Input",
179
179
  "ui.output": "Output",
180
180
  "ui.estimatedCostUsd": "Est. cost · USD",
181
- "ui.includesEstimate": "incl. estimate",
182
- "ui.reportingIncomplete": "Some usage records were lost. Reported totals may be incomplete.",
183
181
  "ui.unknownValue": "Unknown",
184
182
  "ui.unknownModel": "Unknown model",
185
183
  "ui.otherModels": "Other",
@@ -293,8 +291,6 @@
293
291
  "ui.input": "输入",
294
292
  "ui.output": "输出",
295
293
  "ui.estimatedCostUsd": "费用估算 · USD",
296
- "ui.includesEstimate": "含估算",
297
- "ui.reportingIncomplete": "部分用量记录丢失,统计总量可能不完整。",
298
294
  "ui.unknownValue": "未知",
299
295
  "ui.unknownModel": "未知模型",
300
296
  "ui.otherModels": "其他",
@@ -353,7 +349,7 @@
353
349
  "settings.mappingUnavailable": "目标模型已不在价格目录中,费用将显示未知;请选择其他模型或移除此映射。",
354
350
  "widgets.overview.title": "Token 概览",
355
351
  "widgets.time.title": "Token 趋势",
356
- "page.title": "Token 统计",
352
+ "page.title": "Token统计",
357
353
  "page.viewModels": "查看模型统计",
358
354
  "ui.refresh": "刷新",
359
355
  "ui.previousInterval": "较上个{interval}",
@@ -23,7 +23,7 @@
23
23
  $effect(() => {
24
24
  const report = onHeaderChange;
25
25
  const header = { summary: selectedRange, refresh: { label: t('ui.refresh'), busy: state.busy,
26
- disabled: !resource, run: () => { void resource?.refresh(); } } };
26
+ disabled: !resource, run: () => { void resource?.refresh(); } }, actions: headerActions };
27
27
  untrack(() => report?.(header));
28
28
  });
29
29
  onMount(() => {
@@ -32,6 +32,10 @@
32
32
  });
33
33
  </script>
34
34
 
35
+ {#snippet headerActions()}
36
+ <a href={`/#/extensions/${pluginName}/statistics`} class="inline-flex shrink-0 items-center gap-1 whitespace-nowrap text-[11px] text-zinc-400 hover:text-nexus-300 hover:underline">{t('page.viewModels')} <span>→</span></a>
37
+ {/snippet}
38
+
35
39
  <div class="flex h-full min-h-0 flex-col gap-2" data-testid="plugin-widget-token-stats">
36
40
  {#if state.error}<p role="alert" class="shrink-0 text-xs text-red-300">{t('ui.loadFailed')}</p>{/if}
37
41
  {#if state.busy && !state.data}
@@ -45,5 +49,4 @@
45
49
  <div class="min-h-0 flex-1"><UsageTimeChart stats={state.data} range={selectedRange} refreshedAt={state.refreshedAt} {pluginName} /></div>
46
50
  {/if}
47
51
  {/if}
48
- <a href={`/#/extensions/${pluginName}/statistics`} class="shrink-0 self-end text-[11px] text-zinc-400 hover:text-nexus-300 hover:underline">{t('page.viewModels')} →</a>
49
52
  </div>
@@ -3,10 +3,9 @@
3
3
  import { isLoading } from 'svelte-i18n';
4
4
  import { _, formatCompactNumber, type TimeRange } from '@bungee/plugin-sdk';
5
5
  import { getPluginText } from '$utils/plugin-i18n';
6
- import { LoadingIndicator } from '$components/industrial';
7
6
  import KpiTrendValue from '$components/industrial/KpiTrendValue.svelte';
8
7
  import type { NativeWidgetHeaderChange } from '$components/native-widgets/widget-header';
9
- import { estimatedTokens, formatEstimatedUsd, formatTokenCount, usagePresentation } from './labels';
8
+ import { formatEstimatedUsd, formatTokenCount, usagePresentation } from './labels';
10
9
  import { getStatsResource } from './stats';
11
10
  import type { StatsState, StatsResource } from './stats-resource';
12
11
  import { overviewTrends } from './overview-trends';
@@ -40,6 +39,11 @@
40
39
  </script>
41
40
 
42
41
  {#snippet trendFooter()}
42
+ {#if state.error}
43
+ <p role="alert" class="text-xs text-red-300">{t('ui.loadFailed')}</p>
44
+ {:else if usage?.state === 'empty'}
45
+ <p class="text-[10px] text-zinc-400" data-testid="token-stats-empty">{t('ui.noData')}</p>
46
+ {:else}
43
47
  <div class="overview-trends w-full" data-testid="token-stats-overview-trends">
44
48
  {#each trends as item}
45
49
  {@const value = item.isNew ? t('ui.newActivity') : item.percent == null ? '—' : `${item.percent >= 0 ? '+' : ''}${item.percent.toFixed(1)}%`}
@@ -50,14 +54,13 @@
50
54
  </div>
51
55
  {/each}
52
56
  </div>
57
+ {/if}
53
58
  {/snippet}
54
59
 
55
- <div class="kpi-metric-row w-full min-w-0" data-testid="token-stats-overview">
56
- {#if state.busy && !state.data}
57
- <LoadingIndicator size="sm" height="sm" label={t('ui.loading')} />
58
- {:else if state.error && !state.data}
59
- <p role="alert" class="text-xs text-red-300">{t('ui.loadFailed')}</p>
60
- {:else}
60
+ <!-- Keep the same intrinsic metric geometry while requests are pending or fail.
61
+ Desktop GridStack derives its shared row height from this content. -->
62
+ <div class="kpi-metric-row w-full min-w-0" data-testid="token-stats-overview" aria-busy={state.busy}>
63
+ {#if state.busy && !state.data}<span role="status" class="sr-only">{t('ui.loading')}</span>{/if}
61
64
  <div class="kpi-metric-row overview-metrics">
62
65
  {#each ['input', 'output', 'cost'] as metric}
63
66
  {@const fullValue = metric === 'cost' ? formatEstimatedUsd(state.data?.estimatedCostUsd)
@@ -69,16 +72,9 @@
69
72
  <div class="min-w-0" data-testid={`token-stats-metric-${metric}`}>
70
73
  <div class="nx-label mb-2">{t(metric === 'cost' ? 'ui.estimatedCostUsd' : `ui.${metric}`)}</div>
71
74
  <div class="overview-value nx-metric text-zinc-50" title={fullValue} aria-label={fullValue}>{value}</div>
72
- {#if metric !== 'cost' && state.data && (estimatedTokens(state.data, metric as 'input' | 'output') ?? 0) > 0}
73
- <p class="mt-1 text-[10px] text-zinc-400">{t('ui.includesEstimate')}</p>
74
- {/if}
75
75
  </div>
76
76
  {/each}
77
77
  </div>
78
- {#if state.data?.reportingIncomplete}<p role="status" class="mt-2 text-xs text-amber-300">{t('ui.reportingIncomplete')}</p>{/if}
79
- {#if state.error}<p role="alert" class="mt-2 text-xs text-red-300">{t('ui.loadFailed')}</p>
80
- {:else if usage?.state === 'empty'}<p class="mt-2 text-[10px] text-zinc-400" data-testid="token-stats-empty">{t('ui.noData')}</p>{/if}
81
- {/if}
82
78
  </div>
83
79
 
84
80
  <style>
@@ -97,9 +97,6 @@
97
97
  <span>{t('ui.loadFailed')}</span><Button variant="ghost" size="sm" onclick={refresh} disabled={busy}>{t('page.retry')}</Button>
98
98
  </div>
99
99
  {/if}
100
- {#if models.data?.reportingIncomplete || time.data?.reportingIncomplete}
101
- <p role="status" class="border-l-2 border-amber-500 px-3 py-2 text-xs text-amber-300">{t('ui.reportingIncomplete')}</p>
102
- {/if}
103
100
  {#if models.busy && !models.data}
104
101
  <LoadingIndicator label={t('ui.loading')} height="sm" />
105
102
  {:else if models.data}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jeffusion/bungee-core",
3
- "version": "5.15.0",
3
+ "version": "5.15.2",
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.0",
15
+ "@jeffusion/bungee-types": "^5.15.2",
16
16
  "@types/lodash-es": "^4.17.12",
17
17
  "acorn": "^8.15.0",
18
18
  "canonicalize": "3.0.0",