@cat-factory/app 0.261.0 → 0.261.1

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.
@@ -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 class="space-y-2 rounded-lg border border-slate-700 bg-slate-900/40 p-3">
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="flex items-center gap-2 pl-7">
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
- <span v-if="kubeSavedTestResult?.ok" class="text-xs text-emerald-400">
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="flex items-center gap-2">
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
- <span v-if="testResult && testResult.ok" class="text-xs text-emerald-400">
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">
@@ -12,6 +12,7 @@ import { computed, reactive, ref, watch } from 'vue'
12
12
  import { KUBERNETES_ENV_TOKEN_SECRET_KEY } from '@cat-factory/contracts'
13
13
  import type { ConnectionTestResult } from '@cat-factory/contracts'
14
14
  import ConnectionWarnings from '~/components/settings/ConnectionWarnings.vue'
15
+ import ConnectionTestVerdict from '~/components/settings/ConnectionTestVerdict.vue'
15
16
  import SecretInput from '~/components/common/SecretInput.vue'
16
17
  import type { ProviderConnection } from '~/types/providerConnections'
17
18
 
@@ -54,6 +55,10 @@ const form = reactive({
54
55
  urlScheme: 'default' as 'default' | 'http' | 'https',
55
56
  })
56
57
  const apiToken = ref('')
58
+ // Flag a bad paste on the field itself rather than leaving it to surface as an opaque probe
59
+ // failure. Destructured at the top level so the template auto-unwraps the refs. Same rule and
60
+ // same copy as the per-type engine form, via the shared composable.
61
+ const { blocking: tokenBlocking, message: tokenProblem } = useServiceAccountTokenProblem(apiToken)
57
62
 
