@xen-orchestra/web-core 0.33.0 → 0.35.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.
Files changed (34) hide show
  1. package/lib/components/dropdown/{DropdownTitle.vue → VtsDropdownTitle.vue} +4 -4
  2. package/lib/components/input-wrapper/VtsInputWrapper.vue +1 -10
  3. package/lib/components/label-value-list/VtsLabelValueList.vue +46 -0
  4. package/lib/components/menu/MenuList.vue +1 -0
  5. package/lib/components/progress-bar/VtsProgressBar.vue +8 -3
  6. package/lib/components/size-progress-cell/VtsSizeProgressCell.vue +36 -0
  7. package/lib/components/space-card/VtsSpaceCard.vue +94 -0
  8. package/lib/components/ui/input/UiInput.vue +5 -7
  9. package/lib/components/ui/label/UiLabel.vue +4 -15
  10. package/lib/components/ui/tag/UiTag.vue +4 -1
  11. package/lib/components/ui/tag/UiTagsList.vue +11 -1
  12. package/lib/components/ui/text-area/UiTextarea.vue +1 -3
  13. package/lib/i18n.ts +4 -0
  14. package/lib/icons/fa-icons.ts +4 -0
  15. package/lib/icons/legacy-icons.ts +33 -8
  16. package/lib/locales/cs.json +12 -6
  17. package/lib/locales/da.json +261 -0
  18. package/lib/locales/de.json +3 -3
  19. package/lib/locales/en.json +57 -7
  20. package/lib/locales/es.json +1 -5
  21. package/lib/locales/fa.json +1 -1
  22. package/lib/locales/fr.json +61 -11
  23. package/lib/locales/it.json +9 -0
  24. package/lib/locales/nl.json +10 -5
  25. package/lib/locales/pt_BR.json +75 -16
  26. package/lib/locales/ru.json +1 -5
  27. package/lib/locales/sv.json +1 -5
  28. package/lib/locales/uk.json +2 -6
  29. package/lib/packages/remote-resource/README.md +32 -0
  30. package/lib/packages/remote-resource/define-remote-resource.ts +107 -17
  31. package/lib/packages/remote-resource/sse.store.ts +140 -0
  32. package/lib/types/utility.type.ts +6 -0
  33. package/lib/utils/progress.util.ts +7 -5
  34. package/package.json +1 -1
@@ -47,12 +47,8 @@
47
47
  "backup-repositories": "Säkerhetskopieringsförvaring",
48
48
  "backup-repository": "Säkerhetskopieringsförvaring (lokalt, NFS, SMB)",
49
49
  "backup-targets": "Säkerhetskopieringsmål",
50
- "backup.continuous-replication": "Kontinuerlig replikering",
51
- "backup.disaster-recovery": "Katastrofåterställning",
52
50
  "backup.full": "Fullständig säkerhetskopiering",
53
51
  "backup.incremental": "Inkrementell säkerhetskopiering",
54
- "backup.metadata": "Säkerhetskopiering av metadata",
55
- "backup.mirror": "Speglad säkerhetskopiering",
56
52
  "backup.pool-metadata": "Pool-metadata",
57
53
  "backup.rolling-snapshot": "Rullande ögonblicksbild",
58
54
  "backup.xo-config": "XO-konfig",
@@ -75,7 +71,6 @@
75
71
  "bond-devices": "Bindningsenheter",
76
72
  "bond-status": "Bindnings-status",
77
73
  "boot-firmware": "Boot-programvara",
78
- "boot-firmware-bios": "Den här mallen innehåller redan BIOS-strängar",
79
74
  "boot-firmware-uefi": "Boot-programvaran är UEFI",
80
75
  "boot-vm": "Starta VM",
81
76
  "build-number": "Versionsnummer",
@@ -557,6 +552,7 @@
557
552
  "tasks.quick-view.failed": "Misslyckades",
558
553
  "tasks.quick-view.in-progress": "Pågår",
559
554
  "template": "Mall",
555
+ "template-has-bios-strings": "Den här mallen innehåller redan BIOS-strängar",
560
556
  "theme-auto": "Auto",
