mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-08-23 00:00:33 +00:00
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:
@@ -1 +1 @@
|
||||
37695a769b25d16b34107eedc1076793a8b388c8
|
||||
c6902ec2c51dc0ce30962afbfab3e456c5d9b831
|
||||
|
||||
@@ -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
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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'
|
||||
|
||||
Reference in New Issue
Block a user