From e866b68cdf3fcd567e603aff09f4619fa8adc637 Mon Sep 17 00:00:00 2001 From: Guilhem Date: Thu, 20 Aug 2026 22:17:15 +0200 Subject: [PATCH] feat: surface execution usage in the sidebar and explain what an execution is (#10760) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: surface execution usage in the sidebar and explain what an execution is Users read "executions" as a job count and are surprised by the real number, which meters a second of compute. Every place the UI prints an execution count now says so, and the sidebar carries a usage meter for the quota that will bind first. Adds SidebarUsage at the bottom of both sidebar surfaces: a ring in the collapsed rail, a labelled bar when expanded, and a modal breaking down every quota. On the free tier it meters the per-user and per-workspace 1000-execution caps; on a paid plan it meters workspace usage against the executions the workspace's seats already include. Item.tooltip was inert on disabled dropdown rows: DropdownSubmenuItem rendered the info icon inside the disabled button, which swallows hover, and the row's own title attribute shadowed any wrapper title. Both renderers now fall back to a wrapper title the way DropdownV2Inner already intended. Co-Authored-By: Claude Opus 5 (1M context) * fix: keep the usage meter tied to the workspace it describes isPremiumStore held the previous workspace's tier across a switch, which no consumer noticed while it only gated affordances — the usage meter is the first surface to render a number from it, and would have shown a paid seat quota for a free workspace. It is now undefined until the active workspace's tier is known, and a superseded response no longer writes. The seat fetch had the same shape: a slow response for the workspace we left overwrote the current count and stayed wrong until the next switch. The usage wrapper also carried the padding the brand-mark row used to own, which shifted the sidebar bottom by 4px on every instance where the meter renders nothing. The component owns its own padding instead. Names the collapsed ring for assistive tech, which otherwise saw an unlabelled button whose only signal was the arc's color. Co-Authored-By: Claude Opus 5 (1M context) * fix: scope the usage meter to the billing workspace and a known tier A fork's usage, tier and bill all resolve to its billing root, but its member list is deliberately a subset of the root's, so counting fork members metered root usage against a fork-sized cap and invented billed-seat overages. Seats now come from the billing root, and the paid meter stays hidden when that root is not visible from the fork. The tier was cleared only after the user-store round-trip, so the meter rendered the previous workspace's tier for the length of it — a free→paid switch showed the 1000-execution hard cap on a paid workspace, not a race but every time. The clear now happens before the first await. Workspace usage had neither guard: a superseded response overwrote the store permanently, and the meter is the first surface to print that number as its headline rather than bury it in a dropdown. The free-tier counters keyed off `!$isPremiumStore`, which reads an unknown tier as free and flashed the free-tier blocks during a paid-to-paid switch. They wait for a known tier instead. Co-Authored-By: Claude Opus 5 (1M context) * fix: never render an unresolved execution count as zero The workspace-usage clear wrote 0, which is a real usage value: an in-flight or failed fetch rendered as a green "0/1,000" bar, and a rejection left it there for the session because loadUsage had no failure path. Usage is now undefined until it resolves, each endpoint is assigned on its own so one failing leaves the other's number intact, and a quota is listed only once its own usage, tier and cap are known. The legacy counters show an em dash rather than a fabricated 0. The fork gates read an unknown tier as not-premium, so clearing the tier on switch made the fork entry point disappear for the length of the fetch on a paid-to-paid switch. They hold while the tier is unknown. Co-Authored-By: Claude Opus 5 (1M context) * fix: read the usage endpoints as numbers, and fall back to the free tier Both usage endpoints serve text/plain, so the client hands back a string despite the generated `number` type. Interpolation and arithmetic coerced it, which is why nothing noticed before, but `toLocaleString` on a string returns it unchanged — a five-figure count rendered without its thousands separator against a formatted cap. A failed tier fetch left the tier unknown for the session, and consumers hold premium-only affordances through the unknown window so a free workspace kept offering them. It falls back to the free tier instead. Co-Authored-By: Claude Opus 5 (1M context) * fix: keep an unknown tier unknown, and refresh the seat cap on demand Falling back to the free tier on a failed tier fetch fixed the affordance gates by lying to the meter: a paid workspace's real five-figure usage rendered against the 1000 hard cap, red, under "jobs stop running for the rest of the month". The tier stays unknown instead, and the two consumers get what each needs — the meter hides, while affordances read `maybePremium`, which holds through the pending window but fails closed once the fetch has failed. Membership changes elsewhere don't reach this component, so the seat cap could show an overage against a cap that had since grown. It re-resolves when the modal opens, which is when the number is read rather than glanced at. Co-Authored-By: Claude Opus 5 (1M context) * fix: let anything showing executions re-read them The counters were written in one place, the root layout, on a workspace change only — so a tab left open all day showed the count from whenever the workspace was opened, and the modal-open refresh could only reach the seat cap, leaving a freshly computed denominator over a stale numerator. Moves the fetch to lib/usage.ts, next to the stores it writes, so the meter can refresh both numbers when its modal opens. Seats follow a membership signal that WorkspaceUserSettings bumps where it already refetches after every mutation, so the cap stops lagging a role change without either side owning the other. Co-Authored-By: Claude Opus 5 (1M context) * fix: order concurrent usage and seat refreshes The workspace id doesn't order two requests for the same workspace, and both refreshes can now have two in flight: usage through A→B→A or a modal-open refresh landing on one already running, seats through a membership bump arriving mid-request. An older response could win and restore the count it replaced. Each refresh takes a generation and only writes if it is still the newest. The membership signal also fired on a plain read, so opening the users tab made every consumer re-fetch a list identical to the one it held. It bumps on an observed change to the member set instead, never on the first read. Co-Authored-By: Claude Opus 5 (1M context) * fix: count only billable seats, and order the tier requests The cap counted every member row, while the backend bills `NOT disabled AND NOT is_service_account` — a workspace with service accounts got an inflated included quota, which hides a real overage rather than inventing one. The seat basis matches `count_paid_seats` now, and the membership signature carries both fields so enabling or disabling a member re-resolves the cap. The tier fetch was the one refresh still ordered by workspace id alone, so a late failure for a workspace could raise the failure flag over a tier a newer request had already resolved. It takes a generation like the other two. The membership signature is keyed by workspace: this page survives a workspace switch, and comparing one workspace's members against another's reported a membership change where only the workspace had changed. Co-Authored-By: Claude Opus 5 (1M context) * fix: compare the member set only against the same workspace's Qualifying the signature with the workspace put the workspace inside the value being compared, so a switch made every comparison unequal and bumped the version unconditionally — the opposite of the intent, and worse than before the key. The workspace is the key now, not part of the payload: a different one has nothing to compare against and re-baselines silently. Co-Authored-By: Claude Opus 5 (1M context) * refactor: hold the usage and tier fetches in resources Every one of these values belongs to a workspace but lived in a bare store, so each writer and reader re-derived "does this still describe what I'm rendering?" by hand. Nine sites did, and the ones that forgot were most of this branch's review findings: three stale-workspace overwrites, three A→B→A races, and two placeholders (`0` executions, `false` tier) that read as real data because an in-band value was standing in for "not known". `resource` from runed — which frontend/AGENTS.md prescribes for async data, and which ~80 files here already use — supplies all three properties as behaviour rather than convention: a superseded fetch is discarded, the value resets when its key changes, and loading and error are states instead of magic values. The seat count keys on the billing root and the membership version, so both a workspace switch and an added member re-resolve it. That removes three generation counters, two workspace trackers, and the manual clear-and-compare around each fetch. What remains is one publish site that asserts the value still carries the active workspace before it reaches a store. Co-Authored-By: Claude Opus 5 (1M context) * fix: order the resource responses that runed does not The refactor claimed `resource` discards a superseded fetch. It does not: its only ordering is an AbortController whose signal the generated client cannot consume, and `current = result` runs unconditionally once a fetch resolves. So a late answer for a workspace we had left still landed in `current`, and the publish site — which trusted `current` — cleared the value on screen for the workspace we were on. That reinstated the races the generation counters had covered. `loading` was standing in for the missing ordering, and it cannot: it is also true during a `refetch()`, when `current` is still the right value. Gating on it meant every re-read blanked the meter, and clicking it unmounted the modal that same click had opened, since both sit behind the quota it had just cleared. Values now carry the scope they describe and `scopedValue` keeps the newest one matching the active scope, so a superseded answer neither publishes nor erases, and a re-read leaves the display alone. The account-wide user counter keys on the account, so a workspace switch no longer clears it. Co-Authored-By: Claude Opus 5 (1M context) * fix: order responses within a scope, not just across scopes The tag carried what a value described but not when it was asked for, so two fetches for one scope — a refetch landing on an in-flight load, or a second membership invalidation — were indistinguishable and the older won if it landed last. That left the seat cap reading the pre-change number until the next bump or switch, which is the stale cap the generation counters had covered. Widening the tag to the resource key would have fixed it by blanking the bar on every membership change, so the issue order travels alongside the scope instead: `tagged` stamps each request as it is issued, and only a strictly newer answer for the current scope replaces the held one. The unit tests now cover the same-key case they missed; both new ones fail against the key-only guard. Co-Authored-By: Claude Opus 5 (1M context) * fix: ignore a user list a newer read has overtaken `lastSeen` was written unconditionally after the await, so a response for a workspace already left overwrote the baseline for the workspace on screen. The next real membership change there then compared against a baseline that was never taken for it, re-baselined silently, and never bumped `workspaceMembershipVersion` — leaving the sidebar on the old seat cap. The `users` assignment had the same hole: an overtaken list could paint over a newer one. Both now go through a single check: a read whose issue order is behind the last applied one is dropped before it touches either. Also trims the two `scopedValue` docstrings and the membership rationale to the four lines AGENTS.md allows, and records there that a failed refresh keeps the last successful value rather than blanking. Co-Authored-By: Claude Opus 5 (1M context) * fix: do not claim a plan before the tier resolves Widening `isPremiumStore` to `boolean | undefined` left `UserMenu`'s `{:else}` catching the unresolved state: with the tier still in flight, or after the request failed, a free workspace was told it was on the "Premium plan". Both branches under that block assert a plan, so the block now renders only once the tier is known — which also keeps the bordered divider from appearing empty while it resolves. Verified against the running instance with the tier stubbed slow: unresolved shows neither branch, `false` shows the free counters, `true` shows "Premium plan". Reverting the guard reproduces the wrong label at 300ms. Co-Authored-By: Claude Opus 5 (1M context) * test: pin how a late answer orders against the read that replaced it Returning to a scope whose earlier read is still in flight is the one case the guard resolves by scope rather than by sequence, and the suite only covered it with nothing outstanding. It now covers the late answer itself: it stands while it is the only value describing the scope, the read issued on returning supersedes it, and it cannot come back afterwards. Also gives the meter the explicit `type="button"` the sibling sidebar rows use. Co-Authored-By: Claude Opus 5 (1M context) * fix: size the modal's plan button with unifiedSize `size` is deprecated on `Button`. `unifiedSize="sm"` renders the plan button at the same height and weight as the modal's own Close button. Co-Authored-By: Claude Opus 5 (1M context) * fix: match the plan button to the modal's own action button `unifiedSize="sm"` is `h-7`, and the Cancel button `Modal` renders beside it is `px-3 py-[7px]`, i.e. 32px — so the two sat 4px apart. `md` is the unified size that lands on 32px, which pairs them without putting a deprecated prop back. Measured both boxes rather than the new one alone: 32px and 32px, same top. Co-Authored-By: Claude Opus 5 (1M context) * feat: instrument the execution meter, and bill-align PremiumInfo's seats The meter's only interaction is opening the modal, so that is what it counts: `usage_meter/opened`, keyed by the plan tier and the quota that was tightest — `free:user`, `free:workspace`, `paid:workspace`. The full set is a type next to the call site so the vocabulary stays readable in one place. The pair is registered in `FEATURE_USAGE_KINDS` (windmill-ee-private), without which the post is dropped with a 204 and records nothing. Verified both halves: the browser posts `{"feature":"usage_meter","kind":"opened","key":"free:user","value":1}`, the running EE image drops it because its registry predates the entry, and `is_recordable_event` accepts it once the entry is there. `PremiumInfo` computed its seats from an unfiltered user list, so the billing page counted disabled members and service accounts that `count_paid_seats` does not bill. Same filter as the sidebar's cap now. Co-Authored-By: Claude Opus 5 (1M context) * chore: point ee-repo-ref at the usage_meter registration Co-Authored-By: Claude Opus 5 (1M context) * fix: read the member list before the seat rows that depend on it `loadPremiumInfo` reads `users` after its own await and nothing recomputes the seat rows when the list lands, so whenever `premium_info` won the race the page rendered zero developers, zero operators and zero seats and kept them. The list is now fetched first, and a failure to read it no longer costs the rest of the page. Also refreshes the registered-action inventory in `docs/feature-telemetry.md`, which the new pair makes 21 across nine features. Co-Authored-By: Claude Opus 5 (1M context) * docs: scope the seat comment to the counter it matches The comment claimed parity with the seats actually charged, which nothing in this repo computes: `count_paid_seats` documents itself as counting provisioned members rather than billing's active-user population, and the Stripe quantity is not derived here. What the filter buys is agreement with that counter. Co-Authored-By: Claude Opus 5 (1M context) * chore: update ee-repo-ref to c6902ec2c51dc0ce30962afbfab3e456c5d9b831 This commit updates the EE repository reference after PR #735 was merged in windmill-ee-private. Previous ee-repo-ref: bbc48fae6b73b6d72fe2e125e6003794a4ece167 New ee-repo-ref: c6902ec2c51dc0ce30962afbfab3e456c5d9b831 Automated by sync-ee-ref workflow. --------- Co-authored-by: Claude Opus 5 (1M context) Co-authored-by: windmill-internal-app[bot] Co-authored-by: Ruben Fiszel --- backend/ee-repo-ref.txt | 2 +- docs/feature-telemetry.md | 7 +- .../lib/components/DropdownSubmenuItem.svelte | 110 ++++--- .../src/lib/components/DropdownV2Inner.svelte | 5 +- .../lib/components/InstanceSettings.svelte | 13 +- .../sessions/SessionChangesBar.svelte | 6 +- .../sessions/WorkspaceFamilyPicker.svelte | 4 +- .../components/settings/PremiumInfo.svelte | 13 +- .../settings/WorkspaceUserSettings.svelte | 34 +- .../components/sidebar/SettingsMenu.svelte | 6 +- .../components/sidebar/SidebarUsage.svelte | 290 ++++++++++++++++++ .../lib/components/sidebar/UserMenu.svelte | 37 ++- .../components/sidebar/WorkspaceMenu.svelte | 25 +- .../lib/components/sidebar/executionsHint.ts | 7 + frontend/src/lib/stores.ts | 26 +- frontend/src/lib/usage.svelte.ts | 99 ++++++ frontend/src/lib/utils/scopedValue.test.ts | 75 +++++ frontend/src/lib/utils/scopedValue.ts | 33 ++ .../src/routes/(root)/(logged)/+layout.svelte | 42 ++- 19 files changed, 721 insertions(+), 113 deletions(-) create mode 100644 frontend/src/lib/components/sidebar/SidebarUsage.svelte create mode 100644 frontend/src/lib/components/sidebar/executionsHint.ts create mode 100644 frontend/src/lib/usage.svelte.ts create mode 100644 frontend/src/lib/utils/scopedValue.test.ts create mode 100644 frontend/src/lib/utils/scopedValue.ts diff --git a/backend/ee-repo-ref.txt b/backend/ee-repo-ref.txt index 7d07b9132a..40fb2fa235 100644 --- a/backend/ee-repo-ref.txt +++ b/backend/ee-repo-ref.txt @@ -1 +1 @@ -37695a769b25d16b34107eedc1076793a8b388c8 +c6902ec2c51dc0ce30962afbfab3e456c5d9b831 diff --git a/docs/feature-telemetry.md b/docs/feature-telemetry.md index 947329fc45..57467e25ec 100644 --- a/docs/feature-telemetry.md +++ b/docs/feature-telemetry.md @@ -4,9 +4,10 @@ anonymous usage-stats payload. It answers "does anyone use this, and which variant do they pick" without any identifying data leaving the instance. -It currently carries 20 registered actions across eight features (`ai_session`, `ai_chat`, -`flow_editor`, `flow_run`, `flow_step`, `trigger`, `command_script`, `hub_script`). Nearly all of -the product is uninstrumented, so new user-facing work is the opportunity to change that. +It currently carries 21 registered actions across nine features (`ai_session`, `ai_chat`, +`flow_editor`, `flow_run`, `flow_step`, `trigger`, `command_script`, `hub_script`, +`usage_meter`). Nearly all of the product is uninstrumented, so new user-facing work is the +opportunity to change that. ## When to instrument diff --git a/frontend/src/lib/components/DropdownSubmenuItem.svelte b/frontend/src/lib/components/DropdownSubmenuItem.svelte index 631f6374bd..60f7ad2d64 100644 --- a/frontend/src/lib/components/DropdownSubmenuItem.svelte +++ b/frontend/src/lib/components/DropdownSubmenuItem.svelte @@ -52,51 +52,71 @@ {#if subItem.separatorTop}
{/if} - subItem?.action?.(e)} - href={subItem?.href} - target={subItem?.hrefTarget} - disabled={subItem?.disabled} - class={twMerge( - 'px-4 py-2 text-primary font-normal hover:bg-surface-hover cursor-pointer text-xs transition-colors w-full', - 'data-[highlighted]:bg-surface-hover', - 'flex flex-row gap-2 items-center rounded-sm', - subItem?.disabled && 'text-disabled cursor-not-allowed' - )} - item={meltItem} - > - {#if subItem.icon} - - {/if} -

- {subItem.displayName} -

- {@render subItem.extra?.()} - {#if subItem.shortcut || subItem.selected || subItem.toggle !== undefined} -
- {#if subItem.shortcut} - {subItem.shortcut} - {/if} - {#if subItem.selected} - - {/if} - {#if subItem.toggle !== undefined} - - - {/if} -
- {/if} - {#if subItem.tooltip} - - {#snippet text()} - {subItem.tooltip} - {/snippet} - - {/if} -
+ {@render subMenuItem(subItem)} {/each} {/if} + +{#snippet subMenuItem(subItem: Item)} + {#if subItem.disabled && subItem.tooltip} + +
+ {@render row(subItem)} +
+ {:else} + {@render row(subItem)} + {/if} +{/snippet} + +{#snippet row(subItem: Item)} + subItem?.action?.(e)} + href={subItem?.href} + target={subItem?.hrefTarget} + disabled={subItem?.disabled} + class={twMerge( + 'px-4 py-2 text-primary font-normal hover:bg-surface-hover cursor-pointer text-xs transition-colors w-full', + 'data-[highlighted]:bg-surface-hover', + 'flex flex-row gap-2 items-center rounded-sm', + subItem?.disabled && 'text-disabled cursor-not-allowed', + subItem?.disabled && subItem?.tooltip && 'pointer-events-none' + )} + item={meltItem} + > + {#if subItem.icon} + + {/if} +

+ {subItem.displayName} +

+ {@render subItem.extra?.()} + {#if subItem.shortcut || subItem.selected || subItem.toggle !== undefined} +
+ {#if subItem.shortcut} + {subItem.shortcut} + {/if} + {#if subItem.selected} + + {/if} + {#if subItem.toggle !== undefined} + + + {/if} +
+ {/if} + {#if subItem.tooltip && !subItem.disabled} + + {#snippet text()} + {subItem.tooltip} + {/snippet} + + {/if} +
+{/snippet} diff --git a/frontend/src/lib/components/DropdownV2Inner.svelte b/frontend/src/lib/components/DropdownV2Inner.svelte index 425c41d8f5..5f8b96c32f 100644 --- a/frontend/src/lib/components/DropdownV2Inner.svelte +++ b/frontend/src/lib/components/DropdownV2Inner.svelte @@ -53,7 +53,10 @@ {#if item.icon} {/if} -

+

{item.displayName}

{@render item.extra?.()} diff --git a/frontend/src/lib/components/InstanceSettings.svelte b/frontend/src/lib/components/InstanceSettings.svelte index 1f89829c41..62bbe1ef93 100644 --- a/frontend/src/lib/components/InstanceSettings.svelte +++ b/frontend/src/lib/components/InstanceSettings.svelte @@ -1,5 +1,10 @@ + +{#snippet bar(q: Quota)} +
+
+
+{/snippet} + +{#snippet ring(q: Quota)} + +{/snippet} + +{#if isCloudHosted() && tightest} +
+ + {#snippet text()} + {tightest.label} this month: {fmt(tightest.used)}/{fmt(tightest.cap)}. + {EXECUTIONS_HINT} + {/snippet} + + +
+ + +
+ {#each quotas as quota (quota.key)} +
+
+ {quota.label} + {fmt(quota.used)}/{fmt(quota.cap)} +
+ {@render bar(quota)} +
+ {/each} +

+ {EXECUTIONS_HINT} Counters reset at the start of every calendar month. +

+ {#if $isPremiumStore} +

+ Your {seats} seat{seats === 1 ? '' : 's'} include {fmt( + (seats ?? 0) * SEAT_EXECUTION_QUOTA + )} executions per month. Every extra {fmt(SEAT_EXECUTION_QUOTA)} executions beyond that add + one billed seat for the month. +

+ {:else} +

+ Either quota reaching {fmt(FREE_EXECUTION_QUOTA)} stops jobs from running for the rest of the + month. Team and Enterprise plans lift both limits. + {#if !$userStore?.is_admin} + Ask a workspace admin to change the plan. + {/if} +

+ {/if} +
+ {#snippet actions()} + {#if $userStore?.is_admin} + + {/if} + {/snippet} +
+{/if} diff --git a/frontend/src/lib/components/sidebar/UserMenu.svelte b/frontend/src/lib/components/sidebar/UserMenu.svelte index 1ec649cf12..43440249ae 100644 --- a/frontend/src/lib/components/sidebar/UserMenu.svelte +++ b/frontend/src/lib/components/sidebar/UserMenu.svelte @@ -14,8 +14,9 @@ import { Crown, ServerCog, LogOut, Moon, Settings, Sun, User } from 'lucide-svelte' import DarkModeObserver from '../DarkModeObserver.svelte' import MenuButton from './MenuButton.svelte' - import { Menu, MenuItem } from '$lib/components/meltComponents' + import { Menu, MenuItem, Tooltip } from '$lib/components/meltComponents' import { type MenubarBuilders } from '@melt-ui/svelte' + import { EXECUTIONS_HINT } from './executionsHint' let darkMode: boolean = $state(false) @@ -103,27 +104,41 @@ - {#if isCloudHosted()} + + {#if isCloudHosted() && $isPremiumStore !== undefined}
- {#if !$isPremiumStore} - {$usageStore}/1000 user execs + {#if $isPremiumStore === false} + + {$usageStore ?? '—'}/1000 user execs + + {#snippet text()} + {EXECUTIONS_HINT} + {/snippet} + +
-
{#if $workspaceStore != 'demo'} - {$workspaceUsageStore}/1000 free workspace execs + + {$workspaceUsageStore ?? '—'}/1000 free workspace execs + + {#snippet text()} + {EXECUTIONS_HINT} + {/snippet} + +
diff --git a/frontend/src/lib/components/sidebar/WorkspaceMenu.svelte b/frontend/src/lib/components/sidebar/WorkspaceMenu.svelte index 286e044e6b..1555884eff 100644 --- a/frontend/src/lib/components/sidebar/WorkspaceMenu.svelte +++ b/frontend/src/lib/components/sidebar/WorkspaceMenu.svelte @@ -2,6 +2,7 @@ import { workspaceMenuHref } from './workspaceMenuHref' import { isPremiumStore, + maybePremium, superadmin, userStore, userWorkspaces, @@ -16,7 +17,8 @@ import { SvelteSet } from 'svelte/reactivity' import { Badge, CopyButton, NameIdTooltip } from '$lib/components/common' import MenuButton from '$lib/components/sidebar/MenuButton.svelte' - import { Menu, MenuItem } from '$lib/components/meltComponents' + import { Menu, MenuItem, Tooltip } from '$lib/components/meltComponents' + import { EXECUTIONS_HINT } from './executionsHint' import WorkspaceIcon from '$lib/components/workspace/WorkspaceIcon.svelte' import { fixupUrlAfterWorkspaceSwitch } from './workspaceSwitchUrl' import { goto } from '$lib/navigation' @@ -114,9 +116,7 @@ // modal carries its own base-workspace picker). Hidden on non-premium cloud, // in the admins workspace, or when forking is disabled. const canForkHere = $derived( - (!isCloudHosted() || $isPremiumStore) && - $workspaceStore !== 'admins' && - canCreateFork($userStore) + (!isCloudHosted() || $maybePremium) && $workspaceStore !== 'admins' && canCreateFork($userStore) ) const familyWorkspaces = $derived.by(() => { if (strictWorkspaceSelect) return hierarchy @@ -422,14 +422,21 @@
{/if} - {#if isCloudHosted() && !$isPremiumStore && !strictWorkspaceSelect} + {#if isCloudHosted() && $isPremiumStore === false && !strictWorkspaceSelect}
{#if $workspaceStore != 'demo'} - {$workspaceUsageStore}/1000 free workspace execs + + {$workspaceUsageStore ?? '—'}/1000 free workspace execs + + {#snippet text()} + {EXECUTIONS_HINT} + {/snippet} + +
-
{/if} diff --git a/frontend/src/lib/components/sidebar/executionsHint.ts b/frontend/src/lib/components/sidebar/executionsHint.ts new file mode 100644 index 0000000000..6530a6a6cd --- /dev/null +++ b/frontend/src/lib/components/sidebar/executionsHint.ts @@ -0,0 +1,7 @@ +export const FREE_EXECUTION_QUOTA = 1000 + +/** Executions each paid seat includes per month (mirrors the billing page). */ +export const SEAT_EXECUTION_QUOTA = 10000 + +export const EXECUTIONS_HINT = + 'An execution is one second of compute, not one job run: a job counts as 1 execution, plus 1 more for each additional second it runs.' as const diff --git a/frontend/src/lib/stores.ts b/frontend/src/lib/stores.ts index 0058cc8dd0..8710b6d20b 100644 --- a/frontend/src/lib/stores.ts +++ b/frontend/src/lib/stores.ts @@ -80,8 +80,10 @@ export const whitelabelNameStore = derived([enterpriseLicense], ([enterpriseLice return undefined }) export const workerTags = writable(undefined) -export const usageStore = writable(0) -export const workspaceUsageStore = writable(0) +// `undefined` while unresolved. `0` is a real usage value, so a placeholder that +// reads as one lets a failed or in-flight fetch render as "no executions used". +export const usageStore = writable(undefined) +export const workspaceUsageStore = writable(undefined) export const initialArgsStore = writable(undefined) export const oauthStore = writable(undefined) export const userStore = writable(undefined) @@ -90,7 +92,25 @@ export const workspaceStore = writable( ) export const defaultScripts = writable(undefined) export const dbClockDrift = writable(undefined) -export const isPremiumStore = writable(false) +// `undefined` until the active workspace's tier is known — a tier belongs to a +// workspace, so consumers rendering a number from it must not read the previous +// one's value across a switch. `false` is a claim, not a safe default: it meters a +// paid workspace against the free cap, so a failed fetch leaves this `undefined`. +export const isPremiumStore = writable(undefined) +// Set when the tier fetch for the active workspace failed, which is indistinguishable +// from "still pending" in `isPremiumStore` alone. +export const premiumFetchFailed = writable(false) +// For affordances rather than numbers: gate on this so a paid→paid switch doesn't +// retract a button for the length of the fetch, while a failed fetch still fails +// closed instead of leaving it enabled for the session. +export const maybePremium: Readable = derived( + [isPremiumStore, premiumFetchFailed], + ([premium, failed]) => premium !== false && !failed +) +// Bumped when the active workspace's membership is seen to have changed, so anything +// deriving a number from the member count (paid seats) can re-resolve it without +// polling or owning its own invalidation. +export const workspaceMembershipVersion = writable(0) export const usersWorkspaceStore = writable(undefined) export const superadmin = writable(undefined) export const devopsRole = writable(undefined) diff --git a/frontend/src/lib/usage.svelte.ts b/frontend/src/lib/usage.svelte.ts new file mode 100644 index 0000000000..d64934e94b --- /dev/null +++ b/frontend/src/lib/usage.svelte.ts @@ -0,0 +1,99 @@ +import { resource } from 'runed' +import { UserService, WorkspaceService } from '$lib/gen' +import { isCloudHosted } from '$lib/cloud' +import { scopedValue, tagged } from '$lib/utils/scopedValue' +import { + isPremiumStore, + premiumFetchFailed, + usageStore, + workspaceUsageStore, + type UserExt +} from '$lib/stores' + +/** + * The cloud execution counters and the workspace's plan tier. Call once, at layout init: + * these are app-wide values, and the logged-in layout outlives every in-app navigation. + */ +export function createUsageResources(args: { + workspace: () => string | undefined + user: () => UserExt | undefined +}) { + // All three need an authenticated membership, so they key on the user being loaded + // *for this workspace* — a switch must not fire them against the workspace we left. + const readyWorkspace = () => { + const workspace = args.workspace() + if (!isCloudHosted() || !workspace) return undefined + return args.user()?.workspace_id === workspace ? workspace : undefined + } + // The user counter is account-wide, so its key is the account: a workspace switch + // is not a change of key and must not re-fetch or clear it. + const readyUser = () => (isCloudHosted() ? args.user()?.email : undefined) + + // `Number(...)`: both usage endpoints serve text/plain, so the client hands back a + // string despite the generated `number` type. Interpolation and arithmetic coerce + // it, but `toLocaleString` on a string returns it unchanged — the thousands + // separator would silently go missing above 999. + const fetchWorkspaceExecutions = tagged(async (workspace: string) => + Number(await WorkspaceService.getWorkspaceUsage({ workspace })) + ) + const fetchUserExecutions = tagged(async (_email: string) => Number(await UserService.getUsage())) + const fetchPremium = tagged((workspace: string) => WorkspaceService.getIsPremium({ workspace })) + + const workspaceExecutions = resource(readyWorkspace, async (workspace) => + workspace ? await fetchWorkspaceExecutions(workspace) : undefined + ) + + const userExecutions = resource(readyUser, async (email) => + email ? await fetchUserExecutions(email) : undefined + ) + + const premium = resource(readyWorkspace, async (workspace) => + workspace ? await fetchPremium(workspace) : undefined + ) + + const scopedWorkspaceExecutions = scopedValue() + const scopedUserExecutions = scopedValue() + const scopedPremium = scopedValue() + + // The only place any of this reaches a store. `undefined` until a value for the + // active scope has arrived — never a stand-in like `0` or `false`, both of which are + // legal values a consumer would render as real. + $effect(() => { + workspaceUsageStore.set( + scopedWorkspaceExecutions(args.workspace(), workspaceExecutions.current) + ) + }) + + $effect(() => { + usageStore.set(scopedUserExecutions(args.user()?.email, userExecutions.current)) + }) + + $effect(() => { + const tier = scopedPremium(args.workspace(), premium.current) + isPremiumStore.set(tier) + // Only a failure that left us with no tier for this workspace counts: a late + // rejection for a workspace we left must not retract affordances here. + premiumFetchFailed.set(!!premium.error && tier === undefined) + }) + + return { + /** Re-reads the counters. Executions accrue continuously, so anything displaying + * them needs this — the workspace-change refetch alone leaves an open tab stale. */ + refreshExecutions() { + void workspaceExecutions.refetch() + void userExecutions.refetch() + } + } +} + +// Registered by the layout so components can ask for a re-read without owning the +// resources or reaching back into the layout. +let handle: ReturnType | undefined = undefined + +export function registerUsageResources(h: ReturnType): void { + handle = h +} + +export function refreshExecutions(): void { + handle?.refreshExecutions() +} diff --git a/frontend/src/lib/utils/scopedValue.test.ts b/frontend/src/lib/utils/scopedValue.test.ts new file mode 100644 index 0000000000..202547693c --- /dev/null +++ b/frontend/src/lib/utils/scopedValue.test.ts @@ -0,0 +1,75 @@ +import { describe, expect, it } from 'vitest' +import { scopedValue, tagged } from './scopedValue' + +// The ordering these assert is the one `resource` does not provide, and the one whose +// absence produced the stale-workspace and A→B→A defects this guard replaced. +describe('scopedValue', () => { + it('holds a value only for the key it describes', () => { + const held = scopedValue() + expect(held('a', undefined)).toBe(undefined) + expect(held('a', { key: 'a', seq: 1, value: 1 })).toBe(1) + // Switched to b, nothing fetched for it yet: a's value must not stand in. + expect(held('b', { key: 'a', seq: 1, value: 1 })).toBe(undefined) + expect(held('b', { key: 'b', seq: 2, value: 2 })).toBe(2) + }) + + it('ignores an answer for a scope we left, instead of publishing or erasing', () => { + const held = scopedValue() + held('a', { key: 'a', seq: 1, value: 1 }) + held('b', { key: 'b', seq: 2, value: 2 }) + // A's slow response lands after B resolved: neither replaces B's value nor blanks it. + expect(held('b', { key: 'a', seq: 1, value: 99 })).toBe(2) + }) + + it('ignores an answer overtaken by a newer one for the same key', () => { + const held = scopedValue() + // Two fetches for one key — a refetch landing on an in-flight load, or a second + // invalidation — resolving inverted. The later-issued value must win. + expect(held('a', { key: 'a', seq: 2, value: 20 })).toBe(20) + expect(held('a', { key: 'a', seq: 1, value: 10 })).toBe(20) + }) + + it('keeps the value across a re-read of the same key', () => { + const held = scopedValue() + held('a', { key: 'a', seq: 1, value: 1 }) + // A refetch leaves the previous value in place until the new one lands, so the + // display never blanks mid-refresh. + expect(held('a', { key: 'a', seq: 1, value: 1 })).toBe(1) + expect(held('a', { key: 'a', seq: 2, value: 5 })).toBe(5) + }) + + it('treats returning to a key as unknown until it is fetched again', () => { + const held = scopedValue() + held('a', { key: 'a', seq: 1, value: 1 }) + held('b', { key: 'b', seq: 2, value: 2 }) + expect(held('a', undefined)).toBe(undefined) + }) + + it('orders a late answer against the read issued on returning to its key', () => { + const held = scopedValue() + // A's first read is still in flight when we leave for B, so nothing for A is held. + expect(held('b', { key: 'b', seq: 2, value: 2 })).toBe(2) + // Back on A, that late answer is the only value describing A, so it stands... + expect(held('a', { key: 'a', seq: 1, value: 10 })).toBe(10) + // ...until the read issued on returning lands, and cannot come back afterwards. + expect(held('a', { key: 'a', seq: 3, value: 30 })).toBe(30) + expect(held('a', { key: 'a', seq: 1, value: 10 })).toBe(30) + }) + + it('stamps issue order even when responses resolve inverted', async () => { + const settle: Array<(v: number) => void> = [] + const fetch = tagged((_key: string) => new Promise((r) => settle.push(r))) + const first = fetch('a') + const second = fetch('a') + // Resolve the second request first, then the first: the seq must reflect the + // order they were *issued*, not the order they came back. + settle[1](20) + settle[0](10) + expect(await first).toEqual({ key: 'a', seq: 1, value: 10 }) + expect(await second).toEqual({ key: 'a', seq: 2, value: 20 }) + + const held = scopedValue() + expect(held('a', await second)).toBe(20) + expect(held('a', await first)).toBe(20) + }) +}) diff --git a/frontend/src/lib/utils/scopedValue.ts b/frontend/src/lib/utils/scopedValue.ts new file mode 100644 index 0000000000..86bdfb8a7d --- /dev/null +++ b/frontend/src/lib/utils/scopedValue.ts @@ -0,0 +1,33 @@ +export type Tagged = { key: string; seq: number; value: T } + +/** + * Stamps each result with the scope it describes and the order its request was issued in. + * `resource` orders nothing: it assigns `current` unconditionally on resolve, and cancels + * through an `AbortSignal` the generated client cannot consume. The scope alone cannot + * order two requests for one scope, so the issue order travels alongside it. + */ +export function tagged( + fetch: (key: K) => Promise +): (key: K) => Promise> { + let issued = 0 + return async (key: K) => { + const seq = ++issued + return { key, seq, value: await fetch(key) } + } +} + +/** + * Holds the newest value fetched for `key`; a late answer for a scope we left, or one + * overtaken for this scope, neither publishes nor erases. A failed refresh leaves the + * last successful value standing, which is why `loading` cannot gate this: it is true + * throughout a re-read whose held value is still the right one to show. + */ +export function scopedValue() { + let held: Tagged | undefined = undefined + return (key: string | undefined, fetched: Tagged | undefined) => { + if (fetched && fetched.key === key && (held?.key !== key || fetched.seq > held.seq)) { + held = fetched + } + return held && held.key === key ? held.value : undefined + } +} diff --git a/frontend/src/routes/(root)/(logged)/+layout.svelte b/frontend/src/routes/(root)/(logged)/+layout.svelte index ead6fa6d8d..e55cfd54a9 100644 --- a/frontend/src/routes/(root)/(logged)/+layout.svelte +++ b/frontend/src/routes/(root)/(logged)/+layout.svelte @@ -16,6 +16,7 @@ import WorkspaceMenu from '$lib/components/sidebar/WorkspaceMenu.svelte' import SidebarContent from '$lib/components/sidebar/SidebarContent.svelte' import SettingsMenu from '$lib/components/sidebar/SettingsMenu.svelte' + import SidebarUsage from '$lib/components/sidebar/SidebarUsage.svelte' import SidebarScrollArea from '$lib/components/sidebar/SidebarScrollArea.svelte' import { SIDEBAR_BG, SIDEBAR_BG_DARK } from '$lib/components/sidebar/sidebarChrome' import CriticalAlertModal from '$lib/components/sidebar/CriticalAlertModal.svelte' @@ -23,10 +24,7 @@ import UpdateDevWorkspaceModal from '$lib/components/UpdateDevWorkspaceModal.svelte' import { enterpriseLicense, - isPremiumStore, superadmin, - usageStore, - workspaceUsageStore, userStore, workspaceStore, userWorkspaces, @@ -72,6 +70,7 @@ import MenuButton from '$lib/components/sidebar/MenuButton.svelte' import MenuLink from '$lib/components/sidebar/MenuLink.svelte' import { loadProtectionRules } from '$lib/workspaceProtectionRules.svelte' + import { createUsageResources, registerUsageResources } from '$lib/usage.svelte' import { purgeLegacyUserDrafts } from '$lib/userDraftLegacyMigration' import { migrateUserDraftsToDb } from '$lib/userDraftDbMigration' import DraftMigrationErrorModal from '$lib/components/DraftMigrationErrorModal.svelte' @@ -108,6 +107,15 @@ let { children }: Props = $props() OpenAPI.WITH_CREDENTIALS = true + // Owned here because the logged-in layout is the app's lifetime: it outlives every + // in-app navigation, so the counters and tier resolve once per workspace rather than + // per mounting component, and no detached `$effect.root` is needed to hold them. + registerUsageResources( + createUsageResources({ + workspace: () => $workspaceStore, + user: () => $userStore + }) + ) let menuOpen = $state(false) // Set by the workspace⇄session switch before it navigates, so the mobile menu // drawer stays open across a mode toggle (unlike a normal link navigation, @@ -327,16 +335,6 @@ } catch (e) { console.error('Could not persist username to local storage', e) } - // Populate for all members (not just admins) so non-admin developers also get premium-gated - // affordances like the fork entry points on cloud. The `is_premium` endpoint is a boolean - // and no longer admin-gated. Best-effort: a failure here must not block user-store init. - if (isCloudHosted()) { - try { - isPremiumStore.set(await WorkspaceService.getIsPremium({ workspace })) - } catch (e) { - console.error('Could not fetch premium status', e) - } - } } else { userStore.set(undefined) } @@ -460,7 +458,6 @@ function onLoad() { loadFavorites() - loadUsage() syncTutorialsTodos() loadHubBaseUrl() loadWsBaseUrl() @@ -468,15 +465,6 @@ loadUsedTriggerKinds() } - async function loadUsage() { - if (isCloudHosted() && $workspaceStore) { - $usageStore = await UserService.getUsage() - $workspaceUsageStore = await WorkspaceService.getWorkspaceUsage({ - workspace: $workspaceStore! - }) - } - } - async function loadHubBaseUrl() { $hubBaseUrlStore = ((await SettingService.getGlobal({ key: 'hub_accessible_url' })) as string) || @@ -1090,6 +1078,10 @@ {/if} +
+ +
+
{@render brandMark(false)}
@@ -1224,6 +1216,10 @@ {/if} +
+ +
+