@xen-orchestra/web-core 0.42.0 → 0.43.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.
@@ -0,0 +1,50 @@
1
+ <template>
2
+ <div :class="className" class="vts-banner">
3
+ <slot />
4
+ <div v-if="slots.addons">
5
+ <slot name="addons" />
6
+ </div>
7
+ </div>
8
+ </template>
9
+
10
+ <script setup lang="ts">
11
+ import { toVariants } from '@core/utils/to-variants.util.ts'
12
+ import { computed } from 'vue'
13
+
14
+ export type BannerAccent = 'brand' | 'warning' | 'danger'
15
+
16
+ const { accent } = defineProps<{
17
+ accent: BannerAccent
18
+ }>()
19
+
20
+ const slots = defineSlots<{
21
+ default(): any
22
+ addons?(): any
23
+ }>()
24
+
25
+ const className = computed(() => toVariants({ accent }))
26
+ </script>
27
+
28
+ <style scoped lang="postcss">
29
+ .vts-banner {
30
+ display: flex;
31
+ justify-content: center;
32
+ align-items: center;
33
+ gap: 1.6rem;
34
+ padding: 0.8rem 1.6rem;
35
+ border-block-end: 0.1rem solid var(--color-neutral-border);
36
+
37
+ /* ACCENT */
38
+ &.accent--brand {
39
+ background-color: var(--color-brand-background-selected);
40
+ }
41
+
42
+ &.accent--warning {
43
+ background-color: var(--color-warning-background-selected);
44
+ }
45
+
46
+ &.accent--danger {
47
+ background-color: var(--color-danger-background-selected);
48
+ }
49
+ }
50
+ </style>
@@ -78,6 +78,7 @@ provide(IK_INPUT_WRAPPER_CONTROLLER, wrapperController)
78
78
  display: flex;
79
79
  flex-direction: column;
80
80
  gap: 0.4rem;
81
+ min-width: 0;
81
82
 
82
83
  .label {
83
84
  min-height: 2.4rem;
@@ -78,10 +78,10 @@ const attrs = useAttrs()
78
78
  border-color 0.125s ease-in-out;
79
79
  border: 0.2rem solid transparent;
80
80
  border-radius: 0.2rem;
81
+ flex-shrink: 0;
81
82
 
82
83
  .icon {
83
84
  font-size: 0.75rem;
84
- position: absolute;
85
85
  color: var(--color-info-txt-item);
86
86
  transition: opacity 0.125s ease-in-out;
87
87
  }
@@ -68,6 +68,7 @@ const slots = defineSlots<{
68
68
  display: flex;
69
69
  align-items: center;
70
70
  gap: 0.8rem;
71
+ min-width: 0;
71
72
  }
72
73
  }
73
74
  </style>