58
63
  const manifestSourceItems = computed(() => [
59
64
  { label: t('settings.providerConnection.kubernetesEnv.sourceColocated'), value: 'colocated' },
@@ -168,6 +173,7 @@ const canSave = computed(
168
173
  !!form.label.trim() &&
169
174
  !!form.apiServerUrl.trim() &&
170
175
  !!apiToken.value.trim() &&
176
+ !tokenBlocking.value &&
171
177
  manifestSourceValid.value &&
172
178
  urlValid.value,
173
179
  )
@@ -191,6 +197,8 @@ const connectBlockedReason = computed(() => {
191
197
  missing.push(t('settings.providerConnection.kubernetesEnv.serviceName'))
192
198
  if (missing.length)
193
199
  return t('settings.providerConnection.form.missingFields', { fields: missing.join(', ') })
200
+ // Repeated from under the token field, so the disabled button is never left unexplained.
201
+ if (tokenBlocking.value) return tokenProblem.value
194
202
  return t('settings.providerConnection.kubernetesEnv.invalidFields')
195
203
  })
196
204
 
@@ -273,6 +281,16 @@ function optional(label: string): string {
273
281
  :help="t('settings.providerConnection.kubernetesEnv.apiTokenHelp')"
274
282
  >
275
283
  <SecretInput v-model="apiToken" class="w-full font-mono" />
284
+ <!-- Rose when the paste is impossible (blocks Test/Save), amber when it is only suspicious
285
+ and the operator may legitimately overrule it. -->
286
+ <p
287
+ v-if="tokenProblem"
288
+ class="mt-1 text-[11px]"
289
+ :class="tokenBlocking ? 'text-rose-400' : 'text-amber-400'"
290
+ data-testid="service-account-token-problem"
291
+ >
292
+ {{ tokenProblem }}
293
+ </p>
276
294
  </UFormField>
277
295
 
278
296
  <!-- Manifest source: where the per-PR resources are read from. -->
@@ -391,7 +409,7 @@ function optional(label: string): string {
391
409
  />
392
410
  </UFormField>
393
411
 
394
- <div v-if="supportsTest" class="flex items-center gap-2">
412
+ <div v-if="supportsTest" class="space-y-1.5">
395
413
  <UButton
396
414
  color="neutral"
397
415
  variant="soft"
@@ -403,12 +421,7 @@ function optional(label: string): string {
403
421
  >
404
422
  {{ t('settings.providerConnection.test.button') }}
405
423
  </UButton>
406
- <span v-if="testResult && testResult.ok" class="text-xs text-emerald-400">
407
- {{ testResult.message ?? t('settings.providerConnection.test.ok') }}
408
- </span>
409
- <span v-else-if="testResult" class="text-xs text-rose-400">
410
- {{ testResult.message ?? t('settings.providerConnection.test.failed') }}
411
- </span>
424
+ <ConnectionTestVerdict :result="testResult" />
412
425
  </div>
413
426
 
414
427
  <ConnectionWarnings :warnings="testResult?.warnings" />
@@ -15,6 +15,7 @@ import { computed, ref, toRaw, watch } from 'vue'
15
15
  import type { ConnectionTestResult } from '@cat-factory/contracts'
16
16
  import type { ProviderConfigField, ProviderConnectionKind } from '~/types/providerConnections'
17
17
  import ConnectionWarnings from '~/components/settings/ConnectionWarnings.vue'
18
+ import ConnectionTestVerdict from '~/components/settings/ConnectionTestVerdict.vue'
18
19
  import ProvisioningLogsDrawer from '~/components/provisioning/ProvisioningLogsDrawer.vue'
19
20
  import ProviderManifestEditor from '~/components/settings/ProviderManifestEditor.vue'
20
21
  import KubernetesEnvironmentForm from '~/components/settings/KubernetesEnvironmentForm.vue'
@@ -498,7 +499,7 @@ function fieldHelp(key: string): string | undefined {
498
499
  />
499
500
  </UFormField>
500
501
 
501
- <div v-if="descriptor.supportsTest" class="flex items-center gap-2">
502
+ <div v-if="descriptor.supportsTest" class="space-y-1.5">
502
503
  <UButton
503
504
  color="neutral"
504
505
  variant="soft"
@@ -509,12 +510,7 @@ function fieldHelp(key: string): string | undefined {
509
510
  >
510
511
  {{ t('settings.providerConnection.test.button') }}
511
512
  </UButton>
512
- <span v-if="testResult && testResult.ok" class="text-xs text-emerald-400">
513
- {{ testResult.message ?? t('settings.providerConnection.test.ok') }}
514
- </span>
515
- <span v-else-if="testResult" class="text-xs text-rose-400">
516
- {{ testResult.message ?? t('settings.providerConnection.test.failed') }}
517
- </span>
513
+ <ConnectionTestVerdict :result="testResult" />
518
514
  </div>
519
515
 
520
516
  <ConnectionWarnings :warnings="testResult?.warnings" />
@@ -20,6 +20,7 @@ import { environmentManifestSchema, runnerPoolManifestSchema } from '@cat-factor
20
20
  import type { ConnectionTestResult } from '@cat-factory/contracts'
21
21
  import type { ProviderConnectionKind } from '~/types/providerConnections'
22
22
  import ConnectionWarnings from '~/components/settings/ConnectionWarnings.vue'
23
+ import ConnectionTestVerdict from '~/components/settings/ConnectionTestVerdict.vue'
23
24
  import SecretInput from '~/components/common/SecretInput.vue'
24
25
 
25
26
  const props = defineProps<{
@@ -267,7 +268,7 @@ function onSave() {
267
268
  </UFormField>
268
269
  </div>
269
270
 
270
- <div v-if="supportsTest" class="flex items-center gap-2">
271
+ <div v-if="supportsTest" class="space-y-1.5">
271
272
  <UButton
272
273
  color="neutral"
273
274
  variant="soft"
@@ -280,12 +281,7 @@ function onSave() {
280
281
  >
281
282
  {{ t('settings.providerConnection.test.button') }}
282
283
  </UButton>
283
- <span v-if="testResult && testResult.ok" class="text-xs text-emerald-400">
284
- {{ testResult.message ?? t('settings.providerConnection.test.ok') }}
285
- </span>
286
- <span v-else-if="testResult" class="text-xs text-rose-400">
287
- {{ testResult.message ?? t('settings.providerConnection.test.failed') }}
288
- </span>
284
+ <ConnectionTestVerdict :result="testResult" />
289
285
  </div>
290
286
 
291
287
  <ConnectionWarnings :warnings="testResult?.warnings" />
@@ -0,0 +1,52 @@
1
+ import { computed, type ComputedRef, type Ref } from 'vue'
2
+ import {
3
+ classifyServiceAccountToken,
4
+ isFatalServiceAccountTokenProblem,
5
+ type ServiceAccountTokenProblem,
6
+ } from '@cat-factory/contracts'
7
+
8
+ // Inline validation of a pasted Kubernetes ServiceAccount token, shared by the two kube connect
9
+ // forms so they cannot drift on what a bad paste is or on what to say about it.
10
+ //
11
+ // The rule itself is in `@cat-factory/contracts` because the backend enforces the same one (see
12
+ // `KubernetesApiClient`), and this is the SPA half of the split CLAUDE.md prescribes: the backend
13
+ // emits a machine-readable code, the SPA owns the translated prose. So the map below is the one
14
+ // place a problem code becomes copy.
15
+
16
+ /**
17
+ * The message key per problem, as an exhaustive `Record`: a code added to the contract union fails
18
+ * the typecheck here until it has copy, rather than rendering as a silently missing hint. The keys
19
+ * are literals for the same reason they are elsewhere in the SPA (an assembled key is invisible to
20
+ * the typed-message-key check), and they sit under the shared `providerConnection` namespace
21
+ * because both the per-type engine form and the legacy single-connection form show them.
22
+ */
23
+ const MESSAGE_KEYS: Record<ServiceAccountTokenProblem, string> = {
24
+ whitespace: 'settings.providerConnection.serviceAccountToken.whitespace',
25
+ 'base64-encoded': 'settings.providerConnection.serviceAccountToken.base64Encoded',
26
+ 'not-a-jwt': 'settings.providerConnection.serviceAccountToken.notAJwt',
27
+ }
28
+
29
+ export interface ServiceAccountTokenCheck {
30
+ /** The problem code, or null when the value looks fine (and when it is empty). */
31
+ problem: ComputedRef<ServiceAccountTokenProblem | null>
32
+ /**
33
+ * Whether the problem should BLOCK Test and Save. True only for the impossible case (whitespace
34
+ * inside the token), never for the merely-suspicious shapes: a `--token-auth-file` apiserver
35
+ * accepts an arbitrary static bearer token, and a check that cannot be sure must not be the
36
+ * thing that stops a legitimate cluster being configured.
37
+ */
38
+ blocking: ComputedRef<boolean>
39
+ /** The translated hint to render under the field, or '' when there is nothing to say. */
40
+ message: ComputedRef<string>
41
+ }
42
+
43
+ /** Classify the live value of a token field and render the verdict as translated copy. */
44
+ export function useServiceAccountTokenProblem(token: Ref<string>): ServiceAccountTokenCheck {
45
+ const { t } = useI18n()
46
+ const problem = computed(() => classifyServiceAccountToken(token.value))
47
+ return {
48
+ problem,
49
+ blocking: computed(() => !!problem.value && isFatalServiceAccountTokenProblem(problem.value)),
50
+ message: computed(() => (problem.value ? t(MESSAGE_KEYS[problem.value]) : '')),
51
+ }
52
+ }
@@ -0,0 +1,79 @@
1
+ import { describe, it, expect, beforeEach } from 'vitest'
2
+ import { createUiModals } from '~/stores/ui/modals'
3
+
4
+ /**
5
+ * The `cat-factory k3s` CLI hand-off (`?infraSetup=local-k3s&…`), driven through the modals slice
6
+ * directly (plain refs/functions, no Pinia) exactly as the overlay-host slice tests do.
7
+ *
8
+ * What is worth pinning here is the ARRIVAL, not the parsing: the CLI's whole promise is that the
9
+ * operator lands on the one form it just filled in, and both halves of that (the tab AND the
10
+ * section anchor within it) are set in this one function. The prefill is asserted alongside
11
+ * because the deep link is the only thing that ever sets it.
12
+ */
13
+ function openWith(search: string): void {
14
+ window.history.replaceState(null, '', `/${search}`)
15
+ }
16
+
17
+ const K3S_LINK =
18
+ '?infraSetup=local-k3s&label=Local+k3s&apiServerUrl=https%3A%2F%2F127.0.0.1%3A6443' +
19
+ '&namespaceTemplate=cf-env-%7B%7BpullNumber%7D%7D&hostTemplate=%7B%7Bbranch%7D%7D.127.0.0.1.nip.io' +
20
+ '&insecureSkipTlsVerify=1'
21
+
22
+ describe('consumeK3sSetupDeepLink', () => {
23
+ beforeEach(() => {
24
+ openWith('')
25
+ })
26
+
27
+ it('opens the Test-environments tab ANCHORED on the Kubernetes section', () => {
28
+ const ui = createUiModals()
29
+ openWith(K3S_LINK)
30
+ ui.consumeK3sSetupDeepLink()
31
+
32
+ expect(ui.infrastructureOpen.value).toBe(true)
33
+ expect(ui.infrastructureTab.value).toBe('environment')
34
+ // The tab opens on the default-provision picker, so without this the operator lands above
35
+ // the form the CLI just described and has to scroll to find it.
36
+ expect(ui.infrastructureScrollTarget.value).toBe('kubernetes')
37
+ expect(ui.k3sSetupPrefill.value).toEqual({
38
+ label: 'Local k3s',
39
+ apiServerUrl: 'https://127.0.0.1:6443',
40
+ namespaceTemplate: 'cf-env-{{pullNumber}}',
41
+ hostTemplate: '{{branch}}.127.0.0.1.nip.io',
42
+ insecureSkipTlsVerify: true,
43
+ })
44
+ })
45
+
46
+ it('strips the params so a reload neither re-opens the window nor re-anchors it', () => {
47
+ const ui = createUiModals()
48
+ openWith(K3S_LINK)
49
+ ui.consumeK3sSetupDeepLink()
50
+ expect(window.location.search).toBe('')
51
+
52
+ const reloaded = createUiModals()
53
+ reloaded.consumeK3sSetupDeepLink()
54
+ expect(reloaded.infrastructureOpen.value).toBe(false)
55
+ expect(reloaded.infrastructureScrollTarget.value).toBeNull()
56
+ })
57
+
58
+ it('drops an UNCONSUMED anchor on close, so the next plain open does not scroll', () => {
59
+ // The panel clears the target once it has scrolled. Closing before it rendered (the window
60
+ // was dismissed, or the infra probe never resolved) must not leave the anchor armed.
61
+ const ui = createUiModals()
62
+ openWith(K3S_LINK)
63
+ ui.consumeK3sSetupDeepLink()
64
+ ui.closeProviderConnection()
65
+
66
+ expect(ui.infrastructureScrollTarget.value).toBeNull()
67
+ expect(ui.k3sSetupPrefill.value).toBeNull()
68
+ })
69
+
70
+ it('is a no-op for an unrelated query string', () => {
71
+ const ui = createUiModals()
72
+ openWith('?settings=default-test-env')
73
+ ui.consumeK3sSetupDeepLink()
74
+
75
+ expect(ui.infrastructureOpen.value).toBe(false)
76
+ expect(ui.infrastructureScrollTarget.value).toBeNull()
77
+ expect(window.location.search).toBe('?settings=default-test-env')
78
+ })
79
+ })
@@ -1,6 +1,10 @@
1
1
  import { ref } from 'vue'
2
2
  import type { DocumentSourceKind, InfraSetupArea, TaskSourceKind } from '~/types/domain'
3
- import type { InfrastructureTab, ProviderConnectionKind } from '~/types/providerConnections'
3
+ import type {
4
+ InfrastructureScrollTarget,
5
+ InfrastructureTab,
6
+ ProviderConnectionKind,
7
+ } from '~/types/providerConnections'
4
8
  import type { PendingContext } from '~/composables/useContextLinking'
5
9
  import {
6
10
  infraSetupDismissalKey,
@@ -737,6 +741,13 @@ function createInfraModals(resetHubReturn: ResetHubReturn) {
737
741
  // `local-k3s` connection from it; the ServiceAccount token is deliberately NOT in the link (a
738
742
  // secret in a URL leaks into history/logs), so the user still pastes it before Test → Save.
739
743
  const k3sSetupPrefill = ref<K3sSetupPrefill | null>(null)
744
+ // A one-shot deep-link anchor into a SECTION of the open tab, mirroring
745
+ // `accountSettingsScrollTarget`. The Test-environments tab opens on the default-provision
746
+ // picker and the Compose wizard, with the per-type handler sections between them, so landing an
747
+ // operator at the top of it after a `cat-factory k3s` hand-off leaves them scrolling to find the
748
+ // very form the CLI just filled in. The owning panel scrolls the section into view once and
749
+ // then calls `clearInfrastructureScrollTarget`, so a later plain open doesn't re-scroll.
750
+ const infrastructureScrollTarget = ref<InfrastructureScrollTarget | null>(null)
740
751
  // Environment setup wizard (shared-stacks slice 7): the guided detect → review → preflight →
741
752
  // trial → save flow for a service frame's `docker-compose` provisioning. `environmentWizardOpen`
742
753
  // is the modal flag; `environmentWizardFrameId` preselects the service frame the flow targets
@@ -761,8 +772,14 @@ function createInfraModals(resetHubReturn: ResetHubReturn) {
761
772
  }
762
773
  function closeProviderConnection() {
763
774
  infrastructureOpen.value = false
764
- // Drop any consumed CLI prefill so re-opening the window normally doesn't re-seed the form.
775
+ // Drop any consumed CLI prefill so re-opening the window normally doesn't re-seed the form,
776
+ // and the anchor with it: an unconsumed target (the window was closed before the section
777
+ // rendered) would otherwise scroll the next, unrelated open.
765
778
  k3sSetupPrefill.value = null
779
+ infrastructureScrollTarget.value = null
780
+ }
781
+ function clearInfrastructureScrollTarget() {
782
+ infrastructureScrollTarget.value = null
766
783
  }
767
784
  // Capture a `cat-factory k3s` deep-link (`?infraSetup=local-k3s&…`) on app load: stash the
768
785
  // non-secret connection values, open the Infrastructure window on the Test-environments tab so
@@ -786,6 +803,10 @@ function createInfraModals(resetHubReturn: ResetHubReturn) {
786
803
  }
787
804
  resetHubReturn()
788
805
  infrastructureTab.value = 'environment'
806
+ // The hand-off is about ONE form, so land on it: the Kubernetes section sits below the
807
+ // default-provision picker, far enough down the tab that an operator arriving from the CLI
808
+ // would otherwise have to go looking for the fields it just told them about.
809
+ infrastructureScrollTarget.value = 'kubernetes'
789
810
  infrastructureOpen.value = true
790
811
  for (const key of [
791
812
  'infraSetup',
@@ -840,6 +861,8 @@ function createInfraModals(resetHubReturn: ResetHubReturn) {
840
861
  infrastructureTab,
841
862
  openInfrastructure,
842
863
  k3sSetupPrefill,
864
+ infrastructureScrollTarget,
865
+ clearInfrastructureScrollTarget,
843
866
  consumeK3sSetupDeepLink,
844
867
  environmentWizardOpen,
845
868
  environmentWizardFrameId,
@@ -33,6 +33,15 @@ export type InfrastructureTab =
33
33
  | 'package-registries'
34
34
  | 'capability-credentials'
35
35
 
36
+ /**
37
+ * A SECTION within an Infrastructure tab that a deep link can land the user on, rather than at
38
+ * the top of the tab with the section to hunt for. A closed union rather than a bare string, so
39
+ * the store's setter and the panel that honours it cannot drift apart silently: today's only
40
+ * member is the `kubernetes` provision-type section the `cat-factory k3s` hand-off targets, which
41
+ * sits below the default-provision picker in a tab long enough to need scrolling.
42
+ */
43
+ export type InfrastructureScrollTarget = 'kubernetes'
44
+
36
45
  /** A workspace's provider binding, as exposed to clients (never secret values). */
37
46
  export interface ProviderConnection {
38
47
  /** The runner-backend kind for a runner-pool connection (`manifest` | `kubernetes`). */
@@ -0,0 +1,32 @@
1
+ import type { ConnectionFailureCause } from '@cat-factory/contracts'
2
+
3
+ // A connection test that never got an ANSWER reports the transport failure CLASS as a
4
+ // machine-readable `failureCause` (the backend does not localize prose), and the copy the operator
5
+ // reads lives here. The backend's own English account of the failure, including the remedy it can
6
+ // phrase with the concrete host in it, stays beside the headline as the technical detail.
7
+ //
8
+ // The exhaustive `Record<ConnectionFailureCause, …>` is the tier-2 drift guard, as in
9
+ // `connectionWarnings.ts`: a backend that adds a cause fails this typecheck until the SPA has copy
10
+ // for it, which the typed-key check cannot catch for a runtime-assembled key.
11
+
12
+ /**
13
+ * Failure class → i18n key, or `null` where there is deliberately no headline to render.
14
+ *
15
+ * `unknown` is that case, and it is the reason the values are nullable: the chain was read and
16
+ * matched nothing, so the only honest statement about it is the backend's verbatim account, which
17
+ * is then rendered as the primary line instead of a headline that would have to invent a class.
18
+ */
19
+ export const CONNECTION_FAILURE_CAUSE_KEYS: Record<ConnectionFailureCause, string | null> = {
20
+ refused: 'settings.providerConnection.test.causes.refused',
21
+ dns: 'settings.providerConnection.test.causes.dns',
22
+ timeout: 'settings.providerConnection.test.causes.timeout',
23
+ aborted: 'settings.providerConnection.test.causes.aborted',
24
+ unreachable: 'settings.providerConnection.test.causes.unreachable',
25
+ reset: 'settings.providerConnection.test.causes.reset',
26
+ 'tls-untrusted': 'settings.providerConnection.test.causes.tlsUntrusted',
27
+ 'tls-expired': 'settings.providerConnection.test.causes.tlsExpired',
28
+ 'tls-hostname': 'settings.providerConnection.test.causes.tlsHostname',
29
+ 'tls-protocol': 'settings.providerConnection.test.causes.tlsProtocol',
30
+ 'invalid-header': 'settings.providerConnection.test.causes.invalidHeader',
31
+ unknown: null,
32
+ }
@@ -278,6 +278,11 @@
278
278
  "blurb": "Wo die Coding-Agenten laufen, wenn keine Cloudflare Containers verwendet werden. Wähle einen selbst gehosteten Runner-Pool (deinen eigenen Scheduler) oder einen Kubernetes-Cluster und konfiguriere dann dessen Endpunkt und Zugangsdaten."
279
279
  }
280
280
  },
281
+ "serviceAccountToken": {
282
+ "whitespace": "Dieses Token enthält ein Leerzeichen oder einen Zeilenumbruch, was bei einem Bearer-Token nie vorkommt. Vermutlich wurde es über einen Zeilenumbruch im Terminal hinweg kopiert. Kopieren Sie es erneut als eine einzige ununterbrochene Zeile.",
283
+ "base64Encoded": "Das sieht nach dem Base64-Wert aus dem Feld .data.token des Secrets aus, nicht nach dem Token selbst. Dekodieren Sie ihn zuerst, zum Beispiel mit base64 -d.",
284
+ "notAJwt": "Das sieht nicht nach einem ServiceAccount-Token aus, das ein JWT aus drei durch Punkte getrennten Teilen ist. Prüfen Sie, ob der gesamte Wert kopiert wurde. Ignorieren Sie diesen Hinweis, wenn Ihr Cluster statische Bearer-Token verwendet."
285
+ },
281
286
  "kubernetesEnv": {
282
287
  "label": "Name",
283
288
  "labelPlaceholder": "Preview-Cluster",
@@ -364,6 +369,19 @@
364
369
  "button": "Verbindung testen",
365
370
  "ok": "Verbindung OK",
366
371
  "failed": "Verbindung fehlgeschlagen",
372
+ "causes": {
373
+ "refused": "An dieser Adresse wartet nichts: Die Verbindung wurde abgelehnt.",
374
+ "dns": "Dieser Hostname lässt sich von dieser Installation aus nicht auflösen.",
375
+ "timeout": "Es kam keine Antwort, bevor die Prüfung abgelaufen ist.",
376
+ "aborted": "Die Anfrage wurde abgebrochen, bevor eine Antwort ankam.",
377
+ "unreachable": "Von dieser Installation aus gibt es keine Netzwerkroute zu dieser Adresse.",
378
+ "reset": "Die Verbindung wurde geschlossen, bevor eine Antwort ankam.",
379
+ "tlsUntrusted": "Diese Installation vertraut dem TLS-Zertifikat nicht.",
380
+ "tlsExpired": "Das TLS-Zertifikat liegt außerhalb seines Gültigkeitszeitraums.",
381
+ "tlsHostname": "Das TLS-Zertifikat wurde nicht für diesen Hostnamen ausgestellt.",
382
+ "tlsProtocol": "Der TLS-Handshake ist fehlgeschlagen.",
383
+ "invalidHeader": "Die Anfrage ließ sich nicht erstellen: Ein Zugangsdaten-Wert enthält ein Zeichen, das ein HTTP-Header nicht übertragen kann."
384
+ },
367
385
  "warningsTitle": "Lücken in dieser Konfiguration",
368
386
  "warnings": {
369
387
  "runner_manifest_no_release": "Kein Release-Template: Beim Abbrechen eines Laufs kann dem Pool nicht mitgeteilt werden, dass er seinen Job stoppen soll. Ein verwaister Job belegt seinen Runner, bis der Pool ihn von selbst zurücknimmt.",
@@ -2961,6 +2961,11 @@
2961
2961
  "blurb": "Where the coding agents run when not using Cloudflare Containers. Choose a self-hosted runner pool (your own scheduler) or a Kubernetes cluster, then configure its endpoint and credentials."
2962
2962
  }
2963
2963
  },
2964
+ "serviceAccountToken": {
2965
+ "whitespace": "This token contains a space or line break, which a bearer token never has. It was most likely copied across a wrapped line in your terminal. Re-copy it as a single unbroken line.",
2966
+ "base64Encoded": "This looks like the base64 value from the Secret's .data.token field rather than the token itself. Decode it first, for example with base64 -d.",
2967
+ "notAJwt": "This does not look like a ServiceAccount token, which is a JWT of three dot-separated parts. Check that the whole value was copied. Ignore this if your cluster uses static bearer tokens."
2968
+ },
2964
2969
  "kubernetesEnv": {
2965
2970
  "label": "Name",
2966
2971
  "labelPlaceholder": "Preview cluster",
@@ -3050,6 +3055,19 @@
3050
3055
  "button": "Test connection",
3051
3056
  "ok": "Connection OK",
3052
3057
  "failed": "Connection failed",
3058
+ "causes": {
3059
+ "refused": "Nothing is listening at that address: the connection was refused.",
3060
+ "dns": "That host name does not resolve from this deployment.",
3061
+ "timeout": "No answer arrived before the test timed out.",
3062
+ "aborted": "The request was cancelled before an answer arrived.",
3063
+ "unreachable": "There is no network route to that address from this deployment.",
3064
+ "reset": "The connection was closed before an answer arrived.",
3065
+ "tlsUntrusted": "This deployment does not trust the TLS certificate.",
3066
+ "tlsExpired": "The TLS certificate is outside its validity window.",
3067
+ "tlsHostname": "The TLS certificate was not issued for that host name.",
3068
+ "tlsProtocol": "The TLS handshake failed.",
3069
+ "invalidHeader": "The request could not be built: a credential holds a character an HTTP header cannot carry."
3070
+ },
3053
3071
  "warningsTitle": "Gaps in this configuration",
3054
3072
  "warnings": {
3055
3073
  "runner_manifest_no_release": "No release template: cancelling a run cannot tell the pool to stop its job, so an orphaned job keeps its runner until the pool reclaims it on its own.",
@@ -2744,6 +2744,19 @@
2744
2744
  "button": "Probar conexión",
2745
2745
  "ok": "Conexión correcta",
2746
2746
  "failed": "Falló la conexión",
2747
+ "causes": {
2748
+ "refused": "No hay nada escuchando en esa dirección: la conexión fue rechazada.",
2749
+ "dns": "Ese nombre de host no se resuelve desde esta instalación.",
2750
+ "timeout": "No llegó ninguna respuesta antes de que la prueba agotara su tiempo.",
2751
+ "aborted": "La solicitud se canceló antes de que llegara una respuesta.",
2752
+ "unreachable": "No hay ruta de red hacia esa dirección desde esta instalación.",
2753
+ "reset": "La conexión se cerró antes de que llegara una respuesta.",
2754
+ "tlsUntrusted": "Esta instalación no confía en el certificado TLS.",
2755
+ "tlsExpired": "El certificado TLS está fuera de su periodo de validez.",
2756
+ "tlsHostname": "El certificado TLS no se emitió para ese nombre de host.",
2757
+ "tlsProtocol": "El protocolo de enlace TLS falló.",
2758
+ "invalidHeader": "No se pudo construir la solicitud: una credencial contiene un carácter que una cabecera HTTP no puede transportar."
2759
+ },
2747
2760
  "warningsTitle": "Carencias en esta configuración",
2748
2761
  "warnings": {
2749
2762
  "runner_manifest_no_release": "Sin plantilla de release: al cancelar una ejecución no se puede indicar al pool que detenga su trabajo, así que un trabajo huérfano ocupa su runner hasta que el pool lo recupere por su cuenta.",
@@ -2759,6 +2772,11 @@
2759
2772
  "removed": "Conexión eliminada",
2760
2773
  "removeFailed": "No se pudo eliminar la conexión"
2761
2774
  },
2775
+ "serviceAccountToken": {
2776
+ "whitespace": "Este token contiene un espacio o un salto de línea, algo que nunca ocurre en un token de portador. Lo más probable es que se haya copiado a través de una línea ajustada en la terminal. Vuelve a copiarlo como una única línea continua.",
2777
+ "base64Encoded": "Esto parece el valor en base64 del campo .data.token del Secret, no el token en sí. Descodifícalo primero, por ejemplo con base64 -d.",
2778
+ "notAJwt": "Esto no parece un token de ServiceAccount, que es un JWT de tres partes separadas por puntos. Comprueba que has copiado el valor completo. Ignora este aviso si tu clúster usa tokens de portador estáticos."
2779
+ },
2762
2780
  "kubernetesEnv": {
2763
2781
  "label": "Nombre",
2764
2782
  "labelPlaceholder": "Clúster de vista previa",
@@ -2744,6 +2744,19 @@
2744
2744
  "button": "Tester la connexion",
2745
2745
  "ok": "Connexion réussie",
2746
2746
  "failed": "Échec de la connexion",
2747
+ "causes": {
2748
+ "refused": "Rien n'écoute à cette adresse : la connexion a été refusée.",
2749
+ "dns": "Ce nom d'hôte n'est pas résolu depuis ce déploiement.",
2750
+ "timeout": "Aucune réponse n'est arrivée avant l'expiration du test.",
2751
+ "aborted": "La requête a été annulée avant l'arrivée d'une réponse.",
2752
+ "unreachable": "Aucune route réseau ne mène à cette adresse depuis ce déploiement.",
2753
+ "reset": "La connexion a été fermée avant l'arrivée d'une réponse.",
2754
+ "tlsUntrusted": "Ce déploiement ne fait pas confiance au certificat TLS.",
2755
+ "tlsExpired": "Le certificat TLS est en dehors de sa période de validité.",
2756
+ "tlsHostname": "Le certificat TLS n'a pas été émis pour ce nom d'hôte.",
2757
+ "tlsProtocol": "La négociation TLS a échoué.",
2758
+ "invalidHeader": "La requête n'a pas pu être construite : un identifiant contient un caractère qu'un en-tête HTTP ne peut pas transporter."
2759
+ },
2747
2760
  "warningsTitle": "Lacunes dans cette configuration",
2748
2761
  "warnings": {
2749
2762
  "runner_manifest_no_release": "Aucun modèle de release : annuler une exécution ne permet pas de demander au pool d'arrêter son job, donc un job orphelin occupe son runner jusqu'à ce que le pool le récupère de lui-même.",
@@ -2759,6 +2772,11 @@
2759
2772
  "removed": "Connexion supprimée",
2760
2773
  "removeFailed": "Impossible de supprimer la connexion"
2761
2774
  },
2775
+ "serviceAccountToken": {
2776
+ "whitespace": "Ce jeton contient une espace ou un saut de ligne, ce qu'un jeton porteur ne contient jamais. Il a probablement été copié à cheval sur un retour à la ligne du terminal. Recopiez-le sur une seule ligne ininterrompue.",
2777
+ "base64Encoded": "Ceci ressemble à la valeur base64 du champ .data.token du Secret, et non au jeton lui-même. Décodez-la d'abord, par exemple avec base64 -d.",
2778
+ "notAJwt": "Ceci ne ressemble pas à un jeton de ServiceAccount, qui est un JWT composé de trois parties séparées par des points. Vérifiez que la valeur a été copiée en entier. Ignorez cet avertissement si votre cluster utilise des jetons porteurs statiques."
2779
+ },
2762
2780
  "kubernetesEnv": {
2763
2781
  "label": "Nom",
2764
2782
  "labelPlaceholder": "Cluster de prévisualisation",
@@ -2854,6 +2854,11 @@
2854
2854
  "blurb": "היכן סוכני הקוד רצים כשלא משתמשים ב-Cloudflare Containers. בחר מאגר מריצים בניהול עצמי (מתזמן משלך) או אשכול Kubernetes, ואז הגדר את נקודת הקצה והאישורים שלו."
2855
2855
  }
2856
2856
  },
2857
+ "serviceAccountToken": {
2858
+ "whitespace": "האסימון הזה מכיל רווח או שבירת שורה, דבר שלא קיים באסימון נושא. סביר להניח שהוא הועתק תוך חציית שורה שנשברה במסוף. העתיקו אותו מחדש כשורה אחת רציפה.",
2859
+ "base64Encoded": "זה נראה כמו הערך בבסיס 64 מהשדה .data.token של ה-Secret, ולא כמו האסימון עצמו. פענחו אותו קודם, למשל באמצעות base64 -d.",
2860
+ "notAJwt": "זה לא נראה כמו אסימון ServiceAccount, שהוא JWT בן שלושה חלקים המופרדים בנקודות. ודאו שהערך הועתק במלואו. התעלמו מההודעה אם האשכול שלכם משתמש באסימוני נושא סטטיים."
2861
+ },
2857
2862
  "kubernetesEnv": {
2858
2863
  "label": "שם",
2859
2864
  "labelPlaceholder": "אשכול תצוגה מקדימה",
@@ -2940,6 +2945,19 @@
2940
2945
  "button": "בדוק חיבור",
2941
2946
  "ok": "החיבור תקין",
2942
2947
  "failed": "החיבור נכשל",
2948
+ "causes": {
2949
+ "refused": "אין דבר שמאזין בכתובת הזו: החיבור נדחה.",
2950
+ "dns": "שם המחשב המארח הזה אינו נפתר מהפריסה הזו.",
2951
+ "timeout": "לא הגיעה תשובה לפני שתם הזמן שהוקצב לבדיקה.",
2952
+ "aborted": "הבקשה בוטלה לפני שהגיעה תשובה.",
2953
+ "unreachable": "אין נתיב רשת לכתובת הזו מהפריסה הזו.",
2954
+ "reset": "החיבור נסגר לפני שהגיעה תשובה.",
2955
+ "tlsUntrusted": "הפריסה הזו אינה סומכת על אישור ה-TLS.",
2956
+ "tlsExpired": "אישור ה-TLS נמצא מחוץ לתקופת התוקף שלו.",
2957
+ "tlsHostname": "אישור ה-TLS לא הונפק עבור שם המחשב המארח הזה.",
2958
+ "tlsProtocol": "לחיצת היד של TLS נכשלה.",
2959
+ "invalidHeader": "לא ניתן היה לבנות את הבקשה: פרטי גישה מכילים תו שכותרת HTTP אינה יכולה לשאת."
2960
+ },
2943
2961
  "warningsTitle": "פערים בתצורה הזו",
2944
2962
  "warnings": {
2945
2963
  "runner_manifest_no_release": "אין תבנית שחרור: ביטול הרצה לא יכול להודיע למאגר להפסיק את המשימה שלו, ולכן משימה יתומה תופסת את הראנר שלה עד שהמאגר משחרר אותה בעצמו.",
@@ -278,6 +278,11 @@
278
278
  "blurb": "Dove vengono eseguiti gli agenti di coding quando non si usano i Cloudflare Containers. Scegli un pool di runner self-hosted (il tuo scheduler) o un cluster Kubernetes, poi configura il suo endpoint e le sue credenziali."
279
279
  }
280
280
  },
281
+ "serviceAccountToken": {
282
+ "whitespace": "Questo token contiene uno spazio o un'interruzione di riga, cosa che un token bearer non ha mai. Probabilmente è stato copiato a cavallo di una riga mandata a capo nel terminale. Ricopialo come un'unica riga ininterrotta.",
283
+ "base64Encoded": "Sembra il valore base64 del campo .data.token del Secret, non il token vero e proprio. Decodificalo prima, ad esempio con base64 -d.",
284
+ "notAJwt": "Non sembra un token di ServiceAccount, che è un JWT composto da tre parti separate da punti. Verifica di aver copiato il valore completo. Ignora questo avviso se il tuo cluster usa token bearer statici."
285
+ },
281
286
  "kubernetesEnv": {
282
287
  "label": "Nome",
283
288
  "labelPlaceholder": "Cluster di anteprima",
@@ -364,6 +369,19 @@
364
369
  "button": "Testa la connessione",
365
370
  "ok": "Connessione OK",
366
371
  "failed": "Connessione fallita",
372
+ "causes": {
373
+ "refused": "Nessuno è in ascolto a quell'indirizzo: la connessione è stata rifiutata.",
374
+ "dns": "Quel nome host non viene risolto da questa installazione.",
375
+ "timeout": "Nessuna risposta è arrivata prima della scadenza del test.",
376
+ "aborted": "La richiesta è stata annullata prima che arrivasse una risposta.",
377
+ "unreachable": "Non esiste una rotta di rete verso quell'indirizzo da questa installazione.",
378
+ "reset": "La connessione è stata chiusa prima che arrivasse una risposta.",
379
+ "tlsUntrusted": "Questa installazione non considera attendibile il certificato TLS.",
380
+ "tlsExpired": "Il certificato TLS è fuori dal suo periodo di validità.",
381
+ "tlsHostname": "Il certificato TLS non è stato emesso per quel nome host.",
382
+ "tlsProtocol": "L'handshake TLS è fallito.",
383
+ "invalidHeader": "Non è stato possibile costruire la richiesta: una credenziale contiene un carattere che un header HTTP non può trasportare."
384
+ },
367
385
  "warningsTitle": "Lacune in questa configurazione",
368
386
  "warnings": {
369
387
  "runner_manifest_no_release": "Nessun template di release: annullare un'esecuzione non può dire al pool di fermare il suo job, quindi un job orfano tiene occupato il suo runner finché il pool non lo recupera da solo.",
@@ -2854,6 +2854,11 @@
2854
2854
  "blurb": "Cloudflare Containers を使用しない場合にコーディングエージェントが実行される場所。セルフホストのランナープール (自前のスケジューラー) または Kubernetes クラスターを選択し、エンドポイントと認証情報を構成します。"
2855
2855
  }
2856
2856
  },
2857
+ "serviceAccountToken": {
2858
+ "whitespace": "このトークンにはスペースまたは改行が含まれています。ベアラートークンには本来含まれないもので、ターミナルで折り返された行をまたいでコピーした可能性が高いです。改行のない 1 行としてコピーし直してください。",
2859
+ "base64Encoded": "これはトークン自体ではなく、Secret の .data.token フィールドの Base64 値のように見えます。先に base64 -d などでデコードしてください。",
2860
+ "notAJwt": "これは ServiceAccount トークン (ピリオドで区切られた 3 つの部分から成る JWT) には見えません。値全体をコピーしたか確認してください。クラスターが静的なベアラートークンを使用している場合は無視して構いません。"
2861
+ },
2857
2862
  "kubernetesEnv": {
2858
2863
  "label": "名前",
2859
2864
  "labelPlaceholder": "プレビュークラスター",
@@ -2940,6 +2945,19 @@
2940
2945
  "button": "接続をテスト",
2941
2946
  "ok": "接続 OK",
2942
2947
  "failed": "接続に失敗しました",
2948
+ "causes": {
2949
+ "refused": "そのアドレスでは何も待ち受けていません。接続が拒否されました。",
2950
+ "dns": "そのホスト名はこのデプロイからは解決できません。",
2951
+ "timeout": "テストがタイムアウトするまでに応答がありませんでした。",
2952
+ "aborted": "応答が届く前にリクエストがキャンセルされました。",
2953
+ "unreachable": "このデプロイからそのアドレスへのネットワーク経路がありません。",
2954
+ "reset": "応答が届く前に接続が閉じられました。",
2955
+ "tlsUntrusted": "このデプロイは TLS 証明書を信頼していません。",
2956
+ "tlsExpired": "TLS 証明書は有効期間を外れています。",
2957
+ "tlsHostname": "TLS 証明書はそのホスト名向けに発行されていません。",
2958
+ "tlsProtocol": "TLS ハンドシェイクに失敗しました。",
2959
+ "invalidHeader": "リクエストを組み立てられませんでした。認証情報に HTTP ヘッダーが扱えない文字が含まれています。"
2960
+ },
2943
2961
  "warningsTitle": "この設定の不足点",
2944
2962
  "warnings": {
2945
2963
  "runner_manifest_no_release": "release テンプレートがありません。実行をキャンセルしてもプールにジョブの停止を伝えられないため、取り残されたジョブはプールが自分で回収するまでランナーを占有し続けます。",
@@ -2744,6 +2744,19 @@
2744
2744
  "button": "Przetestuj połączenie",
2745
2745
  "ok": "Połączenie poprawne",
2746
2746
  "failed": "Połączenie nieudane",
2747
+ "causes": {
2748
+ "refused": "Nic nie nasłuchuje pod tym adresem: połączenie zostało odrzucone.",
2749
+ "dns": "Ta nazwa hosta nie jest rozwiązywana z tego wdrożenia.",
2750
+ "timeout": "Nie nadeszła żadna odpowiedź przed upływem czasu testu.",
2751
+ "aborted": "Żądanie zostało anulowane, zanim nadeszła odpowiedź.",
2752
+ "unreachable": "Z tego wdrożenia nie ma trasy sieciowej do tego adresu.",
2753
+ "reset": "Połączenie zostało zamknięte, zanim nadeszła odpowiedź.",
2754
+ "tlsUntrusted": "To wdrożenie nie ufa certyfikatowi TLS.",
2755
+ "tlsExpired": "Certyfikat TLS jest poza okresem swojej ważności.",
2756
+ "tlsHostname": "Certyfikat TLS nie został wystawiony dla tej nazwy hosta.",
2757
+ "tlsProtocol": "Uzgadnianie TLS nie powiodło się.",
2758
+ "invalidHeader": "Nie udało się zbudować żądania: dane uwierzytelniające zawierają znak, którego nagłówek HTTP nie może przenieść."
2759
+ },
2747
2760
  "warningsTitle": "Braki w tej konfiguracji",
2748
2761
  "warnings": {
2749
2762
  "runner_manifest_no_release": "Brak szablonu release: anulowanie przebiegu nie może przekazać puli, że ma zatrzymać zadanie, więc osierocone zadanie zajmuje swojego runnera, dopóki pula sama go nie odzyska.",
@@ -2759,6 +2772,11 @@
2759
2772
  "removed": "Połączenie usunięte",
2760
2773
  "removeFailed": "Nie udało się usunąć połączenia"
2761
2774
  },
2775
+ "serviceAccountToken": {
2776
+ "whitespace": "Ten token zawiera spację lub znak nowego wiersza, czego token typu bearer nigdy nie ma. Najprawdopodobniej został skopiowany przez zawinięty wiersz w terminalu. Skopiuj go ponownie jako jeden nieprzerwany wiersz.",
2777
+ "base64Encoded": "To wygląda na wartość base64 z pola .data.token obiektu Secret, a nie na sam token. Najpierw ją zdekoduj, na przykład poleceniem base64 -d.",
2778
+ "notAJwt": "To nie wygląda na token ServiceAccount, który jest tokenem JWT złożonym z trzech części oddzielonych kropkami. Sprawdź, czy skopiowano całą wartość. Zignoruj to ostrzeżenie, jeśli Twój klaster używa statycznych tokenów bearer."
2779
+ },
2762
2780
  "kubernetesEnv": {
2763
2781
  "label": "Nazwa",
2764
2782
  "labelPlaceholder": "Klaster podglądu",
@@ -2854,6 +2854,11 @@
2854
2854
  "blurb": "Cloudflare Containers kullanılmadığında kodlama ajanlarının çalıştığı yer. Bir self-hosted runner havuzu (kendi zamanlayıcınız) veya bir Kubernetes kümesi seçin, ardından uç noktasını ve kimlik bilgilerini yapılandırın."
2855
2855
  }
2856
2856
  },
2857
+ "serviceAccountToken": {
2858
+ "whitespace": "Bu belirteç bir boşluk ya da satır sonu içeriyor; taşıyıcı belirteçlerde bu asla bulunmaz. Büyük olasılıkla terminalde kaydırılmış bir satırın üzerinden kopyalanmış. Tek ve kesintisiz bir satır olarak yeniden kopyalayın.",
2859
+ "base64Encoded": "Bu, belirtecin kendisi değil, Secret nesnesinin .data.token alanındaki base64 değeri gibi görünüyor. Önce çözün, örneğin base64 -d ile.",
2860
+ "notAJwt": "Bu, noktayla ayrılmış üç bölümden oluşan bir JWT olan ServiceAccount belirtecine benzemiyor. Değerin tamamının kopyalandığını doğrulayın. Kümeniz statik taşıyıcı belirteç kullanıyorsa bu uyarıyı yoksayın."
2861
+ },
2857
2862
  "kubernetesEnv": {
2858
2863
  "label": "Ad",
2859
2864
  "labelPlaceholder": "Önizleme kümesi",
@@ -2940,6 +2945,19 @@
2940
2945
  "button": "Bağlantıyı test et",
2941
2946
  "ok": "Bağlantı tamam",
2942
2947
  "failed": "Bağlantı başarısız",
2948
+ "causes": {
2949
+ "refused": "Bu adreste dinleyen bir şey yok: bağlantı reddedildi.",
2950
+ "dns": "Bu ana bilgisayar adı bu kurulumdan çözümlenemiyor.",
2951
+ "timeout": "Test zaman aşımına uğrayana kadar yanıt gelmedi.",
2952
+ "aborted": "Yanıt gelmeden önce istek iptal edildi.",
2953
+ "unreachable": "Bu kurulumdan bu adrese ağ yolu yok.",
2954
+ "reset": "Yanıt gelmeden önce bağlantı kapatıldı.",
2955
+ "tlsUntrusted": "Bu kurulum TLS sertifikasına güvenmiyor.",
2956
+ "tlsExpired": "TLS sertifikası geçerlilik süresinin dışında.",
2957
+ "tlsHostname": "TLS sertifikası bu ana bilgisayar adı için verilmemiş.",
2958
+ "tlsProtocol": "TLS el sıkışması başarısız oldu.",
2959
+ "invalidHeader": "İstek oluşturulamadı: kimlik bilgisi, bir HTTP başlığının taşıyamayacağı bir karakter içeriyor."
2960
+ },
2943
2961
  "warningsTitle": "Bu yapılandırmadaki eksikler",
2944
2962
  "warnings": {
2945
2963
  "runner_manifest_no_release": "Release şablonu yok: bir çalıştırma iptal edildiğinde havuza işi durdurması söylenemez, bu yüzden sahipsiz kalan iş, havuz onu kendi kendine geri alana kadar runner'ını meşgul tutar.",
@@ -2744,6 +2744,19 @@
2744
2744
  "button": "Перевірити підключення",
2745
2745
  "ok": "Підключення в порядку",
2746
2746
  "failed": "Не вдалося підключитися",
2747
+ "causes": {
2748
+ "refused": "За цією адресою ніщо не слухає: з'єднання відхилено.",
2749
+ "dns": "Це ім'я хоста не розпізнається з цього розгортання.",
2750
+ "timeout": "Відповідь не надійшла до завершення часу перевірки.",
2751
+ "aborted": "Запит скасовано, перш ніж надійшла відповідь.",
2752
+ "unreachable": "З цього розгортання немає мережевого маршруту до цієї адреси.",
2753
+ "reset": "З'єднання закрито, перш ніж надійшла відповідь.",
2754
+ "tlsUntrusted": "Це розгортання не довіряє сертифікату TLS.",
2755
+ "tlsExpired": "Сертифікат TLS поза строком дії.",
2756
+ "tlsHostname": "Сертифікат TLS видано не для цього імені хоста.",
2757
+ "tlsProtocol": "Рукостискання TLS не вдалося.",
2758
+ "invalidHeader": "Не вдалося сформувати запит: дані для входу містять символ, який заголовок HTTP не може передати."
2759
+ },
2747
2760
  "warningsTitle": "Прогалини в цій конфігурації",
2748
2761
  "warnings": {
2749
2762
  "runner_manifest_no_release": "Немає шаблону release: скасування запуску не може повідомити пулу, що завдання треба зупинити, тож осиротіле завдання займає свій раннер, доки пул не забере його сам.",
@@ -2759,6 +2772,11 @@
2759
2772
  "removed": "Підключення видалено",
2760
2773
  "removeFailed": "Не вдалося видалити підключення"
2761
2774
  },
2775
+ "serviceAccountToken": {
2776
+ "whitespace": "Цей токен містить пробіл або розрив рядка, чого в токені-носії ніколи не буває. Найімовірніше, його скопійовано через перенесений рядок у терміналі. Скопіюйте його ще раз одним суцільним рядком.",
2777
+ "base64Encoded": "Це схоже на значення в base64 з поля .data.token об'єкта Secret, а не на сам токен. Спершу розкодуйте його, наприклад за допомогою base64 -d.",
2778
+ "notAJwt": "Це не схоже на токен ServiceAccount, який є JWT із трьох частин, розділених крапками. Переконайтеся, що скопійовано все значення. Знехтуйте цим попередженням, якщо ваш кластер використовує статичні токени-носії."
2779
+ },
2762
2780
  "kubernetesEnv": {
2763
2781
  "label": "Назва",
2764
2782
  "labelPlaceholder": "Кластер попереднього перегляду",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cat-factory/app",
3
- "version": "0.261.0",
3
+ "version": "0.261.1",
4
4
  "description": "Reusable Nuxt layer for the Agent Architecture Board SPA (components, stores, composables, pages). Consume it from a thin deployment app via `extends: ['@cat-factory/app']` and point it at your backend with NUXT_PUBLIC_API_BASE. See deploy/frontend for an example.",
5
5
  "repository": {
6
6
  "type": "git",
@@ -40,7 +40,7 @@
40
40
  "valibot": "^1.4.2",
41
41
  "vue": "3.5.41",
42
42
  "wretch": "^3.0.9",
43
- "@cat-factory/contracts": "0.292.0"
43
+ "@cat-factory/contracts": "0.292.1"
44
44
  },
45
45
  "devDependencies": {
46
46
  "@toad-contracts/testing": "0.3.2",