diff --git a/backend/ee-repo-ref.txt b/backend/ee-repo-ref.txt index 7d07b9132a..40fb2fa235 100644 --- a/backend/ee-repo-ref.txt +++ b/backend/ee-repo-ref.txt @@ -1 +1 @@ -37695a769b25d16b34107eedc1076793a8b388c8 +c6902ec2c51dc0ce30962afbfab3e456c5d9b831 diff --git a/docs/feature-telemetry.md b/docs/feature-telemetry.md index 947329fc45..57467e25ec 100644 --- a/docs/feature-telemetry.md +++ b/docs/feature-telemetry.md @@ -4,9 +4,10 @@ anonymous usage-stats payload. It answers "does anyone use this, and which variant do they pick" without any identifying data leaving the instance. -It currently carries 20 registered actions across eight features (`ai_session`, `ai_chat`, -`flow_editor`, `flow_run`, `flow_step`, `trigger`, `command_script`, `hub_script`). Nearly all of -the product is uninstrumented, so new user-facing work is the opportunity to change that. +It currently carries 21 registered actions across nine features (`ai_session`, `ai_chat`, +`flow_editor`, `flow_run`, `flow_step`, `trigger`, `command_script`, `hub_script`, +`usage_meter`). Nearly all of the product is uninstrumented, so new user-facing work is the +opportunity to change that. ## When to instrument diff --git a/frontend/src/lib/components/DropdownSubmenuItem.svelte b/frontend/src/lib/components/DropdownSubmenuItem.svelte index 631f6374bd..60f7ad2d64 100644 --- a/frontend/src/lib/components/DropdownSubmenuItem.svelte +++ b/frontend/src/lib/components/DropdownSubmenuItem.svelte @@ -52,51 +52,71 @@ {#if subItem.separatorTop}
{/if} - subItem?.action?.(e)} - href={subItem?.href} - target={subItem?.hrefTarget} - disabled={subItem?.disabled} - class={twMerge( - 'px-4 py-2 text-primary font-normal hover:bg-surface-hover cursor-pointer text-xs transition-colors w-full', - 'data-[highlighted]:bg-surface-hover', - 'flex flex-row gap-2 items-center rounded-sm', - subItem?.disabled && 'text-disabled cursor-not-allowed' - )} - item={meltItem} - > - {#if subItem.icon} - - {/if} -

- {subItem.displayName} -

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

+ {subItem.displayName} +

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

+

{item.displayName}

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

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

+ {#if $isPremiumStore} +

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

+ {:else} +

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

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