@@ -41,6 +41,7 @@ const isDisabled = useDisabled(() => disabled)
41
41
  .radio-container {
42
42
  display: inline-flex;
43
43
  align-items: center;
44
+ flex-shrink: 0;
44
45
  justify-content: center;
45
46
  border: 0.2rem solid var(--accent-color);
46
47
  background-color: transparent;
@@ -80,7 +81,6 @@ const isDisabled = useDisabled(() => disabled)
80
81
 
81
82
  .radio-icon {
82
83
  font-size: 0.8rem;
83
- position: absolute;
84
84
  transition: font-size 0.125s ease-in-out;
85
85
  color: var(--radio-icon-color);
86
86
  --radio-icon-color: var(--color-neutral-background-primary);
@@ -6,7 +6,7 @@
6
6
  {{ label }}
7
7
  </UiLabel>
8
8
  </slot>
9
- <div class="group" :class="{ vertical }">
9
+ <div class="group" :class="[className, { vertical }]">
10
10
  <slot />
11
11
  </div>
12
12
  <slot v-if="slots.info || info !== undefined" name="info">
@@ -20,10 +20,12 @@
20
20
  <script setup lang="ts">
21
21
  import UiInfo from '@core/components/ui/info/UiInfo.vue'
22
22
  import UiLabel from '@core/components/ui/label/UiLabel.vue'
23
+ import { toVariants } from '@core/utils/to-variants.util.ts'
23
24
  import { computed } from 'vue'
24
25
 
25
- const { accent } = defineProps<{
26
+ const { accent, gap } = defineProps<{
26
27
  accent: 'brand' | 'warning' | 'danger'
28
+ gap: 'narrow' | 'wide'
27
29
  label?: string
28
30
  info?: string
29
31
  vertical?: boolean
@@ -35,6 +37,7 @@ const slots = defineSlots<{
35
37
  info?(): any
36
38
  }>()
37
39
  const labelAccent = computed(() => (accent === 'brand' ? 'neutral' : accent))
40
+ const className = computed(() => toVariants({ gap }))
38
41
  </script>
39
42
 
40
43
  <style scoped lang="postcss">
@@ -45,12 +48,22 @@ const labelAccent = computed(() => (accent === 'brand' ? 'neutral' : accent))
45
48
 
46
49
  .group {
47
50
  display: flex;
48
- gap: 6.4rem;
49
51
 
50
52
  &.vertical {
51
53
  flex-direction: column;
52
54
  gap: 0.8rem;
53
55
  }
56
+
57
+ /* GAP */
58
+
59
+ &.gap--narrow {
60
+ flex-wrap: wrap;
61
+ gap: 0.4rem;
62
+ }
63
+
64
+ &.gap--wide {
65
+ gap: 6.4rem;
66
+ }
54
67
  }
55
68
  }
56
69
  </style>
@@ -0,0 +1,247 @@
1
+ <!-- v1 -->
2
+ <template>
3
+ <label :class="variants" class="ui-rich-radio-button typo-body-regular">
4
+ <span class="rich-image">
5
+ <img :src :alt />
6
+ </span>
7
+ <span class="radio-wrapper typo-body-regular">
8
+ <span class="radio-container">
9
+ <input v-model="model" :disabled="isDisabled" :value class="input" type="radio" />
10
+ <VtsIcon name="fa:circle" size="small" class="radio-icon" />
11
+ </span>
12
+ <slot />
13
+ </span>
14
+ </label>
15
+ </template>
16
+
17
+ <script lang="ts" setup>
18
+ import VtsIcon from '@core/components/icon/VtsIcon.vue'
19
+ import { useDisabled } from '@core/composables/disabled.composable.ts'
20
+ import { toVariants } from '@core/utils/to-variants.util.ts'
21
+ import { computed } from 'vue'
22
+
23
+ const { accent, value, disabled, size } = defineProps<{
24
+ accent: 'brand' | 'warning' | 'danger'
25
+ size: 'small' | 'medium'
26
+ value: unknown
27
+ src: string
28
+ alt: string
29
+ disabled?: boolean
30
+ }>()
31
+
32
+ const model = defineModel<string>()
33
+
34
+ defineSlots<{
35
+ default(): any
36
+ }>()
37
+
38
+ const variants = computed(() => toVariants({ accent, size }))
39
+ const isDisabled = useDisabled(() => disabled)
40
+ </script>
41
+
42
+ <style lang="postcss" scoped>
43
+ .ui-rich-radio-button {
44
+ cursor: pointer;
45
+ border: 0.1rem solid var(--color-neutral-border);
46
+ border-radius: 0.4rem;
47
+ overflow: hidden;
48
+
49
+ .rich-image {
50
+ border-bottom: 0.1rem solid;
51
+ border-color: inherit;
52
+ overflow: hidden;
53
+ width: 100%;
54
+ display: flex;
55
+ align-items: center;
56
+ justify-content: center;
57
+
58
+ img {
59
+ width: 100%;
60
+ height: 100%;
61
+ object-fit: cover;
62
+ }
63
+ }
64
+
65
+ .radio-container {
66
+ display: inline-flex;
67
+ align-items: center;
68
+ justify-content: center;
69
+ border: 0.2rem solid var(--accent-color);
70
+ background-color: transparent;
71
+ border-radius: 0.8rem;
72
+ width: 1.6rem;
73
+ height: 1.6rem;
74
+ position: relative;
75
+ transition:
76
+ border-color 0.125s ease-in-out,
77
+ background-color 0.125s ease-in-out;
78
+
79
+ &:has(input:focus-visible) {
80
+ outline: none;
81
+
82
+ &::after {
83
+ position: absolute;
84
+ content: '';
85
+ inset: -0.5rem;
86
+ border: 0.2rem solid var(--color-brand-txt-base);
87
+ border-radius: 0.4rem;
88
+ }
89
+ }
90
+
91
+ &:has(.input:checked) {
92
+ border-color: var(--accent-checked-color);
93
+ background-color: var(--accent-checked-color);
94
+ }
95
+
96
+ &:has(.input:checked:disabled) {
97
+ border-color: var(--color-neutral-txt-secondary);
98
+ background-color: var(--color-neutral-txt-secondary);
99
+ }
100
+
101
+ .input {
102
+ opacity: 0;
103
+ position: absolute;
104
+ pointer-events: none;
105
+ }
106
+
107
+ .radio-icon {
108
+ font-size: 0.8rem;
109
+ position: absolute;
110
+ transition: font-size 0.125s ease-in-out;
111
+ color: var(--radio-icon-color);
112
+ --radio-icon-color: var(--color-neutral-background-primary);
113
+ }
114
+
115
+ .input:not(:checked) + .radio-icon {
116
+ font-size: 1.2rem;
117
+ }
118
+
119
+ .input:disabled + .radio-icon {
120
+ --radio-icon-color: var(--color-neutral-background-disabled);
121
+ }
122
+ }
123
+
124
+ /* ACCENT */
125
+
126
+ &.accent--brand {
127
+ --accent-color: var(--color-brand-txt-base);
128
+ --accent-hover-color: var(--color-brand-txt-hover);
129
+ --accent-checked-color: var(--color-brand-item-base);
130
+ --accent-active-color: var(--color-brand-txt-active);
131
+ }
132
+
133
+ &.accent--warning {
134
+ --accent-color: var(--color-warning-txt-base);
135
+ --accent-hover-color: var(--color-warning-txt-hover);
136
+ --accent-checked-color: var(--color-warning-item-base);
137
+ --accent-active-color: var(--color-warning-txt-active);
138
+ }
139
+
140
+ &.accent--danger {
141
+ --accent-color: var(--color-danger-txt-base);
142
+ --accent-hover-color: var(--color-danger-txt-hover);
143
+ --accent-checked-color: var(--color-danger-item-base);
144
+ --accent-active-color: var(--color-danger-txt-active);
145
+ }
146
+
147
+ /* DISABLED */
148
+
149
+ &:has(.input:disabled) {
150
+ cursor: not-allowed;
151
+ --accent-color: var(--color-neutral-txt-secondary);
152
+ }
153
+ .radio-wrapper {
154
+ display: flex;
155
+ align-items: center;
156
+ gap: 0.8rem;
157
+ padding: 1.6rem;
158
+ background-color: var(--color-neutral-background-primary);
159
+ cursor: pointer;
160
+ }
161
+
162
+ /* INITIAL BORDER EXCEPT BRAND */
163
+
164
+ &.accent--warning,
165
+ &.accent--danger {
166
+ border-color: var(--accent-color);
167
+ }
168
+
169
+ /* CHECKED STATE */
170
+
171
+ &:has(input:checked:not(:disabled)) {
172
+ border-color: var(--accent-checked-color);
173
+
174
+ .radio-container {
175
+ border-color: var(--accent-checked-color);
176
+ background-color: var(--accent-checked-color);
177
+ }
178
+ }
179
+
180
+ /* HOVER STATE */
181
+
182
+ &:hover:not(:has(input:disabled)) {
183
+ border-color: var(--accent-hover-color);
184
+
185
+ .radio-container {
186
+ border-color: var(--accent-hover-color);
187
+ }
188
+ .radio-container:has(.input:checked) {
189
+ background-color: var(--accent-hover-color);
190
+ }
191
+ }
192
+
193
+ /* ACTIVE STATE */
194
+
195
+ &:active:not(:has(input:disabled)) {
196
+ .radio-container {
197
+ border-color: var(--accent-active-color);
198
+ }
199
+ .radio-container:has(.input:checked) {
200
+ background-color: var(--accent-active-color);
201
+ }
202
+ }
203
+
204
+ /* DISABLED STATE */
205
+
206
+ &:has(input:disabled) {
207
+ border-color: var(--color-neutral-border);
208
+
209
+ .rich-image {
210
+ opacity: 0.5;
211
+ }
212
+
213
+ .radio-wrapper {
214
+ background-color: var(--color-neutral-background-disabled);
215
+ cursor: not-allowed;
216
+ }
217
+ }
218
+
219
+ /* SIZES */
220
+
221
+ &.size--small {
222
+ height: 16.4rem;
223
+ width: 12.8rem;
224
+
225
+ .rich-image {
226
+ height: 10.8rem;
227
+ }
228
+
229
+ .radio-wrapper {
230
+ height: 5.6rem;
231
+ }
232
+ }
233
+
234
+ &.size--medium {
235
+ height: 25.6rem;
236
+ width: 20rem;
237
+
238
+ .rich-image {
239
+ height: 20rem;
240
+ }
241
+
242
+ .radio-wrapper {
243
+ height: 5.6rem;
244
+ }
245
+ }
246
+ }
247
+ </style>
@@ -16,6 +16,16 @@
16
16
  />
17
17
  <slot name="app-header" />
18
18
  </header>
19
+ <VtsBanner v-if="showBanner" accent="danger">
20
+ <UiInfo accent="danger">
21
+ {{ t('unable-to-connect-to-xo-server') }}
22
+ </UiInfo>
23
+ <template #addons>
24
+ <UiButton variant="primary" accent="brand" size="small" @click="handleRetry()">
25
+ {{ t('retry') }}
26
+ </UiButton>
27
+ </template>
28
+ </VtsBanner>
19
29
  <div class="container">
20
30
  <template v-if="uiStore.hasUi">
21
31
  <VtsBackdrop
@@ -43,17 +53,33 @@
43
53
 
44
54
  <script lang="ts" setup>
45
55
  import VtsBackdrop from '@core/components/backdrop/VtsBackdrop.vue'
56
+ import VtsBanner from '@core/components/banner/VtsBanner.vue'
46
57
  import VtsLayoutSidebar from '@core/components/layout/VtsLayoutSidebar.vue'
58
+ import UiButton from '@core/components/ui/button/UiButton.vue'
47
59
  import UiButtonIcon from '@core/components/ui/button-icon/UiButtonIcon.vue'
60
+ import UiInfo from '@core/components/ui/info/UiInfo.vue'
48
61
  import { vTooltip } from '@core/directives/tooltip.directive'
62
+ import { useSseStore } from '@core/packages/remote-resource/sse.store.ts'
49
63
  import { useSidebarStore } from '@core/stores/sidebar.store'
50
64
  import { useUiStore } from '@core/stores/ui.store'
65
+ import { storeToRefs } from 'pinia'
66
+ import { computed } from 'vue'
51
67
  import { useI18n } from 'vue-i18n'
52
68
 
53
69
  const { t } = useI18n()
54
70
 
55
71
  const uiStore = useUiStore()
56
72
  const sidebarStore = useSidebarStore()
73
+
74
+ const sseStore = useSseStore()
75
+
76
+ const { hasErrorSse } = storeToRefs(sseStore)
77
+
78
+ const showBanner = computed(() => hasErrorSse.value)
79
+
80
+ function handleRetry() {
81
+ sseStore.retry()
82
+ }
57
83
  </script>
58
84
 
59
85
  <style lang="postcss" scoped>
@@ -661,6 +661,7 @@
661
661
  "resources": "Resources",
662
662
  "resources-overview": "Resources overview",
663
663
  "rest-api": "REST API",
664
+ "retry": "Retry",
664
665
  "root-by-default": "\"root\" by default.",
665
666
  "run": "Run",
666
667
  "running-vm": "Running VM | Running VMs",
@@ -791,6 +792,7 @@
791
792
  "translation-tool": "Translation tool",
792
793
  "unable-to-connect-to": "Unable to connect to {ip}",
793
794
  "unable-to-connect-to-the-pool": "Unable to connect to the pool",
795
+ "unable-to-connect-to-xo-server": "Unable to connect to XO server.",
794
796
  "unknown": "Unknown",
795
797
  "unlocked": "Unlocked",
796
798
  "unreachable-hosts": "Unreachable hosts",
@@ -661,6 +661,7 @@
661
661
  "resources": "Ressources",
662
662
  "resources-overview": "Vue d'ensemble des ressources",
663
663
  "rest-api": "API REST",
664
+ "retry": "Réessayer",
664
665
  "root-by-default": "\"root\" par défaut.",
665
666
  "run": "Run",
666
667
  "running-vm": "VM en cours d'exécution | VMs en cours d'exécution",
@@ -791,6 +792,7 @@
791
792
  "translation-tool": "Outil de traduction",
792
793
  "unable-to-connect-to": "Impossible de se connecter à {ip}",
793
794
  "unable-to-connect-to-the-pool": "Impossible de se connecter au Pool",
795
+ "unable-to-connect-to-xo-server": "Impossible de se connecter au serveur XO.",
794
796
  "unknown": "Inconnu",
795
797
  "unlocked": "Débloqué",
796
798
  "unreachable-hosts": "Hôtes inaccessibles",
@@ -1,8 +1,8 @@
1
1
  import {
2
- useSseStore,
3
2
  type THandleDelete,
4
3
  type THandlePost,
5
4
  type THandleWatching,
5
+ useSseStore,
6
6
  } from '@core/packages/remote-resource/sse.store'
7
7
  import type { ResourceContext, UseRemoteResource } from '@core/packages/remote-resource/types.ts'
8
8
  import type { VoidFunction } from '@core/types/utility.type.ts'
@@ -63,7 +63,7 @@ export function defineRemoteResource<
63
63
  onDataReceived?: (data: Ref<NoInfer<TData>>, receivedData: any) => void
64
64
  onDataRemoved?: (data: Ref<NoInfer<TData>>, receivedData: any) => void
65
65
  stream?: boolean
66
- watchCollection: {
66
+ initWatchCollection: () => {
67
67
  collectionId: string
68
68
  resource: string // reactivity only on XAPI XO record for now
69
69
  getIdentifier: (obj: unknown) => string
@@ -87,7 +87,7 @@ export function defineRemoteResource<
87
87
  cacheExpirationMs?: number | false
88
88
  pollingIntervalMs?: number | false
89
89
  stream?: boolean
90
- watchCollection?: {
90
+ initWatchCollection?: () => {
91
91
  collectionId: string
92
92
  resource: string // reactivity only on XAPI XO record for now
93
93
  getIdentifier: (obj: unknown) => string
@@ -121,8 +121,10 @@ export function defineRemoteResource<
121
121
 
122
122
  const pollingInterval = config.pollingIntervalMs ?? DEFAULT_POLLING_INTERVAL_MS
123
123
 
124
+ const watchCollection = config.initWatchCollection?.()
125
+
124
126
  const removeData = (data: TData[], dataToRemove: any) => {
125
- const getIdentifier = config.watchCollection?.getIdentifier ?? JSON.stringify
127
+ const getIdentifier = watchCollection?.getIdentifier ?? JSON.stringify
126
128
 
127
129
  remove(data, item => {
128
130
  if (typeof item === 'object') {
@@ -156,14 +158,14 @@ export function defineRemoteResource<
156
158
  config.onDataReceived ??
157
159
  ((data: Ref<TData>, receivedData: any, context?: ResourceContext<TArgs>) => {
158
160
  // allow to ignore some update (like for sub collection. E.g. vms/:id/vdis)
159
- if (config.watchCollection?.predicate?.(receivedData, context) === false) {
161
+ if (watchCollection?.predicate?.(receivedData, context) === false) {
160
162
  return
161
163
  }
162
164
 
163
165
  if (data.value === undefined || (Array.isArray(data.value) && Array.isArray(receivedData))) {
164
166
  data.value = receivedData
165
167
 
166
- if (config.watchCollection !== undefined && Array.isArray(data.value)) {
168
+ if (watchCollection !== undefined && Array.isArray(data.value)) {
167
169
  handleBuffer(data as Ref<TData[]>)
168
170
  isBufferEventsProcessed = true
169
171
  }
@@ -187,7 +189,7 @@ export function defineRemoteResource<
187
189
  config.onDataRemoved ??
188
190
  ((data: Ref<TData>, receivedData: any, context?: ResourceContext<TArgs>) => {
189
191
  // allow to ignore some update (like for sub collection. E.g. vms/:id/vdis)
190
- if (config.watchCollection?.predicate?.(receivedData, context) === false) {
192
+ if (watchCollection?.predicate?.(receivedData, context) === false) {
191
193
  return
192
194
  }
193
195
 
@@ -289,8 +291,8 @@ export function defineRemoteResource<
289
291
  let pause: VoidFunction = noop
290
292
  let resume: VoidFunction = execute
291
293
 
292
- if (config.watchCollection !== undefined) {
293
- const { collectionId, resource, handleDelete, handlePost, handleWatching } = config.watchCollection
294
+ if (watchCollection !== undefined) {
295
+ const { collectionId, resource, handleDelete, handlePost, handleWatching } = watchCollection
294
296
  const { watch, unwatch } = useSseStore()
295
297
 
296
298
  pause = () => unwatch({ collectionId, resource, handleDelete })
@@ -1,5 +1,6 @@
1
+ import { useNow } from '@vueuse/core'
1
2
  import { defineStore } from 'pinia'
2
- import { ref, watch as watchVue } from 'vue'
3
+ import { computed, ref, watch as watchVue } from 'vue'
3
4
 
4
5
  type EventFn = (object: unknown) => void
5
6
  export type THandlePost = (sseId: string) => Promise<string>
@@ -22,7 +23,10 @@ export type THandleWatching = (
22
23
  ) => void
23
24
 
24
25
  export const useSseStore = defineStore('sse', () => {
25
- const sse = ref<{ id?: string; isWatching: boolean }>({ isWatching: false })
26
+ const sse = ref<{ id?: string; isWatching: boolean; lastPing?: number; errorSse?: unknown | null }>({
27
+ isWatching: false,
28
+ errorSse: null,
29
+ })
26
30
  const configsByResource: Map<
27
31
  string,
28
32
  {
@@ -38,10 +42,30 @@ export const useSseStore = defineStore('sse', () => {
38
42
  }
39
43
  > = new Map()
40
44
 
45
+ const now = useNow({ interval: 1000 })
46
+
47
+ const isError = computed(() => {
48
+ if (!sse.value.lastPing) {
49
+ return false
50
+ }
51
+
52
+ return now.value.getTime() - sse.value.lastPing > 32_000
53
+ })
54
+
55
+ const hasErrorSse = computed(() => isError.value || sse.value.errorSse !== null)
56
+
57
+ function setErrorSse(error: unknown | null) {
58
+ sse.value.errorSse = error
59
+ }
60
+
41
61
  function updateSseId(id: string) {
42
62
  sse.value.id = id
43
63
  }
44
64
 
65
+ function setPing(timestamp: number) {
66
+ sse.value.lastPing = timestamp
67
+ }
68
+
45
69
  function getConfigsByResource(resource: string) {
46
70
  return configsByResource.get(resource)
47
71
  }
@@ -135,6 +159,10 @@ export const useSseStore = defineStore('sse', () => {
135
159
  })
136
160
  }
137
161
  }
162
+ // TODO need to be improve
163
+ function retry() {
164
+ window.location.reload()
165
+ }
138
166
 
139
- return { watch, unwatch }
167
+ return { watch, unwatch, retry, hasErrorSse, setErrorSse, setPing }
140
168
  })
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@xen-orchestra/web-core",
3
3
  "type": "module",
4
- "version": "0.42.0",
4
+ "version": "0.43.0",
5
5
  "private": false,
6
6
  "exports": {
7
7
  "./*": {