561
557
  "theme-dark": "Mörkt",
562
558
  "theme-light": "Ljust",
@@ -44,12 +44,8 @@
44
44
  "backup-network": "Резервна мережа",
45
45
  "backup-repository": "Сховище резервних копій (local, NFS, SMB)",
46
46
  "backup-targets": "Призначення для резервної копії",
47
- "backup.continuous-replication": "Постійна реплікація",
48
- "backup.disaster-recovery": "Відновлення після критичних помилок",
49
47
  "backup.full": "Повна резервна копія",
50
48
  "backup.incremental": "Інкрементальна резервна копія",
51
- "backup.metadata": "Резервна копія метаданих",
52
- "backup.mirror": "Дзеркальна резервна копія",
53
49
  "backup.pool-metadata": "Метадані пулу",
54
50
  "backup.rolling-snapshot": "Ковзний знімок",
55
51
  "backup.xo-config": "Конфігурація XO",
@@ -72,7 +68,6 @@
72
68
  "bond-devices": "Пристрої Bond",
73
69
  "bond-status": "Статус Bond",
74
70
  "boot-firmware": "Завантажувальна прошивка",
75
- "boot-firmware-bios": "Шаблон вже містить налаштування BIOS",
76
71
  "boot-firmware-uefi": "Завантажувальна прошивка - UEFI",
77
72
  "boot-vm": "Завантажувальна ВМ",
78
73
  "build-number": "Номер збірки",
@@ -311,8 +306,9 @@
311
306
  "ssh-password": "пароль SSH",
312
307
  "ssh-password-confirm": "Підтвердіть пароль SSH",
313
308
  "static-ip": "Статична IP-адреса",
309
+ "template-has-bios-strings": "Шаблон вже містить налаштування BIOS",
314
310
  "uuid": "UUID",
315
- "vcpus": "vCPU | vCPU | vCPUs",
311
+ "vcpus": "vCPU | vCPU | vCPUs",
316
312
  "vdi-throughput": "Пропускна здатність VDI",
317
313
  "vdis": "VDI | VDI | VDIs",
318
314
  "vif": "VIF",
@@ -113,3 +113,35 @@ const useMyResource = defineRemoteResource({
113
113
  cacheDurationMs: 5 * 60_000, // Cache for 5 minutes
114
114
  })
