@cat-factory/app 0.261.0 → 0.261.2
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/foundational/FoundationalServiceManager.vue +1 -1
- package/app/components/fragments/FragmentLibraryManager.vue +1 -1
- package/app/components/layout/BoardTopOverlays.vue +6 -0
- package/app/components/layout/GitHubPatPermissionsBanner.vue +224 -0
- package/app/components/settings/ConnectionTestVerdict.vue +62 -0
- package/app/components/settings/InfraHandlersConfigurator.logic.spec.ts +64 -0
- package/app/components/settings/InfraHandlersConfigurator.logic.ts +41 -0
- package/app/components/settings/InfraHandlersConfigurator.vue +36 -9
- package/app/components/settings/KubernetesEngineForm.vue +24 -7
- package/app/components/settings/KubernetesEnvironmentForm.vue +20 -7
- package/app/components/settings/ProviderConnectionTab.vue +3 -7
- package/app/components/settings/ProviderManifestEditor.vue +3 -7
- package/app/components/skills/SkillLibraryManager.vue +1 -1
- package/app/composables/api/github.ts +7 -0
- package/app/composables/useServiceAccountTokenProblem.ts +52 -0
- package/app/stores/github/probe.ts +97 -0
- package/app/stores/github.spec.ts +109 -1
- package/app/stores/github.ts +26 -42
- package/app/stores/ui/k3sDeepLink.spec.ts +79 -0
- package/app/stores/ui/modals.ts +25 -2
- package/app/types/github.ts +4 -0
- package/app/types/providerConnections.ts +9 -0
- package/app/utils/connectionFailures.ts +32 -0
- package/app/utils/connectionWarnings.ts +1 -0
- package/app/utils/vcs.ts +28 -3
- package/i18n/locales/de.json +41 -1
- package/i18n/locales/en.json +59 -1
- package/i18n/locales/es.json +41 -1
- package/i18n/locales/fr.json +41 -1
- package/i18n/locales/he.json +41 -1
- package/i18n/locales/it.json +41 -1
- package/i18n/locales/ja.json +41 -1
- package/i18n/locales/pl.json +41 -1
- package/i18n/locales/tr.json +41 -1
- package/i18n/locales/uk.json +41 -1
- package/package.json +2 -2
|
@@ -62,7 +62,7 @@ watch(
|
|
|
62
62
|
void documents.probe()
|
|
63
63
|
// The GitHub pickers (repo search + tree browser) need the active board's
|
|
64
64
|
// installation state; probe once so they light up when the App is connected.
|
|
65
|
-
void github.
|
|
65
|
+
void github.ensureProbed()
|
|
66
66
|
},
|
|
67
67
|
{ immediate: true },
|
|
68
68
|
)
|
|
@@ -3,6 +3,7 @@ import BoardToolbar from '~/components/layout/BoardToolbar.vue'
|
|
|
3
3
|
import ConnectionStatusBanner from '~/components/layout/ConnectionStatusBanner.vue'
|
|
4
4
|
import SpendWarningBanner from '~/components/layout/SpendWarningBanner.vue'
|
|
5
5
|
import GitHubPatBanner from '~/components/layout/GitHubPatBanner.vue'
|
|
6
|
+
import GitHubPatPermissionsBanner from '~/components/layout/GitHubPatPermissionsBanner.vue'
|
|
6
7
|
import AiProvidersBanner from '~/components/layout/AiProvidersBanner.vue'
|
|
7
8
|
import ProviderConfigBanner from '~/components/layout/ProviderConfigBanner.vue'
|
|
8
9
|
import InfraSetupBanner from '~/components/layout/InfraSetupBanner.vue'
|
|
@@ -68,6 +69,10 @@ const ui = useUiStore()
|
|
|
68
69
|
- Connection status: what is on screen right now may already be stale.
|
|
69
70
|
- Spend exceeded: runs are blocked until the budget moves.
|
|
70
71
|
- GitHub PAT (local mode): every repo-operating step will fail.
|
|
72
|
+
- GitHub PAT permissions: a token IS configured, but it cannot push or open pull
|
|
73
|
+
requests, so every repo-operating step will fail just as surely. Directly after its
|
|
74
|
+
missing-token sibling, which it can never appear beside (that one raises only when
|
|
75
|
+
there is no token, this one only when there is).
|
|
71
76
|
- AI readiness: no usable model source, or the default preset names unavailable models.
|
|
72
77
|
- Infrastructure provider: env/runner-pool wired but missing mandatory config.
|
|
73
78
|
- Infra setup: an executor / test env / storage this deployment needs is undefined, so
|
|
@@ -82,6 +87,7 @@ const ui = useUiStore()
|
|
|
82
87
|
/>
|
|
83
88
|
<SpendWarningBanner />
|
|
84
89
|
<GitHubPatBanner />
|
|
90
|
+
<GitHubPatPermissionsBanner />
|
|
85
91
|
<AiProvidersBanner />
|
|
86
92
|
<ProviderConfigBanner />
|
|
87
93
|
<InfraSetupBanner />
|
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import { computed, ref } from 'vue'
|
|
3
|
+
import {
|
|
4
|
+
GITHUB_PAT_FINE_GRAINED_PERMISSIONS,
|
|
5
|
+
githubPatCheckNeedsAttention,
|
|
6
|
+
githubPatCheckSource,
|
|
7
|
+
missingGitHubPatCapabilities,
|
|
8
|
+
} from '@cat-factory/contracts'
|
|
9
|
+
import type { GitHubPatCapability, GitHubPatKind } from '~/types/domain'
|
|
10
|
+
import { githubPatRemintUrl } from '~/utils/vcs'
|
|
11
|
+
|
|
12
|
+
// The token a run would authenticate with cannot do what the pipeline needs.
|
|
13
|
+
//
|
|
14
|
+
// This is the board-load half of a warning the backend already logs at boot in local mode: a
|
|
15
|
+
// developer's terminal is easy to miss, and on a HOSTED deployment there is no terminal at all
|
|
16
|
+
// for the case this also covers, where the run initiator's own stored token outranks the App
|
|
17
|
+
// installation. Either way the alternative to saying it here is saying it eight steps into a
|
|
18
|
+
// pipeline as a 403 out of a container, after the run has spent money.
|
|
19
|
+
//
|
|
20
|
+
// It raises on ESTABLISHED blocking gaps only (`githubPatCheckNeedsAttention`). An unreachable
|
|
21
|
+
// GitHub, an advisory-only finding and an unknowable fine-grained permission each render
|
|
22
|
+
// nothing over the board, because none of them is something the reader can act on right now and
|
|
23
|
+
// a banner that appears when nothing is wrong is one people learn to dismiss unread.
|
|
24
|
+
//
|
|
25
|
+
// Positioning/stacking is owned by `BoardTopOverlays`; this renders only its card.
|
|
26
|
+
|
|
27
|
+
const { t } = useI18n()
|
|
28
|
+
const github = useGitHubStore()
|
|
29
|
+
|
|
30
|
+
const dismissed = ref(false)
|
|
31
|
+
const check = computed(() => github.patCheck)
|
|
32
|
+
const show = computed(
|
|
33
|
+
() => !dismissed.value && check.value !== null && githubPatCheckNeedsAttention(check.value),
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
/** The report, when the check produced one. Absent for a token GitHub rejected outright. */
|
|
37
|
+
const report = computed(() => (check.value?.state === 'checked' ? check.value.report : undefined))
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Which claim the card is making. `rejected` is the strictly worse problem (the token does not
|
|
41
|
+
* authenticate at all), so it gets its own copy rather than being folded into "some capability
|
|
42
|
+
* is missing" — every capability would read as missing, which describes the symptom and not the
|
|
43
|
+
* cause.
|
|
44
|
+
*/
|
|
45
|
+
const claim = computed<'rejected' | 'underscoped'>(() =>
|
|
46
|
+
check.value?.state === 'token_rejected' ? 'rejected' : 'underscoped',
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
const missing = computed<GitHubPatCapability[]>(() =>
|
|
50
|
+
report.value ? missingGitHubPatCapabilities(report.value).blocking : [],
|
|
51
|
+
)
|
|
52
|
+
/**
|
|
53
|
+
* Established gaps that do NOT stop a pipeline, listed inside the card but never the reason it
|
|
54
|
+
* opened. Today that is `workflows`: worth fixing while you are on the token page, not worth
|
|
55
|
+
* interrupting a board for on its own.
|
|
56
|
+
*/
|
|
57
|
+
const advisory = computed<GitHubPatCapability[]>(() =>
|
|
58
|
+
report.value ? missingGitHubPatCapabilities(report.value).advisory : [],
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
const CAPABILITY_KEYS: Record<GitHubPatCapability, string> = {
|
|
62
|
+
push: 'layout.githubPatPermissionsBanner.capability.push',
|
|
63
|
+
pullRequests: 'layout.githubPatPermissionsBanner.capability.pullRequests',
|
|
64
|
+
workflows: 'layout.githubPatPermissionsBanner.capability.workflows',
|
|
65
|
+
}
|
|
66
|
+
function capabilityLabel(capability: GitHubPatCapability): string {
|
|
67
|
+
return t(CAPABILITY_KEYS[capability])
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* The kind carried over to the re-mint link. A rejected token was never classified, so it falls
|
|
72
|
+
* back to `unknown` — which lands on the classic form, the only one GitHub lets us pre-fill.
|
|
73
|
+
*/
|
|
74
|
+
const kind = computed<GitHubPatKind>(() => report.value?.kind ?? 'unknown')
|
|
75
|
+
const remintUrl = computed(() =>
|
|
76
|
+
githubPatRemintUrl(kind.value, report.value?.webUrl ?? github.connection?.webUrl),
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Who has to act. A deployment token is replaced by whoever runs the deployment (local mode:
|
|
81
|
+
* the developer at the terminal); an initiator token belongs to the signed-in user and is
|
|
82
|
+
* replaced in their own settings. Sending one to the other's remedy is worse than saying
|
|
83
|
+
* nothing, which is why the source rides the wire rather than being guessed from the shape.
|
|
84
|
+
*
|
|
85
|
+
* Read through the contract's own accessor rather than off `report`, because a REJECTED token
|
|
86
|
+
* produces no report and every state this banner renders carries a source. Deriving it from the
|
|
87
|
+
* report alone left the rejected case falling through to whichever branch the ternary ended on,
|
|
88
|
+
* which told a local developer whose deployment token had expired to replace it in their
|
|
89
|
+
* personal settings: the exact misrouting the wire field exists to prevent.
|
|
90
|
+
*/
|
|
91
|
+
const sourceKey = computed(() =>
|
|
92
|
+
check.value && githubPatCheckSource(check.value) === 'deployment'
|
|
93
|
+
? 'layout.githubPatPermissionsBanner.sourceDeployment'
|
|
94
|
+
: 'layout.githubPatPermissionsBanner.sourceInitiator',
|
|
95
|
+
)
|
|
96
|
+
|
|
97
|
+
/** The fine-grained form takes no prefill, so the permissions have to be named as prose. */
|
|
98
|
+
const fineGrainedPermissions = GITHUB_PAT_FINE_GRAINED_PERMISSIONS.join(', ')
|
|
99
|
+
</script>
|
|
100
|
+
|
|
101
|
+
<template>
|
|
102
|
+
<Transition name="fade">
|
|
103
|
+
<div v-if="show" class="pointer-events-auto w-full max-w-3xl">
|
|
104
|
+
<div
|
|
105
|
+
class="w-full max-w-3xl rounded-2xl border-2 border-red-500/70 bg-red-950/95 p-5 shadow-2xl backdrop-blur"
|
|
106
|
+
role="alert"
|
|
107
|
+
data-testid="github-pat-permissions-banner"
|
|
108
|
+
>
|
|
109
|
+
<div class="flex items-start gap-4">
|
|
110
|
+
<UIcon name="i-lucide-shield-alert" class="mt-0.5 h-9 w-9 shrink-0 text-red-400" />
|
|
111
|
+
<div class="min-w-0 flex-1">
|
|
112
|
+
<div class="flex items-start justify-between gap-3">
|
|
113
|
+
<h2 class="text-lg font-semibold text-red-100">
|
|
114
|
+
{{
|
|
115
|
+
claim === 'rejected'
|
|
116
|
+
? t('layout.githubPatPermissionsBanner.rejectedTitle')
|
|
117
|
+
: t('layout.githubPatPermissionsBanner.title')
|
|
118
|
+
}}
|
|
119
|
+
</h2>
|
|
120
|
+
<UButton
|
|
121
|
+
color="neutral"
|
|
122
|
+
variant="ghost"
|
|
123
|
+
size="xs"
|
|
124
|
+
icon="i-lucide-x"
|
|
125
|
+
:aria-label="t('common.close')"
|
|
126
|
+
@click="
|
|
127
|
+
() => {
|
|
128
|
+
dismissed = true
|
|
129
|
+
}
|
|
130
|
+
"
|
|
131
|
+
/>
|
|
132
|
+
</div>
|
|
133
|
+
|
|
134
|
+
<p class="mt-1 text-sm text-red-200/90">
|
|
135
|
+
{{
|
|
136
|
+
claim === 'rejected'
|
|
137
|
+
? t('layout.githubPatPermissionsBanner.rejectedBody')
|
|
138
|
+
: t('layout.githubPatPermissionsBanner.body')
|
|
139
|
+
}}
|
|
140
|
+
</p>
|
|
141
|
+
|
|
142
|
+
<!-- The established gaps, named one by one. A bare "permissions are missing" leaves
|
|
143
|
+
the reader to guess which box to tick on a form with dozens. -->
|
|
144
|
+
<p v-if="missing.length" class="mt-3 text-sm text-red-100">
|
|
145
|
+
<span class="font-medium">{{ t('layout.githubPatPermissionsBanner.missing') }}</span>
|
|
146
|
+
{{ missing.map(capabilityLabel).join(', ') }}
|
|
147
|
+
</p>
|
|
148
|
+
<p v-if="advisory.length" class="mt-1 text-xs text-red-200/80">
|
|
149
|
+
{{
|
|
150
|
+
t('layout.githubPatPermissionsBanner.alsoMissing', {
|
|
151
|
+
capabilities: advisory.map(capabilityLabel).join(', '),
|
|
152
|
+
})
|
|
153
|
+
}}
|
|
154
|
+
</p>
|
|
155
|
+
|
|
156
|
+
<!-- For a fine-grained token, WHICH repositories it was not granted is the whole
|
|
157
|
+
remedy: the permission list is right and the repository selection is not. -->
|
|
158
|
+
<p v-if="report && report.deniedRepos.length" class="mt-1 text-xs text-red-200/80">
|
|
159
|
+
{{
|
|
160
|
+
t('layout.githubPatPermissionsBanner.deniedRepos', {
|
|
161
|
+
repos: report.deniedRepos.join(', '),
|
|
162
|
+
})
|
|
163
|
+
}}
|
|
164
|
+
</p>
|
|
165
|
+
|
|
166
|
+
<p class="mt-2 text-xs text-red-200/80">{{ t(sourceKey) }}</p>
|
|
167
|
+
|
|
168
|
+
<div class="mt-4">
|
|
169
|
+
<UButton
|
|
170
|
+
:to="remintUrl"
|
|
171
|
+
target="_blank"
|
|
172
|
+
rel="noopener noreferrer"
|
|
173
|
+
color="error"
|
|
174
|
+
variant="solid"
|
|
175
|
+
icon="i-lucide-external-link"
|
|
176
|
+
trailing
|
|
177
|
+
>
|
|
178
|
+
{{
|
|
179
|
+
kind === 'fine_grained'
|
|
180
|
+
? t('layout.githubPatPermissionsBanner.createFineGrained')
|
|
181
|
+
: t('layout.githubPatPermissionsBanner.createClassic')
|
|
182
|
+
}}
|
|
183
|
+
</UButton>
|
|
184
|
+
<!-- The classic form arrives with the scopes ticked; the fine-grained one accepts
|
|
185
|
+
no prefill at all, so its permissions are spelled out. Saying so is the point:
|
|
186
|
+
a link that silently arrived with nothing selected reads as "already done for
|
|
187
|
+
you", which is how the missing permission got there in the first place. -->
|
|
188
|
+
<p class="mt-2 text-xs text-red-300/70">
|
|
189
|
+
{{
|
|
190
|
+
kind === 'fine_grained'
|
|
191
|
+
? t('layout.githubPatPermissionsBanner.fineGrainedHint', {
|
|
192
|
+
permissions: fineGrainedPermissions,
|
|
193
|
+
})
|
|
194
|
+
: t('layout.githubPatPermissionsBanner.classicHint')
|
|
195
|
+
}}
|
|
196
|
+
</p>
|
|
197
|
+
<!-- A fine-grained verdict is a SAMPLE of the linked repositories. Declaring the
|
|
198
|
+
remainder keeps a clean-looking list from reading as a guarantee. -->
|
|
199
|
+
<p v-if="report && report.unprobedRepoCount > 0" class="mt-1 text-xs text-red-300/60">
|
|
200
|
+
{{
|
|
201
|
+
t('layout.githubPatPermissionsBanner.sampled', {
|
|
202
|
+
checked: report.probedRepos.length,
|
|
203
|
+
remaining: report.unprobedRepoCount,
|
|
204
|
+
})
|
|
205
|
+
}}
|
|
206
|
+
</p>
|
|
207
|
+
</div>
|
|
208
|
+
</div>
|
|
209
|
+
</div>
|
|
210
|
+
</div>
|
|
211
|
+
</div>
|
|
212
|
+
</Transition>
|
|
213
|
+
</template>
|
|
214
|
+
|
|
215
|
+
<style scoped>
|
|
216
|
+
.fade-enter-active,
|
|
217
|
+
.fade-leave-active {
|
|
218
|
+
transition: opacity 0.2s ease;
|
|
219
|
+
}
|
|
220
|
+
.fade-enter-from,
|
|
221
|
+
.fade-leave-to {
|
|
222
|
+
opacity: 0;
|
|
223
|
+
}
|
|
224
|
+
</style>
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
// The verdict of one "Test connection" probe, rendered identically everywhere a connect form
|
|
3
|
+
// offers that button.
|
|
4
|
+
//
|
|
5
|
+
// Its own component because the message stopped being a few words. A failed probe now reports
|
|
6
|
+
// the EXACT transport failure plus what to do about it (kernel's `connectionFailureResult`),
|
|
7
|
+
// which runs to a sentence or three, and the inline `<span>` each form used to carry sat inside
|
|
8
|
+
// a `flex items-center` row beside the button, where a long message squashes the button and
|
|
9
|
+
// overflows the panel. So the result is a block that wraps, and the six forms share it rather
|
|
10
|
+
// than each growing its own copy of the same markup.
|
|
11
|
+
//
|
|
12
|
+
// Two lines, because that account is English by construction and this SPA ships in ten languages.
|
|
13
|
+
// The backend states the failure CLASS as a machine-readable `failureCause`; the headline is that
|
|
14
|
+
// class in the operator's own language, and the backend's prose sits under it as the detail. The
|
|
15
|
+
// detail stays VISIBLE rather than folding behind a disclosure: it is the half that names the
|
|
16
|
+
// concrete host, port and remedy, and a probe verdict is read to find out what to go fix.
|
|
17
|
+
import { computed } from 'vue'
|
|
18
|
+
import type { ConnectionFailureCause } from '@cat-factory/contracts'
|
|
19
|
+
import { CONNECTION_FAILURE_CAUSE_KEYS } from '~/utils/connectionFailures'
|
|
20
|
+
|
|
21
|
+
const props = defineProps<{
|
|
22
|
+
/** The probe verdict; null before the first test (renders nothing). */
|
|
23
|
+
result: { ok: boolean; message?: string; failureCause?: ConnectionFailureCause } | null
|
|
24
|
+
}>()
|
|
25
|
+
|
|
26
|
+
const { t, te } = useI18n()
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* The translated-headline key for this failure, or null when there is none: an `unknown` cause, a
|
|
30
|
+
* cause this SPA build predates, or a failure that was an ANSWER (an HTTP status the provider
|
|
31
|
+
* mapped itself, which carries no transport cause). In every one of those the backend's own
|
|
32
|
+
* message becomes the primary line, so a missing translation is never a blank verdict.
|
|
33
|
+
*/
|
|
34
|
+
const causeKey = computed(() => {
|
|
35
|
+
const cause = props.result && !props.result.ok ? props.result.failureCause : undefined
|
|
36
|
+
const key = cause ? CONNECTION_FAILURE_CAUSE_KEYS[cause] : null
|
|
37
|
+
return key && te(key) ? key : null
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
const headline = computed(() =>
|
|
41
|
+
causeKey.value
|
|
42
|
+
? t(causeKey.value)
|
|
43
|
+
: (props.result?.message ?? t('settings.providerConnection.test.failed')),
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
/** The backend's English account, shown only when a headline took the primary line from it. */
|
|
47
|
+
const detail = computed(() => (causeKey.value ? (props.result?.message ?? '') : ''))
|
|
48
|
+
</script>
|
|
49
|
+
|
|
50
|
+
<template>
|
|
51
|
+
<p
|
|
52
|
+
v-if="result?.ok"
|
|
53
|
+
class="text-xs text-emerald-400 break-words"
|
|
54
|
+
data-testid="connection-test-result"
|
|
55
|
+
>
|
|
56
|
+
{{ result.message ?? t('settings.providerConnection.test.ok') }}
|
|
57
|
+
</p>
|
|
58
|
+
<div v-else-if="result" class="space-y-0.5" data-testid="connection-test-result">
|
|
59
|
+
<p class="text-xs text-rose-400 break-words">{{ headline }}</p>
|
|
60
|
+
<p v-if="detail" class="text-[11px] text-slate-400 break-words">{{ detail }}</p>
|
|
61
|
+
</div>
|
|
62
|
+
</template>
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
import {
|
|
3
|
+
consumeKubernetesScrollAnchor,
|
|
4
|
+
type ScrollableSection,
|
|
5
|
+
} from '~/components/settings/InfraHandlersConfigurator.logic'
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* The `cat-factory k3s` hand-off promises the operator lands on the ONE form the CLI just filled
|
|
9
|
+
* in. `k3sDeepLink.spec.ts` pins that the link arms the anchor; this pins the other half, that the
|
|
10
|
+
* panel consumes it exactly once and only when there is something to scroll to.
|
|
11
|
+
*/
|
|
12
|
+
function recorder(): ScrollableSection & { calls: ScrollIntoViewOptions[] } {
|
|
13
|
+
const calls: ScrollIntoViewOptions[] = []
|
|
14
|
+
return { calls, scrollIntoView: (options) => calls.push(options) }
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
describe('consumeKubernetesScrollAnchor', () => {
|
|
18
|
+
it('scrolls the section into view once the panel and the section are both there', () => {
|
|
19
|
+
const section = recorder()
|
|
20
|
+
const outcome = consumeKubernetesScrollAnchor({
|
|
21
|
+
target: 'kubernetes',
|
|
22
|
+
available: true,
|
|
23
|
+
section,
|
|
24
|
+
})
|
|
25
|
+
|
|
26
|
+
expect(outcome).toBe('scrolled')
|
|
27
|
+
expect(section.calls).toEqual([{ behavior: 'smooth', block: 'start' }])
|
|
28
|
+
})
|
|
29
|
+
|
|
30
|
+
it('does nothing when no anchor is pending, so a plain open never jumps', () => {
|
|
31
|
+
const section = recorder()
|
|
32
|
+
expect(consumeKubernetesScrollAnchor({ target: null, available: true, section })).toBe(
|
|
33
|
+
'not-anchored',
|
|
34
|
+
)
|
|
35
|
+
expect(section.calls).toEqual([])
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
it('does nothing while the infra probe is still resolving', () => {
|
|
39
|
+
// The whole configurator is behind `v-if="infra.available === true"`, and that probe resolves
|
|
40
|
+
// AFTER the deep link fires: the section cannot be scrolled to before it exists.
|
|
41
|
+
const section = recorder()
|
|
42
|
+
expect(consumeKubernetesScrollAnchor({ target: 'kubernetes', available: null, section })).toBe(
|
|
43
|
+
'not-anchored',
|
|
44
|
+
)
|
|
45
|
+
expect(section.calls).toEqual([])
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
it('leaves the anchor for the next attempt when the section has not rendered yet', () => {
|
|
49
|
+
// `not-rendered` is what keeps the hand-off alive across the render the probe gates: only
|
|
50
|
+
// `scrolled` tells the caller to clear the store's target, so a miss cannot swallow the link.
|
|
51
|
+
const first = consumeKubernetesScrollAnchor({
|
|
52
|
+
target: 'kubernetes',
|
|
53
|
+
available: true,
|
|
54
|
+
section: null,
|
|
55
|
+
})
|
|
56
|
+
expect(first).toBe('not-rendered')
|
|
57
|
+
|
|
58
|
+
const section = recorder()
|
|
59
|
+
expect(consumeKubernetesScrollAnchor({ target: 'kubernetes', available: true, section })).toBe(
|
|
60
|
+
'scrolled',
|
|
61
|
+
)
|
|
62
|
+
expect(section.calls).toHaveLength(1)
|
|
63
|
+
})
|
|
64
|
+
})
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import type { InfrastructureScrollTarget } from '~/types/providerConnections'
|
|
2
|
+
|
|
3
|
+
// The deep-link SECTION anchor the `cat-factory k3s` hand-off lands on, as a plain function so the
|
|
4
|
+
// rule is testable: the component half is a `watch` plus an `onMounted` retry, and neither is
|
|
5
|
+
// reachable from a spec (this SPA has no component-mounting harness).
|
|
6
|
+
//
|
|
7
|
+
// The rule that needed pinning is the three-way outcome, not the scroll. The anchor is one-shot and
|
|
8
|
+
// is consumed only once the section is actually IN the DOM, but the section renders behind an async
|
|
9
|
+
// probe, so an attempt that finds nothing must leave the anchor ARMED for the next attempt rather
|
|
10
|
+
// than clear it. Clearing on a miss silently swallows the hand-off on a slow probe; never clearing
|
|
11
|
+
// leaves a dead anchor, which is why "scrolled" is the only outcome the caller acts on and why
|
|
12
|
+
// closing the window drops whatever is left (`closeProviderConnection`).
|
|
13
|
+
|
|
14
|
+
/** The minimum of an element this needs, so a spec can pass a recorder instead of a DOM node. */
|
|
15
|
+
export interface ScrollableSection {
|
|
16
|
+
scrollIntoView: (options: ScrollIntoViewOptions) => void
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface ScrollAnchorAttempt {
|
|
20
|
+
/** The store's pending anchor, or null when there is nothing to land on. */
|
|
21
|
+
target: InfrastructureScrollTarget | null
|
|
22
|
+
/** The infra-probe gate the sections render behind; null while it is still resolving. */
|
|
23
|
+
available: boolean | null
|
|
24
|
+
/** The section element, or null when it has not rendered yet. */
|
|
25
|
+
section: ScrollableSection | null
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* - `scrolled`: the anchor was honoured and the CALLER must now clear it.
|
|
30
|
+
* - `not-anchored`: nothing is pending for this section (or the panel is not showing yet).
|
|
31
|
+
* - `not-rendered`: it IS pending, but the section is not in the DOM, so the anchor stays armed.
|
|
32
|
+
*/
|
|
33
|
+
export type ScrollAnchorOutcome = 'scrolled' | 'not-anchored' | 'not-rendered'
|
|
34
|
+
|
|
35
|
+
/** Honour a pending Kubernetes-section anchor, reporting which of the three cases this was. */
|
|
36
|
+
export function consumeKubernetesScrollAnchor(attempt: ScrollAnchorAttempt): ScrollAnchorOutcome {
|
|
37
|
+
if (attempt.target !== 'kubernetes' || attempt.available !== true) return 'not-anchored'
|
|
38
|
+
if (!attempt.section) return 'not-rendered'
|
|
39
|
+
attempt.section.scrollIntoView({ behavior: 'smooth', block: 'start' })
|
|
40
|
+
return 'scrolled'
|
|
41
|
+
}
|
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
// per custom type (matched to a service's pinned `manifestId`).
|
|
13
13
|
// In LOCAL mode each handler additionally offers a per-USER override (this-machine only),
|
|
14
14
|
// written to the `/me/environment-handlers` endpoints. Drives the infraConfig store.
|
|
15
|
-
import { computed, ref, watch } from 'vue'
|
|
15
|
+
import { computed, nextTick, onMounted, ref, watch } from 'vue'
|
|
16
16
|
import type {
|
|
17
17
|
CustomManifestType,
|
|
18
18
|
EnvironmentHandlerView,
|
|
@@ -28,6 +28,8 @@ import KubernetesEngineForm from '~/components/settings/KubernetesEngineForm.vue
|
|
|
28
28
|
import ProviderManifestEditor from '~/components/settings/ProviderManifestEditor.vue'
|
|
29
29
|
import CustomManifestTypeEditor from '~/components/settings/CustomManifestTypeEditor.vue'
|
|
30
30
|
import CloudflareHandlerSection from '~/components/settings/CloudflareHandlerSection.vue'
|
|
31
|
+
import ConnectionTestVerdict from '~/components/settings/ConnectionTestVerdict.vue'
|
|
32
|
+
import { consumeKubernetesScrollAnchor } from '~/components/settings/InfraHandlersConfigurator.logic'
|
|
31
33
|
|
|
32
34
|
const { t } = useI18n()
|
|
33
35
|
const infra = useInfraConfigStore()
|
|
@@ -94,6 +96,32 @@ watch(
|
|
|
94
96
|
{ immediate: true },
|
|
95
97
|
)
|
|
96
98
|
|
|
99
|
+
// Deep-link anchor: the `cat-factory k3s` hand-off opens this window with the ui store's scroll
|
|
100
|
+
// target set to `kubernetes`, so bring that section into view once rather than dropping the
|
|
101
|
+
// operator at the top of the tab to hunt for the form the CLI just described.
|
|
102
|
+
//
|
|
103
|
+
// Attempted from BOTH the watch and `onMounted`, and the anchor is cleared only on a real scroll:
|
|
104
|
+
// the section is behind `v-if="infra.available === true"`, whose probe resolves after the deep link
|
|
105
|
+
// fires, so a single attempt that finds nothing rendered would swallow the hand-off with neither
|
|
106
|
+
// watched value ever changing again to re-drive it. The decision itself is in the logic module,
|
|
107
|
+
// where it is tested.
|
|
108
|
+
const kubeSection = ref<HTMLElement | null>(null)
|
|
109
|
+
async function anchorKubernetesSection() {
|
|
110
|
+
await nextTick()
|
|
111
|
+
const outcome = consumeKubernetesScrollAnchor({
|
|
112
|
+
target: ui.infrastructureScrollTarget,
|
|
113
|
+
available: infra.available,
|
|
114
|
+
section: kubeSection.value,
|
|
115
|
+
})
|
|
116
|
+
if (outcome === 'scrolled') ui.clearInfrastructureScrollTarget()
|
|
117
|
+
}
|
|
118
|
+
watch([() => ui.infrastructureScrollTarget, () => infra.available], () => {
|
|
119
|
+
void anchorKubernetesSection()
|
|
120
|
+
})
|
|
121
|
+
onMounted(() => {
|
|
122
|
+
void anchorKubernetesSection()
|
|
123
|
+
})
|
|
124
|
+
|
|
97
125
|
const busy = ref(false)
|
|
98
126
|
|
|
99
127
|
// Connection-probe state for the kube engine forms (workspace + per-user override kept
|
|
@@ -400,7 +428,11 @@ function notifyError(e: unknown) {
|
|
|
400
428
|
<p class="text-xs text-slate-400">{{ t('settings.infrastructure.handler.intro') }}</p>
|
|
401
429
|
|
|
402
430
|
<!-- kubernetes -->
|
|
403
|
-
<section
|
|
431
|
+
<section
|
|
432
|
+
ref="kubeSection"
|
|
433
|
+
class="space-y-2 rounded-lg border border-slate-700 bg-slate-900/40 p-3"
|
|
434
|
+
data-testid="infra-kubernetes-section"
|
|
435
|
+
>
|
|
404
436
|
<h3 class="text-sm font-semibold text-slate-200">
|
|
405
437
|
{{ t('inspector.testConfig.provisionTypes.kubernetes') }}
|
|
406
438
|
</h3>
|
|
@@ -433,7 +465,7 @@ function notifyError(e: unknown) {
|
|
|
433
465
|
{{ t('settings.infrastructure.handler.activeEngine') }}
|
|
434
466
|
<span class="text-slate-200">{{ kubeHandlerEngineLabel }}</span>
|
|
435
467
|
</p>
|
|
436
|
-
<div class="
|
|
468
|
+
<div class="space-y-1.5 pl-7">
|
|
437
469
|
<UButton
|
|
438
470
|
color="neutral"
|
|
439
471
|
variant="soft"
|
|
@@ -444,12 +476,7 @@ function notifyError(e: unknown) {
|
|
|
444
476
|
>
|
|
445
477
|
{{ t('settings.providerConnection.test.button') }}
|
|
446
478
|
</UButton>
|
|
447
|
-
<
|
|
448
|
-
{{ kubeSavedTestResult.message ?? t('settings.providerConnection.test.ok') }}
|
|
449
|
-
</span>
|
|
450
|
-
<span v-else-if="kubeSavedTestResult" class="text-xs text-rose-400">
|
|
451
|
-
{{ kubeSavedTestResult.message ?? t('settings.providerConnection.test.failed') }}
|
|
452
|
-
</span>
|
|
479
|
+
<ConnectionTestVerdict :result="kubeSavedTestResult" />
|
|
453
480
|
</div>
|
|
454
481
|
</div>
|
|
455
482
|
<p v-else class="flex items-center gap-1.5 text-[12px] text-slate-500">
|
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
import { computed, reactive, ref, watch } from 'vue'
|
|
10
10
|
import { KUBERNETES_ENV_TOKEN_SECRET_KEY } from '@cat-factory/contracts'
|
|
11
11
|
import SecretInput from '~/components/common/SecretInput.vue'
|
|
12
|
+
import ConnectionTestVerdict from '~/components/settings/ConnectionTestVerdict.vue'
|
|
12
13
|
import type {
|
|
13
14
|
EnvironmentHandlerView,
|
|
14
15
|
InfraEngine,
|
|
@@ -75,6 +76,13 @@ const form = reactive({
|
|
|
75
76
|
urlScheme: 'default' as 'default' | 'http' | 'https',
|
|
76
77
|
})
|
|
77
78
|
const apiToken = ref('')
|
|
79
|
+
// Flag a bad paste ON THE FIELD, before Test is ever clicked. The guided CLI flow ends with
|
|
80
|
+
// "copy this token out of your terminal", and a terminal wraps: a token copied across that wrap
|
|
81
|
+
// carries an invisible newline that survives `.trim()` and can never become an `authorization`
|
|
82
|
+
// header. Left to the probe it comes back as an opaque transport failure minutes later.
|
|
83
|
+
// Destructured at the top level so the template auto-unwraps the refs (a ref nested in a plain
|
|
84
|
+
// object is not unwrapped in a template, only a top-level one is).
|
|
85
|
+
const { blocking: tokenBlocking, message: tokenProblem } = useServiceAccountTokenProblem(apiToken)
|
|
78
86
|
|
|
79
87
|
const urlSourceItems = computed(() => [
|
|
80
88
|
{
|
|
@@ -210,6 +218,7 @@ const canSave = computed(
|
|
|
210
218
|
!!form.label.trim() &&
|
|
211
219
|
!!form.apiServerUrl.trim() &&
|
|
212
220
|
(tokenStored.value || !!apiToken.value.trim()) &&
|
|
221
|
+
!tokenBlocking.value &&
|
|
213
222
|
urlValid.value,
|
|
214
223
|
)
|
|
215
224
|
|
|
@@ -232,6 +241,9 @@ const connectBlockedReason = computed(() => {
|
|
|
232
241
|
missing.push(t('settings.infrastructure.kubernetesEngine.serviceName'))
|
|
233
242
|
if (missing.length)
|
|
234
243
|
return t('settings.providerConnection.form.missingFields', { fields: missing.join(', ') })
|
|
244
|
+
// Repeated from under the token field, so the disabled button is never left unexplained for a
|
|
245
|
+
// reader whose eye is on it rather than on the field above.
|
|
246
|
+
if (tokenBlocking.value) return tokenProblem.value
|
|
235
247
|
return t('settings.infrastructure.kubernetesEngine.invalidPort')
|
|
236
248
|
})
|
|
237
249
|
|
|
@@ -414,6 +426,16 @@ async function copyAutoSetupCommand() {
|
|
|
414
426
|
: undefined
|
|
415
427
|
"
|
|
416
428
|
/>
|
|
429
|
+
<!-- Rose when the paste is impossible (blocks Test/Save), amber when it is only suspicious
|
|
430
|
+
and the operator may legitimately overrule it. -->
|
|
431
|
+
<p
|
|
432
|
+
v-if="tokenProblem"
|
|
433
|
+
class="mt-1 text-[11px]"
|
|
434
|
+
:class="tokenBlocking ? 'text-rose-400' : 'text-amber-400'"
|
|
435
|
+
data-testid="service-account-token-problem"
|
|
436
|
+
>
|
|
437
|
+
{{ tokenProblem }}
|
|
438
|
+
</p>
|
|
417
439
|
</UFormField>
|
|
418
440
|
|
|
419
441
|
<!-- URL derivation: how the live environment URL is resolved once the service's
|
|
@@ -517,7 +539,7 @@ async function copyAutoSetupCommand() {
|
|
|
517
539
|
/>
|
|
518
540
|
</UFormField>
|
|
519
541
|
|
|
520
|
-
<div v-if="supportsTest" class="
|
|
542
|
+
<div v-if="supportsTest" class="space-y-1.5">
|
|
521
543
|
<UButton
|
|
522
544
|
color="neutral"
|
|
523
545
|
variant="soft"
|
|
@@ -529,12 +551,7 @@ async function copyAutoSetupCommand() {
|
|
|
529
551
|
>
|
|
530
552
|
{{ t('settings.providerConnection.test.button') }}
|
|
531
553
|
</UButton>
|
|
532
|
-
<
|
|
533
|
-
{{ testResult.message ?? t('settings.providerConnection.test.ok') }}
|
|
534
|
-
</span>
|
|
535
|
-
<span v-else-if="testResult" class="text-xs text-rose-400">
|
|
536
|
-
{{ testResult.message ?? t('settings.providerConnection.test.failed') }}
|
|
537
|
-
</span>
|
|
554
|
+
<ConnectionTestVerdict :result="testResult" />
|
|
538
555
|
</div>
|
|
539
556
|
|
|
540
557
|
<div class="flex items-center justify-end gap-3">
|