@swiss-ai-hub/web 0.318.0 → 0.320.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/README.md +2 -2
- package/{pages/auth/login.vue → components/Auth/LoginPanel.vue} +3 -33
- package/components/FormKit/CronInput.vue +208 -0
- package/components/FormKit/LocaleInput.vue +11 -1
- package/components/Knowledge/Document/List.vue +20 -9
- package/composables/app/useAppVersion.ts +30 -11
- package/composables/document/useScheduledDeletions.ts +16 -8
- package/formkit.config.ts +62 -1
- package/i18n/locales/de.yaml +24 -0
- package/i18n/locales/en.yaml +23 -0
- package/i18n/locales/fr.yaml +24 -0
- package/i18n/locales/it.yaml +24 -0
- package/middleware/auth.global.ts +34 -16
- package/package.json +1 -1
- package/pages/[tenant]/service/agents/[agent_class]-[agent_id]/configuration.vue +3 -0
- package/pages/[tenant]/service/processes/[process_class]-[process_id]/configuration.vue +2 -0
- package/pages/auth/login/[idp].vue +57 -0
- package/pages/auth/login/index.vue +46 -0
- package/sdk/client/client/client.gen.ts +1 -3
- package/sdk/client/client/types.gen.ts +7 -13
- package/sdk/client/client/utils.gen.ts +1 -2
- package/sdk/client/core/queryKeySerializer.gen.ts +1 -6
- package/sdk/client/core/types.gen.ts +5 -3
- package/sdk/client/index.ts +21 -0
- package/sdk/client/schemas.gen.ts +1423 -299
- package/sdk/client/sdk.gen.ts +72 -0
- package/sdk/client/types.gen.ts +937 -35
package/README.md
CHANGED
|
@@ -155,8 +155,8 @@ The same single-instance requirement applies to **PrimeVue** (its theme system i
|
|
|
155
155
|
> { "dependencies": { "vue-router": "4.6.4" } }
|
|
156
156
|
> ```
|
|
157
157
|
>
|
|
158
|
-
> Verify with `pnpm why vue-router` (`@vueuse/router` must show `4.6.4`) and `pnpm build` (`nuxt generate` must succeed
|
|
159
|
-
> forcing a single `vue-router` major instead breaks `@nuxtjs/i18n`, which needs 5.x).
|
|
158
|
+
> Verify with `pnpm why vue-router` (`@vueuse/router` must show `4.6.4`) and `pnpm build` (`nuxt generate` must succeed
|
|
159
|
+
> — forcing a single `vue-router` major instead breaks `@nuxtjs/i18n`, which needs 5.x).
|
|
160
160
|
|
|
161
161
|
## Quick start
|
|
162
162
|
|
|
@@ -21,36 +21,14 @@
|
|
|
21
21
|
</a>
|
|
22
22
|
<div class="flex flex-col gap-2 text-center">
|
|
23
23
|
<h2 class="text-xl text-white">
|
|
24
|
-
|
|
24
|
+
<slot name="heading" />
|
|
25
25
|
</h2>
|
|
26
26
|
<p class="text-surface-400">
|
|
27
|
-
|
|
27
|
+
<slot name="message" />
|
|
28
28
|
</p>
|
|
29
29
|
</div>
|
|
30
30
|
<div class="flex flex-col gap-3">
|
|
31
|
-
<
|
|
32
|
-
v-if="isLoading"
|
|
33
|
-
class="!h-8 !w-8"
|
|
34
|
-
/>
|
|
35
|
-
<template v-else>
|
|
36
|
-
<Button
|
|
37
|
-
v-for="idp in authProviders ?? []"
|
|
38
|
-
:key="idp.alias"
|
|
39
|
-
:label="t('auth.login.loginWith', { provider: idp.display_name })"
|
|
40
|
-
:icon="`pi ${idp.icon}`"
|
|
41
|
-
icon-pos="right"
|
|
42
|
-
class="!bg-white !text-black"
|
|
43
|
-
@click="login(idp.alias || undefined)"
|
|
44
|
-
/>
|
|
45
|
-
<Button
|
|
46
|
-
v-if="(authProviders?.length ?? 0) === 0"
|
|
47
|
-
:label="t('auth.login.title')"
|
|
48
|
-
icon="pi pi-sign-in"
|
|
49
|
-
icon-pos="right"
|
|
50
|
-
class="!bg-white !text-black"
|
|
51
|
-
@click="login()"
|
|
52
|
-
/>
|
|
53
|
-
</template>
|
|
31
|
+
<slot />
|
|
54
32
|
</div>
|
|
55
33
|
</div>
|
|
56
34
|
<a
|
|
@@ -68,13 +46,5 @@
|
|
|
68
46
|
<script setup lang="ts">
|
|
69
47
|
import logo from '@core/assets/images/logo.png'
|
|
70
48
|
|
|
71
|
-
definePageMeta({
|
|
72
|
-
layout: 'anonymous',
|
|
73
|
-
})
|
|
74
|
-
|
|
75
49
|
const { t } = useI18n()
|
|
76
|
-
const { login } = useAuth()
|
|
77
|
-
const { authProviders, isLoading } = useAuthProviders()
|
|
78
|
-
|
|
79
|
-
const companyName = 'bbv Software Services AG'
|
|
80
50
|
</script>
|
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
<template>
|
|
2
|
+
<div class="flex flex-col gap-4">
|
|
3
|
+
<div class="grid grid-cols-2 gap-3 sm:grid-cols-5">
|
|
4
|
+
<div
|
|
5
|
+
v-for="position in POSITIONS"
|
|
6
|
+
:key="position.key"
|
|
7
|
+
>
|
|
8
|
+
<label
|
|
9
|
+
:for="`cron-${position.key}`"
|
|
10
|
+
class="mb-1 block text-sm font-medium"
|
|
11
|
+
>
|
|
12
|
+
{{ t(`lib.cron.${position.key}.label`) }}
|
|
13
|
+
</label>
|
|
14
|
+
<InputText
|
|
15
|
+
:id="`cron-${position.key}`"
|
|
16
|
+
:model-value="schedule[position.key]"
|
|
17
|
+
:aria-label="t(`lib.cron.${position.key}.label`)"
|
|
18
|
+
:invalid="Boolean(errors[position.key])"
|
|
19
|
+
:aria-invalid="Boolean(errors[position.key])"
|
|
20
|
+
class="w-full font-mono"
|
|
21
|
+
@update:model-value="(value) => updatePosition(position.key, value ?? '')"
|
|
22
|
+
/>
|
|
23
|
+
<small
|
|
24
|
+
v-if="errors[position.key]"
|
|
25
|
+
class="mt-1 block text-red-500"
|
|
26
|
+
>
|
|
27
|
+
{{ errors[position.key] }}
|
|
28
|
+
</small>
|
|
29
|
+
<small
|
|
30
|
+
v-else
|
|
31
|
+
class="mt-1 block text-surface-500"
|
|
32
|
+
>{{ position.range }}</small>
|
|
33
|
+
</div>
|
|
34
|
+
</div>
|
|
35
|
+
|
|
36
|
+
<div>
|
|
37
|
+
<label
|
|
38
|
+
for="cron-timezone"
|
|
39
|
+
class="mb-1 block text-sm font-medium"
|
|
40
|
+
>
|
|
41
|
+
{{ t('lib.cron.timezone.label') }}
|
|
42
|
+
</label>
|
|
43
|
+
<Select
|
|
44
|
+
v-model="timezone"
|
|
45
|
+
:aria-label="t('lib.cron.timezone.label')"
|
|
46
|
+
input-id="cron-timezone"
|
|
47
|
+
:options="timezoneOptions"
|
|
48
|
+
:placeholder="timezonePlaceholder ?? t('lib.cron.timezone.placeholder')"
|
|
49
|
+
:filter="filter"
|
|
50
|
+
class="w-full"
|
|
51
|
+
/>
|
|
52
|
+
<small class="mt-1 block text-surface-500">{{ t('lib.cron.timezone.help') }}</small>
|
|
53
|
+
</div>
|
|
54
|
+
|
|
55
|
+
<small class="text-surface-500">
|
|
56
|
+
{{ t('lib.cron.expression') }} <code class="font-mono">{{ expression }}</code>
|
|
57
|
+
</small>
|
|
58
|
+
</div>
|
|
59
|
+
</template>
|
|
60
|
+
|
|
61
|
+
<script setup lang="ts">
|
|
62
|
+
/**
|
|
63
|
+
* Editor for a backend `CronSchedule`: the five cron positions plus an IANA timezone.
|
|
64
|
+
*
|
|
65
|
+
* The emitted value is the schedule object in the backend's own field names, so it round-trips through
|
|
66
|
+
* `config_data` untouched — the scheduler reads those keys directly. Positions are kept separate rather than
|
|
67
|
+
* joined into one "0 12 * * *" string because that is how `CronSchedule` stores them, and flattening here would
|
|
68
|
+
* make every consumer parse it back apart.
|
|
69
|
+
*
|
|
70
|
+
* Deliberately has no presets and no plain-language summary — that is #1581, which extends this component rather
|
|
71
|
+
* than replacing it.
|
|
72
|
+
*/
|
|
73
|
+
|
|
74
|
+
import { createMessage } from '@formkit/core'
|
|
75
|
+
|
|
76
|
+
import type { FormKitNode } from '@formkit/core'
|
|
77
|
+
|
|
78
|
+
interface CronSchedule {
|
|
79
|
+
minute: string
|
|
80
|
+
hour: string
|
|
81
|
+
day_of_month: string
|
|
82
|
+
month: string
|
|
83
|
+
day_of_week: string
|
|
84
|
+
timezone: string
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
type CronPosition = Exclude<keyof CronSchedule, 'timezone'>
|
|
88
|
+
|
|
89
|
+
interface CronInputProps {
|
|
90
|
+
context: {
|
|
91
|
+
node: FormKitNode
|
|
92
|
+
value?: CronSchedule | null
|
|
93
|
+
attrs: Record<string, unknown>
|
|
94
|
+
timezonePlaceholder?: string
|
|
95
|
+
filter?: boolean
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const props = defineProps<CronInputProps>()
|
|
100
|
+
const { t } = useI18n()
|
|
101
|
+
|
|
102
|
+
const POSITIONS: { key: CronPosition, range: string, min: number, max: number }[] = [
|
|
103
|
+
{ key: 'minute', range: '0-59', min: 0, max: 59 },
|
|
104
|
+
{ key: 'hour', range: '0-23', min: 0, max: 23 },
|
|
105
|
+
{ key: 'day_of_month', range: '1-31', min: 1, max: 31 },
|
|
106
|
+
{ key: 'month', range: '1-12', min: 1, max: 12 },
|
|
107
|
+
{ key: 'day_of_week', range: '0-6', min: 0, max: 6 },
|
|
108
|
+
]
|
|
109
|
+
|
|
110
|
+
// Deliberately permissive: croniter accepts names (MON-FRI, JAN), steps, lists and the day-of-week specials, and a
|
|
111
|
+
// stricter pattern here would reject schedules the backend is happy to run. This catches the two things that are
|
|
112
|
+
// wrong in every cron dialect — a blank position and a stray character — and leaves grammar to the server, which
|
|
113
|
+
// now rejects a malformed cron on save rather than storing it.
|
|
114
|
+
const ALLOWED_CHARACTERS = /^[0-9A-Za-z*,\-/?#]+$/
|
|
115
|
+
|
|
116
|
+
// Blocking so the surrounding form cannot be submitted while a position is empty or malformed. Without it the only
|
|
117
|
+
// feedback is a 400 from the save endpoint, and before that endpoint validated, a blank position was stored and
|
|
118
|
+
// silently broke every run of the profile — not only its scheduled ones.
|
|
119
|
+
const ERROR_MESSAGE_KEY = 'cronPositions'
|
|
120
|
+
|
|
121
|
+
// Shown in the inputs the moment the field is enabled, never applied behind the user's back. `CronSchedule`
|
|
122
|
+
// requires all five positions and has no defaults, so leaving them blank would save a schedule the scheduler then
|
|
123
|
+
// skips with only a log line to say why. Hourly is the least surprising visible starting point — `* * * * *` would
|
|
124
|
+
// commit an admin to a run every minute for merely ticking the enable box.
|
|
125
|
+
const HOURLY: CronSchedule = {
|
|
126
|
+
minute: '0',
|
|
127
|
+
hour: '*',
|
|
128
|
+
day_of_month: '*',
|
|
129
|
+
month: '*',
|
|
130
|
+
day_of_week: '*',
|
|
131
|
+
timezone: resolveBrowserTimezone(),
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
const timezonePlaceholder = computed(() => props.context.timezonePlaceholder)
|
|
135
|
+
const filter = computed(() => props.context.filter ?? true)
|
|
136
|
+
|
|
137
|
+
const schedule = computed<CronSchedule>(() => props.context.value ?? HOURLY)
|
|
138
|
+
|
|
139
|
+
const errors = computed<Partial<Record<CronPosition, string>>>(() =>
|
|
140
|
+
Object.fromEntries(
|
|
141
|
+
POSITIONS.map(position => [position.key, positionError(position)]).filter(([, message]) => message),
|
|
142
|
+
),
|
|
143
|
+
)
|
|
144
|
+
|
|
145
|
+
const expression = computed(() =>
|
|
146
|
+
POSITIONS.map(position => schedule.value[position.key] || '?').join(' '),
|
|
147
|
+
)
|
|
148
|
+
|
|
149
|
+
const timezone = computed({
|
|
150
|
+
get: () => schedule.value.timezone,
|
|
151
|
+
set: (value: string) => emitValue({ ...schedule.value, timezone: value }),
|
|
152
|
+
})
|
|
153
|
+
|
|
154
|
+
const timezoneOptions = computed<string[]>(() => {
|
|
155
|
+
// Not every engine implements supportedValuesOf; without it the admin can still keep the resolved zone.
|
|
156
|
+
const supported = (Intl as { supportedValuesOf?: (key: string) => string[] }).supportedValuesOf
|
|
157
|
+
return supported ? supported('timeZone') : [schedule.value.timezone, 'UTC']
|
|
158
|
+
})
|
|
159
|
+
|
|
160
|
+
function resolveBrowserTimezone(): string {
|
|
161
|
+
try {
|
|
162
|
+
return Intl.DateTimeFormat().resolvedOptions().timeZone || 'UTC'
|
|
163
|
+
}
|
|
164
|
+
catch {
|
|
165
|
+
return 'UTC'
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function positionError(position: { key: CronPosition, min: number, max: number }): string | undefined {
|
|
170
|
+
const value = schedule.value[position.key]
|
|
171
|
+
if (!value) return t('lib.cron.errors.required')
|
|
172
|
+
if (!ALLOWED_CHARACTERS.test(value)) return t('lib.cron.errors.invalid')
|
|
173
|
+
|
|
174
|
+
// Only plain integers are range-checked. Anything with a step, range or name is left to the server, whose
|
|
175
|
+
// croniter knows the dialect far better than a regex here could.
|
|
176
|
+
const outOfRange = value
|
|
177
|
+
.split(',')
|
|
178
|
+
.some(term => /^\d+$/.test(term) && (Number(term) < position.min || Number(term) > position.max))
|
|
179
|
+
|
|
180
|
+
return outOfRange ? t('lib.cron.errors.range', { min: position.min, max: position.max }) : undefined
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
function updatePosition(position: CronPosition, value: string) {
|
|
184
|
+
emitValue({ ...schedule.value, [position]: value.trim() })
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
// Always emits every key: a partial schedule passes the generated submission schema (which cannot carry
|
|
188
|
+
// CronSchedule's croniter and timezone validators) and is then rejected by the save endpoint.
|
|
189
|
+
function emitValue(value: CronSchedule) {
|
|
190
|
+
props.context.node.input({ ...value })
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
watch(errors, (current) => {
|
|
194
|
+
props.context.node.store.remove(ERROR_MESSAGE_KEY)
|
|
195
|
+
const first = Object.values(current)[0]
|
|
196
|
+
if (!first) return
|
|
197
|
+
|
|
198
|
+
props.context.node.store.set(
|
|
199
|
+
createMessage({ blocking: true, key: ERROR_MESSAGE_KEY, type: 'error', value: first, visible: true }),
|
|
200
|
+
)
|
|
201
|
+
}, { immediate: true })
|
|
202
|
+
|
|
203
|
+
// Seed the stored value so enabling the field yields a complete, valid schedule even if the admin saves without
|
|
204
|
+
// touching an input — what the inputs show and what gets saved must not diverge.
|
|
205
|
+
onMounted(() => {
|
|
206
|
+
if (!props.context.value) emitValue(HOURLY)
|
|
207
|
+
})
|
|
208
|
+
</script>
|
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
:options="localeOptions"
|
|
8
8
|
option-label="label"
|
|
9
9
|
option-value="value"
|
|
10
|
+
:allow-empty="false"
|
|
10
11
|
size="small"
|
|
11
12
|
>
|
|
12
13
|
<template #option="{ option }">
|
|
@@ -100,15 +101,24 @@ const localeOptions = locales.map(lang => ({
|
|
|
100
101
|
|
|
101
102
|
const activeLocale = ref<Locale>('en')
|
|
102
103
|
|
|
104
|
+
function isLocale(value: unknown): value is Locale {
|
|
105
|
+
return locales.includes(value as Locale)
|
|
106
|
+
}
|
|
107
|
+
|
|
103
108
|
// Get the current locale string value
|
|
104
109
|
const localeValue = computed<LocaleStringDto>(() => {
|
|
105
110
|
return props.context.value ?? { de: null, en: null, fr: null, it: null }
|
|
106
111
|
})
|
|
107
112
|
|
|
108
113
|
// Current input value for the active locale
|
|
114
|
+
// The guard is belt-and-braces next to `:allow-empty="false"`: with an unset `activeLocale`
|
|
115
|
+
// this setter used to write under the key `"null"`, producing a value whose four locales
|
|
116
|
+
// were all empty but which no longer looked like a locale object to the backend's
|
|
117
|
+
// normalization — so it slipped past validation and saved a blank name (issue #135).
|
|
109
118
|
const currentValue = computed({
|
|
110
|
-
get: () => localeValue.value[activeLocale.value] ?? '',
|
|
119
|
+
get: () => (isLocale(activeLocale.value) ? localeValue.value[activeLocale.value] ?? '' : ''),
|
|
111
120
|
set: (newVal: string) => {
|
|
121
|
+
if (!isLocale(activeLocale.value)) return
|
|
112
122
|
const updated: LocaleStringDto = {
|
|
113
123
|
...localeValue.value,
|
|
114
124
|
[activeLocale.value]: newVal || null,
|
|
@@ -140,7 +140,7 @@ const { tenantId } = useTenant()
|
|
|
140
140
|
const { getDocumentSourceUrl } = useDocumentUrl()
|
|
141
141
|
const { deleteDocument, isDeleting } = useDeleteDocument()
|
|
142
142
|
const { deleteDocuments, isDeleting: isBatchDeleting } = useDeleteDocuments()
|
|
143
|
-
const { isScheduled, schedule, unschedule } = useScheduledDeletions(
|
|
143
|
+
const { isScheduled, scheduledAt, schedule, unschedule } = useScheduledDeletions(
|
|
144
144
|
() => route.params.db as string,
|
|
145
145
|
() => route.params.namespace as string,
|
|
146
146
|
)
|
|
@@ -163,18 +163,24 @@ const checkedDocuments = ref<DocumentDto[]>([])
|
|
|
163
163
|
|
|
164
164
|
const formatted = (datestr: string) => useDateFormat(new Date(datestr), 'DD.MM.YYYY')
|
|
165
165
|
|
|
166
|
-
const isDocumentDeleting = (document: DocumentDto) => isScheduled(document.id)
|
|
166
|
+
const isDocumentDeleting = (document: DocumentDto) => isScheduled(document.id)
|
|
167
167
|
|
|
168
|
-
//
|
|
169
|
-
|
|
168
|
+
// Deleting is safe at any stage of ingestion: the API only removes the source file, and the pipeline
|
|
169
|
+
// drops the document's partition before the remove job touches any store, which aborts an in-flight
|
|
170
|
+
// run before it writes. So the only reason to withhold the action is a deletion already in flight.
|
|
171
|
+
const isDocumentDeletable = (document: DocumentDto) => !isDocumentDeleting(document)
|
|
170
172
|
|
|
171
|
-
// Ids derive from the source URI, so re-uploading a just-deleted file reuses its id
|
|
172
|
-
//
|
|
173
|
+
// Ids derive from the source URI, so re-uploading a just-deleted file reuses its id and would inherit
|
|
174
|
+
// its "Deleting" badge. An upload rewrites the document, so an updated_at newer than the moment we
|
|
175
|
+
// scheduled the deletion means this row is the replacement, not the one still on its way out.
|
|
173
176
|
watch(
|
|
174
177
|
() => props.documents,
|
|
175
178
|
(documents) => {
|
|
176
179
|
const reUploadedIds = (documents ?? [])
|
|
177
|
-
.filter(document =>
|
|
180
|
+
.filter((document) => {
|
|
181
|
+
const requestedAt = scheduledAt(document.id)
|
|
182
|
+
return requestedAt != null && new Date(document.updated_at).getTime() > requestedAt
|
|
183
|
+
})
|
|
178
184
|
.map(document => document.id)
|
|
179
185
|
if (reUploadedIds.length > 0) {
|
|
180
186
|
unschedule(reUploadedIds)
|
|
@@ -200,15 +206,20 @@ const onSelectAllChange = ({ checked }: { checked: boolean }) => {
|
|
|
200
206
|
|
|
201
207
|
const handleRowClick = (event: DataTableRowClickEvent) => {
|
|
202
208
|
const document = event.data as DocumentDto
|
|
203
|
-
if (
|
|
209
|
+
if (!isDocumentDeleting(document)) {
|
|
204
210
|
emit('selected', document)
|
|
205
211
|
}
|
|
206
212
|
}
|
|
207
213
|
|
|
214
|
+
// Only a deletion in flight makes a row inert. A document still being ingested stays dimmed but usable —
|
|
215
|
+
// otherwise a failed ingestion leaves a row that can be neither opened nor removed.
|
|
208
216
|
const getRowClass = (data: DocumentDto) => {
|
|
209
|
-
if (
|
|
217
|
+
if (isDocumentDeleting(data)) {
|
|
210
218
|
return 'opacity-50 cursor-not-allowed pointer-events-none'
|
|
211
219
|
}
|
|
220
|
+
if (!data.is_ingested) {
|
|
221
|
+
return 'opacity-50'
|
|
222
|
+
}
|
|
212
223
|
return data.id === route.params.document_id ? 'bg-surface-100 dark:bg-surface-800' : ''
|
|
213
224
|
}
|
|
214
225
|
|
|
@@ -3,18 +3,29 @@ import { minutesToMilliseconds } from 'date-fns'
|
|
|
3
3
|
|
|
4
4
|
// Surfaces the running version of both services. The UI service version is baked
|
|
5
5
|
// into the static bundle at build time (runtimeConfig.public.appVersion); the API
|
|
6
|
-
// version is read from the health endpoint
|
|
7
|
-
// `latest`) the running image was pulled as. When both match a single number is
|
|
6
|
+
// version is read from the health endpoint. When both agree a single number is
|
|
8
7
|
// shown, otherwise both are shown as `UI / API`.
|
|
9
8
|
//
|
|
10
|
-
// A release is promoted
|
|
11
|
-
//
|
|
12
|
-
//
|
|
13
|
-
//
|
|
14
|
-
//
|
|
15
|
-
//
|
|
9
|
+
// A release is promoted by retagging the exact `-rc.N` build that was tested, so
|
|
10
|
+
// the baked UI version keeps its release-candidate suffix (e.g. `v0.317.0-rc.3`)
|
|
11
|
+
// even though the artifact IS the final release. A container cannot read its own
|
|
12
|
+
// image tag, so the release has to be inferred from what the API reports, and the
|
|
13
|
+
// two deployment flavours report it differently: docker-compose pins the channel
|
|
14
|
+
// tag (`latest`), while Helm pins an exact `vX.Y.Z` per instance and leaves the
|
|
15
|
+
// API on its package metadata (`0.319.0`). Both are release signals; on either
|
|
16
|
+
// one the suffix is dropped so a promoted build shows its release version.
|
|
17
|
+
//
|
|
18
|
+
// Versions are compared with the leading `v` normalised away, because one side is
|
|
19
|
+
// a git tag (`v0.319.0`) and the other Python package metadata (`0.319.0`) — the
|
|
20
|
+
// same version, and rendering it as `UI / API` would read as skew.
|
|
21
|
+
//
|
|
22
|
+
// Every other case is genuinely a candidate or a rolling build (another channel
|
|
23
|
+
// such as `staging`, an API pinned to an explicit `-rc.N`, a nightly), so the
|
|
24
|
+
// suffix stays to keep the build identifiable.
|
|
16
25
|
const RELEASE_CHANNEL = 'latest'
|
|
17
|
-
const
|
|
26
|
+
const RC_SUFFIX = /-rc\.\d+$/
|
|
27
|
+
const toReleaseVersion = (version: string): string => version.replace(RC_SUFFIX, '')
|
|
28
|
+
const toComparable = (version: string): string => version.replace(/^v/, '')
|
|
18
29
|
|
|
19
30
|
export const useAppVersion = defineQuery(() => {
|
|
20
31
|
const bakedUiVersion = useRuntimeConfig().public.appVersion as string
|
|
@@ -28,13 +39,21 @@ export const useAppVersion = defineQuery(() => {
|
|
|
28
39
|
},
|
|
29
40
|
})
|
|
30
41
|
|
|
42
|
+
// An API version that still carries `-rc.N` can never equal the stripped UI
|
|
43
|
+
// version, so a candidate deployment fails this check without a separate guard.
|
|
44
|
+
const isReleaseDeployment = computed(() => {
|
|
45
|
+
const api = apiVersion.value
|
|
46
|
+
if (!api) return false
|
|
47
|
+
return api === RELEASE_CHANNEL || toComparable(api) === toComparable(toReleaseVersion(bakedUiVersion))
|
|
48
|
+
})
|
|
49
|
+
|
|
31
50
|
const uiVersion = computed(() =>
|
|
32
|
-
|
|
51
|
+
isReleaseDeployment.value ? toReleaseVersion(bakedUiVersion) : bakedUiVersion,
|
|
33
52
|
)
|
|
34
53
|
|
|
35
54
|
const versionDisplay = computed(() => {
|
|
36
55
|
const api = apiVersion.value
|
|
37
|
-
if (!api || api === uiVersion.value) return uiVersion.value
|
|
56
|
+
if (!api || toComparable(api) === toComparable(uiVersion.value)) return uiVersion.value
|
|
38
57
|
return `${uiVersion.value} / ${api}`
|
|
39
58
|
})
|
|
40
59
|
|
|
@@ -2,11 +2,15 @@ import { useStorage } from '@vueuse/core'
|
|
|
2
2
|
|
|
3
3
|
import type { MaybeRefOrGetter } from 'vue'
|
|
4
4
|
|
|
5
|
-
// Deletion is eventual (the pipeline cleans the stores
|
|
6
|
-
//
|
|
7
|
-
//
|
|
8
|
-
//
|
|
9
|
-
|
|
5
|
+
// Deletion is eventual (the pipeline cleans the stores well after the API returns), so the list keeps
|
|
6
|
+
// returning a deleted document for a while. We persist scheduled ids in localStorage — surviving page
|
|
7
|
+
// refreshes — to drive a "Deleting" badge, and expire them after a TTL so a failed cleanup eventually
|
|
8
|
+
// re-surfaces the row instead of hiding it forever.
|
|
9
|
+
//
|
|
10
|
+
// The TTL has to cover a Dagster backlog, not just a slow run: the cleanup waits on a NATS sensor tick,
|
|
11
|
+
// then the observe job's turn in the queue, then a run-status sensor tick, then the remove job's turn.
|
|
12
|
+
// A busy instance can push that out for hours, so a day is the honest bound.
|
|
13
|
+
const TTL_MS = 24 * 60 * 60 * 1000
|
|
10
14
|
|
|
11
15
|
const store = useStorage<Record<string, number>>('aihub:scheduled-deletions', {})
|
|
12
16
|
|
|
@@ -15,9 +19,13 @@ function entryKey(database: string, namespace: string, documentId: string): stri
|
|
|
15
19
|
}
|
|
16
20
|
|
|
17
21
|
export function useScheduledDeletions(database: MaybeRefOrGetter<string>, namespace: MaybeRefOrGetter<string>) {
|
|
22
|
+
function scheduledAt(documentId: string): number | undefined {
|
|
23
|
+
const at = store.value[entryKey(toValue(database), toValue(namespace), documentId)]
|
|
24
|
+
return at != null && Date.now() - at < TTL_MS ? at : undefined
|
|
25
|
+
}
|
|
26
|
+
|
|
18
27
|
function isScheduled(documentId: string): boolean {
|
|
19
|
-
|
|
20
|
-
return scheduledAt != null && Date.now() - scheduledAt < TTL_MS
|
|
28
|
+
return scheduledAt(documentId) != null
|
|
21
29
|
}
|
|
22
30
|
|
|
23
31
|
function schedule(documentIds: string[]): void {
|
|
@@ -44,5 +52,5 @@ export function useScheduledDeletions(database: MaybeRefOrGetter<string>, namesp
|
|
|
44
52
|
}
|
|
45
53
|
}
|
|
46
54
|
|
|
47
|
-
return { isScheduled, schedule, unschedule }
|
|
55
|
+
return { isScheduled, scheduledAt, schedule, unschedule }
|
|
48
56
|
}
|
package/formkit.config.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import AgentSelector from '@core/components/FormKit/AgentSelector.vue'
|
|
2
2
|
import ChipsInput from '@core/components/FormKit/ChipsInput.vue'
|
|
3
|
+
import CronInput from '@core/components/FormKit/CronInput.vue'
|
|
3
4
|
import IconSelector from '@core/components/FormKit/IconSelector.vue'
|
|
4
5
|
import KnowledgeDatabaseSelector from '@core/components/FormKit/KnowledgeDatabaseSelector.vue'
|
|
5
6
|
import LocaleInput from '@core/components/FormKit/LocaleInput.vue'
|
|
@@ -10,9 +11,66 @@ import { en, de, fr, it } from '@formkit/i18n'
|
|
|
10
11
|
import { createInput } from '@formkit/vue'
|
|
11
12
|
import { primeInputs } from '@sfxcode/formkit-primevue'
|
|
12
13
|
|
|
13
|
-
import type {
|
|
14
|
+
import type { FormKitNode } from '@formkit/core'
|
|
15
|
+
import type { DefaultConfigOptions, PluginConfigs } from '@formkit/vue'
|
|
16
|
+
|
|
17
|
+
const LOCALES = ['de', 'en', 'fr', 'it'] as const
|
|
18
|
+
|
|
19
|
+
// FormKit's built-in `required` rule only asks whether a value is present, and a localeInput's
|
|
20
|
+
// value is always a `{de, en, fr, it}` object — non-empty, so `required` passes even when every
|
|
21
|
+
// locale inside it is blank. Backend `LocaleInput` elements emit `localeRequired` instead
|
|
22
|
+
// (see packages/core/swiss_ai_hub/core/form/elements/locale_input.py).
|
|
23
|
+
function localeRequired(node: FormKitNode): boolean {
|
|
24
|
+
const value = node.value as Record<string, string | null> | null | undefined
|
|
25
|
+
if (!value) return false
|
|
26
|
+
return LOCALES.some(locale => !!value[locale]?.trim())
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// FormKit skips a rule entirely when the node's value is empty, unless the rule opts out —
|
|
30
|
+
// which is why the built-in `required` sets this too. Without it the rule would never run on a
|
|
31
|
+
// never-touched field, whose value is still `null`, and a blank Name would pass on a fresh
|
|
32
|
+
// create form: precisely the case this rule exists to catch.
|
|
33
|
+
localeRequired.skipEmpty = false
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Passes when the value is listed in the sibling field named by `address`, or when that list is
|
|
37
|
+
* empty — backend allow-lists treat empty as unrestricted. Reading the sibling through `node.at()`
|
|
38
|
+
* registers it as a validation dependency, so FormKit re-runs this rule when the list itself
|
|
39
|
+
* changes, not only when this field does.
|
|
40
|
+
*
|
|
41
|
+
* Advisory only: it exists so an admin sees the conflict in the form. The backend never depends on
|
|
42
|
+
* it — a value outside the allow-list is rejected where it is actually used.
|
|
43
|
+
*/
|
|
44
|
+
const memberOf: PluginConfigs['rules'][string] = (node, address: string) => {
|
|
45
|
+
const allowed = node.at(address)?.value
|
|
46
|
+
if (!Array.isArray(allowed) || allowed.length === 0) return true
|
|
47
|
+
return allowed.includes(node.value)
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const localeRequiredMessages = {
|
|
51
|
+
de: 'Mindestens eine Sprache muss ausgefüllt sein.',
|
|
52
|
+
en: 'At least one language must be filled in.',
|
|
53
|
+
fr: 'Au moins une langue doit être renseignée.',
|
|
54
|
+
it: 'Almeno una lingua deve essere compilata.',
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const memberOfMessages = {
|
|
58
|
+
de: 'Dieser Wert steht nicht in der Liste der erlaubten Werte.',
|
|
59
|
+
en: 'This value is not in the allowed list.',
|
|
60
|
+
fr: 'Cette valeur ne figure pas dans la liste des valeurs autorisées.',
|
|
61
|
+
it: 'Questo valore non è presente nell\'elenco dei valori consentiti.',
|
|
62
|
+
}
|
|
14
63
|
|
|
15
64
|
const config: DefaultConfigOptions = {
|
|
65
|
+
rules: { localeRequired, memberOf },
|
|
66
|
+
messages: Object.fromEntries(
|
|
67
|
+
LOCALES.map(locale => [locale, {
|
|
68
|
+
validation: {
|
|
69
|
+
localeRequired: localeRequiredMessages[locale],
|
|
70
|
+
memberOf: memberOfMessages[locale],
|
|
71
|
+
},
|
|
72
|
+
}]),
|
|
73
|
+
),
|
|
16
74
|
inputs: {
|
|
17
75
|
...primeInputs,
|
|
18
76
|
agentSelector: createInput(AgentSelector, {
|
|
@@ -21,6 +79,9 @@ const config: DefaultConfigOptions = {
|
|
|
21
79
|
chipsInput: createInput(ChipsInput, {
|
|
22
80
|
props: ['placeholder'],
|
|
23
81
|
}),
|
|
82
|
+
cronInput: createInput(CronInput, {
|
|
83
|
+
props: ['timezonePlaceholder', 'filter'],
|
|
84
|
+
}),
|
|
24
85
|
knowledgeDatabaseSelector: createInput(KnowledgeDatabaseSelector, {
|
|
25
86
|
props: ['placeholder', 'filter'],
|
|
26
87
|
}),
|
package/i18n/locales/de.yaml
CHANGED
|
@@ -557,7 +557,9 @@ auth:
|
|
|
557
557
|
description: Bitte melden Sie sich an, um auf die Anwendung zuzugreifen
|
|
558
558
|
tagline: Erleben Sie die Zukunft der künstlichen Intelligenz
|
|
559
559
|
welcome: Willkommen im AI Hub von {companyName}
|
|
560
|
+
welcomeProvider: Willkommen im AI Hub — Anmeldung mit {provider}
|
|
560
561
|
pleaseLogin: Bitte melden Sie sich an
|
|
562
|
+
pleaseLoginWith: Bitte melden Sie sich mit {provider} an
|
|
561
563
|
loginWith: Anmelden mit {provider}
|
|
562
564
|
customSolutions: Massgeschneiderte KI-Lösungen für Ihr Unternehmen
|
|
563
565
|
logoAlt: Logo des Unternehmens
|
|
@@ -795,6 +797,28 @@ form:
|
|
|
795
797
|
nicht wie erwartet. Um unerwartetes Verhalten zu vermeiden, deaktivieren Sie
|
|
796
798
|
sie bitte vorübergehend, bis eine Korrektur veröffentlicht wird.
|
|
797
799
|
lib:
|
|
800
|
+
cron:
|
|
801
|
+
errors:
|
|
802
|
+
required: Jede Position ist erforderlich — eine leere ergibt keinen gültigen
|
|
803
|
+
Zeitplan.
|
|
804
|
+
invalid: Hier sind nur Ziffern und die Zeichen * , - / ? erlaubt.
|
|
805
|
+
range: Werte müssen zwischen {min} und {max} liegen.
|
|
806
|
+
expression: 'Resultierender Cron-Ausdruck:'
|
|
807
|
+
minute:
|
|
808
|
+
label: Minute
|
|
809
|
+
hour:
|
|
810
|
+
label: Stunde
|
|
811
|
+
day_of_month:
|
|
812
|
+
label: Tag des Monats
|
|
813
|
+
month:
|
|
814
|
+
label: Monat
|
|
815
|
+
day_of_week:
|
|
816
|
+
label: Wochentag
|
|
817
|
+
timezone:
|
|
818
|
+
label: Zeitzone
|
|
819
|
+
placeholder: Zeitzone auswählen
|
|
820
|
+
help: Die Cron-Positionen werden in dieser Zeitzone interpretiert, damit ein
|
|
821
|
+
täglicher Zeitplan über Zeitumstellungen hinweg dieselbe Uhrzeit behält.
|
|
798
822
|
vectorStore:
|
|
799
823
|
label: Vektorspeicher
|
|
800
824
|
help: Wissensdatenbank und Namensräume für die Suche auswählen
|
package/i18n/locales/en.yaml
CHANGED
|
@@ -548,7 +548,9 @@ auth:
|
|
|
548
548
|
description: Please log in to access the application
|
|
549
549
|
tagline: Experience the future of artificial intelligence
|
|
550
550
|
welcome: Welcome to the AI Hub of {companyName}
|
|
551
|
+
welcomeProvider: Welcome to the AI Hub — sign in with {provider}
|
|
551
552
|
pleaseLogin: Please log in
|
|
553
|
+
pleaseLoginWith: Please log in with {provider}
|
|
552
554
|
loginWith: Login with {provider}
|
|
553
555
|
customSolutions: Custom AI solutions for your company
|
|
554
556
|
logoAlt: Logo of company
|
|
@@ -782,6 +784,27 @@ form:
|
|
|
782
784
|
org_memory_performance: This feature may not work as expected in some cases. To
|
|
783
785
|
avoid unexpected behavior, please temporarily disable it until a fix is released.
|
|
784
786
|
lib:
|
|
787
|
+
cron:
|
|
788
|
+
errors:
|
|
789
|
+
required: Every position is required — a blank one is not a valid schedule.
|
|
790
|
+
invalid: Only digits and the characters * , - / ? are allowed here.
|
|
791
|
+
range: Values must be between {min} and {max}.
|
|
792
|
+
expression: 'Resulting cron expression:'
|
|
793
|
+
minute:
|
|
794
|
+
label: Minute
|
|
795
|
+
hour:
|
|
796
|
+
label: Hour
|
|
797
|
+
day_of_month:
|
|
798
|
+
label: Day of month
|
|
799
|
+
month:
|
|
800
|
+
label: Month
|
|
801
|
+
day_of_week:
|
|
802
|
+
label: Day of week
|
|
803
|
+
timezone:
|
|
804
|
+
label: Timezone
|
|
805
|
+
placeholder: Select a timezone
|
|
806
|
+
help: The cron positions are interpreted in this timezone, so a daily schedule
|
|
807
|
+
keeps the same wall-clock time across daylight-saving changes.
|
|
785
808
|
vectorStore:
|
|
786
809
|
label: Vector Store
|
|
787
810
|
help: Select the knowledge database and namespaces to search
|