@cat-factory/app 0.9.1 → 0.10.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.
Files changed (29) hide show
  1. package/app/components/board/AddTaskModal.vue +209 -21
  2. package/app/components/board/nodes/BlockNode.vue +2 -2
  3. package/app/components/focus/BlockFocusView.vue +2 -2
  4. package/app/components/layout/CommandBar.vue +7 -33
  5. package/app/components/layout/IntegrationsHub.vue +230 -0
  6. package/app/components/layout/SideBar.vue +8 -170
  7. package/app/components/panels/GenericStructuredResultView.vue +131 -0
  8. package/app/components/panels/InspectorPanel.vue +6 -2
  9. package/app/components/panels/StepResultViewHost.vue +4 -0
  10. package/app/components/panels/inspector/ServiceReleaseHealthConfig.vue +148 -0
  11. package/app/components/settings/IssueTrackerWritebackPanel.vue +45 -57
  12. package/app/components/settings/MergeThresholdsPanel.vue +189 -226
  13. package/app/components/settings/ObservabilityConnectionPanel.vue +151 -0
  14. package/app/components/settings/ServiceFragmentDefaultsPanel.vue +46 -61
  15. package/app/components/settings/WorkspaceSettingsPanel.vue +136 -63
  16. package/app/composables/api/releaseHealth.ts +11 -10
  17. package/app/pages/index.vue +4 -8
  18. package/app/stores/agents.ts +27 -2
  19. package/app/stores/releaseHealth.ts +48 -12
  20. package/app/stores/ui.ts +34 -42
  21. package/app/stores/workspace.ts +4 -0
  22. package/app/types/domain.ts +33 -1
  23. package/app/types/execution.ts +6 -0
  24. package/app/types/releaseHealth.ts +19 -11
  25. package/app/utils/catalog.spec.ts +10 -0
  26. package/app/utils/catalog.ts +20 -6
  27. package/package.json +1 -1
  28. package/app/components/board/ContextPicker.vue +0 -367
  29. package/app/components/settings/DatadogPanel.vue +0 -213
@@ -4,23 +4,16 @@
4
4
  // served by GET /prompt-fragments. Changing it does not retroactively change existing
5
5
  // services — each owns its selection from creation. Persisted via the
6
6
  // serviceFragmentDefaults store (the backend replaces the whole list on each change).
7
- import { ref } from 'vue'
7
+ import { onMounted, ref } from 'vue'
8
8
 
9
- const ui = useUiStore()
10
9
  const fragments = useFragmentsStore()
11
10
  const defaults = useServiceFragmentDefaultsStore()
12
11
  const toast = useToast()
13
12
 
14
- const open = computed({
15
- get: () => ui.serviceFragmentDefaultsOpen,
16
- set: (v: boolean) => (v ? ui.openServiceFragmentDefaults() : ui.closeServiceFragmentDefaults()),
17
- })
18
-
19
13
  const busy = ref(false)
20
14
 
21
- watch(open, (isOpen) => {
22
- if (isOpen) void fragments.ensureLoaded()
23
- })
15
+ // The tab renders when Workspace settings opens; load the fragment pool then.
16
+ onMounted(() => void fragments.ensureLoaded())
24
17
 
