@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.
- package/dist/main.js +36 -36
- 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 +21 -56
- package/dist/plugins/key-access/ui/KeyPolicy.svelte +30 -80
- package/dist/plugins/local-accounts/manifest.json +13 -7
- package/dist/plugins/token-budget/manifest.json +11 -4
- package/dist/plugins/token-budget/ui/KeyPolicy.svelte +1 -1
- package/dist/plugins/token-stats/manifest.json +1 -5
- package/dist/plugins/token-stats/ui/TokenStatsChart.svelte +5 -2
- package/dist/plugins/token-stats/ui/TokenStatsMetric.svelte +11 -15
- package/dist/plugins/token-stats/ui/TokenStatsPage.svelte +0 -3
- 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>
|
|
@@ -76,9 +76,15 @@
|
|
|
76
76
|
"capability": "self.password"
|
|
77
77
|
}
|
|
78
78
|
],
|
|
79
|
-
"navigation": [
|
|
80
|
-
|
|
81
|
-
|
|
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
|
|
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
|
-
"
|
|
95
|
-
|
|
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="/#/
|
|
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 {
|
|
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
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
{
|
|
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.
|
|
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.
|
|
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",
|