115
115
  ```
116
+
117
+ ## Watching a collection
118
+
119
+ You can enable real-time synchronization with a remote collection by using the `watchCollection` option.
120
+ When this option is set, the resource will first fetch the complete dataset once, then listen for any changes on the collection (such as additions, updates, or removals) and automatically update the shared store accordingly.
121
+
122
+ ```typescript
123
+ const useMyResource = defineRemoteResource({
124
+ url: '/api/path/to/resource?fields=id,name,status',
125
+ watchCollection: {
126
+ type: 'resource',
127
+ fields: ['id', 'name', 'status'],
128
+ },
129
+ // Optional
130
+ onDataReceived: (currentData, receivedData) => {
131
+ // Called when new or updated data is received
132
+ mergeCollection(currentData.value, receivedData)
133
+ },
134
+ // Optional
135
+ onDataRemoved: (currentData, removedData) => {
136
+ // Called when data is removed from the collection
137
+ removeFromCollection(currentData.value, removedData)
138
+ },
139
+ })
140
+ ```
141
+
142
+ When a collection is being watched:
143
+
144
+ - The initial fetch retrieves the entire dataset.
145
+ - Any subsequent changes (additions, updates, deletions) are automatically reflected in the resource’s data.
146
+ - You can customize how incoming or removed data is handled using the onDataReceived and onDataRemoved callbacks.
147
+ - The subscription to collection changes automatically stops when there are no more active component subscribers.
@@ -1,8 +1,14 @@
1
+ import {
2
+ useSseStore,
3
+ type THandleDelete,
4
+ type THandlePost,
5
+ type THandleWatching,
6
+ } from '@core/packages/remote-resource/sse.store'
1
7
  import type { ResourceContext, UseRemoteResource } from '@core/packages/remote-resource/types.ts'
2
8
  import type { VoidFunction } from '@core/types/utility.type.ts'
3
9
  import { ifElse } from '@core/utils/if-else.utils.ts'
4
10
  import { type MaybeRef, noop, useTimeoutPoll } from '@vueuse/core'
5
- import { merge } from 'lodash-es'
11
+ import { merge, remove } from 'lodash-es'
6
12
  import readNDJSONStream from 'ndjson-readablestream'
7
13
  import {
8
14
  computed,
@@ -19,7 +25,7 @@ import {
19
25
  watch,
20
26
  } from 'vue'
21
27
 
22
- const DEFAULT_CACHE_DURATION_MS = 10_000
28
+ const DEFAULT_CACHE_EXPIRATION_MS = 10_000
23
29
 
24
30
  const DEFAULT_POLLING_INTERVAL_MS = 30_000
25
31
 
@@ -32,8 +38,8 @@ export function defineRemoteResource<
32
38
  initialData: () => TData
33
39
  state?: (data: Ref<NoInfer<TData>>, context: ResourceContext<TArgs>) => TState
34
40
  onDataReceived?: (data: Ref<NoInfer<TData>>, receivedData: any) => void
35
- cacheDurationMs?: number
36
- pollingIntervalMs?: number
41
+ cacheExpirationMs?: number | false
42
+ pollingIntervalMs?: number | false
37
43
  stream?: boolean
38
44
  }): UseRemoteResource<TState, TArgs>
39
45
 
@@ -41,11 +47,33 @@ export function defineRemoteResource<TData, TState extends object, TArgs extends
41
47
  url: string | ((...args: TArgs) => string)
42
48
  state?: (data: Ref<TData | undefined>, context: ResourceContext<TArgs>) => TState
43
49
  onDataReceived?: (data: Ref<TData | undefined>, receivedData: any) => void
44
- cacheDurationMs?: number
45
- pollingIntervalMs?: number
50
+ cacheExpirationMs?: number | false
51
+ pollingIntervalMs?: number | false
46
52
  stream?: boolean
47
53
  }): UseRemoteResource<TState, TArgs>
48
54
 
55
+ export function defineRemoteResource<
56
+ TData,
57
+ TState extends object = { data: Ref<TData> },
58
+ TArgs extends any[] = [],
59
+ >(config: {
60
+ url: string | ((...args: TArgs) => string)
61
+ initialData: () => TData
62
+ state?: (data: Ref<NoInfer<TData>>, context: ResourceContext<TArgs>) => TState
63
+ onDataReceived?: (data: Ref<NoInfer<TData>>, receivedData: any) => void
64
+ onDataRemoved?: (data: Ref<NoInfer<TData>>, receivedData: any) => void
65
+ stream?: boolean
66
+ watchCollection: {
67
+ collectionId: string
68
+ resource: string // reactivity only on XAPI XO record for now
69
+ getIdentifier: (obj: unknown) => string
70
+ handleDelete: THandleDelete
71
+ handlePost: THandlePost
72
+ handleWatching: THandleWatching
73
+ predicate?: (receivedData: TData, context: ResourceContext<TArgs> | undefined) => boolean
74
+ }
75
+ }): UseRemoteResource<TState, TArgs>
76
+
49
77
  export function defineRemoteResource<
50
78
  TData,
51
79
  TState extends object = { data: Ref<TData> },
@@ -55,9 +83,19 @@ export function defineRemoteResource<
55
83
  initialData?: () => TData
56
84
  state?: (data: Ref<TData>, context: ResourceContext<TArgs>) => TState
57
85
  onDataReceived?: (data: Ref<NoInfer<TData>>, receivedData: any) => void
58
- cacheDurationMs?: number
59
- pollingIntervalMs?: number
86
+ onDataRemoved?: (data: Ref<NoInfer<TData>>, receivedData: any) => void
87
+ cacheExpirationMs?: number | false
88
+ pollingIntervalMs?: number | false
60
89
  stream?: boolean
90
+ watchCollection?: {
91
+ collectionId: string
92
+ resource: string // reactivity only on XAPI XO record for now
93
+ getIdentifier: (obj: unknown) => string
94
+ handleDelete: THandleDelete
95
+ handlePost: THandlePost
96
+ handleWatching: THandleWatching
97
+ predicate?: (receivedData: TData, context: ResourceContext<TArgs> | undefined) => boolean
98
+ }
61
99
  }) {
62
100
  const cache = new Map<
63
101
  string,
@@ -79,26 +117,60 @@ export function defineRemoteResource<
79
117
 
80
118
  const buildState = config.state ?? ((data: Ref<TData>) => ({ data }))
81
119
 
82
- const cacheDuration = config.cacheDurationMs ?? DEFAULT_CACHE_DURATION_MS
120
+ const cacheExpiration = config.cacheExpirationMs ?? DEFAULT_CACHE_EXPIRATION_MS
83
121
 
84
122
  const pollingInterval = config.pollingIntervalMs ?? DEFAULT_POLLING_INTERVAL_MS
85
123
 
124
+ const removeData = (data: TData[], dataToRemove: any) => {
125
+ const getIdentifier = config.watchCollection?.getIdentifier ?? JSON.stringify
126
+
127
+ remove(data, item => {
128
+ if (typeof item === 'object') {
129
+ return getIdentifier(item) === getIdentifier(dataToRemove)
130
+ }
131
+
132
+ return item === dataToRemove
133
+ })
134
+ }
135
+
86
136
  const onDataReceived =
87
137
  config.onDataReceived ??
88
- ((data: Ref<TData>, receivedData: any) => {
89
- if (!config.stream || data.value === undefined) {
138
+ ((data: Ref<TData>, receivedData: any, args?: ResourceContext<TArgs>) => {
139
+ // allow to ignore some update (like for sub collection. E.g. vms/:id/vdis)
140
+ if (config.watchCollection?.predicate?.(receivedData, args) === false) {
141
+ return
142
+ }
143
+ if (data.value === undefined || (Array.isArray(data.value) && Array.isArray(receivedData))) {
90
144
  data.value = receivedData
91
145
  return
92
146
  }
93
147
 
94
- if (Array.isArray(data.value) && Array.isArray(receivedData)) {
95
- data.value.push(...receivedData)
148
+ if (Array.isArray(data.value)) {
149
+ removeData(data.value, receivedData)
150
+ data.value.push(receivedData)
96
151
  return
97
152
  }
98
153
 
99
154
  merge(data.value, receivedData)
100
155
  })
101
156
 
157
+ const onDataRemoved =
158
+ config.onDataRemoved ??
159
+ ((data: Ref<TData>, receivedData: any, args?: ResourceContext<TArgs>) => {
160
+ // allow to ignore some update (like for sub collection. E.g. vms/:id/vdis)
161
+ if (!config.watchCollection?.predicate?.(receivedData, args)) {
162
+ return
163
+ }
164
+
165
+ // for now only support `onDataRemoved` when watching XapiXoRecord collection
166
+ if (Array.isArray(data.value) && !Array.isArray(receivedData)) {
167
+ removeData(data.value, receivedData)
168
+ return
169
+ }
170
+
171
+ console.warn('onDataRemoved received but unhandled for:', receivedData)
172
+ })
173
+
102
174
  function subscribeToUrl(url: string) {
103
175
  const entry = cache.get(url)
104
176
 
@@ -128,9 +200,11 @@ export function defineRemoteResource<
128
200
 
129
201
  entry.pause()
130
202
 
131
- setTimeout(() => {
132
- cache.delete(url)
133
- }, cacheDuration)
203
+ if (cacheExpiration !== false) {
204
+ setTimeout(() => {
205
+ cache.delete(url)
206
+ }, cacheExpiration)
207
+ }
134
208
  }
135
209
 
136
210
  function registerUrl(url: string, context: ResourceContext<TArgs>) {
@@ -182,7 +256,23 @@ export function defineRemoteResource<
182
256
  let pause: VoidFunction = noop
183
257
  let resume: VoidFunction = execute
184
258
 
185
- if (pollingInterval > 0) {
259
+ if (config.watchCollection !== undefined) {
260
+ const { collectionId, resource, handleDelete, handlePost, handleWatching } = config.watchCollection
261
+ const { watch, unwatch } = useSseStore()
262
+
263
+ pause = () => unwatch({ collectionId, resource, handleDelete })
264
+ resume = async function () {
265
+ await execute()
266
+ await watch({
267
+ collectionId,
268
+ handleWatching,
269
+ handlePost,
270
+ resource,
271
+ onDataReceived: receivedData => onDataReceived(data, receivedData, context),
272
+ onDataRemoved: receivedData => onDataRemoved(data, receivedData, context),
273
+ })
274
+ }
275
+ } else if (pollingInterval !== false) {
186
276
  const timeoutPoll = useTimeoutPoll(execute, pollingInterval, {
187
277
  immediateCallback: true,
188
278
  immediate: false,
@@ -0,0 +1,140 @@
1
+ import { defineStore } from 'pinia'
2
+ import { ref, watch as watchVue } from 'vue'
3
+
4
+ type EventFn = (object: unknown) => void
5
+ export type THandlePost = (sseId: string) => Promise<string>
6
+ export type THandleDelete = (sseId: string, subscriptionId: string) => Promise<void>
7
+ export type THandleWatching = (
8
+ updateSseId: (id: string) => void,
9
+ getConfigByResource: (resource: string) =>
10
+ | {
11
+ subscriptionId: string
12
+ configs: Record<
13
+ string,
14
+ {
15
+ add: (object: unknown) => void
16
+ update: (object: unknown) => void
17
+ remove: (object: unknown) => void
18
+ }
19
+ >
20
+ }
21
+ | undefined
22
+ ) => void
23
+
24
+ export const useSseStore = defineStore('sse', () => {
25
+ const sse = ref<{ id?: string; isWatching: boolean }>({ isWatching: false })
26
+ const configsByResource: Map<
27
+ string,
28
+ {
29
+ subscriptionId: string
30
+ configs: Record<
31
+ string,
32
+ {
33
+ add: EventFn
34
+ update: EventFn
35
+ remove: EventFn
36
+ }
37
+ >
38
+ }
39
+ > = new Map()
40
+
41
+ function updateSseId(id: string) {
42
+ sse.value.id = id
43
+ }
44
+
45
+ function getConfigsByResource(resource: string) {
46
+ return configsByResource.get(resource)
47
+ }
48
+
49
+ function initializeWatcher(handleWatching: THandleWatching) {
50
+ return new Promise((resolve, reject) => {
51
+ if (sse.value.id !== undefined) {
52
+ return reject(new Error('SSE already initialized'))
53
+ }
54
+
55
+ watchVue(
56
+ sse,
57
+ value => {
58
+ if (value.id !== undefined) {
59
+ resolve(undefined)
60
+ }
61
+ },
62
+ { deep: true }
63
+ )
64
+
65
+ if (!sse.value.isWatching) {
66
+ sse.value.isWatching = true
67
+ handleWatching(updateSseId, getConfigsByResource)
68
+ }
69
+ })
70
+ }
71
+
72
+ async function watch({
73
+ collectionId,
74
+ resource,
75
+ onDataReceived,
76
+ onDataRemoved,
77
+ handlePost,
78
+ handleWatching,
79
+ }: {
80
+ collectionId: string
81
+ resource: string
82
+ onDataReceived: EventFn
83
+ onDataRemoved: EventFn
84
+ handlePost: THandlePost
85
+ handleWatching: THandleWatching
86
+ }) {
87
+ if (sse.value.id === undefined) {
88
+ await initializeWatcher(handleWatching)
89
+ }
90
+
91
+ const id = await handlePost(sse.value.id!)
92
+
93
+ const { subscriptionId, configs } = configsByResource.get(resource) ?? {}
94
+
95
+ if (subscriptionId !== undefined && id !== subscriptionId) {
96
+ throw new Error(`Previous subscription ID: ${subscriptionId} and new one: ${id} are not the same`)
97
+ }
98
+
99
+ configsByResource.set(resource, {
100
+ subscriptionId: id,
101
+ configs: {
102
+ ...configs,
103
+ [collectionId]: {
104
+ add: onDataReceived,
105
+ update: onDataReceived,
106
+ remove: onDataRemoved,
107
+ },
108
+ },
109
+ })
110
+ }
111
+
112
+ async function unwatch({
113
+ collectionId,
114
+ resource,
115
+ handleDelete,
116
+ }: {
117
+ collectionId: string
118
+ resource: string
119
+ handleDelete: THandleDelete
120
+ }) {
121
+ if (sse.value.id === undefined || !configsByResource.has(resource)) {
122
+ return
123
+ }
124
+
125
+ const { configs, subscriptionId } = configsByResource.get(resource)!
126
+ delete configs[collectionId]
127
+
128
+ if (Object.keys(configs).length === 0) {
129
+ await handleDelete(sse.value.id, subscriptionId)
130
+ configsByResource.delete(resource)
131
+ } else {
132
+ configsByResource.set(resource, {
133
+ subscriptionId,
134
+ configs,
135
+ })
136
+ }
137
+ }
138
+
139
+ return { watch, unwatch }
140
+ })
@@ -1,3 +1,5 @@
1
+ import type { XapiXoRecord, XoAlarm } from '@vates/types/'
2
+
1
3
  export type MaybeArray<T> = T | T[]
2
4
 
3
5
  export type VoidFunction = () => void
@@ -18,3 +20,7 @@ export type KeyOfByValue<T, TValue> =
18
20
  : never
19
21
 
20
22
  export type ArrayFilterPredicate<T> = (value: T, index: number, array: T[]) => boolean
23
+
24
+ export type GetRecordByType<TType extends XapiXoRecord['type'] | 'alarm'> = TType extends 'alarm'
25
+ ? XoAlarm
26
+ : Extract<XapiXoRecord, { type: TType }>
@@ -28,24 +28,26 @@ export function cpuProgressThresholds(tooltip?: string): ThresholdConfig<Progres
28
28
 
29
29
  export function useProgressToLegend(
30
30
  rawType: MaybeRefOrGetter<ProgressBarLegendType | undefined>
31
- ): (label: string, progress: Progress | Reactive<Progress>) => ProgressBarLegend | undefined
31
+ ): (label: string | undefined, progress: Progress | Reactive<Progress>) => ProgressBarLegend | undefined
32
32
 
33
33
  export function useProgressToLegend(
34
34
  rawType: MaybeRefOrGetter<ProgressBarLegendType | undefined>,
35
- label: string,
35
+ rawLabel: MaybeRefOrGetter<string | undefined>,
36
36
  progress: Progress | Reactive<Progress>
37
37
  ): ComputedRef<ProgressBarLegend | undefined>
38
38
 
39
39
  export function useProgressToLegend(
40
40
  rawType: MaybeRefOrGetter<ProgressBarLegendType | undefined>,
41
- label?: string,
41
+ rawLabel?: MaybeRefOrGetter<string | undefined>,
42
42
  progress?: Progress | Reactive<Progress>
43
43
  ) {
44
44
  const { n } = useI18n()
45
45
 
46
46
  const type = toComputed(rawType)
47
47
 
48
- function toLegend(label: string, progress: Progress | Reactive<Progress>): ProgressBarLegend | undefined {
48
+ const label = toComputed(rawLabel)
49
+
50
+ function toLegend(label: string = '', progress: Progress | Reactive<Progress>): ProgressBarLegend | undefined {
49
51
  switch (type.value) {
50
52
  case 'percent':
51
53
  return { label, value: n(toValue(progress.percentage) / 100, { maximumFractionDigits: 1, style: 'percent' }) }
@@ -66,7 +68,7 @@ export function useProgressToLegend(
66
68
  }
67
69
 
68
70
  if (label && progress) {
69
- return computed(() => toLegend(label, progress))
71
+ return computed(() => toLegend(label.value, progress))
70
72
  }
71
73
 
72
74
  return toLegend
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@xen-orchestra/web-core",
3
3
  "type": "module",
4
- "version": "0.33.0",
4
+ "version": "0.35.0",
5
5
  "private": false,
6
6
  "exports": {
7
7
  "./*": {