@cat-factory/app 0.70.0 → 0.71.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/auth/LoginScreen.vue +3 -2
- package/app/components/board/AddTaskModal.vue +7 -1
- package/app/components/board/AgentFailureCard.vue +3 -0
- package/app/components/board/RecurringPipelineModal.vue +6 -1
- package/app/components/board/nodes/BlockNode.vue +10 -2
- package/app/components/board/nodes/TaskCard.vue +12 -1
- package/app/components/bootstrap/BootstrapModal.vue +3 -0
- package/app/components/common/ConfirmDialog.vue +59 -0
- package/app/components/common/EmptyState.vue +35 -0
- package/app/components/common/KeyboardShortcutsHelp.vue +48 -0
- package/app/components/documents/ContextDocumentPicker.vue +8 -4
- package/app/components/documents/DocumentSourceConnectModal.vue +4 -3
- package/app/components/focus/BlockFocusView.vue +12 -7
- package/app/components/fragments/FragmentLibraryManager.vue +10 -0
- package/app/components/github/GitHubPanel.vue +9 -0
- package/app/components/humanTest/HumanTestWindow.vue +15 -1
- package/app/components/layout/AccountDeploymentSettings.vue +4 -0
- package/app/components/layout/AccountTeamSettings.vue +8 -2
- package/app/components/layout/AiProvidersBanner.vue +3 -1
- package/app/components/layout/BoardSwitcher.vue +11 -0
- package/app/components/layout/CommandBar.vue +8 -0
- package/app/components/layout/InfraSetupBanner.vue +192 -0
- package/app/components/layout/NotificationsInbox.vue +11 -0
- package/app/components/layout/ProviderConfigBanner.vue +3 -1
- package/app/components/panels/InspectorPanel.vue +18 -20
- package/app/components/panels/StepContainerStatus.vue +37 -0
- package/app/components/panels/inspector/FrontendConfig.vue +19 -4
- package/app/components/panels/inspector/ServiceReleaseHealthConfig.vue +4 -0
- package/app/components/panels/inspector/TaskDependencies.vue +18 -4
- package/app/components/panels/inspector/TaskExecution.vue +24 -0
- package/app/components/panels/inspector/TaskRunSettings.vue +8 -1
- package/app/components/pipeline/PipelineBuilder.vue +13 -1
- package/app/components/providers/ApiKeysSection.vue +4 -0
- package/app/components/providers/PersonalSubscriptionSection.vue +9 -0
- package/app/components/providers/VendorCredentialsModal.vue +12 -3
- package/app/components/settings/CustomManifestTypeEditor.vue +3 -0
- package/app/components/settings/InfraHandlersConfigurator.vue +72 -12
- package/app/components/settings/KubernetesEngineForm.vue +41 -5
- package/app/components/settings/LocalModelEndpointsPanel.vue +11 -0
- package/app/components/settings/MergeThresholdsPanel.vue +9 -0
- package/app/components/settings/ModelConfigurationPanel.vue +9 -0
- package/app/components/settings/ObservabilityConnectionPanel.vue +7 -0
- package/app/components/settings/ProviderConnectionTab.vue +2 -0
- package/app/components/settings/UserSecretsSection.vue +11 -0
- package/app/components/slack/SlackPanel.vue +9 -0
- package/app/components/tasks/ContextIssuePicker.vue +8 -4
- package/app/components/tasks/TaskSourceConnectModal.vue +4 -3
- package/app/composables/useBlockDeletion.ts +63 -0
- package/app/composables/useConfirm.ts +63 -0
- package/app/composables/useConfirmAction.ts +96 -0
- package/app/composables/useKeyboardShortcuts.ts +76 -0
- package/app/composables/usePipelineErrorToast.ts +2 -0
- package/app/composables/useWorkspaceStream.ts +26 -6
- package/app/pages/index.vue +28 -4
- package/app/stores/agentRuns.spec.ts +74 -0
- package/app/stores/agentRuns.ts +33 -5
- package/app/stores/ui.ts +33 -1
- package/app/stores/workspace.ts +10 -2
- package/app/types/domain.ts +3 -0
- package/app/utils/pipeline.ts +22 -0
- package/i18n/locales/en.json +174 -12
- package/i18n/locales/es.json +176 -17
- package/i18n/locales/fr.json +176 -17
- package/i18n/locales/he.json +176 -17
- package/i18n/locales/ja.json +176 -17
- package/i18n/locales/pl.json +176 -17
- package/i18n/locales/tr.json +176 -17
- package/i18n/locales/uk.json +176 -17
- package/package.json +3 -2
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
// Loud prompt that this deployment needs a piece of infrastructure the operator hasn't set up
|
|
3
|
+
// yet, so a whole class of agents can't run. Driven off the server-computed `infraSetup`
|
|
4
|
+
// snapshot projection (`not_defined` per area) — so it only fires on a runtime that actually
|
|
5
|
+
// requires the piece (the runner-pool executor matters on remote Node; binary storage on any
|
|
6
|
+
// runtime whose account picked no backend — incl. Cloudflare without an ARTIFACT_BUCKET binding;
|
|
7
|
+
// ephemeral test environments on any runtime that wires the integration).
|
|
8
|
+
//
|
|
9
|
+
// Positioning/stacking against the sibling advisory banners (AI-readiness, provider-config) is
|
|
10
|
+
// owned by the shared, click-through banner column in `pages/index.vue` — so concurrent prompts
|
|
11
|
+
// stack vertically instead of drawing on top of each other. This component only stacks its OWN
|
|
12
|
+
// (up to three) area cards; each card re-enables pointer events while the column stays inert.
|
|
13
|
+
//
|
|
14
|
+
// Dismissal offers the two choices the product asks for: hide for THIS SESSION (a ui-store flag,
|
|
15
|
+
// cleared on workspace switch, re-nags next load) or "I'm OK with the limitations, don't notify
|
|
16
|
+
// me again" — a PERMANENT, per-USER dismissal persisted in localStorage keyed by the signed-in
|
|
17
|
+
// user id (so it's this-user-only and survives reloads).
|
|
18
|
+
//
|
|
19
|
+
// Scope note: the permanent dismissal is per-USER and DEPLOYMENT-wide, not per-account. That is
|
|
20
|
+
// exact for `agentExecutor`/`ephemeralEnvironments` (deployment-level wiring). `binaryStorage` is
|
|
21
|
+
// per-account, so a user who permanently silences it on one account won't be re-nagged on another
|
|
22
|
+
// account that also has no storage — an accepted trade-off (the setting stays reachable from
|
|
23
|
+
// account settings, and the SESSION dismissal re-nags on the next load regardless).
|
|
24
|
+
//
|
|
25
|
+
// Freshness note: `infraSetup` is a server projection recomputed only on snapshot (re)load, so a
|
|
26
|
+
// banner clears on the next board load after the operator configures the area via the deep-link,
|
|
27
|
+
// not the instant the config panel saves.
|
|
28
|
+
import { useLocalStorage } from '@vueuse/core'
|
|
29
|
+
import { computed } from 'vue'
|
|
30
|
+
// The localStorage key holding the permanent per-user dismissals lives in `@cat-factory/contracts`
|
|
31
|
+
// (a dependency-free package the SPA and the e2e suite both import), so the key + shape can't drift
|
|
32
|
+
// between this component and the e2e seed in `backend/internal/e2e/tests/helpers.ts` (`pinWorkspace`).
|
|
33
|
+
import { INFRA_SETUP_DISMISSED_STORAGE_KEY } from '@cat-factory/contracts'
|
|
34
|
+
import type { DropdownMenuItem } from '@nuxt/ui'
|
|
35
|
+
import type { InfraSetupArea } from '~/types/domain'
|
|
36
|
+
|
|
37
|
+
const { t } = useI18n()
|
|
38
|
+
const ui = useUiStore()
|
|
39
|
+
const auth = useAuthStore()
|
|
40
|
+
const workspace = useWorkspaceStore()
|
|
41
|
+
|
|
42
|
+
// Severity order: no executor blocks EVERY agent, so it leads; a missing test environment blocks
|
|
43
|
+
// only testing agents; missing storage only degrades the UI-tester's screenshots.
|
|
44
|
+
const AREAS: InfraSetupArea[] = ['agentExecutor', 'ephemeralEnvironments', 'binaryStorage']
|
|
45
|
+
|
|
46
|
+
// Exhaustive per-area presentation (an exhaustive `Record<InfraSetupArea, …>`, so adding an area
|
|
47
|
+
// without a meta entry fails typecheck — the tier-2 guard). The i18n keys are resolved
|
|
48
|
+
// dynamically (`t(AREA_META[area].titleKey)`), so tier-1 typed-key checking doesn't cover them;
|
|
49
|
+
// the `i18n:check` drift guard (tier 3) catches any that are absent from the catalog. `action`
|
|
50
|
+
// deep-links into the relevant setup surface.
|
|
51
|
+
const AREA_META: Record<
|
|
52
|
+
InfraSetupArea,
|
|
53
|
+
{ icon: string; titleKey: string; bodyKey: string; actionKey: string; onConfigure: () => void }
|
|
54
|
+
> = {
|
|
55
|
+
agentExecutor: {
|
|
56
|
+
icon: 'i-lucide-server-cog',
|
|
57
|
+
titleKey: 'layout.infraSetupBanner.agentExecutor.title',
|
|
58
|
+
bodyKey: 'layout.infraSetupBanner.agentExecutor.body',
|
|
59
|
+
actionKey: 'layout.infraSetupBanner.agentExecutor.action',
|
|
60
|
+
onConfigure: () => ui.openProviderConnection('runner-pool'),
|
|
61
|
+
},
|
|
62
|
+
ephemeralEnvironments: {
|
|
63
|
+
icon: 'i-lucide-flask-conical',
|
|
64
|
+
titleKey: 'layout.infraSetupBanner.ephemeralEnvironments.title',
|
|
65
|
+
bodyKey: 'layout.infraSetupBanner.ephemeralEnvironments.body',
|
|
66
|
+
actionKey: 'layout.infraSetupBanner.ephemeralEnvironments.action',
|
|
67
|
+
onConfigure: () => ui.openProviderConnection('environment'),
|
|
68
|
+
},
|
|
69
|
+
binaryStorage: {
|
|
70
|
+
icon: 'i-lucide-hard-drive',
|
|
71
|
+
titleKey: 'layout.infraSetupBanner.binaryStorage.title',
|
|
72
|
+
bodyKey: 'layout.infraSetupBanner.binaryStorage.body',
|
|
73
|
+
actionKey: 'layout.infraSetupBanner.binaryStorage.action',
|
|
74
|
+
onConfigure: () => ui.openContentStorageSettings(),
|
|
75
|
+
},
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// Permanent, per-user dismissals: one shared localStorage record keyed BY user id (so it's
|
|
79
|
+
// scoped to the signed-in user and doesn't leak across accounts on a shared browser). No
|
|
80
|
+
// signed-in user (local/auth-off single-user mode) ⇒ the `local` bucket.
|
|
81
|
+
const permanentDismissed = useLocalStorage<Record<string, InfraSetupArea[]>>(
|
|
82
|
+
INFRA_SETUP_DISMISSED_STORAGE_KEY,
|
|
83
|
+
{},
|
|
84
|
+
)
|
|
85
|
+
const userKey = computed(() => auth.user?.id ?? 'local')
|
|
86
|
+
const dismissedForUser = computed(() => permanentDismissed.value[userKey.value] ?? [])
|
|
87
|
+
function dismissPermanently(area: InfraSetupArea) {
|
|
88
|
+
const current = permanentDismissed.value[userKey.value] ?? []
|
|
89
|
+
if (!current.includes(area)) {
|
|
90
|
+
permanentDismissed.value = {
|
|
91
|
+
...permanentDismissed.value,
|
|
92
|
+
[userKey.value]: [...current, area],
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const visible = computed<InfraSetupArea[]>(() => {
|
|
98
|
+
const status = workspace.infraSetup
|
|
99
|
+
if (!status) return []
|
|
100
|
+
return AREAS.filter(
|
|
101
|
+
(area) =>
|
|
102
|
+
status[area] === 'not_defined' &&
|
|
103
|
+
!ui.infraSetupSessionDismissed.includes(area) &&
|
|
104
|
+
!dismissedForUser.value.includes(area),
|
|
105
|
+
)
|
|
106
|
+
})
|
|
107
|
+
|
|
108
|
+
// The dismiss dropdown: the product wants the user asked WHICH kind of dismissal on close.
|
|
109
|
+
function dismissMenu(area: InfraSetupArea): DropdownMenuItem[][] {
|
|
110
|
+
return [
|
|
111
|
+
[
|
|
112
|
+
{
|
|
113
|
+
label: t('layout.infraSetupBanner.dismiss.session'),
|
|
114
|
+
icon: 'i-lucide-clock',
|
|
115
|
+
onSelect: () => ui.dismissInfraSetupForSession(area),
|
|
116
|
+
},
|
|
117
|
+
{
|
|
118
|
+
label: t('layout.infraSetupBanner.dismiss.permanent'),
|
|
119
|
+
icon: 'i-lucide-bell-off',
|
|
120
|
+
onSelect: () => dismissPermanently(area),
|
|
121
|
+
},
|
|
122
|
+
],
|
|
123
|
+
]
|
|
124
|
+
}
|
|
125
|
+
</script>
|
|
126
|
+
|
|
127
|
+
<template>
|
|
128
|
+
<Transition name="fade">
|
|
129
|
+
<!-- One polite live region for ALL area cards (plus the sibling AI/provider banners) rather
|
|
130
|
+
than an assertive `role="alert"` per card — an advisory setup nag shouldn't interrupt a
|
|
131
|
+
screen reader, and up to three stacked alerts would spam it. -->
|
|
132
|
+
<div
|
|
133
|
+
v-if="visible.length > 0"
|
|
134
|
+
class="flex w-full flex-col items-center gap-2"
|
|
135
|
+
role="status"
|
|
136
|
+
aria-live="polite"
|
|
137
|
+
>
|
|
138
|
+
<div
|
|
139
|
+
v-for="area in visible"
|
|
140
|
+
:key="area"
|
|
141
|
+
class="pointer-events-auto w-full max-w-3xl rounded-2xl border-2 border-amber-500/70 bg-amber-950/95 p-5 shadow-2xl backdrop-blur"
|
|
142
|
+
:data-testid="`infra-setup-banner-${area}`"
|
|
143
|
+
>
|
|
144
|
+
<div class="flex items-start gap-4">
|
|
145
|
+
<UIcon :name="AREA_META[area].icon" class="mt-0.5 h-9 w-9 shrink-0 text-amber-400" />
|
|
146
|
+
<div class="min-w-0 flex-1">
|
|
147
|
+
<div class="flex items-start justify-between gap-3">
|
|
148
|
+
<h2 class="text-lg font-semibold text-amber-100">
|
|
149
|
+
{{ t(AREA_META[area].titleKey) }}
|
|
150
|
+
</h2>
|
|
151
|
+
<UDropdownMenu :items="dismissMenu(area)" :content="{ align: 'end' }">
|
|
152
|
+
<UButton
|
|
153
|
+
color="neutral"
|
|
154
|
+
variant="ghost"
|
|
155
|
+
size="xs"
|
|
156
|
+
icon="i-lucide-x"
|
|
157
|
+
:aria-label="t('common.close')"
|
|
158
|
+
:data-testid="`infra-setup-dismiss-${area}`"
|
|
159
|
+
/>
|
|
160
|
+
</UDropdownMenu>
|
|
161
|
+
</div>
|
|
162
|
+
<p class="mt-1 text-sm text-amber-200/90">
|
|
163
|
+
{{ t(AREA_META[area].bodyKey) }}
|
|
164
|
+
</p>
|
|
165
|
+
<div class="mt-4">
|
|
166
|
+
<UButton
|
|
167
|
+
color="warning"
|
|
168
|
+
variant="solid"
|
|
169
|
+
icon="i-lucide-settings"
|
|
170
|
+
:data-testid="`infra-setup-configure-${area}`"
|
|
171
|
+
@click="AREA_META[area].onConfigure()"
|
|
172
|
+
>
|
|
173
|
+
{{ t(AREA_META[area].actionKey) }}
|
|
174
|
+
</UButton>
|
|
175
|
+
</div>
|
|
176
|
+
</div>
|
|
177
|
+
</div>
|
|
178
|
+
</div>
|
|
179
|
+
</div>
|
|
180
|
+
</Transition>
|
|
181
|
+
</template>
|
|
182
|
+
|
|
183
|
+
<style scoped>
|
|
184
|
+
.fade-enter-active,
|
|
185
|
+
.fade-leave-active {
|
|
186
|
+
transition: opacity 0.2s ease;
|
|
187
|
+
}
|
|
188
|
+
.fade-enter-from,
|
|
189
|
+
.fade-leave-to {
|
|
190
|
+
opacity: 0;
|
|
191
|
+
}
|
|
192
|
+
</style>
|
|
@@ -12,6 +12,7 @@ const { t, te } = useI18n()
|
|
|
12
12
|
const notifications = useNotificationsStore()
|
|
13
13
|
const ui = useUiStore()
|
|
14
14
|
const execution = useExecutionStore()
|
|
15
|
+
const toast = useToast()
|
|
15
16
|
|
|
16
17
|
const busy = ref<string | null>(null)
|
|
17
18
|
|
|
@@ -92,6 +93,11 @@ async function act(n: Notification) {
|
|
|
92
93
|
busy.value = n.id
|
|
93
94
|
try {
|
|
94
95
|
await notifications.act(n.id)
|
|
96
|
+
toast.add({
|
|
97
|
+
title: t('layout.notifications.toast.acted'),
|
|
98
|
+
color: 'success',
|
|
99
|
+
icon: 'i-lucide-check',
|
|
100
|
+
})
|
|
95
101
|
} finally {
|
|
96
102
|
busy.value = null
|
|
97
103
|
}
|
|
@@ -101,6 +107,11 @@ async function dismiss(n: Notification) {
|
|
|
101
107
|
busy.value = n.id
|
|
102
108
|
try {
|
|
103
109
|
await notifications.dismiss(n.id)
|
|
110
|
+
toast.add({
|
|
111
|
+
title: t('layout.notifications.toast.dismissed'),
|
|
112
|
+
color: 'neutral',
|
|
113
|
+
icon: 'i-lucide-check',
|
|
114
|
+
})
|
|
104
115
|
} finally {
|
|
105
116
|
busy.value = null
|
|
106
117
|
}
|
|
@@ -38,7 +38,9 @@ const show = computed(() => pending.value.length > 0 && !dismissed.value)
|
|
|
38
38
|
|
|
39
39
|
<template>
|
|
40
40
|
<Transition name="fade">
|
|
41
|
-
|
|
41
|
+
<!-- Positioning/stacking is owned by the shared banner column in `pages/index.vue`; this
|
|
42
|
+
renders only its card and re-enables pointer events on it. -->
|
|
43
|
+
<div v-if="show" class="pointer-events-auto w-full max-w-3xl">
|
|
42
44
|
<div
|
|
43
45
|
class="w-full max-w-3xl rounded-2xl border-2 border-amber-500/70 bg-amber-950/95 p-5 shadow-2xl backdrop-blur"
|
|
44
46
|
role="alert"
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
<script setup lang="ts">
|
|
2
2
|
import type { Block, BlockStatus } from '~/types/domain'
|
|
3
3
|
import { blockTypeMeta, STATUS_META } from '~/utils/catalog'
|
|
4
|
+
import { pipelineAllowedForFrame } from '~/utils/pipeline'
|
|
4
5
|
import TaskContextDocs from '~/components/documents/TaskContextDocs.vue'
|
|
5
6
|
import TaskContextIssues from '~/components/tasks/TaskContextIssues.vue'
|
|
6
7
|
import TaskAgentConfig from '~/components/panels/inspector/TaskAgentConfig.vue'
|
|
@@ -137,28 +138,24 @@ const taskBranchUrl = computed(() => {
|
|
|
137
138
|
return base ? `${base}/tree/${pr.branch}` : null
|
|
138
139
|
})
|
|
139
140
|
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
)
|
|
141
|
+
// Hide UI-testing pipelines when this block's frame has no UI to exercise — they'd be refused
|
|
142
|
+
// at run start (see utils/pipeline + the backend gate).
|
|
143
|
+
const runMenu = computed(() => {
|
|
144
|
+
const frame = block.value ? board.serviceOf(block.value) : undefined
|
|
145
|
+
return pipelines.pipelines
|
|
146
|
+
.filter((p) => pipelineAllowedForFrame(p, frame, board.blocks))
|
|
147
|
+
.map((p) => ({
|
|
148
|
+
label: p.name,
|
|
149
|
+
icon: 'i-lucide-play',
|
|
150
|
+
onSelect: () => block.value && execution.start(block.value.id, p),
|
|
151
|
+
}))
|
|
152
|
+
})
|
|
147
153
|
|
|
154
|
+
// Delegate to the shared confirm-gated deletion so the button and the keyboard shortcut
|
|
155
|
+
// (Delete/Backspace) follow the exact same prompt + optimistic-delete + rollback path.
|
|
156
|
+
const { deleteBlock } = useBlockDeletion()
|
|
148
157
|
function remove() {
|
|
149
|
-
|
|
150
|
-
const id = block.value.id
|
|
151
|
-
// Close the inspector right away; the stores hide the block optimistically and
|
|
152
|
-
// restore it (with a toast) only if the backend delete fails.
|
|
153
|
-
ui.select(null)
|
|
154
|
-
// A schedule owns its reused block + run history — removing the schedule deletes
|
|
155
|
-
// them server-side, so delete the schedule rather than orphaning it.
|
|
156
|
-
if (schedule.value) {
|
|
157
|
-
void recurring.remove(schedule.value.id)
|
|
158
|
-
return
|
|
159
|
-
}
|
|
160
|
-
execution.cancel(id)
|
|
161
|
-
void board.removeBlock(id)
|
|
158
|
+
void deleteBlock(block.value)
|
|
162
159
|
}
|
|
163
160
|
|
|
164
161
|
// ---- failed agent run (bootstrap or execution) ------------------------------
|
|
@@ -492,6 +489,7 @@ const showOriginalDescription = ref(false)
|
|
|
492
489
|
size="sm"
|
|
493
490
|
icon="i-lucide-trash-2"
|
|
494
491
|
class="ms-auto"
|
|
492
|
+
data-testid="inspector-delete"
|
|
495
493
|
:title="deleteLabel"
|
|
496
494
|
@click="remove"
|
|
497
495
|
>
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
<script setup lang="ts">
|
|
2
2
|
import { computed } from 'vue'
|
|
3
|
+
import { useClipboard } from '@vueuse/core'
|
|
3
4
|
import type { PipelineStep, RunContainerStatus } from '~/types/execution'
|
|
4
5
|
import { containerPhaseLabel } from '~/utils/pipelineRender'
|
|
5
6
|
|
|
@@ -61,6 +62,22 @@ const CONTAINER_STATUS_META: Record<
|
|
|
61
62
|
|
|
62
63
|
// The friendly phase label (clone → "Preparing workspace", …); only meaningful while up.
|
|
63
64
|
const phaseLabel = computed(() => containerPhaseLabel(props.step.container?.phase, { t, te }))
|
|
65
|
+
|
|
66
|
+
// Make the container id / URL one-click copyable (they're long and used to be
|
|
67
|
+
// select-and-copy-by-hand), with a toast confirming the copy landed.
|
|
68
|
+
const toast = useToast()
|
|
69
|
+
const { copy, isSupported } = useClipboard()
|
|
70
|
+
async function copyText(text: string) {
|
|
71
|
+
// Only claim success once the write actually landed — a failed/unsupported clipboard
|
|
72
|
+
// (insecure context, denied permission) must not show a misleading "Copied" toast.
|
|
73
|
+
try {
|
|
74
|
+
if (!isSupported.value) throw new Error('clipboard unsupported')
|
|
75
|
+
await copy(text)
|
|
76
|
+
toast.add({ title: t('common.copied'), color: 'success', icon: 'i-lucide-check' })
|
|
77
|
+
} catch {
|
|
78
|
+
toast.add({ title: t('common.copyFailed'), color: 'error', icon: 'i-lucide-x' })
|
|
79
|
+
}
|
|
80
|
+
}
|
|
64
81
|
</script>
|
|
65
82
|
|
|
66
83
|
<template>
|
|
@@ -94,6 +111,16 @@ const phaseLabel = computed(() => containerPhaseLabel(props.step.container?.phas
|
|
|
94
111
|
<dd class="truncate font-mono text-[11px] text-slate-300" :title="step.container.id">
|
|
95
112
|
{{ step.container.id }}
|
|
96
113
|
</dd>
|
|
114
|
+
<UButton
|
|
115
|
+
icon="i-lucide-copy"
|
|
116
|
+
color="neutral"
|
|
117
|
+
variant="ghost"
|
|
118
|
+
size="xs"
|
|
119
|
+
class="ms-auto shrink-0"
|
|
120
|
+
:title="t('panels.stepMeta.container.copyId')"
|
|
121
|
+
:aria-label="t('panels.stepMeta.container.copyId')"
|
|
122
|
+
@click="copyText(step.container.id)"
|
|
123
|
+
/>
|
|
97
124
|
</div>
|
|
98
125
|
<div v-if="step.container?.url" class="flex items-center gap-2">
|
|
99
126
|
<dt class="shrink-0 text-[11px] uppercase tracking-wide text-slate-500">
|
|
@@ -109,6 +136,16 @@ const phaseLabel = computed(() => containerPhaseLabel(props.step.container?.phas
|
|
|
109
136
|
{{ step.container.url }}
|
|
110
137
|
</a>
|
|
111
138
|
</dd>
|
|
139
|
+
<UButton
|
|
140
|
+
icon="i-lucide-copy"
|
|
141
|
+
color="neutral"
|
|
142
|
+
variant="ghost"
|
|
143
|
+
size="xs"
|
|
144
|
+
class="ms-auto shrink-0"
|
|
145
|
+
:title="t('panels.stepMeta.container.copyUrl')"
|
|
146
|
+
:aria-label="t('panels.stepMeta.container.copyUrl')"
|
|
147
|
+
@click="copyText(step.container.url)"
|
|
148
|
+
/>
|
|
112
149
|
</div>
|
|
113
150
|
</dl>
|
|
114
151
|
</div>
|
|
@@ -18,8 +18,15 @@ import type {
|
|
|
18
18
|
const props = defineProps<{ block: Block }>()
|
|
19
19
|
|
|
20
20
|
const board = useBoardStore()
|
|
21
|
+
const auth = useAuthStore()
|
|
21
22
|
const { t } = useI18n()
|
|
22
23
|
|
|
24
|
+
// A browsable preview needs a long-lived host serve, so it is a local/node runtime capability
|
|
25
|
+
// the deployment advertises (`infrastructure.frontendPreview.supported`); the Worker reports it
|
|
26
|
+
// unsupported. Default to true until the auth handshake resolves so the toggle isn't briefly
|
|
27
|
+
// disabled on a runtime that does support it.
|
|
28
|
+
const previewSupported = computed(() => auth.infrastructure?.frontendPreview?.supported !== false)
|
|
29
|
+
|
|
23
30
|
const config = computed<FrontendConfig>(() => props.block.frontendConfig ?? { backendBindings: [] })
|
|
24
31
|
const bindings = computed(() => config.value.backendBindings ?? [])
|
|
25
32
|
|
|
@@ -226,7 +233,7 @@ function removeBinding(index: number) {
|
|
|
226
233
|
step="1"
|
|
227
234
|
size="xs"
|
|
228
235
|
class="font-mono"
|
|
229
|
-
placeholder="
|
|
236
|
+
placeholder="4173"
|
|
230
237
|
@blur="(e: FocusEvent) => saveServePort((e.target as HTMLInputElement).value)"
|
|
231
238
|
/>
|
|
232
239
|
</div>
|
|
@@ -247,6 +254,9 @@ function removeBinding(index: number) {
|
|
|
247
254
|
(e: KeyboardEvent) => saveText('mockMappingsPath', (e.target as HTMLInputElement).value)
|
|
248
255
|
"
|
|
249
256
|
/>
|
|
257
|
+
<p class="col-span-2 text-[11px] leading-snug text-slate-500">
|
|
258
|
+
{{ t('inspector.frontendConfig.mockMappingsHint') }}
|
|
259
|
+
</p>
|
|
250
260
|
</div>
|
|
251
261
|
</div>
|
|
252
262
|
|
|
@@ -331,18 +341,23 @@ function removeBinding(index: number) {
|
|
|
331
341
|
</div>
|
|
332
342
|
</div>
|
|
333
343
|
|
|
334
|
-
<!-- Browsable preview (local/node only). -->
|
|
344
|
+
<!-- Browsable preview (local/node only; the Worker reports it unsupported). -->
|
|
335
345
|
<div class="border-t border-slate-800 pt-2">
|
|
336
346
|
<UCheckbox
|
|
337
|
-
:model-value="config.previewEnabled === true"
|
|
347
|
+
:model-value="previewSupported && config.previewEnabled === true"
|
|
338
348
|
:label="t('inspector.frontendConfig.previewEnabled')"
|
|
349
|
+
:disabled="!previewSupported"
|
|
339
350
|
size="xs"
|
|
340
351
|
@update:model-value="
|
|
341
352
|
(v: boolean | 'indeterminate') => save({ previewEnabled: v === true ? true : undefined })
|
|
342
353
|
"
|
|
343
354
|
/>
|
|
344
355
|
<p class="mt-1 text-[11px] leading-snug text-slate-500">
|
|
345
|
-
{{
|
|
356
|
+
{{
|
|
357
|
+
previewSupported
|
|
358
|
+
? t('inspector.frontendConfig.previewHint')
|
|
359
|
+
: t('inspector.frontendConfig.previewUnsupported')
|
|
360
|
+
}}
|
|
346
361
|
</p>
|
|
347
362
|
</div>
|
|
348
363
|
</div>
|
|
@@ -12,6 +12,7 @@ const store = useReleaseHealthStore()
|
|
|
12
12
|
const ui = useUiStore()
|
|
13
13
|
const toast = useToast()
|
|
14
14
|
const { t } = useI18n()
|
|
15
|
+
const { confirmAction, toastDone } = useConfirmAction()
|
|
15
16
|
|
|
16
17
|
const busy = ref(false)
|
|
17
18
|
const draft = reactive({ monitorIds: '', sloIds: '', envTag: '' })
|
|
@@ -70,12 +71,15 @@ async function save() {
|
|
|
70
71
|
}
|
|
71
72
|
|
|
72
73
|
async function clear() {
|
|
74
|
+
const noun = t('inspector.releaseHealth.configNoun')
|
|
75
|
+
if (!(await confirmAction('clear', noun))) return
|
|
73
76
|
busy.value = true
|
|
74
77
|
try {
|
|
75
78
|
await store.removeConfig(props.block.id)
|
|
76
79
|
draft.monitorIds = ''
|
|
77
80
|
draft.sloIds = ''
|
|
78
81
|
draft.envTag = ''
|
|
82
|
+
toastDone('clear', noun)
|
|
79
83
|
} catch (e) {
|
|
80
84
|
notifyError(t('inspector.releaseHealth.clearFailed'), e)
|
|
81
85
|
} finally {
|
|
@@ -1,10 +1,12 @@
|
|
|
1
1
|
<script setup lang="ts">
|
|
2
2
|
import type { Block } from '~/types/domain'
|
|
3
|
+
import EmptyState from '~/components/common/EmptyState.vue'
|
|
3
4
|
|
|
4
5
|
const props = defineProps<{ block: Block }>()
|
|
5
6
|
|
|
6
7
|
const board = useBoardStore()
|
|
7
8
|
const { depLabel } = useDepLabels()
|
|
9
|
+
const { confirm } = useConfirm()
|
|
8
10
|
const { t } = useI18n()
|
|
9
11
|
|
|
10
12
|
const deps = computed(() =>
|
|
@@ -27,8 +29,15 @@ const depMenu = computed(() => {
|
|
|
27
29
|
}))
|
|
28
30
|
})
|
|
29
31
|
|
|
30
|
-
function removeDep(
|
|
31
|
-
|
|
32
|
+
async function removeDep(dep: Block) {
|
|
33
|
+
const ok = await confirm({
|
|
34
|
+
title: t('inspector.dependencies.confirmRemove.title'),
|
|
35
|
+
description: t('inspector.dependencies.confirmRemove.body', { name: label(dep) }),
|
|
36
|
+
variant: 'destructive',
|
|
37
|
+
confirmLabel: t('common.remove'),
|
|
38
|
+
icon: 'i-lucide-unlink',
|
|
39
|
+
})
|
|
40
|
+
if (ok) board.removeDependency(props.block.id, dep.id)
|
|
32
41
|
}
|
|
33
42
|
</script>
|
|
34
43
|
|
|
@@ -61,13 +70,18 @@ function removeDep(depId: string) {
|
|
|
61
70
|
? t('inspector.dependencies.merged')
|
|
62
71
|
: t('inspector.dependencies.notMerged')
|
|
63
72
|
"
|
|
64
|
-
@click="removeDep(d
|
|
73
|
+
@click="removeDep(d)"
|
|
65
74
|
>
|
|
66
75
|
{{ label(d) }}
|
|
67
76
|
<UIcon name="i-lucide-x" class="ms-0.5 h-3 w-3" />
|
|
68
77
|
</UBadge>
|
|
69
78
|
</div>
|
|
70
|
-
<
|
|
79
|
+
<EmptyState
|
|
80
|
+
v-else
|
|
81
|
+
compact
|
|
82
|
+
icon="i-lucide-git-branch"
|
|
83
|
+
:title="t('inspector.dependencies.empty')"
|
|
84
|
+
/>
|
|
71
85
|
<div v-if="!runnable" class="mt-1 text-[10px] text-amber-400">
|
|
72
86
|
{{ t('inspector.dependencies.blocked') }}
|
|
73
87
|
</div>
|
|
@@ -8,6 +8,7 @@ import {
|
|
|
8
8
|
containerPhaseLabel,
|
|
9
9
|
} from '~/utils/pipelineRender'
|
|
10
10
|
import AgentFailureCard from '~/components/board/AgentFailureCard.vue'
|
|
11
|
+
import EmptyState from '~/components/common/EmptyState.vue'
|
|
11
12
|
|
|
12
13
|
const props = defineProps<{ block: Block }>()
|
|
13
14
|
|
|
@@ -34,6 +35,16 @@ const reviewStageLabel = computed(() =>
|
|
|
34
35
|
)
|
|
35
36
|
|
|
36
37
|
const instance = computed(() => execution.getInstance(props.block.executionId))
|
|
38
|
+
|
|
39
|
+
// Nothing to show yet: no run, no failed run, no PR, and not awaiting a merge — render an
|
|
40
|
+
// empty state instead of a blank gap so the section reads as "no runs yet" rather than broken.
|
|
41
|
+
const isEmpty = computed(
|
|
42
|
+
() =>
|
|
43
|
+
!instance.value &&
|
|
44
|
+
!failedRun.value &&
|
|
45
|
+
!props.block.pullRequest &&
|
|
46
|
+
props.block.status !== 'pr_ready',
|
|
47
|
+
)
|
|
37
48
|
// A failed run is no longer executing: a step left mid-flight must stop showing
|
|
38
49
|
// its live "Spinning up…" phase (the shared failure banner renders below).
|
|
39
50
|
const runFailed = computed(() => instance.value?.status === 'failed')
|
|
@@ -184,6 +195,9 @@ async function resetRun() {
|
|
|
184
195
|
:key="i"
|
|
185
196
|
class="rounded-md px-2 py-1"
|
|
186
197
|
:class="i === instance.currentStep ? 'bg-slate-800/70' : ''"
|
|
198
|
+
data-testid="run-step"
|
|
199
|
+
:data-step-kind="s.agentKind"
|
|
200
|
+
:data-step-state="s.state"
|
|
187
201
|
>
|
|
188
202
|
<div class="flex items-center gap-2">
|
|
189
203
|
<!-- Every agent is clickable: it opens the step-detail overlay (timing,
|
|
@@ -221,6 +235,7 @@ async function resetRun() {
|
|
|
221
235
|
<span
|
|
222
236
|
v-if="s.subtasks && s.subtasks.total > 0"
|
|
223
237
|
class="ms-auto font-mono text-[10px] tabular-nums text-slate-300"
|
|
238
|
+
data-testid="run-subtasks"
|
|
224
239
|
:title="
|
|
225
240
|
s.subtasks.inProgress > 0
|
|
226
241
|
? t('inspector.execution.subtasksProgress', {
|
|
@@ -399,6 +414,15 @@ async function resetRun() {
|
|
|
399
414
|
</p>
|
|
400
415
|
</div>
|
|
401
416
|
|
|
417
|
+
<!-- No run yet: read as "nothing here" rather than a blank gap. -->
|
|
418
|
+
<EmptyState
|
|
419
|
+
v-if="isEmpty"
|
|
420
|
+
compact
|
|
421
|
+
icon="i-lucide-play-circle"
|
|
422
|
+
:title="t('inspector.execution.empty.title')"
|
|
423
|
+
:description="t('inspector.execution.empty.body')"
|
|
424
|
+
/>
|
|
425
|
+
|
|
402
426
|
<!-- PR ready: merge -->
|
|
403
427
|
<UButton
|
|
404
428
|
v-if="block.status === 'pr_ready'"
|
|
@@ -3,6 +3,7 @@ import { computed, onMounted } from 'vue'
|
|
|
3
3
|
import type { Block } from '~/types/domain'
|
|
4
4
|
import type { WritebackOverride } from '~/types/tracker'
|
|
5
5
|
import { mergePresetOptionLabel, mergePresetThresholds } from '~/utils/mergePreset'
|
|
6
|
+
import { pipelineAllowedForFrame } from '~/utils/pipeline'
|
|
6
7
|
|
|
7
8
|
const props = defineProps<{ block: Block }>()
|
|
8
9
|
|
|
@@ -129,6 +130,12 @@ function setModelPreset(id: string) {
|
|
|
129
130
|
const selectedPipeline = computed(() =>
|
|
130
131
|
props.block.pipelineId ? pipelines.getPipeline(props.block.pipelineId) : undefined,
|
|
131
132
|
)
|
|
133
|
+
// Hide UI-testing pipelines when this task's frame has no UI to exercise — they'd be refused at
|
|
134
|
+
// run start (see utils/pipeline + the backend gate).
|
|
135
|
+
const taskFrame = computed(() => board.serviceOf(props.block))
|
|
136
|
+
const selectablePipelines = computed(() =>
|
|
137
|
+
pipelines.pipelines.filter((p) => pipelineAllowedForFrame(p, taskFrame.value, board.blocks)),
|
|
138
|
+
)
|
|
132
139
|
const pipelineMenu = computed(() => [
|
|
133
140
|
[
|
|
134
141
|
{
|
|
@@ -136,7 +143,7 @@ const pipelineMenu = computed(() => [
|
|
|
136
143
|
icon: 'i-lucide-rotate-ccw',
|
|
137
144
|
onSelect: () => setPipeline(''),
|
|
138
145
|
},
|
|
139
|
-
...
|
|
146
|
+
...selectablePipelines.value.map((p) => ({
|
|
140
147
|
label: p.name,
|
|
141
148
|
icon: 'i-lucide-workflow',
|
|
142
149
|
onSelect: () => setPipeline(p.id),
|
|
@@ -198,6 +198,18 @@ function edit(p: Pipeline) {
|
|
|
198
198
|
pipelines.loadForEdit(p)
|
|
199
199
|
}
|
|
200
200
|
|
|
201
|
+
const { confirm } = useConfirm()
|
|
202
|
+
async function removePipeline(p: Pipeline) {
|
|
203
|
+
const ok = await confirm({
|
|
204
|
+
title: t('pipeline.builder.confirmDeletePipeline.title'),
|
|
205
|
+
description: t('pipeline.builder.confirmDeletePipeline.body', { name: p.name }),
|
|
206
|
+
variant: 'destructive',
|
|
207
|
+
confirmLabel: t('common.delete'),
|
|
208
|
+
icon: 'i-lucide-trash-2',
|
|
209
|
+
})
|
|
210
|
+
if (ok) void pipelines.removePipeline(p.id)
|
|
211
|
+
}
|
|
212
|
+
|
|
201
213
|
/** Clone any pipeline (incl. a read-only built-in) into an editable copy, then edit it. */
|
|
202
214
|
async function clone(p: Pipeline) {
|
|
203
215
|
try {
|
|
@@ -776,7 +788,7 @@ async function clone(p: Pipeline) {
|
|
|
776
788
|
variant="ghost"
|
|
777
789
|
size="xs"
|
|
778
790
|
:title="t('pipeline.builder.delete')"
|
|
779
|
-
@click="
|
|
791
|
+
@click="removePipeline(p)"
|
|
780
792
|
/>
|
|
781
793
|
</div>
|
|
782
794
|
</div>
|
|
@@ -27,6 +27,7 @@ const keys = useApiKeysStore()
|
|
|
27
27
|
const models = useModelsStore()
|
|
28
28
|
const auth = useAuthStore()
|
|
29
29
|
const toast = useToast()
|
|
30
|
+
const { confirmAction, toastDone } = useConfirmAction()
|
|
30
31
|
|
|
31
32
|
/** Account-wide mode (single account scope) vs the default workspace/user toggle. */
|
|
32
33
|
const isAccount = computed(() => !!props.accountId)
|
|
@@ -209,11 +210,14 @@ async function add() {
|
|
|
209
210
|
}
|
|
210
211
|
|
|
211
212
|
async function remove(k: ApiKey) {
|
|
213
|
+
const noun = t('providers.apiKeys.keyNoun')
|
|
214
|
+
if (!(await confirmAction('remove', noun))) return
|
|
212
215
|
try {
|
|
213
216
|
if (k.scope === 'account') await keys.removeAccountKey(k.id)
|
|
214
217
|
else if (k.scope === 'workspace') await keys.removeWorkspaceKey(k.id)
|
|
215
218
|
else await keys.removeUserKey(k.id)
|
|
216
219
|
if (workspace.workspaceId) await models.refresh(workspace.workspaceId)
|
|
220
|
+
toastDone('remove', noun)
|
|
217
221
|
} catch (e) {
|
|
218
222
|
toast.add({
|
|
219
223
|
title: t('providers.apiKeys.toast.removeFailed'),
|
|
@@ -13,6 +13,7 @@ const workspace = useWorkspaceStore()
|
|
|
13
13
|
const models = useModelsStore()
|
|
14
14
|
const toast = useToast()
|
|
15
15
|
const { t, d } = useI18n()
|
|
16
|
+
const { confirm } = useConfirm()
|
|
16
17
|
|
|
17
18
|
// Personal subscriptions are stored per-user, so they need a signed-in user. When there
|
|
18
19
|
// isn't one (a deployment running without sign-in), block the form so the user doesn't
|
|
@@ -141,6 +142,14 @@ async function connect() {
|
|
|
141
142
|
}
|
|
142
143
|
|
|
143
144
|
async function disconnect(v: SubscriptionVendor) {
|
|
145
|
+
const ok = await confirm({
|
|
146
|
+
title: t('personalSubscriptions.confirmDisconnect.title'),
|
|
147
|
+
description: t('personalSubscriptions.confirmDisconnect.body', { vendor: vendorLabel(v) }),
|
|
148
|
+
variant: 'destructive',
|
|
149
|
+
confirmLabel: t('common.disconnect'),
|
|
150
|
+
icon: 'i-lucide-unplug',
|
|
151
|
+
})
|
|
152
|
+
if (!ok) return
|
|
144
153
|
try {
|
|
145
154
|
await personal.remove(v)
|
|
146
155
|
// Removing the subscription may drop the workspace's last usable model — refresh so the
|