diff --git a/docs/proof/chat-settings-usage-tab-2026-08-28/capture.log.md b/docs/proof/chat-settings-usage-tab-2026-08-28/capture.log.md new file mode 100644 index 000000000..7c0fe4a67 --- /dev/null +++ b/docs/proof/chat-settings-usage-tab-2026-08-28/capture.log.md @@ -0,0 +1,103 @@ +# Visual proof capture log: chat settings retitle + Usage tab + +PR: feat/chat-settings-usage-tab. Captured 2026-08-28. + +## What is proven + +Two screenshots, captured with Playwright (chromium) against a real, +locally-built Docker image (`docker build -f deploy/docker/Dockerfile.open-webui +...`, the actual shipped Dockerfile's backend-patch pipeline and its own final +integrity assertion all ran and passed against this build; only the frontend +compile stage substituted a direct `npx vite build` for `npm run build` +because this sandbox cannot reach the pyodide asset CDN — see "What could not +be verified" below), running as a real container, reached over plain HTTP on +`localhost` with no mocking of the rendered UI. + +- `proof-general-chat-preferences.png`: Settings modal, General tab. Header + reads "Chat Preferences" (was the literal upstream string "WebUI Settings", + the exact parity-review finding). Tab rail: General, Account, Usage, + Interface, Audio, Data Controls, About — Usage clustered next to Account, + mirroring the Claude Desktop reference's grouping named in the task. +- `proof-usage-tab.png`: Settings modal, Usage tab (new). Shows "Usage isn't + available on this deployment.", a working "Refresh" control, and a "Last + updated" timestamp. This is the honest, designed fallback state, not a + placeholder: `hive_credits.py` (the OWUI-side proxy) fails closed to 404 + when its own upstream (control-plane's credits endpoint) is unreachable, + and `Usage.svelte` renders that as this explicit sentence rather than a + blank panel or a spinner stuck forever. See "What could not be verified" + for why the balance itself could not be exercised in this run. + +## Sign-in path used + +Not the shared QA fixture (`.env` lines 70-71) and not the production OAuth +("Continue with Hive") flow. This local verification stack's `open-webui` +service had `ENABLE_SIGNUP` and `ENABLE_LOGIN_FORM` overridden to `true` for +this run only (via an untracked, uncommitted `docker-compose.verify-override.yml`, +deleted after use), and a brand-new, throwaway account (`verify-@example.com`, random per-run password) was created through OWUI's own +native signup form, the first such account on a freshly created `owui-data` +volume becomes the local instance's own admin. No shared credential was read, +touched, or rotated. + +## What could not be verified, and why (environment, not code) + +Full live-data verification (a Usage tab showing a real non-zero credit +balance) was blocked by a pre-existing, environment-wide problem, confirmed +independently before touching anything: + +- The shared `.env`'s `SUPABASE_URL` (`https://yimgflllgdsbcibnaxqe.supabase.co`) + does not resolve at all (`curl: (6) Could not resolve host`). The Supabase + Cloud project it names was deleted during the self-hosted cutover + (`.wolf/decisions.md`, project self-hosted-Supabase-migration notes). +- `SUPABASE_DB_URL` points at the same dead project's pooler + (`aws-1-us-east-1.pooler.supabase.com`); it answers with a real Postgres + wire-protocol error, `FATAL: (ENOTFOUND) tenant/user + postgres.yimgflllgdsbcibnaxqe not found`, confirming the project reference + itself is gone, not a transient network issue. +- This is not specific to this run or this sandbox: other agents' + concurrently running `control-plane` containers on this same shared box + were independently observed in the same `unhealthy` state before this + session touched anything, using the same shared `.env`. +- The real, live self-hosted GoTrue is reachable and healthy at + `https://console-hive.scubed.co/auth/v1/health` (HTTP 200), confirming the + production deployment itself is fine; only the local `.env`'s pointers are + stale. + +Given this, exercising the real `internal/chat/credits/balance` code path +(which needs a live Postgres connection to resolve tenant -> account -> +balance) was not achievable from this sandbox without fabricating a database +connection string, which was not done. The captured "Usage isn't available on +this deployment." screenshot is the correct, honest behavior of this exact +condition, not a placeholder standing in for something unverified. The +formatter that would render a real balance (`formatUsdFromCredits`, ported +from `apps/web-console/lib/format/model-pricing.ts`) is independently unit +tested end to end in `vendor/open-webui/src/lib/hive/credits.test.ts`, +including the explicit "never renders a bare integer, zero renders `$0`" +invariant. + +Also not verified live: OAuth sign-in through the production "Continue with +Hive" flow, since `OPENID_PROVIDER_URL` in this deployment derives from the +same dead `SUPABASE_URL`. + +## Full test and build evidence (reproducible, not just this capture) + +- `npm run test:frontend -- --run` inside `vendor/open-webui`: 208/208 tests + passed across 16 files, including the 10 new regression tests in + `src/lib/hive/settings-usage-tab.test.ts`. +- `npx vite build` (real production compile of the full SvelteKit app, + including `Usage.svelte` and the `ChartBar` icon import): succeeded, + produced `build/index.html` and every real chunk (`ChartBar.js` present in + the emitted chunk list). +- `docker build -f deploy/docker/Dockerfile.open-webui ...` (the real, + shipped Dockerfile, unmodified except substituting the frontend build + command for the reason above): succeeded, including every one of the + Dockerfile's ~50 backend-patch and assertion steps, ending in its own final + integrity check: `hive: shell present, removed surfaces absent`. + +## Credential handling + +No credential-bearing URL was captured in any screenshot (headless Playwright +screenshots in this run capture only page content, not browser chrome or the +address bar). The throwaway signup email/password are not secrets (random, +single-use, `example.com`/`example.invalid`, never touch any real system) and +are not redacted for that reason. diff --git a/docs/proof/chat-settings-usage-tab-2026-08-29/capture.log.md b/docs/proof/chat-settings-usage-tab-2026-08-29/capture.log.md new file mode 100644 index 000000000..b6c1df646 --- /dev/null +++ b/docs/proof/chat-settings-usage-tab-2026-08-29/capture.log.md @@ -0,0 +1,135 @@ +# Visual proof capture log: Usage tab, populated state and enterprise absence + +PR: feat/chat-settings-usage-tab, second capture. Captured 2026-08-29. + +Supersedes the 2026-08-28 capture in the sibling directory, which proved the +General retitle but showed the Usage tab in its no-data fallback rather than +the claimed "credit balance and today's usage". That gap was the review +finding this capture closes. + +## What is proven + +Three screenshots, taken with Playwright (chromium, 1280x860) against a real +container built from this branch with the shipped +`deploy/docker/Dockerfile.open-webui`, reached over plain HTTP on localhost. +No stubbing of the rendered UI: the only thing intercepted is the one HTTP +response the tab consumes. + +- `20-settings-general.png`: Settings, General tab. Header reads "Chat + Preferences", the retitle. Tab rail reads General, Account, Usage, + Interface, Audio, Data Controls, About, with Usage clustered next to + Account. +- `21-settings-usage.png`: Settings, Usage tab, populated. "Organization + credit balance $12.50", "Organization usage today $0.34", the "Top up" + link, a "Last updated" stamp and the "Refresh" control. Both money labels + carry the organization scope, which is the relabel from the review: the + figures are tenant scope and previously read as personal. +- `22-settings-enterprise.png`: the same build with the credits endpoint + answering its documented 404. The Usage entry is absent from the rail + entirely (General, Account, Interface, Audio, Data Controls, About), which + is the silent absence posture `deploy/docker/owui-patches/hive_credits.py` + describes. Before this change the tab was present and permanently empty. + +## How the populated state was produced without a database + +Issue #1297 leaves this sandbox with no reachable Supabase, so the real +`internal/chat/credits/balance` chain cannot run here. It does not have to: +the browser sees exactly one credits response, and Playwright fulfils it. + +```js +await page.route('**/hive/credits/balance', async (route) => { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ + available_credits: 12500000000, + usage_today_credits: 340000000, + top_up_url: 'https://console-hive.scubed.co/console/billing' + }) + }); +}); +``` + +The two magnitudes are deliberately different, so the screenshot also shows +the figures landing in the right rows: 12,500,000,000 credits is $12.50 at +the D-046 rate of one billion credits per dollar, and 340,000,000 credits is +$0.34. A transposition would be visible in the image itself. The third +capture uses the same route with `status: 404` and the endpoint's own body, +`{"detail": "Credits are unavailable."}`. + +The panel's rendered text, read back from the live DOM in the same run: + +``` +Usage | Organization credit balance | $12.50 | Organization usage today | +$0.34 | Top up | Last updated: 11:07:13 PM | Refresh +``` + +Tab rails, read from the live DOM in the same run: + +``` +credits available: ["General","Account","Usage","Interface","Audio","Data Controls","About"] +credits absent: ["General","Account","Interface","Audio","Data Controls","About"] +``` + +## Sign-in path used + +Not the shared QA fixture and not the production OAuth flow. The container is +a throwaway with its own empty volume, `ENABLE_SIGNUP` and +`ENABLE_LOGIN_FORM` on for this run only, and the first account created on it +becomes its own local admin. That account was created through the container's +own signup endpoint with a random single use password at an `example.invalid` +address, used only against this container, and destroyed with it. Nothing +about it is written here, no shared credential was read, touched or rotated, +and no URL in any screenshot carries a token. + +## Build note, environment rather than code + +The image is the shipped Dockerfile with one substitution: the frontend build +step runs `npx vite build` instead of `npm run build`, because +`npm run pyodide:fetch` cannot reach the pyodide CDN from this sandbox and +fails the build outright (`fetch failed`, `SocketError: other side closed`, +observed twice). Everything else ran unmodified, including the stage that +runs `npm run test:frontend -- --run` against the real vendored node_modules, +which reported 16 files and 221 tests passed. That is the in place run of the +same test sources the pre-merge gate runs in its scratch tree, so the render +assertions added in this PR are confirmed to work in both places. + +The window chrome in these captures reads "Open WebUI" rather than the Hive +name because this standalone container is run without the compose file's +branding environment. It has no bearing on what is being proven. + +## Test evidence backing the same change + +`make test-owui-frontend`, the pre-merge gate, on the unmutated tree: + +``` +Test Files 16 passed (16) + Tests 221 passed (221) +14/14 components compiled +``` + +The same gate, with each of the reviewer's three mutations applied one at a +time: compile error red, transposed figures red on three assertions, emptied +click handler red on one assertion, exit 2 in every case. + +## Recaptured after the second review round + +The screenshots above were first taken at commit `6a30d39`, then retaken +unchanged in appearance at `338b699`, the head that answers the second review +round. That round changed how the number reaches the panel: the settings +modal's availability probe now hands its balance and fetch time straight to +the Usage panel instead of the panel firing its own request, and the probe is +serialized so two opens cannot land out of order. The panel therefore renders +the same figures from a snapshot rather than from its own mount fetch, which +is why this capture was redone rather than reused. + +Same run, read back from the live DOM at that head: + +``` +Usage | Organization credit balance | $12.50 | Organization usage today | +$0.34 | Top up | Last updated: 11:30:24 PM | Refresh +``` + +The image build for that container reported `Test Files 16 passed (16)` and +`Tests 221 passed (221)` from its in place `npm run test:frontend -- --run` +stage, against the same commit. diff --git a/scripts/test-owui-hive-frontend.sh b/scripts/test-owui-hive-frontend.sh index 957d83ddf..f5876fc7d 100755 --- a/scripts/test-owui-hive-frontend.sh +++ b/scripts/test-owui-hive-frontend.sh @@ -40,14 +40,15 @@ cp "$ROOT/vendor/open-webui/src/app.html" "$WORK"/app.html # flattened, so the mirroring described above still holds. cp -R "$SRC"/. "$WORK"/lib/hive/ -# The settings declutter guard pins the rendered surface of chat components, -# plus the layout/page files that also forward directConnections, by reading -# their sources. +# The settings declutter guard (plus the settings retitle/Usage-tab guard) +# pins the rendered surface of chat components, plus the layout/page files +# that also forward directConnections, by reading their sources. COMPONENT_SRC="$ROOT/vendor/open-webui/src/lib/components" for rel in \ chat/SettingsModal.svelte \ chat/ModelSelector/Selector.svelte \ chat/Settings/Account.svelte \ + chat/Settings/General.svelte \ chat/Settings/Advanced/AdvancedParams.svelte \ chat/MessageInput.svelte \ chat/Chat.svelte \ @@ -58,6 +59,19 @@ do cp "$COMPONENT_SRC/$rel" "$WORK/lib/components/$rel" done +# The two locale catalogues the settings guard reads. en-US is the key +# catalogue every other locale is generated from, and bn-BD is the first +# market, so a rename that silently drops a translated string fails here +# rather than in front of a Bangladeshi customer. Only these two travel: the +# other 61 are never asserted against and copying them would cost seconds per +# run for nothing. +I18N_SRC="$ROOT/vendor/open-webui/src/lib/i18n/locales" +for rel in en-US bn-BD +do + mkdir -p "$WORK/lib/i18n/locales/$rel" + cp "$I18N_SRC/$rel/translation.json" "$WORK/lib/i18n/locales/$rel/translation.json" +done + ROUTES_SRC="$ROOT/vendor/open-webui/src/routes" for rel in \ +layout.svelte \ @@ -78,6 +92,24 @@ cp "$ROOT/scripts/owui-hive-svelte-compile-check.mjs" "$WORK"/ # still needs no host node, per the Docker-only testing contract above. cp "$ROOT/vendor/open-webui/package-lock.json" "$WORK"/owui-package-lock.json +# A vitest config for the scratch tree, so a test can IMPORT a Hive component +# and assert what it renders rather than only reading its source as text. The +# in-place run (npm run test:frontend, Dockerfile.open-webui) gets the same +# capability from vite.config.ts's sveltekit() plugin; this is the scratch +# tree's equivalent, and both compile with the same pinned svelte, so a test +# that renders behaves identically in both places. Node environment on purpose: +# the render assertions use svelte/server, which needs no DOM, so nothing here +# depends on a jsdom the vendored lockfile does not carry. +cat > "$WORK"/vitest.config.mjs <<'CONFIG' +import { svelte } from '@sveltejs/vite-plugin-svelte'; +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + plugins: [svelte()], + test: { environment: 'node' } +}); +CONFIG + cd "$WORK" # Runs in a pinned node image rather than on host node, per CLAUDE.md's @@ -108,24 +140,36 @@ docker run --rm \ # drift. Both are installed into the scratch tree rather than pulled # through npx: vitest resolves @vitest/coverage-v8 relative to the project # root it runs from, and packages fetched through separate npx prefixes are - # invisible to that lookup. Same pattern as the svelte install below. + # invisible to that lookup. # The text reporter prints the per-file table plus an All files total line: # advisory measurement for this scratch-tree run, no thresholds. # Scoped to lib/hive, not all of lib: the upstream components and routes # copied in above are text fixtures the declutter guard reads, not code # this suite executes, so including them would drag the total down with # permanently-zero rows. - npm install --no-save --no-audit --no-fund --loglevel=error vitest@2 @vitest/coverage-v8@2 - npx vitest run --coverage --coverage.include="lib/hive/**" --coverage.reporter=text - svelte_version=$(node -e " + # + # Svelte and its vite plugin are installed BEFORE the test run, not after + # it, because the tests now import Hive components and render them; the + # compile pass below reuses the same install. Both versions come from the + # vendored lockfile so this check runs the EXACT versions the image build + # resolves. A major-only pin would let it pass with a different 5.x than + # deploy/docker/Dockerfile.open-webui uses, which is a fresh way to get a + # green check and a red deploy. + pinned=$(node -e " const lock = require(\"/work/owui-package-lock.json\"); - const entry = lock.packages && lock.packages[\"node_modules/svelte\"]; - if (!entry || !entry.version) { - console.error(\"svelte absent from vendor/open-webui/package-lock.json\"); + const pkgs = lock.packages || {}; + const svelte = pkgs[\"node_modules/svelte\"]; + const plugin = pkgs[\"node_modules/@sveltejs/vite-plugin-svelte\"]; + if (!svelte || !svelte.version || !plugin || !plugin.version) { + console.error(\"svelte or its vite plugin absent from vendor/open-webui/package-lock.json\"); process.exit(1); } - process.stdout.write(entry.version); + process.stdout.write(svelte.version + \" \" + plugin.version); ") - echo "compiling components with svelte@$svelte_version, the version the image build resolves" - npm install --no-save --no-audit --no-fund --loglevel=error "svelte@$svelte_version" + svelte_version=${pinned% *} + plugin_version=${pinned#* } + echo "pinning svelte@$svelte_version and @sveltejs/vite-plugin-svelte@$plugin_version, the versions the image build resolves" + npm install --no-save --no-audit --no-fund --loglevel=error \ + vitest@2 @vitest/coverage-v8@2 "svelte@$svelte_version" "@sveltejs/vite-plugin-svelte@$plugin_version" + npx vitest run --coverage --coverage.include="lib/hive/**" --coverage.reporter=text node owui-hive-svelte-compile-check.mjs lib/hive' diff --git a/vendor/open-webui/src/lib/components/chat/Settings/General.svelte b/vendor/open-webui/src/lib/components/chat/Settings/General.svelte index f8d798351..3d77ce6ad 100644 --- a/vendor/open-webui/src/lib/components/chat/Settings/General.svelte +++ b/vendor/open-webui/src/lib/components/chat/Settings/General.svelte @@ -225,7 +225,17 @@
-
{$i18n.t('WebUI Settings')}
+ +
{$i18n.t('Chat Preferences')}
{$i18n.t('Theme')}
diff --git a/vendor/open-webui/src/lib/components/chat/SettingsModal.svelte b/vendor/open-webui/src/lib/components/chat/SettingsModal.svelte index 0bf5890b0..1f0ee92ec 100644 --- a/vendor/open-webui/src/lib/components/chat/SettingsModal.svelte +++ b/vendor/open-webui/src/lib/components/chat/SettingsModal.svelte @@ -4,11 +4,13 @@ import { config, models, settings, user } from '$lib/stores'; import { updateUserSettings } from '$lib/apis/users'; import { getModels as _getModels } from '$lib/apis'; + import { refreshCreditSnapshot, type CreditSnapshot } from '$lib/hive/credits'; import Modal from '../common/Modal.svelte'; import Account from './Settings/Account.svelte'; import About from './Settings/About.svelte'; import General from './Settings/General.svelte'; + import Usage from '$lib/hive/SettingsUsage.svelte'; import Interface from './Settings/Interface.svelte'; import Audio from './Settings/Audio.svelte'; import DataControls from './Settings/DataControls.svelte'; @@ -17,6 +19,7 @@ import XMark from '../icons/XMark.svelte'; import Integrations from './Settings/Integrations.svelte'; import DatabaseSettings from '../icons/DatabaseSettings.svelte'; + import ChartBar from '../icons/ChartBar.svelte'; import SettingsAlt from '../icons/SettingsAlt.svelte'; import UserCircle from '../icons/UserCircle.svelte'; import SoundHigh from '../icons/SoundHigh.svelte'; @@ -34,6 +37,12 @@ selectedTab = show; show = true; } + // Hive: Usage is a hosted-SaaS surface, so the rail entry only appears + // once the credits endpoint actually answers. Probed on open rather + // than on mount because this component stays mounted for the whole + // session and a user who never opens Settings should not pay for a + // request they never see. + void probeCredits(); addScrollListener(); } else { selectedTab = 'general'; @@ -55,6 +64,8 @@ 'advancedparameters', 'advanced params', 'advanced parameters', + 'chat preferences', + 'chatpreferences', 'configuration', 'defaultparameters', 'default parameters', @@ -79,6 +90,72 @@ 'webui settings' ] }, + { + id: 'account', + title: 'Account', + keywords: [ + 'account preferences', + 'account settings', + 'accountpreferences', + 'accountsettings', + 'login', + 'notification webhook url', + 'notificationwebhookurl', + 'personal settings', + 'personalsettings', + 'privacy settings', + 'privacysettings', + 'profileavatar', + 'profile avatar', + 'profile details', + 'profile image', + 'profile picture', + 'profiledetails', + 'profileimage', + 'profilepicture', + 'security settings', + 'securitysettings', + 'update account', + 'updateaccount', + 'user account', + 'user data', + 'user preferences', + 'user profile', + 'useraccount', + 'userdata', + 'username', + 'userpreferences', + 'userprofile', + 'webhook url', + 'webhookurl' + ] + }, + { + // Usage (parity re-score finding: no consumption/credit surface + // anywhere in Settings). Grouped next to Account, mirroring the reference + // Claude Desktop rail (General, Account, Usage clustered together) + // named in that finding. + id: 'usage', + title: 'Usage', + keywords: [ + 'balance', + 'billing', + 'consumption', + 'credit balance', + 'creditbalance', + 'credits', + 'remaining balance', + 'remaining credits', + 'remainingbalance', + 'remainingcredits', + 'spend', + 'spending', + 'top up', + 'top-up', + 'topup', + 'usage' + ] + }, { id: 'interface', title: 'Interface', @@ -341,46 +418,6 @@ 'messagehistory' ] }, - { - id: 'account', - title: 'Account', - keywords: [ - 'account preferences', - 'account settings', - 'accountpreferences', - 'accountsettings', - 'login', - 'notification webhook url', - 'notificationwebhookurl', - 'personal settings', - 'personalsettings', - 'privacy settings', - 'privacysettings', - 'profileavatar', - 'profile avatar', - 'profile details', - 'profile image', - 'profile picture', - 'profiledetails', - 'profileimage', - 'profilepicture', - 'security settings', - 'securitysettings', - 'update account', - 'updateaccount', - 'user account', - 'user data', - 'user preferences', - 'user profile', - 'useraccount', - 'userdata', - 'username', - 'userpreferences', - 'userprofile', - 'webhook url', - 'webhookurl' - ] - }, { id: 'about', title: 'About', @@ -434,11 +471,54 @@ let availableSettings = []; let filteredSettings = []; + /* + * Hive: whether this deployment has a credits surface at all. Enterprise + * deployments never wire the chat container's credits proxy, which then + * fails closed with a 404 (deploy/docker/owui-patches/hive_credits.py), and + * silent absence is that posture's documented behavior. A Usage tab that is + * permanently stuck on "Usage isn't available on this deployment." would + * invert it, so the tab is gated on a balance actually answering. + * + * The answer is kept, not thrown away: it is handed to the Usage panel as + * its starting snapshot, so opening the tab shows the number the probe + * already fetched, with the time that fetch happened, instead of firing a + * second request for the same figure and rendering a spinner over data the + * modal is already holding. + * + * One probe at a time. This runs on every open of a modal that stays + * mounted for the whole session, so without the guard two opens in quick + * succession can land out of order and an older answer can overwrite a + * newer one. + */ + let creditsAvailable = false; + let creditsSnapshot: CreditSnapshot = { balance: null, lastUpdated: null }; + let creditsProbeInFlight = false; + + const probeCredits = async () => { + if (creditsProbeInFlight) return; + creditsProbeInFlight = true; + try { + // refreshCreditSnapshot, not a bare fetch: a failed re-probe keeps + // the last known good balance and its original timestamp rather + // than blanking a figure the customer has already seen. + creditsSnapshot = await refreshCreditSnapshot(creditsSnapshot); + } finally { + creditsProbeInFlight = false; + } + creditsAvailable = creditsSnapshot.balance !== null; + availableSettings = getAvailableSettings(); + setFilteredSettings(); + }; + let search = ''; let searchDebounceTimeout; const getAvailableSettings = () => { return allSettings.filter((tab) => { + if (tab.id === 'usage') { + return creditsAvailable; + } + if (tab.id === 'tools') { return ( $user?.role === 'admin' || @@ -605,6 +685,30 @@
{$i18n.t('General')}
+ {:else if tabId === 'usage'} + {:else if tabId === 'interface'} +
+
diff --git a/vendor/open-webui/src/lib/hive/credits.test.ts b/vendor/open-webui/src/lib/hive/credits.test.ts index 37003909b..0edf12ba3 100644 --- a/vendor/open-webui/src/lib/hive/credits.test.ts +++ b/vendor/open-webui/src/lib/hive/credits.test.ts @@ -8,6 +8,7 @@ import { dismissCredits, fetchCreditBalance, formatUsdFromCredits, + refreshCreditSnapshot, LOW_CREDITS_THRESHOLD } from './credits'; @@ -137,3 +138,39 @@ describe('fetchCreditBalance', () => { vi.unstubAllGlobals(); }); }); + +describe('refreshCreditSnapshot', () => { + const balance = { available_credits: 12_500_000_000, usage_today_credits: 340_000_000 }; + const older = new Date('2026-08-28T10:00:00Z'); + const newer = new Date('2026-08-28T11:00:00Z'); + + it('takes the fetched balance and stamps the time it arrived', async () => { + const previous = { balance: null, lastUpdated: null }; + const next = await refreshCreditSnapshot(previous, async () => balance, () => newer); + expect(next.balance).toEqual(balance); + expect(next.lastUpdated).toBe(newer); + }); + + it('keeps the last known good balance when a refresh fails', async () => { + // The regression: a transient network blip blanking a real number the + // customer is reading, which reads as credits having vanished. + const previous = { balance, lastUpdated: older }; + const next = await refreshCreditSnapshot(previous, async () => null, () => newer); + expect(next.balance).toEqual(balance); + }); + + it('never advances the last-updated stamp on a failed refresh', async () => { + // A stamp that moves on failure lies about how fresh the number beside + // it is, which is worse than a visibly old stamp. + const previous = { balance, lastUpdated: older }; + const next = await refreshCreditSnapshot(previous, async () => null, () => newer); + expect(next.lastUpdated).toBe(older); + }); + + it('stays empty when the very first load fails', async () => { + const previous = { balance: null, lastUpdated: null }; + const next = await refreshCreditSnapshot(previous, async () => null, () => newer); + expect(next.balance).toBeNull(); + expect(next.lastUpdated).toBeNull(); + }); +}); diff --git a/vendor/open-webui/src/lib/hive/credits.ts b/vendor/open-webui/src/lib/hive/credits.ts index 647c1d289..17852aa40 100644 --- a/vendor/open-webui/src/lib/hive/credits.ts +++ b/vendor/open-webui/src/lib/hive/credits.ts @@ -116,3 +116,41 @@ export function dismissCredits(): void { /* storage unavailable: banner simply reappears next mount */ } } + +/** + * What the Usage tab holds between refreshes: the last balance that actually + * arrived, and the moment it arrived. + */ +export interface CreditSnapshot { + balance: CreditBalance | null; + lastUpdated: Date | null; +} + +/** + * The Usage tab's refresh policy, kept in this module rather than inline in + * the component so it is executable in a test rather than only readable in a + * diff. + * + * Two invariants, both learned the hard way on a money surface: + * + * * A failed refresh must never wipe a balance already on screen. + * fetchCreditBalance returns null for every failure, including a network + * blip and an expired session, and blanking a real number on a transient + * failure tells the customer their credits vanished. + * * The last-updated stamp must never advance on a refresh that learned + * nothing. A stamp that moves on failure is a lie about how fresh the + * number beside it is, which is worse than a visibly old stamp. + * + * fetchBalance and now are injectable purely so the two invariants above can + * be asserted without a network or a clock; the app always takes the + * defaults. + */ +export async function refreshCreditSnapshot( + previous: CreditSnapshot, + fetchBalance: () => Promise = fetchCreditBalance, + now: () => Date = () => new Date() +): Promise { + const fetched = await fetchBalance(); + if (fetched === null) return previous; + return { balance: fetched, lastUpdated: now() }; +} diff --git a/vendor/open-webui/src/lib/hive/settings-usage-tab.test.ts b/vendor/open-webui/src/lib/hive/settings-usage-tab.test.ts new file mode 100644 index 000000000..bb0ad661d --- /dev/null +++ b/vendor/open-webui/src/lib/hive/settings-usage-tab.test.ts @@ -0,0 +1,278 @@ +import { describe, expect, it } from 'vitest'; +import { readFileSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; +import { render } from 'svelte/server'; +import { readable } from 'svelte/store'; + +import SettingsUsage from './SettingsUsage.svelte'; +import { formatUsdFromCredits, type CreditBalance, type CreditSnapshot } from './credits'; + +/* + * Regression guard for the P0.5 settings retitle plus Usage tab wave (parity + * finding: the right pane was literally titled "WebUI Settings" and there was + * no consumption or credit surface anywhere in Settings). + * + * Two kinds of assertion here, and the difference matters. The Usage component + * is rendered for real, server side, so a transposition of its two money + * figures fails a test instead of shipping. SettingsModal is pinned at source + * level only: it imports roughly forty sibling components and stores, none of + * which exist in the scratch tree the pre-merge gate runs in, so rendering it + * is not reachable before merge. The source pins below are therefore written + * against the exact mutations they have to catch, an emptied click handler + * included, rather than against the mere presence of a tab id. + */ + +const readSource = (rel: string): string => + readFileSync(fileURLToPath(new URL(rel, import.meta.url)), 'utf8'); + +const settingsModal = () => readSource('../components/chat/SettingsModal.svelte'); +const general = () => readSource('../components/chat/Settings/General.svelte'); +const locale = (code: string) => + JSON.parse(readSource('../i18n/locales/' + code + '/translation.json')); + +/* + * A rendered Usage tab, with the i18n store the app supplies through context. + * Server side render on purpose: it needs no DOM, so it runs identically in + * the scratch-tree gate (scripts/test-owui-hive-frontend.sh) and in the image + * build's in-place run, and the vendored lockfile does not have to grow a + * jsdom for it. The translator is the identity function, so every assertion + * below reads the English source string. + */ +const renderUsage = (initial: CreditSnapshot | null): string => { + const t = (key: string): string => key; + const context = new Map(); + context.set('i18n', readable({ t })); + return render(SettingsUsage, { props: { initial }, context }).body; +}; + +// Two deliberately different magnitudes. Equal figures would render the same +// string in both slots and a transposition would be invisible, which is the +// exact defect these assertions exist to catch. +const AVAILABLE_CREDITS = 12_500_000_000; +const USAGE_TODAY_CREDITS = 340_000_000; + +const snapshotFixture = (overrides: Partial = {}): CreditSnapshot => ({ + balance: { + available_credits: AVAILABLE_CREDITS, + usage_today_credits: USAGE_TODAY_CREDITS, + ...overrides + }, + lastUpdated: null +}); + +// A snapshot whose first load came back with nothing: the enterprise posture +// and the failed-first-fetch case are the same state here. +const EMPTY_SNAPSHOT: CreditSnapshot = { balance: null, lastUpdated: null }; + +/* + * The text a given slot actually renders. Reading the value out of the span + * that carries the test id, rather than searching the whole document, is what + * makes "the right number in the wrong row" a failure: a document-wide + * toContain would pass just as happily with the two figures swapped. + */ +const valueForTestId = (html: string, testId: string): string => { + const marker = 'data-testid="' + testId + '"'; + const at = html.indexOf(marker); + if (at === -1) return ''; + const open = html.indexOf('>', at); + const close = html.indexOf('', open); + // Sliced between the span's own boundaries and compared whole, rather + // than stripped of markup: the value is a formatted currency string with + // no nested elements, so any tag or comment turning up inside it is a + // real change in what the slot renders and should fail this comparison + // rather than be quietly removed from it. + return html.slice(open + 1, close).trim(); +}; + +describe('General tab no longer reads as stock Open WebUI branding', () => { + it('drops the literal WebUI Settings section header', () => { + expect(general()).not.toContain("t('WebUI Settings')"); + }); + + it('renames it to a Hive-authored label', () => { + expect(general()).toContain("t('Chat Preferences')"); + }); + + it('carries the retitle into the search keywords, so the old title is not the only way to find the tab', () => { + const src = settingsModal(); + // Both boundaries asserted before slicing. A missing end marker makes + // indexOf return -1, and slice(start, -1) would quietly widen this to + // most of the file, so the keywords could satisfy it from any other + // tab's descriptor. + const start = src.indexOf("id: 'general'"); + const end = src.indexOf("id: 'account'"); + expect(start).toBeGreaterThan(-1); + expect(end).toBeGreaterThan(start); + const generalBlock = src.slice(start, end); + expect(generalBlock).toContain("'chat preferences'"); + expect(generalBlock).toContain("'chatpreferences'"); + }); +}); + +describe('the new strings exist in the locale files, not only in the markup', () => { + // The rename drops a string that WAS translated in bn-BD: the old key had + // a Bengali value. Bangladesh is the first market, so the replacement and + // every new money label carry a Bengali translation rather than silently + // falling back to English there. The other 61 locales fall back until + // their own translators reach them, which is how every other untranslated + // key in this fork already behaves. + const strings = [ + 'Chat Preferences', + 'Organization credit balance', + 'Organization usage today', + 'Out of credits', + 'Top up', + 'Last updated', + 'Loading usage...', + "Usage isn't available on this deployment." + ]; + + it('registers every new string in en-US, the key catalogue', () => { + const enUS = locale('en-US'); + for (const key of strings) { + expect(Object.prototype.hasOwnProperty.call(enUS, key)).toBe(true); + } + }); + + it('translates every rendered string in bn-BD, the first market', () => { + const bnBD = locale('bn-BD'); + const rendered = strings.concat(['Usage', 'Refresh', 'Low']); + for (const key of rendered) { + expect(bnBD[key] ?? '').not.toBe(''); + } + }); +}); + +describe('Usage tab wiring in the settings modal', () => { + it('imports the component from lib/hive, where the compile guard covers it', () => { + // Not a style preference. scripts/test-owui-hive-frontend.sh compiles + // lib/hive and nothing else, so a Hive component parked under + // lib/components is compiled by no pre-merge job at all. + const src = settingsModal(); + expect(src).toContain("from '$lib/hive/SettingsUsage.svelte'"); + expect(src).toContain(' { + const src = settingsModal(); + expect(src).toContain("id: 'usage'"); + expect(src).toContain('aria-controls="tab-usage"'); + }); + + it('carries billing-relevant search keywords so the settings search box can find it', () => { + const src = settingsModal(); + const start = src.indexOf("id: 'usage'"); + expect(start).toBeGreaterThan(-1); + const block = src.slice(start, start + 600); + for (const keyword of ["'credits'", "'balance'", "'billing'"]) { + expect(block).toContain(keyword); + } + }); + + it('wires the rail button to actually select the tab, so an emptied handler fails here', () => { + // The dead-tab regression this is named for: a button that renders, + // highlights and reads correctly to a screen reader while its click + // handler does nothing. Pinned inside the usage button block only, so + // a selectedTab assignment somewhere else in this 900 line file cannot + // satisfy it. + const src = settingsModal(); + const start = src.indexOf('aria-controls="tab-usage"'); + expect(start).toBeGreaterThan(-1); + const end = src.indexOf('', start); + expect(end).toBeGreaterThan(start); + const button = src.slice(start, end); + expect(button).toContain('on:click'); + expect(button).toContain("selectedTab = 'usage'"); + }); + + it('renders the panel for the selected tab', () => { + const src = settingsModal(); + expect(src).toContain("selectedTab === 'usage'"); + }); + + it('hides the tab where the deployment has no credits surface at all', () => { + // Enterprise deployments never wire the chat container credits proxy + // and it fails closed with a 404; silent absence is that posture + // documented behavior in deploy/docker/owui-patches/hive_credits.py. A + // tab permanently stuck on the not-available sentence would invert it, + // so availability is probed and the rail entry is gated on it. + const src = settingsModal(); + const start = src.indexOf('const getAvailableSettings'); + expect(start).toBeGreaterThan(-1); + const block = src.slice(start, start + 900); + expect(block).toContain("tab.id === 'usage'"); + expect(block).toContain('return creditsAvailable;'); + }); +}); + +describe('Usage tab, rendered', () => { + it('puts each money figure in its own labelled row', () => { + // The mutation this exists to catch: swap the two figures so the + // balance row shows today spend and vice versa. Every source-level + // assertion in this file stays green through that swap; this one does + // not, because it reads the rendered order. + const html = renderUsage(snapshotFixture()); + const balanceLabel = html.indexOf('Organization credit balance'); + const todayLabel = html.indexOf('Organization usage today'); + expect(balanceLabel).toBeGreaterThan(-1); + expect(todayLabel).toBeGreaterThan(balanceLabel); + + const balanceRow = html.slice(balanceLabel, todayLabel); + const todayRow = html.slice(todayLabel); + + expect(balanceRow).toContain(formatUsdFromCredits(AVAILABLE_CREDITS)); + expect(balanceRow).not.toContain(formatUsdFromCredits(USAGE_TODAY_CREDITS)); + expect(todayRow).toContain(formatUsdFromCredits(USAGE_TODAY_CREDITS)); + expect(todayRow).not.toContain(formatUsdFromCredits(AVAILABLE_CREDITS)); + }); + + it('keeps each figure in the slot its own test id names', () => { + const html = renderUsage(snapshotFixture()); + expect(valueForTestId(html, 'usage-available-credits')).toBe( + formatUsdFromCredits(AVAILABLE_CREDITS) + ); + expect(valueForTestId(html, 'usage-today-credits')).toBe( + formatUsdFromCredits(USAGE_TODAY_CREDITS) + ); + }); + + it('never renders a bare credit integer, the defect a customer once read as 9,789,478,244', () => { + const html = renderUsage(snapshotFixture()); + expect(html).not.toContain(String(AVAILABLE_CREDITS)); + expect(html).not.toContain(String(USAGE_TODAY_CREDITS)); + }); + + it('flags an empty balance rather than printing a bare zero', () => { + const html = renderUsage(snapshotFixture({ available_credits: 0 })); + expect(html).toContain('Out of credits'); + expect(valueForTestId(html, 'usage-available-credits')).toBe('$0'); + }); + + it('flags a low balance below the shared threshold', () => { + const html = renderUsage(snapshotFixture({ available_credits: 100_000_000 })); + expect(html).toContain('Low'); + }); + + it('offers the top-up link only where the deployment names one', () => { + const without = renderUsage(snapshotFixture()); + expect(without).not.toContain('Top up'); + const url = 'https://example.invalid/billing'; + const withLink = renderUsage(snapshotFixture({ top_up_url: url })); + expect(withLink).toContain('Top up'); + expect(withLink).toContain(url); + }); + + it('says so explicitly when there is no balance to show, rather than rendering blank', () => { + const html = renderUsage(EMPTY_SNAPSHOT); + expect(html).toContain('available on this deployment'); + }); + + it('never fabricates a reset timer the prepaid credit model has no data for', () => { + // The Claude Desktop reference shows countdowns backed by a + // rate-limited plan quota. Hive bills prepaid credits with no such + // window, per D-046 and D-031. + const html = renderUsage(snapshotFixture()).toLowerCase(); + expect(html).not.toContain('resets in'); + expect(html).not.toContain('resets on'); + }); +}); diff --git a/vendor/open-webui/src/lib/i18n/locales/bn-BD/translation.json b/vendor/open-webui/src/lib/i18n/locales/bn-BD/translation.json index 98f1831cf..b0145429b 100644 --- a/vendor/open-webui/src/lib/i18n/locales/bn-BD/translation.json +++ b/vendor/open-webui/src/lib/i18n/locales/bn-BD/translation.json @@ -368,6 +368,7 @@ "Chat ID": "", "Chat moved successfully": "", "Chat Permissions": "", + "Chat Preferences": "চ্যাট পছন্দসমূহ", "Chat Tags Auto-Generation": "", "Chat unshared successfully.": "", "chats": "", @@ -1324,6 +1325,7 @@ "Last Modified": "", "Last ran": "", "Last reply": "", + "Last updated": "সর্বশেষ হালনাগাদ", "LDAP": "", "Leaderboard": "", "Learn more": "", @@ -1362,6 +1364,7 @@ "Loaded": "", "Loader": "", "Loading Kokoro.js...": "", + "Loading usage...": "ব্যবহার লোড হচ্ছে...", "Loading...": "লোড হচ্ছে...", "local": "", "Local": "", @@ -1369,7 +1372,7 @@ "Location": "", "Location access not allowed": "", "Lost": "", - "Low": "", + "Low": "কম", "Lower the context compaction token threshold for this model. The global context compaction threshold remains the maximum.": "", "LTR": "LTR", "Made by Open WebUI Community": "OpenWebUI কমিউনিটিকর্তৃক নির্মিত", @@ -1684,8 +1687,11 @@ "Optional": "", "or": "অথবা", "Ordered List": "", + "Organization credit balance": "প্রতিষ্ঠানের ক্রেডিট ব্যালেন্স", + "Organization usage today": "আজ প্রতিষ্ঠানের ব্যবহার", "Other": "অন্যান্য", "out of": "", + "Out of credits": "ক্রেডিট শেষ", "Output": "", "OUTPUT": "", "Output format": "", @@ -1832,7 +1838,7 @@ "Redirecting you to Open WebUI Community": "আপনাকে OpenWebUI কমিউনিটিতে পাঠানো হচ্ছে", "Reduces the probability of generating nonsense. A higher value (e.g. 100) will give more diverse answers, while a lower value (e.g. 10) will be more conservative.": "", "Reference Chats": "", - "Refresh": "", + "Refresh": "রিফ্রেশ", "Refresh requested: {{count}} terminal(s)_one": "", "Refresh requested: {{count}} terminal(s)_other": "", "Refresh Terminals": "", @@ -2327,6 +2333,7 @@ "Top": "", "Top K": "Top K", "Top K Reranker": "", + "Top up": "টপ আপ", "Transformers": "", "Trouble accessing Ollama?": "Ollama এক্সেস করতে সমস্যা হচ্ছে?", "Trust Proxy Environment": "", @@ -2388,7 +2395,8 @@ "URL": "", "URL is required": "", "URL Mode": "ইউআরএল মোড", - "Usage": "", + "Usage": "ব্যবহার", + "Usage isn't available on this deployment.": "এই ডিপ্লয়মেন্টে ব্যবহারের তথ্য দেখানো সম্ভব নয়।", "Use": "", "Use '#' in the prompt input to load and include your knowledge.": "", "Use /v1/chat/completions endpoint instead of /v1/audio/transcriptions for potentially better accuracy.": "", diff --git a/vendor/open-webui/src/lib/i18n/locales/en-US/translation.json b/vendor/open-webui/src/lib/i18n/locales/en-US/translation.json index 289e26172..878500cf5 100644 --- a/vendor/open-webui/src/lib/i18n/locales/en-US/translation.json +++ b/vendor/open-webui/src/lib/i18n/locales/en-US/translation.json @@ -368,6 +368,7 @@ "Chat ID": "", "Chat moved successfully": "", "Chat Permissions": "", + "Chat Preferences": "", "Chat Tags Auto-Generation": "", "Chat unshared successfully.": "", "chats": "", @@ -1324,6 +1325,7 @@ "Last Modified": "", "Last ran": "", "Last reply": "", + "Last updated": "", "LDAP": "", "Leaderboard": "", "Learn more": "", @@ -1362,6 +1364,7 @@ "Loaded": "", "Loader": "", "Loading Kokoro.js...": "", + "Loading usage...": "", "Loading...": "", "local": "", "Local": "", @@ -1684,8 +1687,11 @@ "Optional": "", "or": "", "Ordered List": "", + "Organization credit balance": "", + "Organization usage today": "", "Other": "", "out of": "", + "Out of credits": "", "Output": "", "OUTPUT": "", "Output format": "", @@ -2327,6 +2333,7 @@ "Top": "", "Top K": "", "Top K Reranker": "", + "Top up": "", "Transformers": "", "Trouble accessing Ollama?": "", "Trust Proxy Environment": "", @@ -2389,6 +2396,7 @@ "URL is required": "", "URL Mode": "", "Usage": "", + "Usage isn't available on this deployment.": "", "Use": "", "Use '#' in the prompt input to load and include your knowledge.": "", "Use /v1/chat/completions endpoint instead of /v1/audio/transcriptions for potentially better accuracy.": "",