@cat-factory/app 0.101.1 → 0.103.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/app/components/layout/AccountDeploymentSettings.vue +5 -1
- package/app/components/layout/AccountModelPolicySettings.vue +247 -0
- package/app/components/layout/AccountTeamSettings.vue +11 -0
- package/app/components/settings/SharedStacksPanel.vue +102 -1
- package/app/composables/api/sharedStacks.ts +5 -1
- package/app/composables/usePipelineErrorToast.ts +2 -0
- package/app/stores/sharedStacks.ts +11 -2
- package/app/types/accountSettings.ts +5 -0
- package/app/types/sharedStacks.ts +2 -0
- package/i18n/locales/de.json +72 -0
- package/i18n/locales/en.json +72 -0
- package/i18n/locales/es.json +72 -0
- package/i18n/locales/fr.json +72 -0
- package/i18n/locales/he.json +72 -0
- package/i18n/locales/it.json +72 -0
- package/i18n/locales/ja.json +72 -0
- package/i18n/locales/pl.json +72 -0
- package/i18n/locales/tr.json +72 -0
- package/i18n/locales/uk.json +72 -0
- package/package.json +2 -2
|
@@ -129,7 +129,11 @@ async function saveStorage() {
|
|
|
129
129
|
...(cs.forcePathStyle ? { forcePathStyle: true } : {}),
|
|
130
130
|
}
|
|
131
131
|
}
|
|
132
|
-
|
|
132
|
+
// `config` fully REPLACES the stored non-secret config, so carry the rest forward
|
|
133
|
+
// (e.g. the model-family policy) — only `contentStorage` is edited here.
|
|
134
|
+
const input: Parameters<typeof store.save>[1] = {
|
|
135
|
+
config: { ...store.view?.config, contentStorage: config },
|
|
136
|
+
}
|
|
133
137
|
if (backend === 's3') {
|
|
134
138
|
const id = cs.accessKeyId.trim()
|
|
135
139
|
const key = cs.secretAccessKey.trim()
|
|
@@ -0,0 +1,247 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import { computed, onMounted, ref, watch } from 'vue'
|
|
3
|
+
import { MODEL_FAMILY_POLICY_PRESETS } from '@cat-factory/contracts'
|
|
4
|
+
import type {
|
|
5
|
+
AccountRegion,
|
|
6
|
+
ModelFamily,
|
|
7
|
+
ModelFamilyPolicy,
|
|
8
|
+
ModelPolicyMode,
|
|
9
|
+
} from '~/types/accountSettings'
|
|
10
|
+
|
|
11
|
+
// Account-wide model-family allow/block policy (admin only). Constrains which LLM families
|
|
12
|
+
// the account's teams may run; a residency-guaranteed route (`trustedProviders`) can exempt
|
|
13
|
+
// an otherwise-blocked family. Region-grouped built-in presets are one-click templates the
|
|
14
|
+
// admin applies into the editable policy. Mounted only where the deployment supports it
|
|
15
|
+
// (hosted / mothership — never plain local mode), gated by the parent on the infra flag.
|
|
16
|
+
const props = defineProps<{ accountId: string }>()
|
|
17
|
+
|
|
18
|
+
const store = useAccountSettingsStore()
|
|
19
|
+
const toast = useToast()
|
|
20
|
+
const { t } = useI18n()
|
|
21
|
+
|
|
22
|
+
// The family / region / mode / trusted-provider domains, pinned to the contract unions so a
|
|
23
|
+
// new enum member fails the typecheck here (via the exhaustive label maps below).
|
|
24
|
+
const FAMILIES = [
|
|
25
|
+
'claude',
|
|
26
|
+
'openai',
|
|
27
|
+
'gemini',
|
|
28
|
+
'llama',
|
|
29
|
+
'qwen',
|
|
30
|
+
'kimi',
|
|
31
|
+
'deepseek',
|
|
32
|
+
'glm',
|
|
33
|
+
] as const satisfies readonly ModelFamily[]
|
|
34
|
+
const REGIONS = ['usa', 'europe', 'china', 'other'] as const satisfies readonly AccountRegion[]
|
|
35
|
+
const MODES = ['off', 'blocklist', 'allowlist'] as const satisfies readonly ModelPolicyMode[]
|
|
36
|
+
const TRUSTED_PROVIDERS = ['bedrock'] as const
|
|
37
|
+
|
|
38
|
+
// Exhaustive enum→key maps (drift guard): every member resolves to a static literal `t()`
|
|
39
|
+
// key, so adding a family/region/mode without a label fails the typecheck on the Record.
|
|
40
|
+
const familyLabels = computed<Record<ModelFamily, string>>(() => ({
|
|
41
|
+
claude: t('settings.modelPolicy.families.claude'),
|
|
42
|
+
openai: t('settings.modelPolicy.families.openai'),
|
|
43
|
+
gemini: t('settings.modelPolicy.families.gemini'),
|
|
44
|
+
llama: t('settings.modelPolicy.families.llama'),
|
|
45
|
+
qwen: t('settings.modelPolicy.families.qwen'),
|
|
46
|
+
kimi: t('settings.modelPolicy.families.kimi'),
|
|
47
|
+
deepseek: t('settings.modelPolicy.families.deepseek'),
|
|
48
|
+
glm: t('settings.modelPolicy.families.glm'),
|
|
49
|
+
}))
|
|
50
|
+
const regionLabels = computed<Record<AccountRegion, string>>(() => ({
|
|
51
|
+
usa: t('settings.modelPolicy.regions.usa'),
|
|
52
|
+
europe: t('settings.modelPolicy.regions.europe'),
|
|
53
|
+
china: t('settings.modelPolicy.regions.china'),
|
|
54
|
+
other: t('settings.modelPolicy.regions.other'),
|
|
55
|
+
}))
|
|
56
|
+
const modeLabels = computed<Record<ModelPolicyMode, string>>(() => ({
|
|
57
|
+
off: t('settings.modelPolicy.modes.off'),
|
|
58
|
+
blocklist: t('settings.modelPolicy.modes.blocklist'),
|
|
59
|
+
allowlist: t('settings.modelPolicy.modes.allowlist'),
|
|
60
|
+
}))
|
|
61
|
+
const providerLabels = computed<Record<(typeof TRUSTED_PROVIDERS)[number], string>>(() => ({
|
|
62
|
+
bedrock: t('settings.modelPolicy.providers.bedrock'),
|
|
63
|
+
}))
|
|
64
|
+
|
|
65
|
+
const modeItems = computed(() => MODES.map((m) => ({ label: modeLabels.value[m], value: m })))
|
|
66
|
+
const regionItems = computed(() => REGIONS.map((r) => ({ label: regionLabels.value[r], value: r })))
|
|
67
|
+
|
|
68
|
+
// Editable local state (hydrated from the stored policy).
|
|
69
|
+
const mode = ref<ModelPolicyMode>('off')
|
|
70
|
+
const region = ref<AccountRegion>('other')
|
|
71
|
+
const families = ref<ModelFamily[]>([])
|
|
72
|
+
const trusted = ref<string[]>([])
|
|
73
|
+
const saving = ref(false)
|
|
74
|
+
|
|
75
|
+
function hydrate() {
|
|
76
|
+
const p = store.view?.config?.modelPolicy
|
|
77
|
+
mode.value = p?.mode ?? 'off'
|
|
78
|
+
region.value = p?.region ?? 'other'
|
|
79
|
+
families.value = [...(p?.families ?? [])]
|
|
80
|
+
trusted.value = [...(p?.trustedProviders ?? [])]
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// The presets offered for the currently-selected region.
|
|
84
|
+
const regionPresets = computed(() =>
|
|
85
|
+
MODEL_FAMILY_POLICY_PRESETS.filter((preset) => preset.region === region.value),
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
function toggleFamily(fam: ModelFamily) {
|
|
89
|
+
families.value = families.value.includes(fam)
|
|
90
|
+
? families.value.filter((x) => x !== fam)
|
|
91
|
+
: [...families.value, fam]
|
|
92
|
+
}
|
|
93
|
+
function toggleTrusted(provider: string) {
|
|
94
|
+
trusted.value = trusted.value.includes(provider)
|
|
95
|
+
? trusted.value.filter((x) => x !== provider)
|
|
96
|
+
: [...trusted.value, provider]
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function applyPreset(policy: ModelFamilyPolicy) {
|
|
100
|
+
mode.value = policy.mode
|
|
101
|
+
families.value = [...policy.families]
|
|
102
|
+
trusted.value = [...policy.trustedProviders]
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
onMounted(async () => {
|
|
106
|
+
// The sibling deployment-settings panel loads the store on mount too; guard against a
|
|
107
|
+
// double-load being unnecessary by only loading when the view isn't present yet.
|
|
108
|
+
if (!store.view && store.available !== false) {
|
|
109
|
+
try {
|
|
110
|
+
await store.load(props.accountId)
|
|
111
|
+
} catch {
|
|
112
|
+
// The deployment-settings panel surfaces the load error; stay quiet here.
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
hydrate()
|
|
116
|
+
})
|
|
117
|
+
watch(() => store.view, hydrate)
|
|
118
|
+
|
|
119
|
+
async function save() {
|
|
120
|
+
const policy: ModelFamilyPolicy = {
|
|
121
|
+
mode: mode.value,
|
|
122
|
+
// Families are irrelevant when the policy is off; keep them so a later re-enable
|
|
123
|
+
// restores the admin's selection rather than silently emptying it.
|
|
124
|
+
families: families.value,
|
|
125
|
+
trustedProviders: trusted.value,
|
|
126
|
+
region: region.value,
|
|
127
|
+
}
|
|
128
|
+
saving.value = true
|
|
129
|
+
try {
|
|
130
|
+
// `config` fully replaces the stored non-secret config, so carry the rest forward.
|
|
131
|
+
await store.save(props.accountId, { config: { ...store.view?.config, modelPolicy: policy } })
|
|
132
|
+
toast.add({
|
|
133
|
+
title: t('settings.modelPolicy.saved'),
|
|
134
|
+
icon: 'i-lucide-check',
|
|
135
|
+
color: 'success',
|
|
136
|
+
})
|
|
137
|
+
} catch (e) {
|
|
138
|
+
toast.add({
|
|
139
|
+
title: t('settings.modelPolicy.saveFailed'),
|
|
140
|
+
description: e instanceof Error ? e.message : String(e),
|
|
141
|
+
color: 'error',
|
|
142
|
+
})
|
|
143
|
+
} finally {
|
|
144
|
+
saving.value = false
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
</script>
|
|
148
|
+
|
|
149
|
+
<template>
|
|
150
|
+
<section
|
|
151
|
+
v-if="store.available !== false"
|
|
152
|
+
data-testid="account-model-policy"
|
|
153
|
+
class="space-y-3 border-t border-slate-800 pt-6"
|
|
154
|
+
>
|
|
155
|
+
<div>
|
|
156
|
+
<h4 class="text-sm font-semibold text-slate-200">{{ t('settings.modelPolicy.title') }}</h4>
|
|
157
|
+
<p class="text-[11px] text-slate-400">{{ t('settings.modelPolicy.description') }}</p>
|
|
158
|
+
</div>
|
|
159
|
+
|
|
160
|
+
<!-- Region + apply-preset templates -->
|
|
161
|
+
<div class="space-y-2">
|
|
162
|
+
<label class="text-[11px] font-medium text-slate-300">
|
|
163
|
+
{{ t('settings.modelPolicy.regionLabel') }}
|
|
164
|
+
</label>
|
|
165
|
+
<div class="grid grid-cols-1 gap-2 sm:grid-cols-2">
|
|
166
|
+
<USelect v-model="region" :items="regionItems" value-key="value" size="sm" />
|
|
167
|
+
</div>
|
|
168
|
+
<div v-if="regionPresets.length" class="flex flex-wrap items-center gap-2">
|
|
169
|
+
<span class="text-[11px] text-slate-400">{{ t('settings.modelPolicy.applyPreset') }}</span>
|
|
170
|
+
<UButton
|
|
171
|
+
v-for="preset in regionPresets"
|
|
172
|
+
:key="preset.id"
|
|
173
|
+
color="neutral"
|
|
174
|
+
variant="subtle"
|
|
175
|
+
size="xs"
|
|
176
|
+
icon="i-lucide-wand-2"
|
|
177
|
+
:title="t(`settings.modelPolicy.presets.${preset.id}.description`)"
|
|
178
|
+
@click="applyPreset(preset.policy)"
|
|
179
|
+
>
|
|
180
|
+
{{ t(`settings.modelPolicy.presets.${preset.id}.label`) }}
|
|
181
|
+
</UButton>
|
|
182
|
+
</div>
|
|
183
|
+
</div>
|
|
184
|
+
|
|
185
|
+
<!-- Mode -->
|
|
186
|
+
<div class="space-y-2">
|
|
187
|
+
<label class="text-[11px] font-medium text-slate-300">
|
|
188
|
+
{{ t('settings.modelPolicy.modeLabel') }}
|
|
189
|
+
</label>
|
|
190
|
+
<div class="grid grid-cols-1 gap-2 sm:grid-cols-2">
|
|
191
|
+
<USelect v-model="mode" :items="modeItems" value-key="value" size="sm" />
|
|
192
|
+
</div>
|
|
193
|
+
</div>
|
|
194
|
+
|
|
195
|
+
<!-- Families -->
|
|
196
|
+
<div v-if="mode !== 'off'" class="space-y-2">
|
|
197
|
+
<label class="text-[11px] font-medium text-slate-300">
|
|
198
|
+
{{
|
|
199
|
+
mode === 'blocklist'
|
|
200
|
+
? t('settings.modelPolicy.familiesBlockLabel')
|
|
201
|
+
: t('settings.modelPolicy.familiesAllowLabel')
|
|
202
|
+
}}
|
|
203
|
+
</label>
|
|
204
|
+
<div class="grid grid-cols-2 gap-1 sm:grid-cols-4">
|
|
205
|
+
<UCheckbox
|
|
206
|
+
v-for="fam in FAMILIES"
|
|
207
|
+
:key="fam"
|
|
208
|
+
:model-value="families.includes(fam)"
|
|
209
|
+
:label="familyLabels[fam]"
|
|
210
|
+
size="sm"
|
|
211
|
+
@update:model-value="toggleFamily(fam)"
|
|
212
|
+
/>
|
|
213
|
+
</div>
|
|
214
|
+
</div>
|
|
215
|
+
|
|
216
|
+
<!-- Trusted (residency-guaranteed) routes -->
|
|
217
|
+
<div v-if="mode !== 'off'" class="space-y-2">
|
|
218
|
+
<label class="text-[11px] font-medium text-slate-300">
|
|
219
|
+
{{ t('settings.modelPolicy.trustedLabel') }}
|
|
220
|
+
</label>
|
|
221
|
+
<p class="text-[11px] text-slate-400">{{ t('settings.modelPolicy.trustedHint') }}</p>
|
|
222
|
+
<div class="grid grid-cols-2 gap-1 sm:grid-cols-4">
|
|
223
|
+
<UCheckbox
|
|
224
|
+
v-for="provider in TRUSTED_PROVIDERS"
|
|
225
|
+
:key="provider"
|
|
226
|
+
:model-value="trusted.includes(provider)"
|
|
227
|
+
:label="providerLabels[provider]"
|
|
228
|
+
size="sm"
|
|
229
|
+
@update:model-value="toggleTrusted(provider)"
|
|
230
|
+
/>
|
|
231
|
+
</div>
|
|
232
|
+
</div>
|
|
233
|
+
|
|
234
|
+
<div class="flex gap-2">
|
|
235
|
+
<UButton
|
|
236
|
+
color="primary"
|
|
237
|
+
size="xs"
|
|
238
|
+
icon="i-lucide-save"
|
|
239
|
+
:loading="saving"
|
|
240
|
+
data-testid="account-model-policy-save"
|
|
241
|
+
@click="save"
|
|
242
|
+
>
|
|
243
|
+
{{ t('common.save') }}
|
|
244
|
+
</UButton>
|
|
245
|
+
</div>
|
|
246
|
+
</section>
|
|
247
|
+
</template>
|
|
@@ -4,6 +4,7 @@ import { apiErrorEnvelope } from '~/composables/api/errors'
|
|
|
4
4
|
import type { AccountRole } from '~/types/domain'
|
|
5
5
|
import type { InvitationStatus } from '@cat-factory/contracts'
|
|
6
6
|
import AccountDeploymentSettings from '~/components/layout/AccountDeploymentSettings.vue'
|
|
7
|
+
import AccountModelPolicySettings from '~/components/layout/AccountModelPolicySettings.vue'
|
|
7
8
|
import SecretInput from '~/components/common/SecretInput.vue'
|
|
8
9
|
|
|
9
10
|
// Team settings for an org account: the member roster (with combinable admin /
|
|
@@ -13,11 +14,16 @@ import SecretInput from '~/components/common/SecretInput.vue'
|
|
|
13
14
|
const props = defineProps<{ accountId: string }>()
|
|
14
15
|
|
|
15
16
|
const accounts = useAccountsStore()
|
|
17
|
+
const auth = useAuthStore()
|
|
16
18
|
const toast = useToast()
|
|
17
19
|
const { t, te } = useI18n()
|
|
18
20
|
const { confirmAction, toastDone } = useConfirmAction()
|
|
19
21
|
const busy = ref(false)
|
|
20
22
|
|
|
23
|
+
// The account-wide model-family policy is a hosted / mothership-only control (the backend
|
|
24
|
+
// reports `false` in plain local mode, where there is no account admin to govern).
|
|
25
|
+
const modelPolicySupported = computed(() => auth.infrastructure?.modelPolicy?.supported ?? false)
|
|
26
|
+
|
|
21
27
|
const ROLE_ITEMS = computed<{ label: string; value: AccountRole }[]>(() => [
|
|
22
28
|
{ label: t('layout.accountTeam.roles.admin'), value: 'admin' },
|
|
23
29
|
{ label: t('layout.accountTeam.roles.developer'), value: 'developer' },
|
|
@@ -326,5 +332,10 @@ async function disconnectEmail() {
|
|
|
326
332
|
<section v-if="isAdmin">
|
|
327
333
|
<AccountDeploymentSettings :account-id="accountId" />
|
|
328
334
|
</section>
|
|
335
|
+
|
|
336
|
+
<!-- account-wide model-family allow/block policy (admin-only; hosted/mothership only) -->
|
|
337
|
+
<section v-if="isAdmin && modelPolicySupported">
|
|
338
|
+
<AccountModelPolicySettings :account-id="accountId" />
|
|
339
|
+
</section>
|
|
329
340
|
</div>
|
|
330
341
|
</template>
|
|
@@ -6,7 +6,11 @@
|
|
|
6
6
|
// they succeed only on the local facade (elsewhere the backend returns a clear error surfaced as
|
|
7
7
|
// a toast). Renders inline inside the Infrastructure window's "Shared stacks" tab.
|
|
8
8
|
import { computed, reactive, ref } from 'vue'
|
|
9
|
-
import type {
|
|
9
|
+
import type {
|
|
10
|
+
SharedStack,
|
|
11
|
+
SharedStackRecommendation,
|
|
12
|
+
SharedStackStatus,
|
|
13
|
+
} from '~/types/sharedStacks'
|
|
10
14
|
|
|
11
15
|
const { t } = useI18n()
|
|
12
16
|
const store = useSharedStacksStore()
|
|
@@ -16,6 +20,7 @@ const { confirmAction, toastDone } = useConfirmAction()
|
|
|
16
20
|
const stacks = computed(() => store.stacks)
|
|
17
21
|
const busyId = ref<string | null>(null)
|
|
18
22
|
const saving = ref(false)
|
|
23
|
+
const detecting = ref(false)
|
|
19
24
|
// null ⇒ the form is in "add" mode; a stack id ⇒ editing that stack's definition in place.
|
|
20
25
|
const editingId = ref<string | null>(null)
|
|
21
26
|
|
|
@@ -23,12 +28,20 @@ const form = reactive({
|
|
|
23
28
|
name: '',
|
|
24
29
|
cloneUrl: '',
|
|
25
30
|
gitRef: '',
|
|
31
|
+
// Subdirectory the compose stack lives in (monorepo) — a detect-time hint only, NOT persisted:
|
|
32
|
+
// the resolved `composeFiles` already carry the prefix. Absent ⇒ the repo root is scanned.
|
|
33
|
+
directory: '',
|
|
26
34
|
composeFiles: '',
|
|
27
35
|
composeProfiles: '',
|
|
28
36
|
managedNetworks: '',
|
|
29
37
|
allowHostCommands: false,
|
|
30
38
|
})
|
|
31
39
|
|
|
40
|
+
// Env/config templates (`*-dist` → gitignored target) the autodetect scan surfaced. The form has no
|
|
41
|
+
// editor for them, so we carry the detected (or, on edit, the stack's existing) set through to the
|
|
42
|
+
// save payload rather than silently dropping them — they're materialized before `up`.
|
|
43
|
+
const detectedEnvFiles = ref<SharedStack['envFiles']>([])
|
|
44
|
+
|
|
32
45
|
/** Status → badge colour. */
|
|
33
46
|
const STATUS_COLOR: Record<SharedStackStatus, 'neutral' | 'warning' | 'success' | 'error'> = {
|
|
34
47
|
stopped: 'neutral',
|
|
@@ -72,10 +85,12 @@ function resetForm() {
|
|
|
72
85
|
form.name = ''
|
|
73
86
|
form.cloneUrl = ''
|
|
74
87
|
form.gitRef = ''
|
|
88
|
+
form.directory = ''
|
|
75
89
|
form.composeFiles = ''
|
|
76
90
|
form.composeProfiles = ''
|
|
77
91
|
form.managedNetworks = ''
|
|
78
92
|
form.allowHostCommands = false
|
|
93
|
+
detectedEnvFiles.value = []
|
|
79
94
|
}
|
|
80
95
|
|
|
81
96
|
/** Load a stack's definition into the form for in-place editing. */
|
|
@@ -84,10 +99,59 @@ function startEdit(stack: SharedStack) {
|
|
|
84
99
|
form.name = stack.name
|
|
85
100
|
form.cloneUrl = stack.cloneUrl
|
|
86
101
|
form.gitRef = stack.gitRef ?? ''
|
|
102
|
+
form.directory = ''
|
|
87
103
|
form.composeFiles = stack.composeFiles.join(', ')
|
|
88
104
|
form.composeProfiles = stack.composeProfiles.join(', ')
|
|
89
105
|
form.managedNetworks = stack.managedNetworks.join(', ')
|
|
90
106
|
form.allowHostCommands = stack.allowHostCommands
|
|
107
|
+
// Preserve the stack's existing env templates so a save (or a later re-detect) doesn't drop them.
|
|
108
|
+
detectedEnvFiles.value = stack.envFiles
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
const canDetect = computed(() => Boolean(form.cloneUrl.trim()) && !detecting.value)
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Read the repo at the entered clone URL (checkout-free, via the workspace's VCS connection) and
|
|
115
|
+
* PREFILL the compose-shaped fields from the recommendation. Non-binding: the user reviews + edits
|
|
116
|
+
* before saving. A SUCCESSFUL detection is authoritative for the compose-shaped fields — it
|
|
117
|
+
* overwrites them wholesale, including clearing a field the scan found empty (so re-detecting a
|
|
118
|
+
* different repo can't leave a stale managed network / profile behind). Manual entries survive only
|
|
119
|
+
* a `detected:false` result, which returns early and touches nothing. The name is suggested only
|
|
120
|
+
* when still blank (it's a user label, not a repo-derived fact).
|
|
121
|
+
*/
|
|
122
|
+
async function autodetect() {
|
|
123
|
+
detecting.value = true
|
|
124
|
+
try {
|
|
125
|
+
const rec = await store.detect({
|
|
126
|
+
cloneUrl: form.cloneUrl.trim(),
|
|
127
|
+
...(form.gitRef.trim() ? { gitRef: form.gitRef.trim() } : {}),
|
|
128
|
+
...(form.directory.trim() ? { directory: form.directory.trim() } : {}),
|
|
129
|
+
})
|
|
130
|
+
if (!rec.detected) {
|
|
131
|
+
toast.add({
|
|
132
|
+
title: t('settings.sharedStacks.detect.nothing'),
|
|
133
|
+
description: rec.notes[0]?.message ?? '',
|
|
134
|
+
icon: 'i-lucide-info',
|
|
135
|
+
color: 'warning',
|
|
136
|
+
})
|
|
137
|
+
return
|
|
138
|
+
}
|
|
139
|
+
if (rec.name && !form.name.trim()) form.name = rec.name
|
|
140
|
+
form.composeFiles = rec.composeFiles.join(', ')
|
|
141
|
+
form.composeProfiles = rec.composeProfiles.join(', ')
|
|
142
|
+
form.managedNetworks = rec.managedNetworks.join(', ')
|
|
143
|
+
detectedEnvFiles.value = rec.envFiles
|
|
144
|
+
toast.add({
|
|
145
|
+
title: t('settings.sharedStacks.detect.detected'),
|
|
146
|
+
description: t('settings.sharedStacks.detect.detectedBody'),
|
|
147
|
+
icon: 'i-lucide-wand-sparkles',
|
|
148
|
+
color: 'success',
|
|
149
|
+
})
|
|
150
|
+
} catch (e) {
|
|
151
|
+
notifyError(t('settings.sharedStacks.detect.failed'), e)
|
|
152
|
+
} finally {
|
|
153
|
+
detecting.value = false
|
|
154
|
+
}
|
|
91
155
|
}
|
|
92
156
|
|
|
93
157
|
function notifyError(title: string, e: unknown) {
|
|
@@ -110,6 +174,7 @@ async function saveStack() {
|
|
|
110
174
|
composeFiles: tokens(form.composeFiles),
|
|
111
175
|
composeProfiles: tokens(form.composeProfiles),
|
|
112
176
|
managedNetworks: tokens(form.managedNetworks),
|
|
177
|
+
envFiles: detectedEnvFiles.value,
|
|
113
178
|
allowHostCommands: form.allowHostCommands,
|
|
114
179
|
}
|
|
115
180
|
try {
|
|
@@ -300,6 +365,42 @@ async function remove(stack: SharedStack) {
|
|
|
300
365
|
/>
|
|
301
366
|
</UFormField>
|
|
302
367
|
|
|
368
|
+
<UFormField
|
|
369
|
+
:label="t('settings.sharedStacks.add.directory')"
|
|
370
|
+
:help="t('settings.sharedStacks.add.directoryHelp')"
|
|
371
|
+
>
|
|
372
|
+
<UInput
|
|
373
|
+
v-model="form.directory"
|
|
374
|
+
placeholder="shared"
|
|
375
|
+
class="w-full"
|
|
376
|
+
data-testid="shared-stack-directory"
|
|
377
|
+
/>
|
|
378
|
+
</UFormField>
|
|
379
|
+
|
|
380
|
+
<div class="flex items-center gap-2">
|
|
381
|
+
<UButton
|
|
382
|
+
icon="i-lucide-wand-sparkles"
|
|
383
|
+
size="sm"
|
|
384
|
+
variant="soft"
|
|
385
|
+
:loading="detecting"
|
|
386
|
+
:disabled="!canDetect"
|
|
387
|
+
data-testid="shared-stack-autodetect"
|
|
388
|
+
@click="autodetect"
|
|
389
|
+
>
|
|
390
|
+
{{ t('settings.sharedStacks.detect.button') }}
|
|
391
|
+
</UButton>
|
|
392
|
+
<span class="text-[11px] text-slate-500">{{ t('settings.sharedStacks.detect.hint') }}</span>
|
|
393
|
+
</div>
|
|
394
|
+
|
|
395
|
+
<p
|
|
396
|
+
v-if="detectedEnvFiles.length"
|
|
397
|
+
class="text-[11px] text-slate-500"
|
|
398
|
+
data-testid="shared-stack-env-files"
|
|
399
|
+
>
|
|
400
|
+
{{ t('settings.sharedStacks.detect.envFiles') }}
|
|
401
|
+
{{ detectedEnvFiles.map((f) => `${f.template} → ${f.target}`).join(', ') }}
|
|
402
|
+
</p>
|
|
403
|
+
|
|
303
404
|
<UFormField
|
|
304
405
|
:label="t('settings.sharedStacks.add.composeFiles')"
|
|
305
406
|
:help="t('settings.sharedStacks.add.composeFilesHelp')"
|
|
@@ -1,12 +1,13 @@
|
|
|
1
1
|
import {
|
|
2
2
|
createSharedStackContract,
|
|
3
3
|
deleteSharedStackContract,
|
|
4
|
+
detectSharedStackContract,
|
|
4
5
|
ensureSharedStackUpContract,
|
|
5
6
|
listSharedStacksContract,
|
|
6
7
|
teardownSharedStackContract,
|
|
7
8
|
updateSharedStackContract,
|
|
8
9
|
} from '@cat-factory/contracts'
|
|
9
|
-
import type { UpdateSharedStackInput } from '~/types/sharedStacks'
|
|
10
|
+
import type { DetectSharedStackInput, UpdateSharedStackInput } from '~/types/sharedStacks'
|
|
10
11
|
import type { SendParams } from './client'
|
|
11
12
|
import type { ApiContext } from './context'
|
|
12
13
|
|
|
@@ -23,6 +24,9 @@ export function sharedStacksApi({ send, ws }: ApiContext) {
|
|
|
23
24
|
createSharedStack: (workspaceId: string, body: CreateSharedStackBody) =>
|
|
24
25
|
send(createSharedStackContract, { pathPrefix: ws(workspaceId), body }),
|
|
25
26
|
|
|
27
|
+
detectSharedStack: (workspaceId: string, body: DetectSharedStackInput) =>
|
|
28
|
+
send(detectSharedStackContract, { pathPrefix: ws(workspaceId), body }),
|
|
29
|
+
|
|
26
30
|
updateSharedStack: (workspaceId: string, stackId: string, body: UpdateSharedStackInput) =>
|
|
27
31
|
send(updateSharedStackContract, {
|
|
28
32
|
pathPrefix: ws(workspaceId),
|
|
@@ -48,6 +48,8 @@ const CONFLICT_TITLE_KEYS: Record<
|
|
|
48
48
|
provision_type_unhandled: 'errors.conflict.title.provision_type_unhandled',
|
|
49
49
|
preset_unsatisfiable: 'errors.conflict.title.preset_unsatisfiable',
|
|
50
50
|
visual_pipeline_no_frontend: 'errors.conflict.title.visual_pipeline_no_frontend',
|
|
51
|
+
model_policy_blocked: 'errors.conflict.title.model_policy_blocked',
|
|
52
|
+
model_policy_unsupported: 'errors.conflict.title.model_policy_unsupported',
|
|
51
53
|
deployer_required_before_tester: 'errors.conflict.title.deployer_required_before_tester',
|
|
52
54
|
}
|
|
53
55
|
|
|
@@ -1,6 +1,10 @@
|
|
|
1
1
|
import { defineStore } from 'pinia'
|
|
2
2
|
import { ref } from 'vue'
|
|
3
|
-
import type {
|
|
3
|
+
import type {
|
|
4
|
+
DetectSharedStackInput,
|
|
5
|
+
SharedStack,
|
|
6
|
+
UpdateSharedStackInput,
|
|
7
|
+
} from '~/types/sharedStacks'
|
|
4
8
|
import { useWorkspaceStore } from '~/stores/workspace'
|
|
5
9
|
|
|
6
10
|
/**
|
|
@@ -34,6 +38,11 @@ export const useSharedStacksStore = defineStore('sharedStacks', () => {
|
|
|
34
38
|
return created
|
|
35
39
|
}
|
|
36
40
|
|
|
41
|
+
async function detect(input: DetectSharedStackInput) {
|
|
42
|
+
const ws = useWorkspaceStore()
|
|
43
|
+
return api.detectSharedStack(ws.requireId(), input)
|
|
44
|
+
}
|
|
45
|
+
|
|
37
46
|
async function update(stackId: string, patchInput: UpdateSharedStackInput) {
|
|
38
47
|
const ws = useWorkspaceStore()
|
|
39
48
|
const updated = await api.updateSharedStack(ws.requireId(), stackId, patchInput)
|
|
@@ -61,5 +70,5 @@ export const useSharedStacksStore = defineStore('sharedStacks', () => {
|
|
|
61
70
|
return updated
|
|
62
71
|
}
|
|
63
72
|
|
|
64
|
-
return { stacks, hydrate, create, update, remove, ensureUp, teardown }
|
|
73
|
+
return { stacks, hydrate, create, detect, update, remove, ensureUp, teardown }
|
|
65
74
|
})
|
package/i18n/locales/de.json
CHANGED
|
@@ -530,6 +530,8 @@
|
|
|
530
530
|
"cloneUrl": "Clone-URL des Repositorys",
|
|
531
531
|
"cloneUrlHelp": "Das Git-Repository, in dem die Compose-Dateien des Stacks liegen.",
|
|
532
532
|
"gitRef": "Branch oder Tag (optional)",
|
|
533
|
+
"directory": "Unterverzeichnis (optional)",
|
|
534
|
+
"directoryHelp": "Wird nur von der automatischen Erkennung verwendet: das Monorepo-Unterverzeichnis, in dem der Compose-Stack liegt.",
|
|
533
535
|
"composeFiles": "Compose-Dateien",
|
|
534
536
|
"composeFilesHelp": "Kommagetrennt, repo-relativ, in Override-Reihenfolge.",
|
|
535
537
|
"composeProfiles": "Compose-Profile (optional)",
|
|
@@ -538,6 +540,15 @@
|
|
|
538
540
|
"allowHostCommands": "Host-Command-Setup-Schritte erlauben",
|
|
539
541
|
"save": "Stack hinzufügen"
|
|
540
542
|
},
|
|
543
|
+
"detect": {
|
|
544
|
+
"button": "Automatisch erkennen",
|
|
545
|
+
"hint": "Das Repository lesen und Compose-Dateien, Profile und Netzwerke vorausfüllen.",
|
|
546
|
+
"detected": "Stack-Konfiguration erkannt",
|
|
547
|
+
"detectedBody": "Aus dem Repository vorausgefüllt. Überprüfen Sie die Felder vor dem Speichern.",
|
|
548
|
+
"nothing": "Nichts automatisch zu erkennen",
|
|
549
|
+
"failed": "Automatische Erkennung fehlgeschlagen",
|
|
550
|
+
"envFiles": "Env-Vorlagen, die vor dem Start materialisiert werden:"
|
|
551
|
+
},
|
|
541
552
|
"edit": {
|
|
542
553
|
"heading": "Gemeinsamen Stack bearbeiten",
|
|
543
554
|
"save": "Änderungen speichern",
|
|
@@ -734,6 +745,65 @@
|
|
|
734
745
|
"title": "Diesen Runner entfernen?",
|
|
735
746
|
"body": "\"{name}\" wird entfernt. Dies kann nicht rückgängig gemacht werden."
|
|
736
747
|
}
|
|
748
|
+
},
|
|
749
|
+
"modelPolicy": {
|
|
750
|
+
"title": "Modellzugriffsrichtlinie",
|
|
751
|
+
"description": "Beschränkt, welche Modellfamilien die Teams dieses Kontos ausführen dürfen. Das Datenresidenz-Risiko hängt von der Bereitstellungsroute ab, daher kann eine vertrauenswürdige (residenzgarantierte) Route eine andernfalls blockierte Familie freigeben.",
|
|
752
|
+
"regionLabel": "Betriebsregion",
|
|
753
|
+
"applyPreset": "Vorlage anwenden:",
|
|
754
|
+
"modeLabel": "Richtlinienmodus",
|
|
755
|
+
"modes": {
|
|
756
|
+
"off": "Keine Einschränkung",
|
|
757
|
+
"blocklist": "Aufgeführte Familien blockieren",
|
|
758
|
+
"allowlist": "Nur aufgeführte Familien zulassen"
|
|
759
|
+
},
|
|
760
|
+
"familiesBlockLabel": "Blockierte Familien",
|
|
761
|
+
"familiesAllowLabel": "Zugelassene Familien",
|
|
762
|
+
"trustedLabel": "Vertrauenswürdige (residenzgarantierte) Routen",
|
|
763
|
+
"trustedHint": "Eine blockierte Familie bleibt verfügbar, wenn sie über eine dieser Routen bereitgestellt wird.",
|
|
764
|
+
"families": {
|
|
765
|
+
"claude": "Claude (Anthropic)",
|
|
766
|
+
"openai": "OpenAI / ChatGPT",
|
|
767
|
+
"gemini": "Gemini (Google)",
|
|
768
|
+
"llama": "Llama (Meta)",
|
|
769
|
+
"qwen": "Qwen (Alibaba)",
|
|
770
|
+
"kimi": "Kimi (Moonshot)",
|
|
771
|
+
"deepseek": "DeepSeek",
|
|
772
|
+
"glm": "GLM (Z.ai)"
|
|
773
|
+
},
|
|
774
|
+
"regions": {
|
|
775
|
+
"usa": "Vereinigte Staaten",
|
|
776
|
+
"europe": "Europa",
|
|
777
|
+
"china": "China",
|
|
778
|
+
"other": "Andere"
|
|
779
|
+
},
|
|
780
|
+
"providers": {
|
|
781
|
+
"bedrock": "AWS Bedrock"
|
|
782
|
+
},
|
|
783
|
+
"presets": {
|
|
784
|
+
"us-block-cn": {
|
|
785
|
+
"label": "In China gehostete Familien blockieren",
|
|
786
|
+
"description": "Blockiert DeepSeek, Qwen, Kimi und GLM; erlaubt sie nur über eine residenzgarantierte Route (z. B. Bedrock)."
|
|
787
|
+
},
|
|
788
|
+
"eu-block-cn": {
|
|
789
|
+
"label": "EU: in China gehostete Familien blockieren",
|
|
790
|
+
"description": "Blockiert DeepSeek, Qwen, Kimi und GLM; eine residenzgarantierte EU-Route (z. B. Bedrock) bleibt zulässig."
|
|
791
|
+
},
|
|
792
|
+
"eu-guaranteed-only": {
|
|
793
|
+
"label": "EU: nur residenzgarantierte Routen",
|
|
794
|
+
"description": "Erlaubt ein Modell nur, wenn es über eine residenzgarantierte Route bereitgestellt wird."
|
|
795
|
+
},
|
|
796
|
+
"cn-prefer-domestic": {
|
|
797
|
+
"label": "In China gehostete Familien bevorzugen",
|
|
798
|
+
"description": "Erlaubt in China gehostete Familien (plus Llama) und jede residenzgarantierte Route."
|
|
799
|
+
},
|
|
800
|
+
"other-block-cn": {
|
|
801
|
+
"label": "In China gehostete Familien blockieren",
|
|
802
|
+
"description": "Blockiert DeepSeek, Qwen, Kimi und GLM; erlaubt sie nur über eine residenzgarantierte Route."
|
|
803
|
+
}
|
|
804
|
+
},
|
|
805
|
+
"saved": "Modellzugriffsrichtlinie gespeichert",
|
|
806
|
+
"saveFailed": "Modellzugriffsrichtlinie konnte nicht gespeichert werden"
|
|
737
807
|
}
|
|
738
808
|
},
|
|
739
809
|
"inspector": {
|
|
@@ -3580,6 +3650,8 @@
|
|
|
3580
3650
|
"provision_type_unhandled": "Kein Handler für diesen Bereitstellungstyp",
|
|
3581
3651
|
"preset_unsatisfiable": "Modell-Preset kann diese Pipeline nicht ausführen",
|
|
3582
3652
|
"visual_pipeline_no_frontend": "Kein Frontend zum Testen",
|
|
3653
|
+
"model_policy_blocked": "Modell durch Kontorichtlinie blockiert",
|
|
3654
|
+
"model_policy_unsupported": "Modellrichtlinie hier nicht verfügbar",
|
|
3583
3655
|
"deployer_required_before_tester": "Füge einen Deployer vor dem Tester hinzu"
|
|
3584
3656
|
},
|
|
3585
3657
|
"fallbackMessage": "Diese Aktion steht im Konflikt mit dem aktuellen Zustand.",
|