25
18
  const selected = computed(() =>
26
19
  defaults.fragmentIds
@@ -68,57 +61,49 @@ function remove(id: string) {
68
61
  </script>
69
62
 
70
63
  <template>
71
- <UModal v-model:open="open" title="Default service best practices" :ui="{ content: 'max-w-2xl' }">
72
- <template #body>
73
- <div class="space-y-4">
74
- <p class="text-xs text-slate-400">
75
- Pick the best-practice fragments every <span class="text-slate-300">new</span> service
76
- starts with. Their guidance is folded into the prompt of every
77
- <span class="text-slate-300">code-aware</span> agent (coder, reviewer, architect, fixers)
78
- on the service's tasks. You can refine the set per service in its inspector; changing this
79
- default does not affect services that already exist.
80
- </p>
64
+ <div class="space-y-4">
65
+ <p class="text-xs text-slate-400">
66
+ Pick the best-practice fragments every <span class="text-slate-300">new</span> service starts
67
+ with. Their guidance is folded into the prompt of every
68
+ <span class="text-slate-300">code-aware</span> agent (coder, reviewer, architect, fixers) on
69
+ the service's tasks. You can refine the set per service in its inspector; changing this
70
+ default does not affect services that already exist.
71
+ </p>
81
72
 
82
- <div class="flex items-center justify-between">
83
- <span class="text-[11px] font-semibold uppercase tracking-wide text-slate-400">
84
- Default fragments
85
- </span>
86
- <UDropdownMenu
87
- v-if="menu.length"
88
- :items="menu"
89
- :ui="{ content: 'max-h-72 overflow-y-auto' }"
90
- >
91
- <UButton
92
- size="xs"
93
- variant="ghost"
94
- color="neutral"
95
- icon="i-lucide-plus"
96
- trailing-icon="i-lucide-chevron-down"
97
- :loading="busy"
98
- >
99
- Add fragment
100
- </UButton>
101
- </UDropdownMenu>
102
- </div>
73
+ <div class="flex items-center justify-between">
74
+ <span class="text-[11px] font-semibold uppercase tracking-wide text-slate-400">
75
+ Default fragments
76
+ </span>
77
+ <UDropdownMenu v-if="menu.length" :items="menu" :ui="{ content: 'max-h-72 overflow-y-auto' }">
78
+ <UButton
79
+ size="xs"
80
+ variant="ghost"
81
+ color="neutral"
82
+ icon="i-lucide-plus"
83
+ trailing-icon="i-lucide-chevron-down"
84
+ :loading="busy"
85
+ >
86
+ Add fragment
87
+ </UButton>
88
+ </UDropdownMenu>
89
+ </div>
103
90
 
104
- <div v-if="selected.length" class="flex flex-wrap gap-1">
105
- <UBadge
106
- v-for="f in selected"
107
- :key="f.id"
108
- color="primary"
109
- variant="subtle"
110
- size="sm"
111
- class="cursor-pointer"
112
- :title="f.summary"
113
- @click="remove(f.id)"
114
- >
115
- {{ f.title }}<UIcon name="i-lucide-x" class="ml-0.5 h-3 w-3" />
116
- </UBadge>
117
- </div>
118
- <p v-else class="text-[11px] text-slate-500">
119
- None — new services start with no service-level fragments.
120
- </p>
121
- </div>
122
- </template>
123
- </UModal>
91
+ <div v-if="selected.length" class="flex flex-wrap gap-1">
92
+ <UBadge
93
+ v-for="f in selected"
94
+ :key="f.id"
95
+ color="primary"
96
+ variant="subtle"
97
+ size="sm"
98
+ class="cursor-pointer"
99
+ :title="f.summary"
100
+ @click="remove(f.id)"
101
+ >
102
+ {{ f.title }}<UIcon name="i-lucide-x" class="ml-0.5 h-3 w-3" />
103
+ </UBadge>
104
+ </div>
105
+ <p v-else class="text-[11px] text-slate-500">
106
+ None — new services start with no service-level fragments.
107
+ </p>
108
+ </div>
124
109
  </template>
@@ -1,12 +1,17 @@
1
1
  <script setup lang="ts">
2
- // Workspace settings: the run-timing escalation threshold and the per-service
3
- // running-task limit policy.
4
- // - waitingEscalationMinutes runs never time out waiting for a human; after this
5
- // long their notification escalates yellow red ("Overdue") in the inbox.
6
- // - task limit cap how many tasks may run concurrently under one service, either
7
- // as a single shared bucket across all types or one bucket per task type.
2
+ // Workspace settings a single tabbed modal that gathers the workspace-wide
3
+ // configuration that used to live in separate windows:
4
+ // - Workspace: the run-timing escalation threshold + per-service running-task limit.
5
+ // - Merge thresholds: the auto-merge preset library.
6
+ // - Issue writeback: the tracker writeback toggles.
7
+ // - Service best practices: the default fragments new services inherit.
8
+ // The latter three are body-only section components rendered in tabs here (no longer
9
+ // standalone modals).
8
10
  import { reactive, ref, watch } from 'vue'
9
11
  import type { CreateTaskType, TaskLimitMode } from '~/types/domain'
12
+ import MergeThresholdsPanel from '~/components/settings/MergeThresholdsPanel.vue'
13
+ import IssueTrackerWritebackPanel from '~/components/settings/IssueTrackerWritebackPanel.vue'
14
+ import ServiceFragmentDefaultsPanel from '~/components/settings/ServiceFragmentDefaultsPanel.vue'
10
15
 
11
16
  const ui = useUiStore()
12
17
  const store = useWorkspaceSettingsStore()
@@ -17,6 +22,35 @@ const open = computed({
17
22
  set: (v: boolean) => (v ? ui.openWorkspaceSettings() : ui.closeWorkspaceSettings()),
18
23
  })
19
24
 
25
+ // Which tab is shown — driven by the ui store so other surfaces (command bar,
26
+ // integrations) can deep-link straight to a tab.
27
+ const activeTab = computed({
28
+ get: () => ui.workspaceSettingsTab,
29
+ set: (v: string) => ui.setWorkspaceSettingsTab(v),
30
+ })
31
+
32
+ const tabs = [
33
+ {
34
+ value: 'workspace',
35
+ label: 'Workspace',
36
+ icon: 'i-lucide-sliders-horizontal',
37
+ slot: 'workspace',
38
+ },
39
+ { value: 'merge', label: 'Merge thresholds', icon: 'i-lucide-git-merge', slot: 'merge' },
40
+ {
41
+ value: 'writeback',
42
+ label: 'Issue writeback',
43
+ icon: 'i-lucide-message-square-reply',
44
+ slot: 'writeback',
45
+ },
46
+ {
47
+ value: 'fragments',
48
+ label: 'Service best practices',
49
+ icon: 'i-lucide-book-open-check',
50
+ slot: 'fragments',
51
+ },
52
+ ]
53
+
20
54
  const TASK_TYPES: CreateTaskType[] = ['feature', 'bug', 'document', 'spike']
21
55
  const MODES: { value: TaskLimitMode; label: string }[] = [
22
56
  { value: 'off', label: 'No limit' },
@@ -78,65 +112,104 @@ async function save() {
78
112
  </script>
79
113
 
80
114
  <template>
81
- <UModal v-model:open="open" title="Workspace settings" :ui="{ content: 'max-w-xl' }">
115
+ <UModal v-model:open="open" title="Workspace settings" :ui="{ content: 'max-w-3xl' }">
82
116
  <template #body>
83
- <div class="space-y-6">
84
- <!-- Run-timing escalation -->
85
- <section class="space-y-2">
86
- <h3 class="text-sm font-semibold text-slate-200">Waiting for a human</h3>
87
- <p class="text-[11px] text-slate-400">
88
- A run parked on a human decision (a review, an approval, a merge) waits as long as it
89
- needs it is never cancelled. After this many minutes its notification turns red and is
90
- flagged <span class="text-error-400">Overdue</span> in the inbox.
91
- </p>
92
- <label class="block w-48">
93
- <span class="mb-1 block text-[10px] uppercase tracking-wide text-slate-500">
94
- Escalate after (minutes)
95
- </span>
96
- <UInput
97
- v-model.number="draft.waitingEscalationMinutes"
98
- type="number"
99
- :min="1"
100
- size="sm"
101
- />
102
- </label>
103
- </section>
104
-
105
- <!-- Per-service running-task limit -->
106
- <section class="space-y-2">
107
- <h3 class="text-sm font-semibold text-slate-200">Running tasks per service</h3>
108
- <p class="text-[11px] text-slate-400">
109
- Cap how many tasks may run at once under one service. Starting a task over the limit is
110
- refused with a clear message until a running task finishes.
111
- </p>
112
- <label class="block w-64">
113
- <span class="mb-1 block text-[10px] uppercase tracking-wide text-slate-500">Mode</span>
114
- <USelect v-model="draft.taskLimitMode" :items="MODES" value-key="value" size="sm" />
115
- </label>
116
-
117
- <label v-if="draft.taskLimitMode === 'shared'" class="block w-48">
118
- <span class="mb-1 block text-[10px] uppercase tracking-wide text-slate-500">
119
- Max running tasks
120
- </span>
121
- <UInput v-model.number="draft.taskLimitShared" type="number" :min="1" size="sm" />
122
- </label>
123
-
124
- <div v-else-if="draft.taskLimitMode === 'per_type'" class="grid grid-cols-2 gap-3">
125
- <label v-for="t in TASK_TYPES" :key="t" class="block">
126
- <span class="mb-1 block text-[10px] uppercase tracking-wide text-slate-500">
127
- Max {{ t }} tasks
128
- </span>
129
- <UInput v-model.number="draft.perType[t]" type="number" :min="1" size="sm" />
130
- </label>
117
+ <UTabs
118
+ v-model="activeTab"
119
+ :items="tabs"
120
+ variant="link"
121
+ :ui="{ root: 'gap-4', list: 'overflow-x-auto' }"
122
+ >
123
+ <!-- Workspace -->
124
+ <template #workspace>
125
+ <div class="space-y-6">
126
+ <!-- Run-timing escalation -->
127
+ <section class="space-y-2">
128
+ <h3 class="text-sm font-semibold text-slate-200">Waiting for a human</h3>
129
+ <p class="text-[11px] text-slate-400">
130
+ A run parked on a human decision (a review, an approval, a merge) waits as long as
131
+ it needs — it is never cancelled. After this many minutes its notification turns red
132
+ and is flagged <span class="text-error-400">Overdue</span> in the inbox.
133
+ </p>
134
+ <label class="block w-48">
135
+ <span class="mb-1 block text-[10px] uppercase tracking-wide text-slate-500">
136
+ Escalate after (minutes)
137
+ </span>
138
+ <UInput
139
+ v-model.number="draft.waitingEscalationMinutes"
140
+ type="number"
141
+ :min="1"
142
+ size="sm"
143
+ />
144
+ </label>
145
+ </section>
146
+
147
+ <!-- Per-service running-task limit -->
148
+ <section class="space-y-2">
149
+ <h3 class="text-sm font-semibold text-slate-200">Running tasks per service</h3>
150
+ <p class="text-[11px] text-slate-400">
151
+ Cap how many tasks may run at once under one service. Starting a task over the limit
152
+ is refused with a clear message until a running task finishes.
153
+ </p>
154
+ <label class="block w-64">
155
+ <span class="mb-1 block text-[10px] uppercase tracking-wide text-slate-500"
156
+ >Mode</span
157
+ >
158
+ <USelect
159
+ v-model="draft.taskLimitMode"
160
+ :items="MODES"
161
+ value-key="value"
162
+ size="sm"
163
+ class="w-full"
164
+ />
165
+ </label>
166
+
167
+ <label v-if="draft.taskLimitMode === 'shared'" class="block w-48">
168
+ <span class="mb-1 block text-[10px] uppercase tracking-wide text-slate-500">
169
+ Max running tasks
170
+ </span>
171
+ <UInput v-model.number="draft.taskLimitShared" type="number" :min="1" size="sm" />
172
+ </label>
173
+
174
+ <div v-else-if="draft.taskLimitMode === 'per_type'" class="grid grid-cols-2 gap-3">
175
+ <label v-for="t in TASK_TYPES" :key="t" class="block">
176
+ <span class="mb-1 block text-[10px] uppercase tracking-wide text-slate-500">
177
+ Max {{ t }} tasks
178
+ </span>
179
+ <UInput v-model.number="draft.perType[t]" type="number" :min="1" size="sm" />
180
+ </label>
181
+ </div>
182
+ </section>
183
+
184
+ <div class="flex justify-end">
185
+ <UButton
186
+ color="primary"
187
+ icon="i-lucide-save"
188
+ size="sm"
189
+ :loading="saving"
190
+ @click="save"
191
+ >
192
+ Save
193
+ </UButton>
194
+ </div>
131
195
  </div>
132
- </section>
133
-
134
- <div class="flex justify-end">
135
- <UButton color="primary" icon="i-lucide-save" size="sm" :loading="saving" @click="save">
136
- Save
137
- </UButton>
138
- </div>
139
- </div>
196
+ </template>
197
+
198
+ <!-- Merge thresholds -->
199
+ <template #merge>
200
+ <MergeThresholdsPanel />
201
+ </template>
202
+
203
+ <!-- Issue writeback -->
204
+ <template #writeback>
205
+ <IssueTrackerWritebackPanel />
206
+ </template>
207
+
208
+ <!-- Service best practices -->
209
+ <template #fragments>
210
+ <ServiceFragmentDefaultsPanel />
211
+ </template>
212
+ </UTabs>
140
213
  </template>
141
214
  </UModal>
142
215
  </template>
@@ -1,27 +1,28 @@
1
1
  import type {
2
- DatadogConnectionView,
2
+ ObservabilityConnectionView,
3
3
  ReleaseHealthConfig,
4
- UpsertDatadogConnectionInput,
4
+ UpsertObservabilityConnectionInput,
5
5
  UpsertReleaseHealthConfigInput,
6
6
  } from '~/types/releaseHealth'
7
7
  import type { ApiContext } from './context'
8
8
 
9
- /** Datadog post-release-health: the connection + per-block monitor/SLO mapping. */
9
+ /** Post-release-health: the observability connection + per-block monitor/SLO mapping. */
10
10
  export function releaseHealthApi({ http, ws }: ApiContext) {
11
11
  return {
12
- // ---- Datadog post-release-health settings -----------------------------
13
- getDatadogConnection: (workspaceId: string) =>
14
- http<DatadogConnectionView>(`${ws(workspaceId)}/datadog/connection`),
12
+ // ---- Observability connection ------------------------------------------
13
+ getObservabilityConnection: (workspaceId: string) =>
14
+ http<ObservabilityConnectionView>(`${ws(workspaceId)}/observability/connection`),
15
15
 
16
- setDatadogConnection: (workspaceId: string, body: UpsertDatadogConnectionInput) =>
17
- http<DatadogConnectionView>(`${ws(workspaceId)}/datadog/connection`, {
16
+ setObservabilityConnection: (workspaceId: string, body: UpsertObservabilityConnectionInput) =>
17
+ http<ObservabilityConnectionView>(`${ws(workspaceId)}/observability/connection`, {
18
18
  method: 'PUT',
19
19
  body,
20
20
  }),
21
21
 
22
- deleteDatadogConnection: (workspaceId: string) =>
23
- http(`${ws(workspaceId)}/datadog/connection`, { method: 'DELETE' }),
22
+ deleteObservabilityConnection: (workspaceId: string) =>
23
+ http(`${ws(workspaceId)}/observability/connection`, { method: 'DELETE' }),
24
24
 
25
+ // ---- Per-block monitor/SLO config --------------------------------------
25
26
  listReleaseHealthConfigs: (workspaceId: string) =>
26
27
  http<ReleaseHealthConfig[]>(`${ws(workspaceId)}/release-health-configs`),
27
28
 
@@ -25,12 +25,10 @@ import SlackPanel from '~/components/slack/SlackPanel.vue'
25
25
  import GitHubOnboarding from '~/components/github/GitHubOnboarding.vue'
26
26
  import FragmentLibraryPanel from '~/components/fragments/FragmentLibraryPanel.vue'
27
27
  import CommandBar from '~/components/layout/CommandBar.vue'
28
- import MergeThresholdsPanel from '~/components/settings/MergeThresholdsPanel.vue'
29
- import IssueTrackerWritebackPanel from '~/components/settings/IssueTrackerWritebackPanel.vue'
28
+ import IntegrationsHub from '~/components/layout/IntegrationsHub.vue'
30
29
  import WorkspaceSettingsPanel from '~/components/settings/WorkspaceSettingsPanel.vue'
31
- import DatadogPanel from '~/components/settings/DatadogPanel.vue'
30
+ import ObservabilityConnectionPanel from '~/components/settings/ObservabilityConnectionPanel.vue'
32
31
  import ModelConfigurationPanel from '~/components/settings/ModelConfigurationPanel.vue'
33
- import ServiceFragmentDefaultsPanel from '~/components/settings/ServiceFragmentDefaultsPanel.vue'
34
32
  import LocalModelEndpointsPanel from '~/components/settings/LocalModelEndpointsPanel.vue'
35
33
  import OpenRouterCatalogPanel from '~/components/settings/OpenRouterCatalogPanel.vue'
36
34
  import VendorCredentialsModal from '~/components/providers/VendorCredentialsModal.vue'
@@ -118,12 +116,10 @@ watch(
118
116
  <SlackPanel />
119
117
  <FragmentLibraryPanel />
120
118
  <CommandBar />
121
- <MergeThresholdsPanel />
122
- <IssueTrackerWritebackPanel />
119
+ <IntegrationsHub />
123
120
  <WorkspaceSettingsPanel />
124
- <DatadogPanel />
121
+ <ObservabilityConnectionPanel />
125
122
  <ModelConfigurationPanel />
126
- <ServiceFragmentDefaultsPanel />
127
123
  <LocalModelEndpointsPanel />
128
124
  <OpenRouterCatalogPanel />
129
125
  <VendorCredentialsModal />
@@ -1,7 +1,7 @@
1
1
  import { defineStore } from 'pinia'
2
2
  import { ref } from 'vue'
3
3
  import { AGENT_ARCHETYPES, AGENT_BY_KIND, uid } from '~/utils/catalog'
4
- import type { AgentArchetype, AgentKind } from '~/types/domain'
4
+ import type { AgentArchetype, AgentKind, CustomAgentKind } from '~/types/domain'
5
5
 
6
6
  /**
7
7
  * The agent palette. Seeded from the static catalog, but custom agents can be
@@ -36,5 +36,30 @@ export const useAgentsStore = defineStore('agents', () => {
36
36
  return archetype
37
37
  }
38
38
 
39
- return { archetypes, get, addAgent }
39
+ /**
40
+ * Merge the deployment's registered CUSTOM agent kinds (from the workspace snapshot)
41
+ * into the palette catalog: each becomes a first-class palette block + a kind-based
42
+ * lookup (so timelines / inspectors render it instead of the generic fallback), and its
43
+ * declared `resultView` opens through the same registry the built-ins use. Idempotent
44
+ * and built-in-safe — a kind already known (a built-in, or a prior load) is left
45
+ * untouched, so a snapshot can't shadow a built-in or duplicate on reload.
46
+ */
47
+ function registerCustomKinds(kinds: CustomAgentKind[]) {
48
+ for (const { kind, presentation } of kinds) {
49
+ if (AGENT_BY_KIND[kind]) continue
50
+ const archetype: AgentArchetype = {
51
+ kind,
52
+ label: presentation.label,
53
+ icon: presentation.icon,
54
+ color: presentation.color,
55
+ description: presentation.description,
56
+ ...(presentation.category ? { category: presentation.category } : {}),
57
+ ...(presentation.resultView ? { resultView: presentation.resultView } : {}),
58
+ }
59
+ AGENT_BY_KIND[kind] = archetype
60
+ archetypes.value.push(archetype)
61
+ }
62
+ }
63
+
64
+ return { archetypes, get, addAgent, registerCustomKinds }
40
65
  })
@@ -1,50 +1,83 @@
1
1
  import { defineStore } from 'pinia'
2
2
  import { ref } from 'vue'
3
3
  import type {
4
- DatadogConnectionView,
4
+ ObservabilityConnectionView,
5
5
  ReleaseHealthConfig,
6
- UpsertDatadogConnectionInput,
6
+ UpsertObservabilityConnectionInput,
7
7
  UpsertReleaseHealthConfigInput,
8
8
  } from '~/types/releaseHealth'
9
9
  import { useWorkspaceStore } from '~/stores/workspace'
10
10
 
11
11
  /**
12
- * The workspace's Datadog post-release-health settings: the (single) connection — keys
13
- * are write-only, never read back — and the per-block monitor/SLO mappings the
14
- * `post-release-health` gate reads. Loaded on demand (the settings panel), not from the
15
- * snapshot, since the secrets never leave the server.
12
+ * The workspace's post-release-health settings: the (single) observability connection —
13
+ * provider + credentials, write-only, never read back — and the per-block monitor/SLO
14
+ * mappings the `post-release-health` gate reads. Loaded on demand (the observability panel
15
+ * + the service inspector), not from the snapshot, since the secrets never leave the server.
16
16
  */
17
17
  export const useReleaseHealthStore = defineStore('releaseHealth', () => {
18
18
  const api = useApi()
19
19
 
20
- const connection = ref<DatadogConnectionView>({ connected: false, site: null })
20
+ const connection = ref<ObservabilityConnectionView>({
21
+ connected: false,
22
+ provider: null,
23
+ summary: null,
24
+ })
21
25
  const configs = ref<ReleaseHealthConfig[]>([])
22
26
  const loading = ref(false)
27
+ // Mirrors the backend's opt-in gate (`OBSERVABILITY_ENABLED`): `null` until first
28
+ // probed, then `true`/`false`. The hub + inspector hide their observability entry
29
+ // points when this is false, so a disabled backend doesn't surface a dead control.
30
+ const available = ref<boolean | null>(null)
31
+ let inFlight: Promise<void> | null = null
23
32
 
33
+ /** Force a refresh of the connection + per-block configs (used after a save/remove). */
24
34
  async function load() {
25
35
  const ws = useWorkspaceStore()
26
36
  loading.value = true
27
37
  try {
28
38
  const [conn, list] = await Promise.all([
29
- api.getDatadogConnection(ws.requireId()),
39
+ api.getObservabilityConnection(ws.requireId()),
30
40
  api.listReleaseHealthConfigs(ws.requireId()),
31
41
  ])
32
42
  connection.value = conn
33
43
  configs.value = list
44
+ available.value = true
45
+ } catch {
46
+ // 503 (observability disabled) or any error → hide the UI entry points.
47
+ available.value = false
48
+ connection.value = { connected: false, provider: null, summary: null }
49
+ configs.value = []
34
50
  } finally {
35
51
  loading.value = false
36
52
  }
37
53
  }
38
54
 
39
- async function saveConnection(input: UpsertDatadogConnectionInput) {
55
+ /**
56
+ * Load once and share the result: repeated hub opens / frame-inspector mounts reuse
57
+ * the resolved state (and coalesce a concurrent in-flight request) instead of each
58
+ * re-fetching the connection + the whole configs list. Use `load()` to force a refresh.
59
+ */
60
+ async function ensureLoaded() {
61
+ if (available.value !== null) return
62
+ if (!inFlight) inFlight = load().finally(() => (inFlight = null))
63
+ return inFlight
64
+ }
65
+
66
+ async function saveConnection(input: UpsertObservabilityConnectionInput) {
40
67
  const ws = useWorkspaceStore()
41
- connection.value = await api.setDatadogConnection(ws.requireId(), input)
68
+ connection.value = await api.setObservabilityConnection(ws.requireId(), input)
69
+ available.value = true
42
70
  }
43
71
 
44
72
  async function removeConnection() {
45
73
  const ws = useWorkspaceStore()
46
- await api.deleteDatadogConnection(ws.requireId())
47
- connection.value = { connected: false, site: null }
74
+ await api.deleteObservabilityConnection(ws.requireId())
75
+ connection.value = { connected: false, provider: null, summary: null }
76
+ }
77
+
78
+ /** The saved config for a specific block (the service inspector reads this). */
79
+ function configForBlock(blockId: string): ReleaseHealthConfig | undefined {
80
+ return configs.value.find((c) => c.blockId === blockId)
48
81
  }
49
82
 
50
83
  async function saveConfig(blockId: string, input: UpsertReleaseHealthConfigInput) {
@@ -66,9 +99,12 @@ export const useReleaseHealthStore = defineStore('releaseHealth', () => {
66
99
  connection,
67
100
  configs,
68
101
  loading,
102
+ available,
69
103
  load,
104
+ ensureLoaded,
70
105
  saveConnection,
71
106
  removeConnection,
107
+ configForBlock,
72
108
  saveConfig,
73
109
  removeConfig,
74
110
  }