@cat-factory/app 0.122.2 → 0.123.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.
- package/app/components/layout/SideBar.vue +18 -0
- package/app/components/panels/OperatorDashboardPanel.vue +394 -0
- package/app/composables/api/platformObservability.ts +18 -0
- package/app/composables/useApi.ts +2 -0
- package/app/pages/index.vue +4 -0
- package/app/stores/platformObservability.ts +49 -0
- package/app/stores/ui/modals.ts +15 -0
- package/app/types/execution.ts +5 -0
- package/i18n/locales/de.json +59 -0
- package/i18n/locales/en.json +59 -0
- package/i18n/locales/es.json +59 -0
- package/i18n/locales/fr.json +59 -0
- package/i18n/locales/he.json +59 -0
- package/i18n/locales/it.json +59 -0
- package/i18n/locales/ja.json +59 -0
- package/i18n/locales/pl.json +59 -0
- package/i18n/locales/tr.json +59 -0
- package/i18n/locales/uk.json +59 -0
- package/package.json +2 -2
|
@@ -24,6 +24,10 @@ const auth = useAuthStore()
|
|
|
24
24
|
const providerConnections = useProviderConnectionsStore()
|
|
25
25
|
const ui = useUiStore()
|
|
26
26
|
|
|
27
|
+
// The operator dashboard (deployment-level run health) is sensitive cross-workspace data,
|
|
28
|
+
// so it's shown only to an admin of the active account (matching the backend admin gate).
|
|
29
|
+
const isAccountAdmin = computed(() => accounts.activeAccount?.roles?.includes('admin') ?? false)
|
|
30
|
+
|
|
27
31
|
// The Infrastructure menu (agent-container execution + test environments) shows whenever the
|
|
28
32
|
// deployment reports its infrastructure capability — every facade populates `auth.infrastructure`
|
|
29
33
|
// (it drives the execution-backend selector), so there is always an execution + test-env backend
|
|
@@ -367,6 +371,20 @@ watch(
|
|
|
367
371
|
>
|
|
368
372
|
{{ t('nav.accountSettings') }}
|
|
369
373
|
</UButton>
|
|
374
|
+
<!-- Platform observability: deployment-level run health. Admin-only, like the backend gate. -->
|
|
375
|
+
<UButton
|
|
376
|
+
v-if="accounts.enabled && isAccountAdmin"
|
|
377
|
+
block
|
|
378
|
+
color="primary"
|
|
379
|
+
variant="soft"
|
|
380
|
+
size="sm"
|
|
381
|
+
icon="i-lucide-gauge"
|
|
382
|
+
class="justify-start"
|
|
383
|
+
data-testid="nav-operator-dashboard"
|
|
384
|
+
@click="ui.openOperatorDashboard()"
|
|
385
|
+
>
|
|
386
|
+
{{ t('nav.operatorDashboard') }}
|
|
387
|
+
</UButton>
|
|
370
388
|
</div>
|
|
371
389
|
</section>
|
|
372
390
|
</div>
|
|
@@ -0,0 +1,394 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import { computed, watch } from 'vue'
|
|
3
|
+
import { onKeyStroke } from '@vueuse/core'
|
|
4
|
+
import type { AgentFailureKind, PlatformObservabilityWindow } from '~/types/execution'
|
|
5
|
+
import { formatMs } from '~/utils/observability'
|
|
6
|
+
|
|
7
|
+
// Deployment-level (platform-operator) observability dashboard: the aggregate health of the
|
|
8
|
+
// active account's runs — outcome totals + success rate, a time-bucketed outcome trend, the
|
|
9
|
+
// failure-kind taxonomy, live/parked depth, and duration stats — over a selectable window.
|
|
10
|
+
// Admin-gated; opened via `ui.openOperatorDashboard()` from the sidebar. The account-scoped
|
|
11
|
+
// counterpart of the per-run `ObservabilityPanel`.
|
|
12
|
+
const ui = useUiStore()
|
|
13
|
+
const accounts = useAccountsStore()
|
|
14
|
+
const platform = usePlatformObservabilityStore()
|
|
15
|
+
const { t, d, n } = useI18n()
|
|
16
|
+
|
|
17
|
+
const open = computed(() => ui.operatorDashboardOpen)
|
|
18
|
+
const view = computed(() => platform.view)
|
|
19
|
+
const loading = computed(() => platform.loading)
|
|
20
|
+
const error = computed(() => platform.error)
|
|
21
|
+
const accountName = computed(() => accounts.activeAccount?.name ?? '')
|
|
22
|
+
|
|
23
|
+
// Window options as static literal keys (keeps the typed-message-key check live).
|
|
24
|
+
const WINDOWS: { value: PlatformObservabilityWindow; label: string }[] = [
|
|
25
|
+
{ value: '1h', label: t('platformObservability.window.oneHour') },
|
|
26
|
+
{ value: '24h', label: t('platformObservability.window.oneDay') },
|
|
27
|
+
{ value: '7d', label: t('platformObservability.window.sevenDays') },
|
|
28
|
+
]
|
|
29
|
+
|
|
30
|
+
// Exhaustive enum→label map (tier-2 dynamic-key guard): a new AgentFailureKind fails the
|
|
31
|
+
// typecheck here, and an out-of-enum kind falls back to its raw code below.
|
|
32
|
+
const FAILURE_KIND_KEYS: Record<AgentFailureKind, string> = {
|
|
33
|
+
preflight: 'platformObservability.failureKind.preflight',
|
|
34
|
+
dispatch: 'platformObservability.failureKind.dispatch',
|
|
35
|
+
environment: 'platformObservability.failureKind.environment',
|
|
36
|
+
evicted: 'platformObservability.failureKind.evicted',
|
|
37
|
+
timeout: 'platformObservability.failureKind.timeout',
|
|
38
|
+
agent: 'platformObservability.failureKind.agent',
|
|
39
|
+
job_failed: 'platformObservability.failureKind.job_failed',
|
|
40
|
+
rejected: 'platformObservability.failureKind.rejected',
|
|
41
|
+
companion_rejected: 'platformObservability.failureKind.companion_rejected',
|
|
42
|
+
stalled: 'platformObservability.failureKind.stalled',
|
|
43
|
+
cancelled: 'platformObservability.failureKind.cancelled',
|
|
44
|
+
unknown: 'platformObservability.failureKind.unknown',
|
|
45
|
+
}
|
|
46
|
+
function failureLabel(kind: string): string {
|
|
47
|
+
const key = FAILURE_KIND_KEYS[kind as AgentFailureKind]
|
|
48
|
+
return key ? t(key) : kind
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// The largest failure count, so each taxonomy bar is drawn relative to the leader.
|
|
52
|
+
const maxFailure = computed(() => Math.max(1, ...(view.value?.failures ?? []).map((f) => f.count)))
|
|
53
|
+
// The largest total in any trend bucket, so each stacked column scales to the tallest.
|
|
54
|
+
const maxTrend = computed(() =>
|
|
55
|
+
Math.max(1, ...(view.value?.trend.points ?? []).map((p) => p.done + p.failed + p.other)),
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
function barPct(count: number, max: number): number {
|
|
59
|
+
return Math.round((count / max) * 100)
|
|
60
|
+
}
|
|
61
|
+
function heightPct(count: number, max: number): number {
|
|
62
|
+
// Floor a non-zero column to 4% so a single run is still visible in the sparkline.
|
|
63
|
+
return count === 0 ? 0 : Math.max(4, Math.round((count / max) * 100))
|
|
64
|
+
}
|
|
65
|
+
function trendTooltip(p: { start: number; done: number; failed: number; other: number }): string {
|
|
66
|
+
return `${d(new Date(p.start), 'short')} · ${t('platformObservability.trend.done')} ${p.done} · ${t('platformObservability.trend.failed')} ${p.failed} · ${t('platformObservability.trend.other')} ${p.other}`
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function setWindow(w: PlatformObservabilityWindow) {
|
|
70
|
+
void platform.setWindow(w)
|
|
71
|
+
}
|
|
72
|
+
function refresh() {
|
|
73
|
+
void platform.load()
|
|
74
|
+
}
|
|
75
|
+
function close() {
|
|
76
|
+
ui.closeOperatorDashboard()
|
|
77
|
+
}
|
|
78
|
+
onKeyStroke('Escape', () => {
|
|
79
|
+
if (open.value) close()
|
|
80
|
+
})
|
|
81
|
+
|
|
82
|
+
// Load (and refresh) whenever the dashboard opens.
|
|
83
|
+
watch(
|
|
84
|
+
open,
|
|
85
|
+
(isOpen) => {
|
|
86
|
+
if (isOpen) void platform.load()
|
|
87
|
+
},
|
|
88
|
+
{ immediate: true },
|
|
89
|
+
)
|
|
90
|
+
</script>
|
|
91
|
+
|
|
92
|
+
<template>
|
|
93
|
+
<Teleport to="body">
|
|
94
|
+
<Transition name="obs-fade">
|
|
95
|
+
<div
|
|
96
|
+
v-if="open"
|
|
97
|
+
class="fixed inset-0 z-[60] flex flex-col bg-slate-950/96 backdrop-blur-sm"
|
|
98
|
+
role="dialog"
|
|
99
|
+
aria-modal="true"
|
|
100
|
+
data-testid="operator-dashboard"
|
|
101
|
+
>
|
|
102
|
+
<header class="flex items-center gap-3 border-b border-slate-800 px-6 py-4">
|
|
103
|
+
<div class="flex h-9 w-9 shrink-0 items-center justify-center rounded-lg bg-sky-500/15">
|
|
104
|
+
<UIcon name="i-lucide-gauge" class="h-5 w-5 text-sky-400" />
|
|
105
|
+
</div>
|
|
106
|
+
<div class="min-w-0">
|
|
107
|
+
<h1 class="truncate text-base font-semibold text-white">
|
|
108
|
+
{{ t('platformObservability.title') }}
|
|
109
|
+
</h1>
|
|
110
|
+
<p v-if="accountName" class="truncate text-xs text-slate-500">{{ accountName }}</p>
|
|
111
|
+
</div>
|
|
112
|
+
<div class="ms-auto flex items-center gap-1.5">
|
|
113
|
+
<div class="me-1 flex rounded-lg border border-slate-800 p-0.5 text-[12px]">
|
|
114
|
+
<button
|
|
115
|
+
v-for="opt in WINDOWS"
|
|
116
|
+
:key="opt.value"
|
|
117
|
+
class="rounded-md px-2.5 py-1 transition"
|
|
118
|
+
:class="
|
|
119
|
+
platform.window === opt.value
|
|
120
|
+
? 'bg-slate-800 text-slate-100'
|
|
121
|
+
: 'text-slate-400 hover:text-slate-200'
|
|
122
|
+
"
|
|
123
|
+
:data-testid="`operator-window-${opt.value}`"
|
|
124
|
+
@click="setWindow(opt.value)"
|
|
125
|
+
>
|
|
126
|
+
{{ opt.label }}
|
|
127
|
+
</button>
|
|
128
|
+
</div>
|
|
129
|
+
<button
|
|
130
|
+
class="rounded-lg border border-slate-800 p-1.5 text-slate-400 transition hover:text-slate-200"
|
|
131
|
+
:title="t('platformObservability.refresh')"
|
|
132
|
+
:aria-label="t('platformObservability.refresh')"
|
|
133
|
+
data-testid="operator-refresh"
|
|
134
|
+
@click="refresh"
|
|
135
|
+
>
|
|
136
|
+
<UIcon
|
|
137
|
+
name="i-lucide-refresh-cw"
|
|
138
|
+
class="h-4 w-4"
|
|
139
|
+
:class="{ 'animate-spin': loading }"
|
|
140
|
+
/>
|
|
141
|
+
</button>
|
|
142
|
+
<button
|
|
143
|
+
class="rounded-lg border border-slate-800 p-1.5 text-slate-400 transition hover:text-slate-200"
|
|
144
|
+
:title="t('platformObservability.close')"
|
|
145
|
+
:aria-label="t('platformObservability.close')"
|
|
146
|
+
data-testid="operator-close"
|
|
147
|
+
@click="close"
|
|
148
|
+
>
|
|
149
|
+
<UIcon name="i-lucide-x" class="h-4 w-4" />
|
|
150
|
+
</button>
|
|
151
|
+
</div>
|
|
152
|
+
</header>
|
|
153
|
+
|
|
154
|
+
<div class="flex-1 overflow-y-auto px-6 py-5">
|
|
155
|
+
<div
|
|
156
|
+
v-if="error"
|
|
157
|
+
class="mx-auto max-w-2xl rounded-lg border border-rose-800/60 bg-rose-950/40 p-4 text-sm text-rose-200"
|
|
158
|
+
>
|
|
159
|
+
<p>{{ error }}</p>
|
|
160
|
+
<button
|
|
161
|
+
class="mt-2 rounded-md border border-rose-700 px-3 py-1 text-xs hover:bg-rose-900/40"
|
|
162
|
+
@click="refresh"
|
|
163
|
+
>
|
|
164
|
+
{{ t('platformObservability.retry') }}
|
|
165
|
+
</button>
|
|
166
|
+
</div>
|
|
167
|
+
|
|
168
|
+
<div v-else-if="loading && !view" class="py-16 text-center text-sm text-slate-400">
|
|
169
|
+
{{ t('platformObservability.loading') }}
|
|
170
|
+
</div>
|
|
171
|
+
|
|
172
|
+
<div v-else-if="view" class="mx-auto flex max-w-5xl flex-col gap-6">
|
|
173
|
+
<!-- Outcome summary tiles -->
|
|
174
|
+
<section>
|
|
175
|
+
<h2 class="mb-2 text-xs font-semibold uppercase tracking-wide text-slate-500">
|
|
176
|
+
{{ t('platformObservability.outcomes.title') }}
|
|
177
|
+
</h2>
|
|
178
|
+
<div class="grid grid-cols-2 gap-3 sm:grid-cols-4">
|
|
179
|
+
<div class="rounded-lg border border-slate-800 bg-slate-900/40 p-3">
|
|
180
|
+
<p class="text-2xl font-semibold text-white">
|
|
181
|
+
{{ n(view.outcomes.total, 'decimal') }}
|
|
182
|
+
</p>
|
|
183
|
+
<p class="text-xs text-slate-500">
|
|
184
|
+
{{ t('platformObservability.outcomes.total') }}
|
|
185
|
+
</p>
|
|
186
|
+
</div>
|
|
187
|
+
<div class="rounded-lg border border-slate-800 bg-slate-900/40 p-3">
|
|
188
|
+
<p class="text-2xl font-semibold text-emerald-400">
|
|
189
|
+
{{ n(view.outcomes.done, 'decimal') }}
|
|
190
|
+
</p>
|
|
191
|
+
<p class="text-xs text-slate-500">
|
|
192
|
+
{{ t('platformObservability.outcomes.done') }}
|
|
193
|
+
</p>
|
|
194
|
+
</div>
|
|
195
|
+
<div class="rounded-lg border border-slate-800 bg-slate-900/40 p-3">
|
|
196
|
+
<p class="text-2xl font-semibold text-rose-400">
|
|
197
|
+
{{ n(view.outcomes.failed, 'decimal') }}
|
|
198
|
+
</p>
|
|
199
|
+
<p class="text-xs text-slate-500">
|
|
200
|
+
{{ t('platformObservability.outcomes.failed') }}
|
|
201
|
+
</p>
|
|
202
|
+
</div>
|
|
203
|
+
<div class="rounded-lg border border-slate-800 bg-slate-900/40 p-3">
|
|
204
|
+
<p
|
|
205
|
+
class="text-2xl font-semibold text-sky-400"
|
|
206
|
+
data-testid="operator-success-rate"
|
|
207
|
+
>
|
|
208
|
+
{{
|
|
209
|
+
view.outcomes.successRate == null
|
|
210
|
+
? '—'
|
|
211
|
+
: n(view.outcomes.successRate, 'percent')
|
|
212
|
+
}}
|
|
213
|
+
</p>
|
|
214
|
+
<p class="text-xs text-slate-500">
|
|
215
|
+
{{ t('platformObservability.outcomes.successRate') }}
|
|
216
|
+
</p>
|
|
217
|
+
</div>
|
|
218
|
+
</div>
|
|
219
|
+
</section>
|
|
220
|
+
|
|
221
|
+
<!-- Outcome trend sparkline -->
|
|
222
|
+
<section>
|
|
223
|
+
<h2 class="mb-2 text-xs font-semibold uppercase tracking-wide text-slate-500">
|
|
224
|
+
{{ t('platformObservability.trend.title') }}
|
|
225
|
+
</h2>
|
|
226
|
+
<div class="rounded-lg border border-slate-800 bg-slate-900/40 p-4">
|
|
227
|
+
<div
|
|
228
|
+
v-if="view.outcomes.total === 0"
|
|
229
|
+
class="py-6 text-center text-xs text-slate-500"
|
|
230
|
+
>
|
|
231
|
+
{{ t('platformObservability.trend.empty') }}
|
|
232
|
+
</div>
|
|
233
|
+
<div v-else class="flex h-28 items-end gap-0.5" data-testid="operator-trend">
|
|
234
|
+
<div
|
|
235
|
+
v-for="p in view.trend.points"
|
|
236
|
+
:key="p.start"
|
|
237
|
+
class="flex flex-1 flex-col justify-end"
|
|
238
|
+
:title="trendTooltip(p)"
|
|
239
|
+
>
|
|
240
|
+
<div
|
|
241
|
+
class="w-full rounded-t-sm bg-rose-500/80"
|
|
242
|
+
:style="{ height: `${heightPct(p.failed, maxTrend)}%` }"
|
|
243
|
+
/>
|
|
244
|
+
<div
|
|
245
|
+
class="w-full bg-slate-500/60"
|
|
246
|
+
:style="{ height: `${heightPct(p.other, maxTrend)}%` }"
|
|
247
|
+
/>
|
|
248
|
+
<div
|
|
249
|
+
class="w-full rounded-b-sm bg-emerald-500/80"
|
|
250
|
+
:style="{ height: `${heightPct(p.done, maxTrend)}%` }"
|
|
251
|
+
/>
|
|
252
|
+
</div>
|
|
253
|
+
</div>
|
|
254
|
+
<div class="mt-2 flex items-center gap-4 text-[11px] text-slate-500">
|
|
255
|
+
<span class="flex items-center gap-1"
|
|
256
|
+
><span class="h-2 w-2 rounded-sm bg-emerald-500/80" />{{
|
|
257
|
+
t('platformObservability.trend.done')
|
|
258
|
+
}}</span
|
|
259
|
+
>
|
|
260
|
+
<span class="flex items-center gap-1"
|
|
261
|
+
><span class="h-2 w-2 rounded-sm bg-rose-500/80" />{{
|
|
262
|
+
t('platformObservability.trend.failed')
|
|
263
|
+
}}</span
|
|
264
|
+
>
|
|
265
|
+
<span class="flex items-center gap-1"
|
|
266
|
+
><span class="h-2 w-2 rounded-sm bg-slate-500/60" />{{
|
|
267
|
+
t('platformObservability.trend.other')
|
|
268
|
+
}}</span
|
|
269
|
+
>
|
|
270
|
+
</div>
|
|
271
|
+
</div>
|
|
272
|
+
</section>
|
|
273
|
+
|
|
274
|
+
<div class="grid gap-6 md:grid-cols-2">
|
|
275
|
+
<!-- Failure taxonomy -->
|
|
276
|
+
<section>
|
|
277
|
+
<h2 class="mb-2 text-xs font-semibold uppercase tracking-wide text-slate-500">
|
|
278
|
+
{{ t('platformObservability.failures.title') }}
|
|
279
|
+
</h2>
|
|
280
|
+
<div class="rounded-lg border border-slate-800 bg-slate-900/40 p-4">
|
|
281
|
+
<div v-if="!view.failures.length" class="py-4 text-center text-xs text-slate-500">
|
|
282
|
+
{{ t('platformObservability.failures.empty') }}
|
|
283
|
+
</div>
|
|
284
|
+
<ul v-else class="flex flex-col gap-2" data-testid="operator-failures">
|
|
285
|
+
<li v-for="f in view.failures" :key="f.kind" class="text-xs">
|
|
286
|
+
<div class="mb-0.5 flex items-center justify-between">
|
|
287
|
+
<span class="text-slate-300">{{ failureLabel(f.kind) }}</span>
|
|
288
|
+
<span class="tabular-nums text-slate-400">{{ f.count }}</span>
|
|
289
|
+
</div>
|
|
290
|
+
<div class="h-1.5 rounded-full bg-slate-800">
|
|
291
|
+
<div
|
|
292
|
+
class="h-1.5 rounded-full bg-rose-500/70"
|
|
293
|
+
:style="{ width: `${barPct(f.count, maxFailure)}%` }"
|
|
294
|
+
/>
|
|
295
|
+
</div>
|
|
296
|
+
</li>
|
|
297
|
+
</ul>
|
|
298
|
+
</div>
|
|
299
|
+
</section>
|
|
300
|
+
|
|
301
|
+
<!-- Live depth + durations -->
|
|
302
|
+
<section class="flex flex-col gap-4">
|
|
303
|
+
<div>
|
|
304
|
+
<h2 class="mb-2 text-xs font-semibold uppercase tracking-wide text-slate-500">
|
|
305
|
+
{{ t('platformObservability.live.title') }}
|
|
306
|
+
</h2>
|
|
307
|
+
<div
|
|
308
|
+
class="grid grid-cols-4 gap-2 rounded-lg border border-slate-800 bg-slate-900/40 p-3 text-center"
|
|
309
|
+
data-testid="operator-live"
|
|
310
|
+
>
|
|
311
|
+
<div>
|
|
312
|
+
<p class="text-lg font-semibold text-sky-400">{{ view.live.running }}</p>
|
|
313
|
+
<p class="text-[11px] text-slate-500">
|
|
314
|
+
{{ t('platformObservability.outcomes.running') }}
|
|
315
|
+
</p>
|
|
316
|
+
</div>
|
|
317
|
+
<div>
|
|
318
|
+
<p class="text-lg font-semibold text-amber-400">{{ view.live.blocked }}</p>
|
|
319
|
+
<p class="text-[11px] text-slate-500">
|
|
320
|
+
{{ t('platformObservability.outcomes.blocked') }}
|
|
321
|
+
</p>
|
|
322
|
+
</div>
|
|
323
|
+
<div>
|
|
324
|
+
<p class="text-lg font-semibold text-slate-300">{{ view.live.paused }}</p>
|
|
325
|
+
<p class="text-[11px] text-slate-500">
|
|
326
|
+
{{ t('platformObservability.outcomes.paused') }}
|
|
327
|
+
</p>
|
|
328
|
+
</div>
|
|
329
|
+
<div>
|
|
330
|
+
<p class="text-lg font-semibold text-slate-300">{{ view.live.pending }}</p>
|
|
331
|
+
<p class="text-[11px] text-slate-500">
|
|
332
|
+
{{ t('platformObservability.outcomes.pending') }}
|
|
333
|
+
</p>
|
|
334
|
+
</div>
|
|
335
|
+
</div>
|
|
336
|
+
</div>
|
|
337
|
+
<div>
|
|
338
|
+
<h2 class="mb-2 text-xs font-semibold uppercase tracking-wide text-slate-500">
|
|
339
|
+
{{ t('platformObservability.durations.title') }}
|
|
340
|
+
</h2>
|
|
341
|
+
<div class="rounded-lg border border-slate-800 bg-slate-900/40 p-3 text-sm">
|
|
342
|
+
<div
|
|
343
|
+
v-if="view.durations.count === 0"
|
|
344
|
+
class="py-2 text-center text-xs text-slate-500"
|
|
345
|
+
>
|
|
346
|
+
{{ t('platformObservability.durations.empty') }}
|
|
347
|
+
</div>
|
|
348
|
+
<dl v-else class="flex items-center justify-between gap-2 text-center">
|
|
349
|
+
<div class="flex-1">
|
|
350
|
+
<dt class="text-[11px] text-slate-500">
|
|
351
|
+
{{ t('platformObservability.durations.avg') }}
|
|
352
|
+
</dt>
|
|
353
|
+
<dd class="font-semibold text-white">
|
|
354
|
+
{{ view.durations.avgMs == null ? '—' : formatMs(view.durations.avgMs) }}
|
|
355
|
+
</dd>
|
|
356
|
+
</div>
|
|
357
|
+
<div class="flex-1">
|
|
358
|
+
<dt class="text-[11px] text-slate-500">
|
|
359
|
+
{{ t('platformObservability.durations.min') }}
|
|
360
|
+
</dt>
|
|
361
|
+
<dd class="font-semibold text-slate-300">
|
|
362
|
+
{{ view.durations.minMs == null ? '—' : formatMs(view.durations.minMs) }}
|
|
363
|
+
</dd>
|
|
364
|
+
</div>
|
|
365
|
+
<div class="flex-1">
|
|
366
|
+
<dt class="text-[11px] text-slate-500">
|
|
367
|
+
{{ t('platformObservability.durations.max') }}
|
|
368
|
+
</dt>
|
|
369
|
+
<dd class="font-semibold text-slate-300">
|
|
370
|
+
{{ view.durations.maxMs == null ? '—' : formatMs(view.durations.maxMs) }}
|
|
371
|
+
</dd>
|
|
372
|
+
</div>
|
|
373
|
+
</dl>
|
|
374
|
+
</div>
|
|
375
|
+
</div>
|
|
376
|
+
</section>
|
|
377
|
+
</div>
|
|
378
|
+
</div>
|
|
379
|
+
</div>
|
|
380
|
+
</div>
|
|
381
|
+
</Transition>
|
|
382
|
+
</Teleport>
|
|
383
|
+
</template>
|
|
384
|
+
|
|
385
|
+
<style scoped>
|
|
386
|
+
.obs-fade-enter-active,
|
|
387
|
+
.obs-fade-leave-active {
|
|
388
|
+
transition: opacity 0.15s ease;
|
|
389
|
+
}
|
|
390
|
+
.obs-fade-enter-from,
|
|
391
|
+
.obs-fade-leave-to {
|
|
392
|
+
opacity: 0;
|
|
393
|
+
}
|
|
394
|
+
</style>
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { getPlatformObservabilityContract } from '@cat-factory/contracts'
|
|
2
|
+
import type { PlatformObservabilityWindow } from '~/types/execution'
|
|
3
|
+
import type { ApiContext } from './context'
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Platform-operator observability: the deployment-level aggregate health of an
|
|
7
|
+
* account's runs over a time window (admin-gated). The dual of `executionApi`'s
|
|
8
|
+
* per-run `getLlmMetrics` — account-scoped, not workspace-scoped.
|
|
9
|
+
*/
|
|
10
|
+
export function platformObservabilityApi({ send }: ApiContext) {
|
|
11
|
+
return {
|
|
12
|
+
getPlatformObservability: (accountId: string, window: PlatformObservabilityWindow) =>
|
|
13
|
+
send(getPlatformObservabilityContract, {
|
|
14
|
+
pathParams: { accountId },
|
|
15
|
+
queryParams: { window },
|
|
16
|
+
}),
|
|
17
|
+
}
|
|
18
|
+
}
|
|
@@ -2,6 +2,7 @@ import type { FragmentOwnerKind } from '~/types/domain'
|
|
|
2
2
|
import { createApiClient, createSend, createSendWith } from './api/client'
|
|
3
3
|
import type { ApiContext } from './api/context'
|
|
4
4
|
import { accountsApi } from './api/accounts'
|
|
5
|
+
import { platformObservabilityApi } from './api/platformObservability'
|
|
5
6
|
import { authApi } from './api/auth'
|
|
6
7
|
import { bootstrapApi } from './api/bootstrap'
|
|
7
8
|
import { boardApi } from './api/board'
|
|
@@ -104,6 +105,7 @@ export function useApi() {
|
|
|
104
105
|
...fragmentsApi(ctx),
|
|
105
106
|
...modelsApi(ctx),
|
|
106
107
|
...accountsApi(ctx),
|
|
108
|
+
...platformObservabilityApi(ctx),
|
|
107
109
|
...workspacesApi(ctx),
|
|
108
110
|
...boardApi(ctx),
|
|
109
111
|
...executionApi(ctx),
|
package/app/pages/index.vue
CHANGED
|
@@ -30,6 +30,9 @@ import KeyboardShortcutsHelp from '~/components/common/KeyboardShortcutsHelp.vue
|
|
|
30
30
|
const ObservabilityPanel = defineAsyncComponent(
|
|
31
31
|
() => import('~/components/panels/ObservabilityPanel.vue'),
|
|
32
32
|
)
|
|
33
|
+
const OperatorDashboardPanel = defineAsyncComponent(
|
|
34
|
+
() => import('~/components/panels/OperatorDashboardPanel.vue'),
|
|
35
|
+
)
|
|
33
36
|
const KaizenPanel = defineAsyncComponent(() => import('~/components/kaizen/KaizenPanel.vue'))
|
|
34
37
|
// Occasional, externally store-gated surfaces — deferred to their own chunks like the
|
|
35
38
|
// sibling document modals above. Each mounts only while its ui open-flag is set, so it
|
|
@@ -386,6 +389,7 @@ watch(
|
|
|
386
389
|
<TaskImportModal v-if="ui.taskImport" />
|
|
387
390
|
<RecurringPipelineModal v-if="ui.addRecurringFrameId" />
|
|
388
391
|
<ObservabilityPanel v-if="ui.observabilityInstanceId" />
|
|
392
|
+
<OperatorDashboardPanel v-if="ui.operatorDashboardOpen" />
|
|
389
393
|
<KaizenPanel v-if="ui.kaizenScreenOpen" />
|
|
390
394
|
<DocumentSourceConnectModal v-if="ui.documentConnect" />
|
|
391
395
|
<DocumentImportModal v-if="ui.documentImport" />
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { defineStore } from 'pinia'
|
|
2
|
+
import { computed, ref } from 'vue'
|
|
3
|
+
import type { PlatformObservability, PlatformObservabilityWindow } from '~/types/execution'
|
|
4
|
+
import { useAccountsStore } from '~/stores/accounts'
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Platform-operator observability: the deployment-level aggregate health of the active
|
|
8
|
+
* account's runs (outcomes, failure taxonomy, live/parked depth, duration + trend) over a
|
|
9
|
+
* time window. The account-scoped, admin-gated counterpart of the per-run `observability`
|
|
10
|
+
* store — loaded on demand when the operator dashboard opens and re-loaded when the window
|
|
11
|
+
* changes. Nothing is pushed live (these are periodic rollups); a manual refresh re-fetches.
|
|
12
|
+
*/
|
|
13
|
+
export const usePlatformObservabilityStore = defineStore('platformObservability', () => {
|
|
14
|
+
const api = useApi()
|
|
15
|
+
const accounts = useAccountsStore()
|
|
16
|
+
|
|
17
|
+
const window = ref<PlatformObservabilityWindow>('24h')
|
|
18
|
+
const view = ref<PlatformObservability | null>(null)
|
|
19
|
+
const loading = ref(false)
|
|
20
|
+
const error = ref<string | null>(null)
|
|
21
|
+
|
|
22
|
+
const accountId = computed(() => accounts.activeAccount?.id ?? null)
|
|
23
|
+
|
|
24
|
+
async function load(nextWindow?: PlatformObservabilityWindow) {
|
|
25
|
+
if (nextWindow) window.value = nextWindow
|
|
26
|
+
const id = accountId.value
|
|
27
|
+
if (!id) {
|
|
28
|
+
view.value = null
|
|
29
|
+
return
|
|
30
|
+
}
|
|
31
|
+
loading.value = true
|
|
32
|
+
error.value = null
|
|
33
|
+
try {
|
|
34
|
+
view.value = await api.getPlatformObservability(id, window.value)
|
|
35
|
+
} catch (err) {
|
|
36
|
+
error.value = err instanceof Error ? err.message : 'Failed to load platform observability'
|
|
37
|
+
} finally {
|
|
38
|
+
loading.value = false
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Switch the window and reload. */
|
|
43
|
+
async function setWindow(next: PlatformObservabilityWindow) {
|
|
44
|
+
if (next === window.value && view.value) return
|
|
45
|
+
await load(next)
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
return { window, view, loading, error, accountId, load, setWindow }
|
|
49
|
+
})
|
package/app/stores/ui/modals.ts
CHANGED
|
@@ -167,6 +167,11 @@ export function createUiModals() {
|
|
|
167
167
|
// today, pluggable). NB: distinct from `observabilityInstanceId`, which is the
|
|
168
168
|
// LLM per-call observability panel (see the result-views slice).
|
|
169
169
|
const observabilityConnectionOpen = ref(false)
|
|
170
|
+
// Platform-operator observability: the deployment-level dashboard (aggregate run health of
|
|
171
|
+
// the account — outcomes, failure taxonomy, live depth, durations). Admin-gated. Distinct
|
|
172
|
+
// from `observabilityConnectionOpen` (the Datadog connection) AND `observabilityInstanceId`
|
|
173
|
+
// (the per-run LLM call panel).
|
|
174
|
+
const operatorDashboardOpen = ref(false)
|
|
170
175
|
// Private package registries: the workspace's npm/GitHub-Packages entries agent
|
|
171
176
|
// containers install with. Opened from the Integrations hub.
|
|
172
177
|
const packageRegistriesOpen = ref(false)
|
|
@@ -497,6 +502,13 @@ export function createUiModals() {
|
|
|
497
502
|
function closeObservabilityConnection() {
|
|
498
503
|
observabilityConnectionOpen.value = false
|
|
499
504
|
}
|
|
505
|
+
function openOperatorDashboard() {
|
|
506
|
+
resetHubReturn()
|
|
507
|
+
operatorDashboardOpen.value = true
|
|
508
|
+
}
|
|
509
|
+
function closeOperatorDashboard() {
|
|
510
|
+
operatorDashboardOpen.value = false
|
|
511
|
+
}
|
|
500
512
|
function openPackageRegistries() {
|
|
501
513
|
resetHubReturn()
|
|
502
514
|
packageRegistriesOpen.value = true
|
|
@@ -690,6 +702,7 @@ export function createUiModals() {
|
|
|
690
702
|
accountSettingsTab,
|
|
691
703
|
accountSettingsScrollTarget,
|
|
692
704
|
observabilityConnectionOpen,
|
|
705
|
+
operatorDashboardOpen,
|
|
693
706
|
packageRegistriesOpen,
|
|
694
707
|
apiTokensOpen,
|
|
695
708
|
infrastructureOpen,
|
|
@@ -778,6 +791,8 @@ export function createUiModals() {
|
|
|
778
791
|
setAccountSettingsTab,
|
|
779
792
|
openObservabilityConnection,
|
|
780
793
|
closeObservabilityConnection,
|
|
794
|
+
openOperatorDashboard,
|
|
795
|
+
closeOperatorDashboard,
|
|
781
796
|
openPackageRegistries,
|
|
782
797
|
closePackageRegistries,
|
|
783
798
|
openApiTokens,
|
package/app/types/execution.ts
CHANGED
|
@@ -25,6 +25,11 @@ export type {
|
|
|
25
25
|
LlmCallActivity,
|
|
26
26
|
LlmExportInsight,
|
|
27
27
|
LlmMetricsExport,
|
|
28
|
+
PlatformObservability,
|
|
29
|
+
PlatformObservabilityWindow,
|
|
30
|
+
PlatformOutcomeTotals,
|
|
31
|
+
PlatformTrendPoint,
|
|
32
|
+
PlatformFailureSlice,
|
|
28
33
|
AgentSearchQuery,
|
|
29
34
|
WebSearchAvailability,
|
|
30
35
|
WebSearchProvider,
|
package/i18n/locales/de.json
CHANGED
|
@@ -2745,6 +2745,64 @@
|
|
|
2745
2745
|
"queriesTitle": "Durchgeführte Suchen"
|
|
2746
2746
|
}
|
|
2747
2747
|
},
|
|
2748
|
+
"platformObservability": {
|
|
2749
|
+
"title": "Plattform-Observability",
|
|
2750
|
+
"refresh": "Aktualisieren",
|
|
2751
|
+
"close": "Schließen",
|
|
2752
|
+
"loading": "Plattformzustand wird geladen…",
|
|
2753
|
+
"retry": "Erneut versuchen",
|
|
2754
|
+
"window": {
|
|
2755
|
+
"oneHour": "Letzte Stunde",
|
|
2756
|
+
"oneDay": "Letzte 24 Stunden",
|
|
2757
|
+
"sevenDays": "Letzte 7 Tage"
|
|
2758
|
+
},
|
|
2759
|
+
"outcomes": {
|
|
2760
|
+
"title": "Lauf-Ergebnisse",
|
|
2761
|
+
"total": "Läufe gesamt",
|
|
2762
|
+
"done": "Abgeschlossen",
|
|
2763
|
+
"failed": "Fehlgeschlagen",
|
|
2764
|
+
"successRate": "Erfolgsquote",
|
|
2765
|
+
"running": "Läuft",
|
|
2766
|
+
"blocked": "Blockiert",
|
|
2767
|
+
"paused": "Pausiert",
|
|
2768
|
+
"pending": "Ausstehend"
|
|
2769
|
+
},
|
|
2770
|
+
"trend": {
|
|
2771
|
+
"title": "Ergebnisverlauf",
|
|
2772
|
+
"done": "Abgeschlossen",
|
|
2773
|
+
"failed": "Fehlgeschlagen",
|
|
2774
|
+
"other": "Sonstige",
|
|
2775
|
+
"empty": "Keine Läufe in diesem Zeitraum."
|
|
2776
|
+
},
|
|
2777
|
+
"failures": {
|
|
2778
|
+
"title": "Fehleraufschlüsselung",
|
|
2779
|
+
"empty": "Keine Fehler in diesem Zeitraum."
|
|
2780
|
+
},
|
|
2781
|
+
"failureKind": {
|
|
2782
|
+
"preflight": "Vorprüfung",
|
|
2783
|
+
"dispatch": "Zustellung",
|
|
2784
|
+
"environment": "Umgebung",
|
|
2785
|
+
"evicted": "Verdrängt",
|
|
2786
|
+
"timeout": "Zeitüberschreitung",
|
|
2787
|
+
"agent": "Agent",
|
|
2788
|
+
"job_failed": "Job fehlgeschlagen",
|
|
2789
|
+
"rejected": "Abgelehnt",
|
|
2790
|
+
"companion_rejected": "Begleiter abgelehnt",
|
|
2791
|
+
"stalled": "Hängengeblieben",
|
|
2792
|
+
"cancelled": "Abgebrochen",
|
|
2793
|
+
"unknown": "Unbekannt"
|
|
2794
|
+
},
|
|
2795
|
+
"durations": {
|
|
2796
|
+
"title": "Laufdauer",
|
|
2797
|
+
"avg": "Durchschnitt",
|
|
2798
|
+
"min": "Min.",
|
|
2799
|
+
"max": "Max.",
|
|
2800
|
+
"empty": "Keine abgeschlossenen Läufe in diesem Zeitraum."
|
|
2801
|
+
},
|
|
2802
|
+
"live": {
|
|
2803
|
+
"title": "Jetzt aktiv"
|
|
2804
|
+
}
|
|
2805
|
+
},
|
|
2748
2806
|
"auth": {
|
|
2749
2807
|
"gate": {
|
|
2750
2808
|
"loading": "Wird geladen…"
|
|
@@ -3849,6 +3907,7 @@
|
|
|
3849
3907
|
"workspaceSettings": "Workspace-Einstellungen",
|
|
3850
3908
|
"modelConfiguration": "Modellkonfiguration",
|
|
3851
3909
|
"accountSettings": "Kontoeinstellungen",
|
|
3910
|
+
"operatorDashboard": "Plattform-Observability",
|
|
3852
3911
|
"environmentSetup": "Umgebungseinrichtung"
|
|
3853
3912
|
},
|
|
3854
3913
|
"errors": {
|