feat: surface execution usage in the sidebar and explain what an execution is (#10760)

* 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) <noreply@anthropic.com>

* 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) <noreply@anthropic.com>

* 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) <noreply@anthropic.com>

* 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) <noreply@anthropic.com>

* 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) <noreply@anthropic.com>

* 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) <noreply@anthropic.com>

* 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) <noreply@anthropic.com>

* 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) <noreply@anthropic.com>

* 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) <noreply@anthropic.com>

* 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) <noreply@anthropic.com>

* 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) <noreply@anthropic.com>

* 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) <noreply@anthropic.com>

* 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) <noreply@anthropic.com>

* 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) <noreply@anthropic.com>

* 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) <noreply@anthropic.com>

* 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) <noreply@anthropic.com>

* 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) <noreply@anthropic.com>

* 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) <noreply@anthropic.com>

* 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) <noreply@anthropic.com>

* chore: point ee-repo-ref at the usage_meter registration

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* 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) <noreply@anthropic.com>

* 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) <noreply@anthropic.com>

* 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) <noreply@anthropic.com>
Co-authored-by: windmill-internal-app[bot] <windmill-internal-app[bot]@users.noreply.github.com>
Co-authored-by: Ruben Fiszel <ruben@windmill.dev>
This commit is contained in:
Guilhem
2026-08-20 22:17:15 +02:00
committed by GitHub
parent ee1f9814c2
commit e866b68cdf
19 changed files with 721 additions and 113 deletions
+1 -1
View File
@@ -1 +1 @@
37695a769b25d16b34107eedc1076793a8b388c8
c6902ec2c51dc0ce30962afbfab3e456c5d9b831
+4 -3
View File
@@ -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
@@ -52,51 +52,71 @@
{#if subItem.separatorTop}
<div class="my-1 border-t border-border-light"></div>
{/if}
<MenuItem
onClick={(e) => 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}
<subItem.icon size={14} color={subItem.iconColor} class="shrink-0" {...subItem.iconProps ?? {}} />
{/if}
<p title={subItem.displayName} class="truncate grow min-w-0 whitespace-nowrap text-left">
{subItem.displayName}
</p>
{@render subItem.extra?.()}
{#if subItem.shortcut || subItem.selected || subItem.toggle !== undefined}
<div class="ml-auto flex shrink-0 items-center gap-2">
{#if subItem.shortcut}
<span class="pl-4 text-2xs text-secondary">{subItem.shortcut}</span>
{/if}
{#if subItem.selected}
<Check size={14} class="text-primary" />
{/if}
{#if subItem.toggle !== undefined}
<!-- Indicator only: the click belongs to the row, so the switch must not
take it (nor answer for the row to a screen reader). -->
<span class="pointer-events-none" aria-hidden="true">
<Toggle size="2xs" checked={subItem.toggle} />
</span>
{/if}
</div>
{/if}
{#if subItem.tooltip}
<Tooltip>
{#snippet text()}
{subItem.tooltip}
{/snippet}
</Tooltip>
{/if}
</MenuItem>
{@render subMenuItem(subItem)}
{/each}
</div>
{/if}
{#snippet subMenuItem(subItem: Item)}
{#if subItem.disabled && subItem.tooltip}
<!-- Wrapper carries the native `title`: the disabled button swallows the pointer events
the ⓘ tooltip would need, and its `pointer-events-none` lets the hover reach here. -->
<div title={subItem.tooltip} class="w-full">
{@render row(subItem)}
</div>
{:else}
{@render row(subItem)}
{/if}
{/snippet}
{#snippet row(subItem: Item)}
<MenuItem
onClick={(e) => 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}
<subItem.icon size={14} color={subItem.iconColor} class="shrink-0" {...subItem.iconProps ?? {}} />
{/if}
<p
title={subItem.disabled && subItem.tooltip ? undefined : subItem.displayName}
class="truncate grow min-w-0 whitespace-nowrap text-left"
>
{subItem.displayName}
</p>
{@render subItem.extra?.()}
{#if subItem.shortcut || subItem.selected || subItem.toggle !== undefined}
<div class="ml-auto flex shrink-0 items-center gap-2">
{#if subItem.shortcut}
<span class="pl-4 text-2xs text-secondary">{subItem.shortcut}</span>
{/if}
{#if subItem.selected}
<Check size={14} class="text-primary" />
{/if}
{#if subItem.toggle !== undefined}
<!-- Indicator only: the click belongs to the row, so the switch must not
take it (nor answer for the row to a screen reader). -->
<span class="pointer-events-none" aria-hidden="true">
<Toggle size="2xs" checked={subItem.toggle} />
</span>
{/if}
</div>
{/if}
{#if subItem.tooltip && !subItem.disabled}
<Tooltip>
{#snippet text()}
{subItem.tooltip}
{/snippet}
</Tooltip>
{/if}
</MenuItem>
{/snippet}
@@ -53,7 +53,10 @@
{#if item.icon}
<item.icon size={14} color={item.iconColor} class="shrink-0" {...item.iconProps ?? {}} />
{/if}
<p title={item.displayName} class="truncate grow min-w-0 whitespace-nowrap text-left">
<p
title={item.disabled && item.tooltip ? undefined : item.displayName}
class="truncate grow min-w-0 whitespace-nowrap text-left"
>
{item.displayName}
</p>
{@render item.extra?.()}
@@ -1,5 +1,10 @@
<script lang="ts">
import { scimSamlSetting, settings, settingsKeys, instanceSettingsSaved } from './instanceSettings'
import {
scimSamlSetting,
settings,
settingsKeys,
instanceSettingsSaved
} from './instanceSettings'
import { Alert, Button, Tab, TabContent, Tabs } from '$lib/components/common'
import { SettingService, SettingsService } from '$lib/gen'
import type { TeamsChannel } from '$lib/gen/types.gen'
@@ -1064,7 +1069,8 @@
<li>git sync repo count (sync vs promotion mode)</li>
<li
>feature usage (counts of which product features are used, including AI provider and
model identifiers and the names of public hub scripts used, last 30 days)</li
model identifiers, the names of public hub scripts used, and the plan tier and quota
shown when the execution meter is opened, last 30 days)</li
>
<li
>feature adoption (counts of which flow, script, trigger and worker features your
@@ -1115,7 +1121,8 @@
<li>development instance status</li>
<li
>feature usage (counts of which product features are used, including AI provider and
model identifiers and the names of public hub scripts used, last 30 days)</li
model identifiers, the names of public hub scripts used, and the plan tier and quota
shown when the execution meter is opened, last 30 days)</li
>
<li
>feature adoption (counts of which flow, script, trigger and worker features your
@@ -11,7 +11,7 @@
import Badge from '$lib/components/common/badge/Badge.svelte'
import SessionStatusPopover from './SessionStatusPopover.svelte'
import WorkspaceFamilyPicker from './WorkspaceFamilyPicker.svelte'
import { isPremiumStore, userStore, userWorkspaces, workspaceStore } from '$lib/stores'
import { maybePremium, userStore, userWorkspaces, workspaceStore } from '$lib/stores'
import { canCreateFork } from '$lib/utils/editInFork'
import { isCloudHosted } from '$lib/cloud'
import { sessionState, type Session } from './sessionState.svelte'
@@ -63,9 +63,7 @@
// Same gate as the sidebar WorkspaceMenu / SessionWorkspaceBar. On cloud,
// forking is a premium-only feature (backend caps it per paid seat).
const forksAllowed = $derived(
(!isCloudHosted() || $isPremiumStore) &&
canCreateFork($userStore) &&
$workspaceStore !== 'admins'
(!isCloudHosted() || $maybePremium) && canCreateFork($userStore) && $workspaceStore !== 'admins'
)
const runtime = $derived(getRuntime(session.id))
@@ -2,7 +2,7 @@
import type { Snippet } from 'svelte'
import {
enterpriseLicense,
isPremiumStore,
maybePremium,
superadmin,
userStore,
userWorkspaces,
@@ -174,7 +174,7 @@
// (a locked prod) doesn't apply when there's a dev to fork from instead — the dev isn't locked, and
// devOfRoot only resolves when the user is a member of it.
const forksGateOpen = $derived(
(!isCloudHosted() || $isPremiumStore) &&
(!isCloudHosted() || $maybePremium) &&
$workspaceStore !== 'admins' &&
(canCreateFork($userStore) || !!devOfRoot)
)
@@ -68,8 +68,12 @@
async function loadPremiumInfo() {
const info = await WorkspaceService.getPremiumInfo({ workspace: $workspaceStore! })
const developerNb = users?.filter((x) => !x.operator)?.length ?? 0
const operatorNb = users?.filter((x) => x.operator)?.length ?? 0
// Same basis as the backend's `count_paid_seats`, which excludes disabled members
// and service accounts: counting them here would put this page at a different
// seat count from the rest of the product.
const billable = users?.filter((x) => !x.disabled && !x.is_service_account) ?? []
const developerNb = billable.filter((x) => !x.operator).length
const operatorNb = billable.length - developerNb
const usage = info.usage ?? 0
const seatsFromUsers = Math.ceil(developerNb + operatorNb / 2)
@@ -128,8 +132,9 @@
const formatNumber = (value: number) => value.toLocaleString('en-US')
run(() => {
if ($workspaceStore) {
loadPremiumInfo()
listUsers()
// The seat rows are computed from the member list and nothing recomputes them
// when it lands, so a fast `premium_info` would render zero seats and keep them.
listUsers().catch(console.warn).then(loadPremiumInfo)
getThresholdAlert()
}
});
@@ -19,7 +19,8 @@
workspaceStore,
superadmin,
globalEmailInvite,
enterpriseLicense
enterpriseLicense,
workspaceMembershipVersion
} from '$lib/stores'
import { sendUserToast } from '$lib/toast'
import { Loader2, Mails, Search, Plus, UserMinus, X, Bot, LogIn } from 'lucide-svelte'
@@ -146,7 +147,36 @@
}
async function listUsers(): Promise<void> {
users = await UserService.listUsers({ workspace: $workspaceStore! })
// Mounting, switching workspace (the switch only rewrites `?workspace=`, so this
// component survives) and every membership mutation all refetch through here.
// Bump only when the same workspace's member set is seen to change; anything else
// re-baselines silently, or consumers re-fetch for a list nothing changed about.
const workspace = $workspaceStore!
const seq = ++usersIssued
const previous = lastSeen?.workspace === workspace ? lastSeen.signature : undefined
const list = await UserService.listUsers({ workspace })
// A response a newer read has already overtaken describes a member set that is no
// longer on screen: baselining on it makes the next real change look like none.
if (lastSeen && seq < lastSeen.seq) return
users = list
const signature = membershipSignature(list)
lastSeen = { workspace, signature, seq }
if (previous !== undefined && previous !== signature) {
$workspaceMembershipVersion++
}
}
// The last member set applied, which workspace it belonged to, and the read it came from.
let lastSeen: { workspace: string; signature: string; seq: number } | undefined = undefined
let usersIssued = 0
// Every field a paid seat count depends on — the developer/operator split, and the
// two categories billing excludes.
function membershipSignature(list: User[] | undefined): string {
return (list ?? [])
.map((u) => `${u.email}:${u.operator}:${u.disabled}:${u.is_service_account}`)
.sort()
.join(',')
}
async function listInvites(): Promise<void> {
@@ -36,6 +36,7 @@
import SideBarNotification from './SideBarNotification.svelte'
import { markChangelogsOpened, readRecentChangelogs } from './changelogs'
import { USER_SETTINGS_HASH, SUPERADMIN_SETTINGS_HASH } from './settings'
import { EXECUTIONS_HINT } from './executionsHint'
import {
userWorkspaces,
workspaceStore,
@@ -232,11 +233,12 @@
icon: Settings,
action: () => goto(USER_SETTINGS_HASH)
},
...(cloudHosted && !$isPremiumStore
...(cloudHosted && $isPremiumStore === false
? [
{
displayName: `${$usageStore}/1000 user execs`,
displayName: `${$usageStore ?? '—'}/1000 user execs`,
icon: Gauge,
tooltip: EXECUTIONS_HINT,
disabled: true
}
]
@@ -0,0 +1,290 @@
<script lang="ts">
import { resource } from 'runed'
import { goto } from '$lib/navigation'
import { isCloudHosted } from '$lib/cloud'
import { UserService } from '$lib/gen'
import {
isPremiumStore,
usageStore,
userStore,
userWorkspaces,
workspaceMembershipVersion,
workspaceStore,
workspaceUsageStore,
type UserWorkspace
} from '$lib/stores'
import { refreshExecutions } from '$lib/usage.svelte'
import { logFeatureUsage } from '$lib/utils/featureUsage'
import { scopedValue, tagged } from '$lib/utils/scopedValue'
import { findWorkspaceAncestors } from '$lib/utils/workspaceHierarchy'
import { Button } from '$lib/components/common'
import Modal from '$lib/components/common/modal/Modal.svelte'
import { Tooltip } from '$lib/components/meltComponents'
import { EXECUTIONS_HINT, FREE_EXECUTION_QUOTA, SEAT_EXECUTION_QUOTA } from './executionsHint'
interface Props {
isCollapsed?: boolean
}
let { isCollapsed = false }: Props = $props()
let open = $state(false)
// A fork's usage and tier resolve to its billing root while its member list is a
// subset of the root's, so seats must come from the root or the cap is fork-sized
// against root usage. `undefined` when the root isn't visible from here: the cap
// is then unknowable, and the caller hides the meter rather than guessing.
function billingRoot(workspace: string, all: UserWorkspace[]): string | undefined {
const self = all.find((w) => w.id === workspace)
if (!self) return undefined
if (!self.parent_workspace_id) return workspace
const top = findWorkspaceAncestors(workspace, all).at(-1)
return top && !top.parent_workspace_id ? top.id : undefined
}
// Seat count for a paid workspace, the basis of its included executions. Only the
// user list is needed: `premium_info` carries the same usage number as
// `workspaceUsageStore` but requires admin and only exists when Stripe is
// configured, so it would leave regular members with no block at all.
const fetchSeats = tagged(async (root: string) => {
// Throws for a fork member with no seat in the root, which is the same answer as
// an unresolvable root: leave the paid meter hidden.
const users = await UserService.listUsers({ workspace: root })
// Same basis as the backend's `count_paid_seats`: disabled members and service
// accounts are not billed, so counting them inflates the cap and hides a real
// overage. 1 developer = 1 seat, 2 operators = 1 seat.
const billable = users.filter((u) => !u.disabled && !u.is_service_account)
const developers = billable.filter((u) => !u.operator).length
const operators = billable.length - developers
return Math.ceil(developers + operators / 2)
})
const billingRootId = $derived.by(() => {
const workspace = $workspaceStore
if (!isCloudHosted() || !$isPremiumStore || !workspace) return undefined
return billingRoot(workspace, $userWorkspaces ?? [])
})
// The membership version is in the key so a change re-resolves the cap, but not in
// the tag: tagging by it would blank the bar on every change.
const seatsResource = resource(
() =>
billingRootId ? { root: billingRootId, version: $workspaceMembershipVersion } : undefined,
async (key) => (key ? await fetchSeats(key.root) : undefined)
)
const scopedSeats = scopedValue<number>()
const seats = $derived(scopedSeats(billingRootId, seatsResource.current))
type QuotaKey = 'user' | 'workspace'
/** Every key the meter can report, so the whole vocabulary is readable here. A paid
workspace has no per-user quota, so `paid:user` does not exist. */
type UsageMeterKey = `free:${QuotaKey}` | 'paid:workspace'
type Quota = {
key: QuotaKey
label: string
short: string
used: number
cap: number
/** Reaching the cap stops jobs, rather than adding to the bill. */
hard: boolean
}
// Every input is tri-state while it resolves, and a quota built from a missing
// one would render as a real number: each is listed only once its own usage,
// tier and cap are known. Free tier: two caps apply at once and either stops
// jobs on its own. Paid: one soft cap, the executions the seats already include.
const quotas = $derived<Quota[]>(
$isPremiumStore === undefined
? []
: $isPremiumStore
? seats !== undefined && $workspaceUsageStore !== undefined
? [
{
key: 'workspace' as const,
label: 'Workspace executions',
short: 'Workspace execs',
used: $workspaceUsageStore,
cap: seats * SEAT_EXECUTION_QUOTA,
hard: false
}
]
: []
: [
...($usageStore !== undefined
? [
{
key: 'user' as const,
label: 'Your executions',
short: 'Your execs',
used: $usageStore,
cap: FREE_EXECUTION_QUOTA,
hard: true
}
]
: []),
// The demo workspace has no workspace-level quota.
...($workspaceStore !== 'demo' && $workspaceUsageStore !== undefined
? [
{
key: 'workspace' as const,
label: 'Workspace executions',
short: 'Workspace execs',
used: $workspaceUsageStore,
cap: FREE_EXECUTION_QUOTA,
hard: true
}
]
: [])
]
)
const tightest = $derived(
quotas.length ? quotas.reduce((a, b) => (b.used / b.cap > a.used / a.cap ? b : a)) : undefined
)
const FILL = {
over: { bar: 'bg-red-500', ring: 'stroke-red-500' },
near: { bar: 'bg-yellow-500', ring: 'stroke-yellow-500' },
ok: { bar: 'bg-surface-accent-primary', ring: 'stroke-surface-accent-primary' }
}
function fill(q: Quota) {
// Passing a soft cap only means extra billed seats, never a stop, so it
// never reaches the red the free tier's hard cap gets.
if (q.used >= q.cap) return q.hard ? FILL.over : FILL.near
if (q.used >= q.cap * 0.9) return FILL.near
return FILL.ok
}
function ratio(q: Quota): number {
return q.cap > 0 ? Math.min(q.used / q.cap, 1) : 0
}
const fmt = (value: number) => value.toLocaleString('en-US')
// r=6 with a 2-wide stroke keeps the outer edge at 7, inside the 16x16 box.
const RING_RADIUS = 6
const RING_CIRCUMFERENCE = 2 * Math.PI * RING_RADIUS
</script>
{#snippet bar(q: Quota)}
<div class="w-full h-1 rounded-full bg-surface-sunken overflow-hidden">
<div class="h-full rounded-full {fill(q).bar}" style="width: {ratio(q) * 100}%"></div>
</div>
{/snippet}
{#snippet ring(q: Quota)}
<svg viewBox="0 0 16 16" class="h-4 w-4" aria-hidden="true">
<circle
cx="8"
cy="8"
r={RING_RADIUS}
fill="none"
stroke-width="2"
class="stroke-surface-sunken"
/>
<!-- Rotated in SVG user space, not with a CSS transform: the arc is rasterized
already turned, so its edges stay crisp at this size. -->
<circle
cx="8"
cy="8"
r={RING_RADIUS}
fill="none"
stroke-width="2"
transform="rotate(-90 8 8)"
stroke-dasharray={RING_CIRCUMFERENCE}
stroke-dashoffset={RING_CIRCUMFERENCE * (1 - ratio(q))}
class={fill(q).ring}
/>
</svg>
{/snippet}
{#if isCloudHosted() && tightest}
<div class="px-2 pt-2 pb-2">
<Tooltip placement="right" class="w-full">
{#snippet text()}
{tightest.label} this month: {fmt(tightest.used)}/{fmt(tightest.cap)}.
{EXECUTIONS_HINT}
{/snippet}
<button
type="button"
class="w-full rounded p-1.5 hover:bg-surface-hover flex {isCollapsed
? 'justify-center'
: 'flex-col gap-1'}"
onclick={() => {
open = true
const key: UsageMeterKey = $isPremiumStore ? 'paid:workspace' : `free:${tightest.key}`
logFeatureUsage('usage_meter', 'opened', { key })
// Executions accrue continuously and seats move with membership; both
// are read here rather than glanced at, so re-read both.
refreshExecutions()
void seatsResource.refetch()
}}
aria-label="{tightest.label} this month: {fmt(tightest.used)} of {fmt(tightest.cap)}"
>
{#if isCollapsed}
{@render ring(tightest)}
{:else}
<div
class="w-full flex items-baseline justify-between gap-2 text-2xs font-normal text-secondary"
>
<span class="truncate">{tightest.short}</span>
<span class="shrink-0 tabular-nums">{fmt(tightest.used)}/{fmt(tightest.cap)}</span>
</div>
{@render bar(tightest)}
{/if}
</button>
</Tooltip>
</div>
<Modal title="Executions this month" bind:open cancelText="Close">
<div class="flex flex-col gap-4">
{#each quotas as quota (quota.key)}
<div class="flex flex-col gap-1.5">
<div class="flex items-baseline justify-between gap-2">
<span class="text-sm text-emphasis">{quota.label}</span>
<span class="text-sm text-secondary tabular-nums"
>{fmt(quota.used)}/{fmt(quota.cap)}</span
>
</div>
{@render bar(quota)}
</div>
{/each}
<p class="text-xs text-secondary">
{EXECUTIONS_HINT} Counters reset at the start of every calendar month.
</p>
{#if $isPremiumStore}
<p class="text-xs text-secondary">
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.
</p>
{:else}
<p class="text-xs text-secondary">
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}
</p>
{/if}
</div>
{#snippet actions()}
{#if $userStore?.is_admin}
<Button
unifiedSize="md"
onclick={() => {
open = false
goto('/workspace_settings?tab=premium')
}}
>
{$isPremiumStore ? 'See billing' : 'See plans'}
</Button>
{/if}
{/snippet}
</Modal>
{/if}
@@ -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 @@
</MenuItem>
</div>
{#if isCloudHosted()}
<!-- Both branches below assert a plan, so neither may render for an unresolved or
failed tier: `{:else}` would read `undefined` as paid and claim "Premium plan". -->
{#if isCloudHosted() && $isPremiumStore !== undefined}
<div class="border-t">
{#if !$isPremiumStore}
<span class="text-secondary block w-full text-left px-4 py-2 text-xs"
>{$usageStore}/1000 user execs</span
>
{#if $isPremiumStore === false}
<span class="text-secondary block w-full text-left px-4 py-2 text-xs">
{$usageStore ?? '—'}/1000 user execs
<Tooltip small>
{#snippet text()}
{EXECUTIONS_HINT}
{/snippet}
</Tooltip>
</span>
<div class="px-4 w-full h-1 mb-1">
<div class="bg-gray-200 h-full rounded-sm overflow-hidden">
<div class="bg-blue-400 h-full" style="width: {Math.min($usageStore, 1000) / 10}%"
<div
class="bg-blue-400 h-full"
style="width: {Math.min($usageStore ?? 0, 1000) / 10}%"
></div>
</div>
</div>
{#if $workspaceStore != 'demo'}
<span class="text-secondary block w-full text-left px-4 py-2 text-xs"
>{$workspaceUsageStore}/1000 free workspace execs</span
>
<span class="text-secondary block w-full text-left px-4 py-2 text-xs">
{$workspaceUsageStore ?? '—'}/1000 free workspace execs
<Tooltip small>
{#snippet text()}
{EXECUTIONS_HINT}
{/snippet}
</Tooltip>
</span>
<div class="px-4 w-full h-1 mb-1">
<div class="bg-gray-200 h-full rounded-sm overflow-hidden">
<div
class="bg-blue-400 h-full"
style="width: {Math.min($workspaceUsageStore, 1000) / 10}%"
style="width: {Math.min($workspaceUsageStore ?? 0, 1000) / 10}%"
></div>
</div>
</div>
@@ -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 @@
</div>
{/if}
</div>
{#if isCloudHosted() && !$isPremiumStore && !strictWorkspaceSelect}
{#if isCloudHosted() && $isPremiumStore === false && !strictWorkspaceSelect}
<div class="py-1" role="none">
{#if $workspaceStore != 'demo'}
<span class="text-secondary block w-full text-left px-4 py-2 text-xs"
>{$workspaceUsageStore}/1000 free workspace execs</span
>
<span class="text-secondary block w-full text-left px-4 py-2 text-xs">
{$workspaceUsageStore ?? '—'}/1000 free workspace execs
<Tooltip small>
{#snippet text()}
{EXECUTIONS_HINT}
{/snippet}
</Tooltip>
</span>
<div class="w-full bg-gray-200 h-1">
<div class="bg-blue-400 h-1" style="width: {Math.min($workspaceUsageStore, 1000) / 10}%"
<div
class="bg-blue-400 h-1"
style="width: {Math.min($workspaceUsageStore ?? 0, 1000) / 10}%"
></div>
</div>
{/if}
@@ -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
+23 -3
View File
@@ -80,8 +80,10 @@ export const whitelabelNameStore = derived([enterpriseLicense], ([enterpriseLice
return undefined
})
export const workerTags = writable<string[] | undefined>(undefined)
export const usageStore = writable<number>(0)
export const workspaceUsageStore = writable<number>(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<number | undefined>(undefined)
export const workspaceUsageStore = writable<number | undefined>(undefined)
export const initialArgsStore = writable<any>(undefined)
export const oauthStore = writable<TokenResponse | undefined>(undefined)
export const userStore = writable<UserExt | undefined>(undefined)
@@ -90,7 +92,25 @@ export const workspaceStore = writable<string | undefined>(
)
export const defaultScripts = writable<WorkspaceDefaultScripts | undefined>(undefined)
export const dbClockDrift = writable<number | undefined>(undefined)
export const isPremiumStore = writable<boolean>(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<boolean | undefined>(undefined)
// Set when the tier fetch for the active workspace failed, which is indistinguishable
// from "still pending" in `isPremiumStore` alone.
export const premiumFetchFailed = writable<boolean>(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<boolean> = 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<number>(0)
export const usersWorkspaceStore = writable<UserWorkspaceList | undefined>(undefined)
export const superadmin = writable<string | false | undefined>(undefined)
export const devopsRole = writable<string | false | undefined>(undefined)
+99
View File
@@ -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<number>()
const scopedUserExecutions = scopedValue<number>()
const scopedPremium = scopedValue<boolean>()
// 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<typeof createUsageResources> | undefined = undefined
export function registerUsageResources(h: ReturnType<typeof createUsageResources>): void {
handle = h
}
export function refreshExecutions(): void {
handle?.refreshExecutions()
}
@@ -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<number>()
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<number>()
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<number>()
// 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<number>()
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<number>()
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<number>()
// 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<number>((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<number>()
expect(held('a', await second)).toBe(20)
expect(held('a', await first)).toBe(20)
})
})
+33
View File
@@ -0,0 +1,33 @@
export type Tagged<T> = { 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<K extends string, V>(
fetch: (key: K) => Promise<V>
): (key: K) => Promise<Tagged<V>> {
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<T>() {
let held: Tagged<T> | undefined = undefined
return (key: string | undefined, fetched: Tagged<T> | undefined) => {
if (fetched && fetched.key === key && (held?.key !== key || fetched.seq > held.seq)) {
held = fetched
}
return held && held.key === key ? held.value : undefined
}
}
@@ -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 @@
</SidebarScrollArea>
{/if}
<div class="w-52">
<SidebarUsage isCollapsed={false} />
</div>
<div class="px-4 pt-3 pb-3.5 w-52">
{@render brandMark(false)}
</div>
@@ -1224,6 +1216,10 @@
</SidebarScrollArea>
{/if}
<div class="flex-shrink-0">
<SidebarUsage {isCollapsed} />
</div>
<div
class="flex-shrink-0 flex pt-3 pb-3.5 {isCollapsed
? 'flex-col items-center gap-3'