@cat-factory/app 0.97.1 → 0.99.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -43,6 +43,7 @@ const github = useGitHubStore()
43
43
  const services = useServicesStore()
44
44
  const infra = useInfraConfigStore()
45
45
  const agentRuns = useAgentRunsStore()
46
+ const ui = useUiStore()
46
47
  const { t } = useI18n()
47
48
 
48
49
  // The custom-manifest-type catalog feeds the `custom` picker. Cheap + shared (coalesced).
@@ -443,6 +444,34 @@ function setSize(value: InstanceSize) {
443
444
  </p>
444
445
  </div>
445
446
 
447
+ <!-- Nudge into the guided environment setup wizard for a docker-compose service: the wizard
448
+ drives detect → review (recipe + analyst draft) → preflight → save so the single Deployer
449
+ provisions the compose stack, rather than editing the raw path inline. -->
450
+ <div
451
+ v-if="provisionType === 'docker-compose'"
452
+ class="flex items-center justify-between gap-2 rounded border border-primary-800/40 bg-primary-950/20 p-2"
453
+ data-testid="env-setup-nudge"
454
+ >
455
+ <div class="min-w-0">
456
+ <p class="text-[11px] font-medium text-primary-200/90">
457
+ {{ t('inspector.testConfig.envWizard.title') }}
458
+ </p>
459
+ <p class="text-[11px] leading-snug text-slate-500">
460
+ {{ t('inspector.testConfig.envWizard.hint') }}
461
+ </p>
462
+ </div>
463
+ <UButton
464
+ size="xs"
465
+ variant="soft"
466
+ color="primary"
467
+ icon="i-lucide-flask-conical"
468
+ data-testid="env-setup-nudge-open"
469
+ @click="ui.openEnvironmentSetup(props.block.id)"
470
+ >
471
+ {{ t('inspector.testConfig.envWizard.open') }}
472
+ </UButton>
473
+ </div>
474
+
446
475
  <!-- Auto-detect a recommended provisioning config from the repo (slice 11). Non-binding:
447
476
  it prefills the form below + the kube edit refs; the user confirms/edits everything. -->
448
477
  <div v-if="repoContext" class="space-y-2 rounded border border-slate-800 bg-slate-900/40 p-2">
@@ -0,0 +1,294 @@
1
+ <script setup lang="ts">
2
+ // The Budget configuration screen: the three spend-budget tiers (workspace, account,
3
+ // user). The workspace tier is a per-workspace monthly limit + currency; the account and
4
+ // user tiers are monthly ceilings that gate a run when EITHER is exhausted. When the
5
+ // operator sets a hard cap env var, the account/user input cannot exceed it and the cap is
6
+ // shown here. See docs/initiatives/tiered-budgets.md.
7
+ import { computed, reactive, ref, watch, type Ref } from 'vue'
8
+
9
+ const { t, n } = useI18n()
10
+ const toast = useToast()
11
+
12
+ const settingsStore = useWorkspaceSettingsStore()
13
+ const userSettingsStore = useUserSettingsStore()
14
+ const accounts = useAccountsStore()
15
+ const workspace = useWorkspaceStore()
16
+
17
+ const caps = computed(() => workspace.budgetCaps)
18
+ const capCurrency = computed(() => caps.value?.currency ?? 'EUR')
19
+ // Format an amount in a given currency. The account/user tiers are in the base pricing
20
+ // currency (`capCurrency`); the workspace tier is in its OWN overridden `spend.currency`, so
21
+ // its callers pass that in — otherwise a USD workspace on a EUR deployment renders `€` on USD.
22
+ const money = (value: number, currency: string = capCurrency.value) =>
23
+ n(value, { key: 'currency', currency })
24
+
25
+ // Persist one tier's budget: save, toast success, then best-effort refresh the snapshot AFTER
26
+ // the save has succeeded. A transient snapshot-refresh failure must NOT report a persisted
27
+ // budget as failed (the spend meter also catches up on the next pushed snapshot); a genuine
28
+ // save rejection surfaces its message so the user sees why (e.g. the operator hard-cap reject).
29
+ async function runSave(saving: Ref<boolean>, save: () => Promise<unknown>) {
30
+ saving.value = true
31
+ try {
32
+ await save()
33
+ toast.add({ title: t('settings.workspaceSettings.toast.budgetSaved'), color: 'success' })
34
+ try {
35
+ await useWorkspaceStore().refresh()
36
+ } catch {
37
+ // ignore — the budget is persisted; the meter will catch up on the next snapshot.
38
+ }
39
+ } catch (e) {
40
+ toast.add({
41
+ title: t('settings.workspaceSettings.toast.budgetSaveFailed'),
42
+ description: e instanceof Error ? e.message : String(e),
43
+ color: 'error',
44
+ })
45
+ } finally {
46
+ saving.value = false
47
+ }
48
+ }
49
+
50
+ // ---- Workspace tier -------------------------------------------------------
51
+ const wsDraft = reactive({ spendCurrency: '', spendMonthlyLimit: '' })
52
+ function hydrateWorkspace() {
53
+ const s = settingsStore.settings
54
+ wsDraft.spendCurrency = s.spendCurrency ?? ''
55
+ wsDraft.spendMonthlyLimit = s.spendMonthlyLimit == null ? '' : String(s.spendMonthlyLimit)
56
+ }
57
+ watch(() => settingsStore.settings, hydrateWorkspace, { immediate: true })
58
+
59
+ const savingWorkspace = ref(false)
60
+ function saveWorkspace() {
61
+ const raw = String(wsDraft.spendMonthlyLimit ?? '').trim()
62
+ return runSave(savingWorkspace, () =>
63
+ settingsStore.update({
64
+ spendCurrency: wsDraft.spendCurrency.trim()
65
+ ? wsDraft.spendCurrency.trim().toUpperCase()
66
+ : null,
67
+ spendMonthlyLimit: raw === '' ? null : Number(raw),
68
+ }),
69
+ )
70
+ }
71
+
72
+ // ---- Account tier ---------------------------------------------------------
73
+ const account = computed(() => accounts.activeAccount)
74
+ const canEditAccount = computed(() => account.value?.roles?.includes('admin') ?? false)
75
+ const accountCap = computed(() => caps.value?.accountMonthlyLimitMax ?? null)
76
+ const accountDraft = ref('')
77
+ watch(
78
+ account,
79
+ (a) => {
80
+ accountDraft.value = a?.spendMonthlyLimit == null ? '' : String(a.spendMonthlyLimit)
81
+ },
82
+ { immediate: true },
83
+ )
84
+ const accountOverCap = computed(
85
+ () =>
86
+ accountCap.value != null &&
87
+ accountDraft.value.trim() !== '' &&
88
+ Number(accountDraft.value) > accountCap.value,
89
+ )
90
+ const savingAccount = ref(false)
91
+ function saveAccount() {
92
+ const acc = account.value
93
+ if (!acc || accountOverCap.value) return
94
+ const raw = accountDraft.value.trim()
95
+ return runSave(savingAccount, () =>
96
+ accounts.setSpendMonthlyLimit(acc.id, raw === '' ? null : Number(raw)),
97
+ )
98
+ }
99
+
100
+ // ---- User tier ------------------------------------------------------------
101
+ const userCap = computed(() => caps.value?.userMonthlyLimitMax ?? null)
102
+ const userDraft = ref('')
103
+ watch(
104
+ () => userSettingsStore.settings,
105
+ (s) => {
106
+ userDraft.value = s.spendMonthlyLimit == null ? '' : String(s.spendMonthlyLimit)
107
+ },
108
+ { immediate: true },
109
+ )
110
+ const userOverCap = computed(
111
+ () =>
112
+ userCap.value != null &&
113
+ userDraft.value.trim() !== '' &&
114
+ Number(userDraft.value) > userCap.value,
115
+ )
116
+ const savingUser = ref(false)
117
+ function saveUser() {
118
+ if (userOverCap.value) return
119
+ const raw = userDraft.value.trim()
120
+ return runSave(savingUser, () =>
121
+ userSettingsStore.update({ spendMonthlyLimit: raw === '' ? null : Number(raw) }),
122
+ )
123
+ }
124
+ </script>
125
+
126
+ <template>
127
+ <div class="space-y-8">
128
+ <p class="text-[11px] text-slate-400">
129
+ {{ t('settings.workspaceSettings.budget.body') }}
130
+ </p>
131
+
132
+ <!-- Workspace tier -->
133
+ <section class="space-y-2">
134
+ <h3 class="text-sm font-semibold text-slate-200">
135
+ {{ t('settings.workspaceSettings.budget.workspace') }}
136
+ </h3>
137
+ <div class="grid grid-cols-2 gap-3">
138
+ <label class="block">
139
+ <span class="mb-1 block text-[10px] uppercase tracking-wide text-slate-500">
140
+ {{ t('settings.workspaceSettings.budget.monthlyLimit') }}
141
+ </span>
142
+ <UInput
143
+ v-model="wsDraft.spendMonthlyLimit"
144
+ type="number"
145
+ :min="0"
146
+ :placeholder="t('settings.workspaceSettings.budget.defaultPlaceholder')"
147
+ size="sm"
148
+ />
149
+ </label>
150
+ <label class="block">
151
+ <span class="mb-1 block text-[10px] uppercase tracking-wide text-slate-500">
152
+ {{ t('settings.workspaceSettings.budget.currency') }}
153
+ </span>
154
+ <UInput
155
+ v-model="wsDraft.spendCurrency"
156
+ placeholder="EUR"
157
+ maxlength="3"
158
+ size="sm"
159
+ class="uppercase"
160
+ />
161
+ </label>
162
+ </div>
163
+ <div v-if="workspace.spend" class="text-[11px] text-slate-400">
164
+ {{
165
+ t('settings.workspaceSettings.budget.spent', {
166
+ spent: money(workspace.spend.costSpent, workspace.spend.currency),
167
+ limit: money(workspace.spend.costLimit, workspace.spend.currency),
168
+ })
169
+ }}
170
+ </div>
171
+ <div class="flex justify-end">
172
+ <UButton
173
+ color="primary"
174
+ icon="i-lucide-save"
175
+ size="sm"
176
+ :loading="savingWorkspace"
177
+ @click="saveWorkspace"
178
+ >
179
+ {{ t('settings.workspaceSettings.budget.saveTier') }}
180
+ </UButton>
181
+ </div>
182
+ </section>
183
+
184
+ <!-- Account tier -->
185
+ <section v-if="account" class="space-y-2">
186
+ <h3 class="text-sm font-semibold text-slate-200">
187
+ {{ t('settings.workspaceSettings.budget.account') }}
188
+ </h3>
189
+ <p class="text-[11px] text-slate-400">
190
+ {{ t('settings.workspaceSettings.budget.accountBody') }}
191
+ </p>
192
+ <label class="block">
193
+ <span class="mb-1 block text-[10px] uppercase tracking-wide text-slate-500">
194
+ {{ t('settings.workspaceSettings.budget.monthlyLimit') }}
195
+ </span>
196
+ <UInput
197
+ v-model="accountDraft"
198
+ type="number"
199
+ :min="0"
200
+ :max="accountCap ?? undefined"
201
+ :disabled="!canEditAccount"
202
+ :placeholder="t('settings.workspaceSettings.budget.noLimitPlaceholder')"
203
+ size="sm"
204
+ />
205
+ </label>
206
+ <p
207
+ v-if="accountCap != null"
208
+ class="text-[11px]"
209
+ :class="accountOverCap ? 'text-rose-400' : 'text-amber-400'"
210
+ >
211
+ {{ t('settings.workspaceSettings.budget.hardCap', { amount: money(accountCap) }) }}
212
+ <span class="text-slate-500"
213
+ >({{ t('settings.workspaceSettings.budget.hardCapHint') }})</span
214
+ >
215
+ </p>
216
+ <div v-if="workspace.accountSpend" class="text-[11px] text-slate-400">
217
+ {{
218
+ t('settings.workspaceSettings.budget.spent', {
219
+ spent: money(workspace.accountSpend.costSpent),
220
+ limit: money(workspace.accountSpend.costLimit),
221
+ })
222
+ }}
223
+ </div>
224
+ <p v-if="!canEditAccount" class="text-[11px] text-slate-500">
225
+ {{ t('settings.workspaceSettings.budget.adminOnly') }}
226
+ </p>
227
+ <div v-if="canEditAccount" class="flex justify-end">
228
+ <UButton
229
+ color="primary"
230
+ icon="i-lucide-save"
231
+ size="sm"
232
+ :loading="savingAccount"
233
+ :disabled="accountOverCap"
234
+ @click="saveAccount"
235
+ >
236
+ {{ t('settings.workspaceSettings.budget.saveTier') }}
237
+ </UButton>
238
+ </div>
239
+ </section>
240
+
241
+ <!-- User tier -->
242
+ <section class="space-y-2">
243
+ <h3 class="text-sm font-semibold text-slate-200">
244
+ {{ t('settings.workspaceSettings.budget.user') }}
245
+ </h3>
246
+ <p class="text-[11px] text-slate-400">
247
+ {{ t('settings.workspaceSettings.budget.userBody') }}
248
+ </p>
249
+ <label class="block">
250
+ <span class="mb-1 block text-[10px] uppercase tracking-wide text-slate-500">
251
+ {{ t('settings.workspaceSettings.budget.monthlyLimit') }}
252
+ </span>
253
+ <UInput
254
+ v-model="userDraft"
255
+ type="number"
256
+ :min="0"
257
+ :max="userCap ?? undefined"
258
+ :placeholder="t('settings.workspaceSettings.budget.noLimitPlaceholder')"
259
+ size="sm"
260
+ />
261
+ </label>
262
+ <p
263
+ v-if="userCap != null"
264
+ class="text-[11px]"
265
+ :class="userOverCap ? 'text-rose-400' : 'text-amber-400'"
266
+ >
267
+ {{ t('settings.workspaceSettings.budget.hardCap', { amount: money(userCap) }) }}
268
+ <span class="text-slate-500"
269
+ >({{ t('settings.workspaceSettings.budget.hardCapHint') }})</span
270
+ >
271
+ </p>
272
+ <div v-if="workspace.userSpend" class="text-[11px] text-slate-400">
273
+ {{
274
+ t('settings.workspaceSettings.budget.spent', {
275
+ spent: money(workspace.userSpend.costSpent),
276
+ limit: money(workspace.userSpend.costLimit),
277
+ })
278
+ }}
279
+ </div>
280
+ <div class="flex justify-end">
281
+ <UButton
282
+ color="primary"
283
+ icon="i-lucide-save"
284
+ size="sm"
285
+ :loading="savingUser"
286
+ :disabled="userOverCap"
287
+ @click="saveUser"
288
+ >
289
+ {{ t('settings.workspaceSettings.budget.saveTier') }}
290
+ </UButton>
291
+ </div>
292
+ </section>
293
+ </div>
294
+ </template>
@@ -12,6 +12,7 @@ import type { CreateTaskType, TaskLimitMode } from '~/types/domain'
12
12
  import MergeThresholdsPanel from '~/components/settings/MergeThresholdsPanel.vue'
13
13
  import IssueTrackerPanel from '~/components/settings/IssueTrackerPanel.vue'
14
14
  import ServiceFragmentDefaultsPanel from '~/components/settings/ServiceFragmentDefaultsPanel.vue'
15
+ import BudgetSettings from '~/components/settings/BudgetSettings.vue'
15
16
  import IntegrationBackTitle from '~/components/layout/IntegrationBackTitle.vue'
16
17
 
17
18
  const { t, te } = useI18n()
@@ -114,9 +115,6 @@ const draft = reactive({
114
115
  storeAgentContext: true,
115
116
  artifactRetentionDays: 14,
116
117
  kaizenEnabled: true,
117
- // Budget: empty string ⇒ "use the built-in default" (null on the wire).
118
- spendCurrency: '',
119
- spendMonthlyLimit: '',
120
118
  })
121
119
 
122
120
  function hydrate() {
@@ -129,8 +127,6 @@ function hydrate() {
129
127
  draft.storeAgentContext = s.storeAgentContext
130
128
  draft.artifactRetentionDays = s.artifactRetentionDays
131
129
  draft.kaizenEnabled = s.kaizenEnabled
132
- draft.spendCurrency = s.spendCurrency ?? ''
133
- draft.spendMonthlyLimit = s.spendMonthlyLimit == null ? '' : String(s.spendMonthlyLimit)
134
130
  }
135
131
 
136
132
  // `store.settings` is always replaced wholesale (store hydrate/update reassign the ref),
@@ -176,46 +172,6 @@ async function save() {
176
172
  saving.value = false
177
173
  }
178
174
  }
179
-
180
- const savingBudget = ref(false)
181
-
182
- async function saveBudget() {
183
- savingBudget.value = true
184
- // The number input emits a raw number once edited but starts as a string from hydrate, so
185
- // coerce through String() before trimming. Blank ⇒ "use the built-in default" (null on the wire).
186
- const raw = String(draft.spendMonthlyLimit ?? '').trim()
187
- const monthlyLimit = raw === '' ? null : Number(raw)
188
- try {
189
- await store.update({
190
- spendCurrency: draft.spendCurrency.trim() ? draft.spendCurrency.trim().toUpperCase() : null,
191
- spendMonthlyLimit: monthlyLimit,
192
- })
193
- toast.add({
194
- title: t('settings.workspaceSettings.toast.budgetSaved'),
195
- icon: 'i-lucide-check',
196
- color: 'success',
197
- })
198
- // The settings PUT only returns the settings; re-fetch the snapshot so the toolbar's
199
- // spend meter reflects the newly-set limit/currency (spendService.status) right away.
200
- // Best-effort and AFTER the save succeeded: a transient snapshot-refresh failure must
201
- // not report a successfully-saved budget as failed (the meter also catches up on the
202
- // next snapshot pushed over the stream).
203
- try {
204
- await useWorkspaceStore().refresh()
205
- } catch {
206
- // ignore — the budget is persisted; the meter will catch up on the next snapshot.
207
- }
208
- } catch (e) {
209
- toast.add({
210
- title: t('settings.workspaceSettings.toast.budgetSaveFailed'),
211
- description: e instanceof Error ? e.message : String(e),
212
- icon: 'i-lucide-triangle-alert',
213
- color: 'error',
214
- })
215
- } finally {
216
- savingBudget.value = false
217
- }
218
- }
219
175
  </script>
220
176
 
221
177
  <template>
@@ -370,56 +326,9 @@ async function saveBudget() {
370
326
  </div>
371
327
  </template>
372
328
 
373
- <!-- Budget -->
329
+ <!-- Budget (workspace / account / user tiers) -->
374
330
  <template #budget>
375
- <div class="space-y-6">
376
- <section class="space-y-2">
377
- <h3 class="text-sm font-semibold text-slate-200">
378
- {{ t('settings.workspaceSettings.budget.heading') }}
379
- </h3>
380
- <p class="text-[11px] text-slate-400">
381
- {{ t('settings.workspaceSettings.budget.body') }}
382
- </p>
383
- <div class="grid grid-cols-2 gap-3">
384
- <label class="block">
385
- <span class="mb-1 block text-[10px] uppercase tracking-wide text-slate-500">
386
- {{ t('settings.workspaceSettings.budget.monthlyLimit') }}
387
- </span>
388
- <UInput
389
- v-model="draft.spendMonthlyLimit"
390
- type="number"
391
- :min="0"
392
- :placeholder="t('settings.workspaceSettings.budget.defaultPlaceholder')"
393
- size="sm"
394
- />
395
- </label>
396
- <label class="block">
397
- <span class="mb-1 block text-[10px] uppercase tracking-wide text-slate-500">
398
- {{ t('settings.workspaceSettings.budget.currency') }}
399
- </span>
400
- <UInput
401
- v-model="draft.spendCurrency"
402
- placeholder="EUR"
403
- maxlength="3"
404
- size="sm"
405
- class="uppercase"
406
- />
407
- </label>
408
- </div>
409
- </section>
410
-
411
- <div class="flex justify-end">
412
- <UButton
413
- color="primary"
414
- icon="i-lucide-save"
415
- size="sm"
416
- :loading="savingBudget"
417
- @click="saveBudget"
418
- >
419
- {{ t('settings.workspaceSettings.budget.save') }}
420
- </UButton>
421
- </div>
422
- </div>
331
+ <BudgetSettings />
423
332
  </template>
424
333
 
425
334
  <!-- Merge thresholds -->
@@ -1,4 +1,5 @@
1
- import { listEnvironmentsContract } from '@cat-factory/contracts'
1
+ import { listEnvironmentsContract, provisionEnvironmentContract } from '@cat-factory/contracts'
2
+ import type { ProvisionEnvironmentInput } from '@cat-factory/contracts'
2
3
  import type { ApiContext } from './context'
3
4
 
4
5
  /** Ephemeral environments: the workspace's live env handles (used to resolve frontend bindings). */
@@ -6,5 +7,10 @@ export function environmentsApi({ send, ws }: ApiContext) {
6
7
  return {
7
8
  listEnvironments: (workspaceId: string) =>
8
9
  send(listEnvironmentsContract, { pathPrefix: ws(workspaceId) }),
10
+
11
+ // Manually provision an environment for a service frame (outside a pipeline run) — the setup
12
+ // wizard's "trial provision" against the just-saved config. Returns the resulting handle.
13
+ provisionEnvironment: (workspaceId: string, body: ProvisionEnvironmentInput) =>
14
+ send(provisionEnvironmentContract, { pathPrefix: ws(workspaceId), body }),
9
15
  }
10
16
  }
@@ -0,0 +1,16 @@
1
+ import { runPreflightsContract } from '@cat-factory/contracts'
2
+ import type { PreflightRef } from '@cat-factory/contracts'
3
+ import type { ApiContext } from './context'
4
+
5
+ /**
6
+ * Preflight checks (machine-prerequisite probes for a stack recipe): run a set of refs and get one
7
+ * verdict each (pass / fail / warn + detail + remediation). Used by the environment setup wizard's
8
+ * checklist + live re-check. The probes run only on the local (host) facade; the endpoint 503s
9
+ * elsewhere. See PreflightController in @cat-factory/server.
10
+ */
11
+ export function preflightsApi({ send, ws }: ApiContext) {
12
+ return {
13
+ runPreflights: (workspaceId: string, prerequisites: PreflightRef[]) =>
14
+ send(runPreflightsContract, { pathPrefix: ws(workspaceId), body: { prerequisites } }),
15
+ }
16
+ }
@@ -0,0 +1,12 @@
1
+ import { getUserSettingsContract, updateUserSettingsContract } from '@cat-factory/contracts'
2
+ import type { UpdateUserSettingsInput } from '~/types/domain'
3
+ import type { ApiContext } from './context'
4
+
5
+ /** Per-user settings (the user-tier spend budget), scoped to the signed-in user. */
6
+ export function userSettingsApi({ send }: ApiContext) {
7
+ return {
8
+ getUserSettings: () => send(getUserSettingsContract, {}),
9
+ updateUserSettings: (body: UpdateUserSettingsInput) =>
10
+ send(updateUserSettingsContract, { body }),
11
+ }
12
+ }
@@ -21,6 +21,7 @@ import { localSettingsApi } from './api/localSettings'
21
21
  import { modelsApi } from './api/models'
22
22
  import { notificationsApi } from './api/notifications'
23
23
  import { packageRegistriesApi } from './api/packageRegistries'
24
+ import { preflightsApi } from './api/preflights'
24
25
  import { presetsApi } from './api/presets'
25
26
  import { sharedStacksApi } from './api/sharedStacks'
26
27
  import { providerConnectionsApi } from './api/providerConnections'
@@ -35,6 +36,7 @@ import { slackApi } from './api/slack'
35
36
  import { specApi } from './api/spec'
36
37
  import { tasksApi } from './api/tasks'
37
38
  import { userSecretsApi } from './api/userSecrets'
39
+ import { userSettingsApi } from './api/userSettings'
38
40
  import { workspacesApi } from './api/workspaces'
39
41
 
40
42
  /**
@@ -113,6 +115,7 @@ export function useApi() {
113
115
  ...specApi(ctx),
114
116
  ...notificationsApi(ctx),
115
117
  ...presetsApi(ctx),
118
+ ...preflightsApi(ctx),
116
119
  ...sharedStacksApi(ctx),
117
120
  ...providerConnectionsApi(ctx),
118
121
  ...infraHandlersApi(ctx),
@@ -129,5 +132,6 @@ export function useApi() {
129
132
  ...slackApi(ctx),
130
133
  ...bootstrapApi(ctx),
131
134
  ...userSecretsApi(ctx),
135
+ ...userSettingsApi(ctx),
132
136
  }
133
137
  }
@@ -95,6 +95,9 @@ const PackageRegistriesPanel = defineAsyncComponent(
95
95
  const InfrastructureWindow = defineAsyncComponent(
96
96
  () => import('~/components/settings/InfrastructureWindow.vue'),
97
97
  )
98
+ const EnvironmentSetupWizard = defineAsyncComponent(
99
+ () => import('~/components/environments/EnvironmentSetupWizard.vue'),
100
+ )
98
101
  const ModelConfigurationPanel = defineAsyncComponent(
99
102
  () => import('~/components/settings/ModelConfigurationPanel.vue'),
100
103
  )
@@ -373,6 +376,7 @@ watch(
373
376
  <ObservabilityConnectionPanel v-if="ui.observabilityConnectionOpen" />
374
377
  <PackageRegistriesPanel v-if="ui.packageRegistriesOpen" />
375
378
  <InfrastructureWindow v-if="ui.infrastructureOpen" />
379
+ <EnvironmentSetupWizard v-if="ui.environmentWizardOpen" />
376
380
  <ModelConfigurationPanel v-if="ui.modelConfigOpen" />
377
381
  <LocalModelEndpointsPanel v-if="ui.localModelsOpen" />
378
382
  <SandboxPanel v-if="ui.sandboxOpen" />
@@ -67,6 +67,17 @@ export const useAccountsStore = defineStore(
67
67
  return updated
68
68
  }
69
69
 
70
+ /**
71
+ * Set an account's monthly spend budget (the account tier). Admin-only on the
72
+ * backend; `null` clears the limit. Patches the loaded account in place on success.
73
+ */
74
+ async function setSpendMonthlyLimit(id: string, limit: number | null) {
75
+ const updated = await api.updateAccount(id, { spendMonthlyLimit: limit })
76
+ const i = accounts.value.findIndex((a) => a.id === id)
77
+ if (i >= 0) accounts.value[i] = updated
78
+ return updated
79
+ }
80
+
70
81
  // ---- members + invitations -------------------------------------------
71
82
 
72
83
  const members = ref<AccountMember[]>([])
@@ -139,6 +150,7 @@ export const useAccountsStore = defineStore(
139
150
  createOrg,
140
151
  switchTo,
141
152
  setDefaultCloudProvider,
153
+ setSpendMonthlyLimit,
142
154
  loadRoster,
143
155
  invite,
144
156
  revokeInvite,