Compare commits

..

51 Commits

Author SHA1 Message Date
mathieuRA
2102bec236 add listener on Index to be able ton get the collection type even if the event remove is emit 2025-01-30 12:34:24 +01:00
mathieuRA
f3ff78f790 possibility to register route from outside 2025-01-29 17:22:40 +01:00
mathieuRA
d97873242a export import/export vdi 2025-01-27 14:36:47 +01:00
mathieuRA
ce651df896 add small action 2025-01-24 18:42:22 +01:00
mathieuRA
bdcd8925c7 setup error middleware 2025-01-24 17:45:30 +01:00
mathieuRA
235d0ae103 setup auth middleware 2025-01-24 16:40:21 +01:00
mathieuRA
5ad7caeb1c mutualize some code + enhance typing 2025-01-24 15:51:29 +01:00
mathieuRA
34da6fd857 auto bind class for DI 2025-01-23 21:10:57 +01:00
mathieuRA
a62aec0e0b clean deps 2025-01-23 12:07:13 +01:00
mathieuRA
4bf887076d update dependency 2025-01-23 11:38:20 +01:00
mathieuRA
06dd432803 remove unused folder 2025-01-23 11:34:50 +01:00
mathieuRA
344906e5df use dependency injection 2025-01-23 11:32:14 +01:00
mathieuRA
6df0ebc135 expose dashboard + send event if changes 2025-01-23 11:32:14 +01:00
mathieuRA
22bc90847a remove unused code 2025-01-23 11:32:14 +01:00
mathieuRA
300d16ae1c do not pass all xoApp on the router 2025-01-23 11:32:14 +01:00
mathieuRA
d11526b277 watch event for XAPI objects 2025-01-23 11:32:14 +01:00
mathieuRA
8c7a427225 enhance XoApp types 2025-01-23 11:32:14 +01:00
mathieuRA
d0252cb13a fix various ts error 2025-01-23 11:32:14 +01:00
mathieuRA
906f35cf7b fix yarn.lock 2025-01-23 11:32:14 +01:00
mathieuRA
58c2235737 configure eslint 2025-01-23 11:32:14 +01:00
mathieuRA
10026de7a4 move into src folder 2025-01-23 11:32:14 +01:00
mathieuRA
1e84568dc6 expose vms and servers 2025-01-23 11:32:14 +01:00
mathieuRA
86effd8117 WIP: REST-API with auto-generated swagger documentation 2025-01-23 11:32:12 +01:00
Mathieu
9af58185cf fix(xo-server, xo-web): fix install patches and RPU on XS8 (#8241) 2025-01-22 15:58:18 +01:00
Sebastian CZECH
2b63829e91 feat(web-core): inverse the logic of border props in MenuList.vue (#8246) 2025-01-22 15:34:56 +01:00
Sebastian CZECH
300bb18a64 feat(web-core): focus on console (#8235) 2025-01-22 15:14:09 +01:00
Sebastian CZECH
42f205e473 feat(xo-6): switch to language based on xo5 cookie (#8242) 2025-01-22 14:55:01 +01:00
Thierry Goettelmann
fe5276217f fix(web-core): useRouteQuery composable type issue (#8261) 2025-01-22 09:04:59 +01:00
Julien Fontanet
1765600437 fix(xo-server-audit): ignore host.getIpmiSensors
Introduced by e30f28907
2025-01-21 09:07:09 +01:00
Stephane
f0680df1e1 feat(backups): add encryption chacha20-poly1305, allow file larger than 64GB on encrypted remote (#8237)
This will also incite users to create new remote with the chacha20-poly1305 instead of aes-256-gcm that will continue to work on exusting remote
2025-01-20 20:10:34 +01:00
Sebastian CZECH
6d34cf67fb feat(web-core): move chart lib from xo-lite to web-core (#8248) 2025-01-20 15:15:46 +01:00
Sebastian CZECH
9c97ac903f feat(xo-6): add Ctrl+Alt+Del (#8239)
functionality to console
2025-01-20 14:56:46 +01:00
OlivierFL
a96d5e64de docs(xo-core): update guidelines (#8194) 2025-01-20 11:44:49 +01:00
b-Nollet
4744071098 fix(usage-report): avoid report failure when previous stats are invalid (#8240) 2025-01-20 11:43:02 +01:00
b-Nollet
dc9dbd9018 fix(xo-web): display mirror backup transfer size (#8224) 2025-01-20 11:40:31 +01:00
b-Nollet
6405eeb1b5 fix(xo-server): throw when remote uses encryption without data blocks (#8244) 2025-01-20 11:38:42 +01:00
Stephane
7c666d1156 fix(backups): fix metadata.isVhdDifferencing key computation (#8216)
This PR fix metadata.isVhdDifferencing where an object was used instead of a string to set the filename.
2025-01-20 10:55:56 +01:00
Sebastian CZECH
7ca6829c27 feat(web-core): standardize console component between xo-lite and xo-6 (#8245) 2025-01-20 10:25:30 +01:00
Florent BEAUCHAMP
97cd50af44 test(backups): fix parallel test (#8257) 2025-01-20 10:16:43 +01:00
David Johnston
5bfd462616 feat(web-stack): add Spanish translation (#8220) 2025-01-20 09:03:41 +01:00
Florent BEAUCHAMP
2b964576f2 test(vhd-lib): fix ci (#8255)
Assertion messages can now contain extra info since Node 20.18.1
2025-01-17 11:50:59 +01:00
MlssFrncJrg
d044dda59a fix(xo-web/SelectTag): list SR tags (#8251)
See https://xcp-ng.org/forum/topic/9953
2025-01-16 15:22:42 +01:00
Julien Fontanet
11a4ceddce chore: update dev deps 2025-01-15 14:46:20 +01:00
Thierry Goettelmann
b97268727c feat(web-core,lite,xo6): add useDisabled composable and replace DisabledContext usage (#8133) 2025-01-13 16:30:24 +01:00
Sebastian CZECH
3a21290fde feat(web-core): add loading state on console (#8226) 2025-01-09 14:43:09 +01:00
Julien Fontanet
299a109eb6 fix(CHANGELOG): fix date
Introduced by 4235e9816
2025-01-09 14:28:28 +01:00
Julien Fontanet
4235e98166 fix(CHANGELOG): add missing date
Introduced by 281ccbb8b
2025-01-09 12:03:04 +01:00
Julien Fontanet
281ccbb8ba feat: release 5.102.1 2025-01-09 11:42:32 +01:00
Julien Fontanet
7053fc4a08 feat(xo-server-backup-reports): 1.4.4 2025-01-09 10:31:12 +01:00
b-Nollet
cd2f74cd63 fix(xo-server-backup-reports): fix undefined function in partial (#8229)
In some failure cases, backup reports were not sent because of an error during handlebars compilation.
The `formatDate` function was incorrectly passed from partial to partial causing the `executeFunction` helper to execute `undefined` as a function (the `vmFailure` helper used default `formatDate=./formatDate` instead of `formatDate=../formatDate`, the `..` being necessary because of the `#each` loop)


Introduced by aa9c49ce6a
2025-01-08 17:25:01 +01:00
Thomas Moraine
6d836df6bf docs(xoa): NTP synchronization with systemd-timesyncd (#8217)
On recent versions of Xen Orchestra, we switched to systemd-timesyncd for NTP management.
XOA documentation is updated to reflect this change.
2025-01-08 09:07:50 +01:00
135 changed files with 5509 additions and 2768 deletions

View File

@@ -227,7 +227,7 @@ module.exports = {
'@intlify/vue-i18n/no-v-html': 'error',
'@intlify/vue-i18n/valid-message-syntax': 'error',
'@intlify/vue-i18n/no-duplicate-keys-in-locale': 'error',
'@intlify/vue-i18n/no-missing-keys-in-other-locales': ['error', { ignoreLocales: ['de', 'fa', 'cs'] }],
'@intlify/vue-i18n/no-missing-keys-in-other-locales': ['error', { ignoreLocales: ['de', 'fa', 'cs', 'es' ] }],
},
},
// Specific rules for XO dev-related files

View File

@@ -288,8 +288,7 @@ test('it finish unterminated merge ', async () => {
// each of the vhd can be a file, a directory, an alias to a file or an alias to a directory
// the message an resulting files should be identical to the output with vhd files which is tested independantly
describe('tests multiple combination ', () => {
describe('tests multiple combination ', { concurrency: 1 }, () => {
for (const useAlias of [true, false]) {
for (const vhdMode of ['file', 'directory']) {
test(`alias : ${useAlias}, mode: ${vhdMode}`, async () => {
@@ -431,7 +430,6 @@ describe('tests multiple combination ', () => {
}
}
})
test('it cleans orphan merge states ', async () => {
await handler.writeFile(`${basePath}/.orphan.vhd.merge.json`, '')

View File

@@ -575,7 +575,7 @@ export async function cleanVm(
if (mergedSize) {
// all disks are now key disk
metadata.isVhdDifferencing = {}
for (const id of Object.values(metadata.vdis ?? {})) {
for (const id of Object.keys(metadata.vdis ?? {})) {
metadata.isVhdDifferencing[`${id}.vhd`] = false
}
}

View File

@@ -2,8 +2,11 @@ const { pipeline } = require('node:stream')
const { readChunk } = require('@vates/read-chunk')
const crypto = require('crypto')
export const DEFAULT_ENCRYPTION_ALGORITHM = 'aes-256-gcm'
const CHACHA20 = 'chacha20-poly1305'
const AES256 = 'aes-256-gcm'
export const DEFAULT_ENCRYPTION_ALGORITHM = CHACHA20
export const UNENCRYPTED_ALGORITHM = 'none'
export const SUPPORTED_ALGORITHM = new Set([UNENCRYPTED_ALGORITHM, AES256, CHACHA20, DEFAULT_ENCRYPTION_ALGORITHM])
export function isLegacyEncryptionAlgorithm(algorithm) {
return algorithm !== UNENCRYPTED_ALGORITHM && algorithm !== DEFAULT_ENCRYPTION_ALGORITHM
@@ -33,7 +36,7 @@ function getEncryptor(algorithm = DEFAULT_ENCRYPTION_ALGORITHM, key) {
throw error
}
const { ivLength, mode } = info
const authTagLength = ['gcm', 'ccm', 'ocb'].includes(mode) ? 16 : 0
const authTagLength = ['gcm', 'ccm', 'ocb'].includes(mode) || algorithm === CHACHA20 ? 16 : 0
function encryptStream(input) {
return pipeline(

View File

@@ -3,6 +3,9 @@
## **next**
- Fix persistent scrollbar on the right (PR [#8191](https://github.com/vatesfr/xen-orchestra/pull/8191))
- [Console]: Displays a loader when the console is loading (PR [#8226](https://github.com/vatesfr/xen-orchestra/pull/8226))
- [i18n] Add Spanish translation (contribution made by [@DSJ2](https://github.com/DSJ2)) (PR [#8220](https://github.com/vatesfr/xen-orchestra/pull/8220))
- [Console]: Adding a border when console is focused (PR [#8235](https://github.com/vatesfr/xen-orchestra/pull/8235))
## **0.6.0** (2024-11-29)

View File

@@ -36,7 +36,6 @@
"complex-matcher": "^0.7.1",
"d3-time-format": "^4.1.0",
"decorator-synchronized": "^0.6.0",
"echarts": "^5.4.3",
"file-saver": "^2.0.5",
"highlight.js": "^11.9.0",
"human-format": "^1.2.1",
@@ -58,7 +57,6 @@
"typescript": "~5.6.3",
"vite": "^5.4.10",
"vue": "~3.5.12",
"vue-echarts": "^6.6.8",
"vue-i18n": "^9.9.0",
"vue-router": "^4.4.0",
"vue-tsc": "^2.1.10",

View File

@@ -20,11 +20,11 @@ import AppHeader from '@/components/AppHeader.vue'
import AppLogin from '@/components/AppLogin.vue'
import AppNavigation from '@/components/AppNavigation.vue'
import ModalList from '@/components/ui/modals/ModalList.vue'
import { useChartTheme } from '@/composables/chart-theme.composable'
import { useUnreachableHosts } from '@/composables/unreachable-hosts.composable'
import { usePoolStore } from '@/stores/xen-api/pool.store'
import { useXenApiStore } from '@/stores/xen-api.store'
import VtsTooltipList from '@core/components/tooltip-list/VtsTooltipList.vue'
import { useChartTheme } from '@core/composables/chart-theme.composable'
import { useUiStore } from '@core/stores/ui.store'
import { useActiveElement, useMagicKeys, whenever } from '@vueuse/core'
import { logicAnd } from '@vueuse/math'

View File

@@ -1,125 +0,0 @@
<template>
<div ref="vmConsoleContainer" class="vm-console" />
</template>
<script lang="ts" setup>
import { useXenApiStore } from '@/stores/xen-api.store'
import VncClient from '@novnc/novnc/lib/rfb'
import { promiseTimeout } from '@vueuse/shared'
import { fibonacci } from 'iterable-backoff'
import { computed, onBeforeUnmount, ref, watchEffect } from 'vue'
const props = defineProps<{
location: string
isConsoleAvailable: boolean
}>()
const N_TOTAL_TRIES = 8
const FIBONACCI_MS_ARRAY: number[] = Array.from(fibonacci().toMs().take(N_TOTAL_TRIES))
const vmConsoleContainer = ref<HTMLDivElement>()
const xenApiStore = useXenApiStore()
const url = computed(() => {
if (xenApiStore.currentSessionId == null) {
return
}
const _url = new URL(props.location)
_url.protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'
_url.searchParams.set('session_id', xenApiStore.currentSessionId)
return _url
})
let vncClient: VncClient | undefined
let nConnectionAttempts = 0
function handleDisconnectionEvent() {
clearVncClient()
if (props.isConsoleAvailable) {
nConnectionAttempts++
if (nConnectionAttempts > N_TOTAL_TRIES) {
console.error('The number of reconnection attempts has been exceeded for:', props.location)
return
}
console.error(
`Connection lost for the remote console: ${props.location}. New attempt in ${
FIBONACCI_MS_ARRAY[nConnectionAttempts - 1]
}ms`
)
createVncConnection()
}
}
function handleConnectionEvent() {
nConnectionAttempts = 0
}
function clearVncClient() {
if (vncClient === undefined) {
return
}
vncClient.removeEventListener('disconnect', handleDisconnectionEvent)
vncClient.removeEventListener('connect', handleConnectionEvent)
vncClient.disconnect()
vncClient = undefined
}
async function createVncConnection() {
if (nConnectionAttempts !== 0) {
await promiseTimeout(FIBONACCI_MS_ARRAY[nConnectionAttempts - 1])
if (vncClient !== undefined) {
// New VNC Client may have been created in the meantime
return
}
}
vncClient = new VncClient(vmConsoleContainer.value!, url.value!.toString(), {
wsProtocols: ['binary'],
})
vncClient.scaleViewport = true
vncClient.addEventListener('disconnect', handleDisconnectionEvent)
vncClient.addEventListener('connect', handleConnectionEvent)
}
watchEffect(() => {
if (url.value === undefined || vmConsoleContainer.value === undefined || !props.isConsoleAvailable) {
return
}
nConnectionAttempts = 0
clearVncClient()
createVncConnection()
})
onBeforeUnmount(() => {
clearVncClient()
})
defineExpose({
sendCtrlAltDel: () => vncClient?.sendCtrlAltDel(),
})
</script>
<style lang="postcss" scoped>
.vm-console {
flex: 1;
max-width: 100%;
& > :deep(div) {
background-color: transparent !important;
}
/* Required because the library adds "margin: auto" to the canvas which makes the canvas centered in space and not aligned to the rest of the layout */
:deep(canvas) {
margin: 0 auto !important;
}
}
</style>

View File

@@ -13,17 +13,13 @@
<script lang="ts" setup>
import TabItem from '@core/components/tab/TabItem.vue'
import { useContext } from '@core/composables/context.composable'
import { DisabledContext } from '@core/context'
import { useDisabled } from '@core/composables/disabled.composable'
import type { RouteLocationRaw } from 'vue-router'
const props = withDefaults(
defineProps<{
to: RouteLocationRaw
disabled?: boolean
}>(),
{ disabled: undefined }
)
const props = defineProps<{
to: RouteLocationRaw
disabled?: boolean
}>()
const isDisabled = useContext(DisabledContext, () => props.disabled)
const isDisabled = useDisabled(() => props.disabled)
</script>

View File

@@ -1,53 +1,61 @@
<template>
<UiAccountMenuButton
v-tooltip="menu.$isOpen ? false : { content: $t('settings'), placement: 'left' }"
size="medium"
v-bind="omit(menu.$trigger, ['as', 'submenu'])"
/>
<VtsMenuList border v-bind="menu.$target">
<VtsMenuItem :icon="faGear" v-bind="menu.settings">{{ $t('settings') }}</VtsMenuItem>
<VtsMenuItem :icon="faBook" v-bind="menu.documentation">
<MenuList placement="bottom-end">
<template #trigger="{ open, isOpen }">
<UiAccountMenuButton
v-tooltip="isOpen ? false : { content: $t('settings'), placement: 'left' }"
:selected="isOpen"
size="medium"
@click="open($event)"
/>
</template>
<MenuItem :icon="faGear" @click="openSettings">{{ $t('settings') }}</MenuItem>
<MenuItem :icon="faBook" @click="openUrl('https://docs.xcp-ng.org?utm_campaign=xolite&utm_term=xcpdoc')">
{{ $t('documentation-name', { name: 'XCP-ng' }) }}
</VtsMenuItem>
<VtsMenuItem :icon="faHeadset" v-bind="menu.support">{{ $t('professional-support') }}</VtsMenuItem>
<VtsMenuItem :icon="faComments" v-bind="menu.forum">{{ $t('access-forum') }}</VtsMenuItem>
<VtsMenuItem :icon="faArrowRightFromBracket" class="menu-item-logout" v-bind="menu.logout">
</MenuItem>
<MenuItem
:icon="faHeadset"
@click="openUrl('https://vates.tech/pricing-and-support?utm_campaign=xolite&utm_term=pricing')"
>
{{ $t('professional-support') }}
</MenuItem>
<MenuItem
:icon="faComments"
@click="openUrl('https://xcp-ng.org/forum/topic/4731/xen-orchestra-lite?utm_campaign=xolite&utm_term=forum')"
>
{{ $t('access-forum') }}
</MenuItem>
<MenuItem :icon="faArrowRightFromBracket" class="menu-item-logout" @click="logout">
{{ $t('log-out') }}
</VtsMenuItem>
</VtsMenuList>
</MenuItem>
</MenuList>
</template>
<script lang="ts" setup>
import { useXenApiStore } from '@/stores/xen-api.store'
import VtsMenuItem from '@core/components/menu/VtsMenuItem.vue'
import VtsMenuList from '@core/components/menu/VtsMenuList.vue'
import MenuItem from '@core/components/menu/MenuItem.vue'
import MenuList from '@core/components/menu/MenuList.vue'
import UiAccountMenuButton from '@core/components/ui/account-menu-button/UiAccountMenuButton.vue'
import { vTooltip } from '@core/directives/tooltip.directive'
import { action, link, routerLink, useMenuToggle } from '@core/packages/menu'
import { faArrowRightFromBracket, faBook, faComments, faGear, faHeadset } from '@fortawesome/free-solid-svg-icons'
import { objectOmit as omit } from '@vueuse/shared'
import { nextTick } from 'vue'
import { useRouter } from 'vue-router'
const router = useRouter()
const menu = useMenuToggle({
placement: 'bottom-end',
items: {
settings: routerLink({ name: 'settings' }),
documentation: link('https://docs.xcp-ng.org?utm_campaign=xolite&utm_term=xcpdoc'),
support: link('https://vates.tech/pricing-and-support?utm_campaign=xolite&utm_term=pricing'),
forum: link('https://xcp-ng.org/forum/topic/4731/xen-orchestra-lite?utm_campaign=xolite&utm_term=forum'),
logout: action(async () => {
await useXenApiStore().disconnect()
void router.push({ name: 'home' })
}),
},
})
const logout = () => {
const xenApiStore = useXenApiStore()
xenApiStore.disconnect()
nextTick(() => router.push({ name: 'home' }))
}
const openUrl = (url: string) => {
window.open(url, '_blank', 'noopener noreferrer')
}
const openSettings = () => router.push({ name: 'settings' })
</script>
<style lang="postcss" scoped>
:deep(.menu-item-logout) {
.menu-item-logout {
color: var(--color-danger-txt-base);
}
</style>

View File

@@ -7,23 +7,23 @@
v-if="unreadEventsCount > 0"
:value="unreadEventsCount"
accent="success"
size="small"
variant="primary"
size="small"
/>
</TabItem>
<TabItem v-bind="tab(TAB.SLOTS, slotParams)">Slots</TabItem>
<TabItem v-bind="tab(TAB.SETTINGS, settingParams)">Settings</TabItem>
<TabItem class="preset-tab" v-bind="presetMenu.$trigger">
<UiIcon :icon="faSliders" />
Presets
</TabItem>
<Teleport to="body">
<VtsMenuList border v-bind="presetMenu.$target">
<VtsMenuItem v-for="(preset, label) in presets" :key="label" v-bind="presetMenu[label]">
{{ label }}
</VtsMenuItem>
</VtsMenuList>
</Teleport>
<MenuList placement="bottom">
<template #trigger="{ open, isOpen }">
<TabItem :active="isOpen" :disabled="presets === undefined" class="preset-tab" @click="open">
<UiIcon :icon="faSliders" />
Presets
</TabItem>
</template>
<MenuItem v-for="(preset, label) in presets" :key="label" @click="applyPreset(preset)">
{{ label }}
</MenuItem>
</MenuList>
</TabList>
<div :class="{ 'full-width': fullWidthComponent }" class="tabs">
@@ -43,8 +43,8 @@
<template #right>
<UiButton
v-if="eventsLog.length > 0"
accent="info"
size="medium"
accent="info"
variant="tertiary"
@click="eventsLog = []"
>
@@ -91,13 +91,12 @@ import {
ModelParam,
type Param,
} from '@/libs/story/story-param'
import VtsMenuItem from '@core/components/menu/VtsMenuItem.vue'
import VtsMenuList from '@core/components/menu/VtsMenuList.vue'
import MenuItem from '@core/components/menu/MenuItem.vue'
import MenuList from '@core/components/menu/MenuList.vue'
import TabItem from '@core/components/tab/TabItem.vue'
import TabList from '@core/components/tab/TabList.vue'
import UiButton from '@core/components/ui/button/UiButton.vue'
import UiCounter from '@core/components/ui/counter/UiCounter.vue'
import { action, useMenuToggle } from '@core/packages/menu'
import { faSliders } from '@fortawesome/free-solid-svg-icons'
import 'highlight.js/styles/github-dark.css'
import { uniqueId, upperFirst } from 'lodash-es'
@@ -267,17 +266,6 @@ const applyPreset = (preset: { props?: Record<string, any>; settings?: Record<st
})
}
}
const presetMenu = useMenuToggle({
placement: 'bottom-end',
items: computed(() =>
Object.fromEntries(
Object.entries(props.presets ?? {}).map(([label, preset]) => {
return [label, action(() => applyPreset(preset))]
})
)
),
})
</script>
<style lang="postcss" scoped>

View File

@@ -16,23 +16,19 @@
<script lang="ts" setup>
import UiIcon from '@/components/ui/icon/UiIcon.vue'
import { useContext } from '@/composables/context.composable'
import { DisabledContext } from '@/context'
import { IK_CHECKBOX_TYPE, IK_FORM_HAS_LABEL } from '@/types/injection-keys'
import { useDisabled } from '@core/composables/disabled.composable'
import { faCheck, faCircle, faMinus } from '@fortawesome/free-solid-svg-icons'
import { useVModel } from '@vueuse/core'
import { computed, type HTMLAttributes, inject, useAttrs } from 'vue'
defineOptions({ inheritAttrs: false })
const props = withDefaults(
defineProps<{
modelValue?: unknown
disabled?: boolean
wrapperAttrs?: HTMLAttributes
}>(),
{ disabled: undefined }
)
const props = defineProps<{
modelValue?: unknown
disabled?: boolean
wrapperAttrs?: HTMLAttributes
}>()
const emit = defineEmits<{
'update:modelValue': [value: boolean]
@@ -46,7 +42,7 @@ const hasLabel = inject(
IK_FORM_HAS_LABEL,
computed(() => false)
)
const isDisabled = useContext(DisabledContext, () => props.disabled)
const isDisabled = useDisabled(() => props.disabled)
const icon = computed(() => {
if (type !== 'checkbox') {
return faCircle

View File

@@ -53,9 +53,10 @@
<script lang="ts" setup>
import UiIcon from '@/components/ui/icon/UiIcon.vue'
import { useContext } from '@/composables/context.composable'
import { ColorContext, DisabledContext } from '@/context'
import { ColorContext } from '@/context'
import type { Color } from '@/types'
import { IK_INPUT_ID, IK_INPUT_TYPE } from '@/types/injection-keys'
import { useDisabled } from '@core/composables/disabled.composable'
import type { IconDefinition } from '@fortawesome/fontawesome-common-types'
import { faAngleDown } from '@fortawesome/free-solid-svg-icons'
import { useTextareaAutosize, useVModel } from '@vueuse/core'
@@ -63,22 +64,19 @@ import { computed, type HTMLAttributes, inject, nextTick, ref, useAttrs, watch }
defineOptions({ inheritAttrs: false })
const props = withDefaults(
defineProps<{
id?: string
modelValue?: any
color?: Color
before?: IconDefinition | string
after?: IconDefinition | string
beforeWidth?: string
afterWidth?: string
disabled?: boolean
required?: boolean
right?: boolean
wrapperAttrs?: HTMLAttributes
}>(),
{ disabled: undefined }
)
const props = defineProps<{
id?: string
modelValue?: any
color?: Color
before?: IconDefinition | string
after?: IconDefinition | string
beforeWidth?: string
afterWidth?: string
disabled?: boolean
required?: boolean
right?: boolean
wrapperAttrs?: HTMLAttributes
}>()
const emit = defineEmits<{
'update:modelValue': [value: any]
@@ -94,7 +92,7 @@ const value = useVModel(props, 'modelValue', emit)
const isEmpty = computed(() => props.modelValue == null || String(props.modelValue).trim() === '')
const inputType = inject(IK_INPUT_TYPE, 'input')
const isDisabled = useContext(DisabledContext, () => props.disabled)
const isDisabled = useDisabled(() => props.disabled)
const wrapperClass = computed(() => [
`form-${inputType}`,

View File

@@ -36,28 +36,26 @@
<script lang="ts" setup>
import UiIcon from '@/components/ui/icon/UiIcon.vue'
import { useContext } from '@/composables/context.composable'
import { ColorContext, DisabledContext } from '@/context'
import { ColorContext } from '@/context'
import type { Color } from '@/types'
import { IK_FORM_HAS_LABEL, IK_INPUT_ID } from '@/types/injection-keys'
import { useDisabled } from '@core/composables/disabled.composable'
import type { IconDefinition } from '@fortawesome/fontawesome-common-types'
import { faInfoCircle } from '@fortawesome/free-solid-svg-icons'
import { uniqueId } from 'lodash-es'
import { computed, provide } from 'vue'
const props = withDefaults(
defineProps<{
label?: string
id?: string
icon?: IconDefinition
learnMoreUrl?: string
warning?: string
error?: string
help?: string
disabled?: boolean
light?: boolean
}>(),
{ disabled: undefined }
)
const props = defineProps<{
label?: string
id?: string
icon?: IconDefinition
learnMoreUrl?: string
warning?: string
error?: string
help?: string
disabled?: boolean
light?: boolean
}>()
const id = computed(() => props.id ?? uniqueId('form-input-'))
provide(IK_INPUT_ID, id)
@@ -80,7 +78,7 @@ provide(
)
useContext(ColorContext, color)
useContext(DisabledContext, () => props.disabled)
useDisabled(() => props.disabled)
</script>
<style lang="postcss" scoped>

View File

@@ -19,16 +19,16 @@ import { formatSize } from '@/libs/utils'
import { RRD_STEP_FROM_STRING } from '@/libs/xapi-stats'
import type { HostStats } from '@/libs/xapi-stats'
import { useHostStore } from '@/stores/xen-api/host.store'
import type { LinearChartData } from '@/types/chart'
import { UiCardTitleLevel } from '@/types/enums'
import { IK_HOST_LAST_WEEK_STATS } from '@/types/injection-keys'
import type { LinearChartData } from '@core/types/chart'
import { map } from 'lodash-es'
import { computed, defineAsyncComponent, inject } from 'vue'
import { useI18n } from 'vue-i18n'
const { t } = useI18n()
const LinearChart = defineAsyncComponent(() => import('@/components/charts/LinearChart.vue'))
const LinearChart = defineAsyncComponent(() => import('@core/components/charts/LinearChart.vue'))
const hostLastWeekStats = inject(IK_HOST_LAST_WEEK_STATS)
const { hasError, isFetching } = useHostStore().subscribe()

View File

@@ -18,14 +18,14 @@ import UiCardTitle from '@/components/ui/UiCardTitle.vue'
import type { HostStats } from '@/libs/xapi-stats'
import { RRD_STEP_FROM_STRING } from '@/libs/xapi-stats'
import { useHostStore } from '@/stores/xen-api/host.store'
import type { LinearChartData, ValueFormatter } from '@/types/chart'
import { UiCardTitleLevel } from '@/types/enums'
import { IK_HOST_LAST_WEEK_STATS } from '@/types/injection-keys'
import type { LinearChartData, ValueFormatter } from '@core/types/chart'
import { sumBy } from 'lodash-es'
import { computed, defineAsyncComponent, inject } from 'vue'
import { useI18n } from 'vue-i18n'
const LinearChart = defineAsyncComponent(() => import('@/components/charts/LinearChart.vue'))
const LinearChart = defineAsyncComponent(() => import('@core/components/charts/LinearChart.vue'))
const { t, n } = useI18n()

View File

@@ -21,14 +21,14 @@ import { formatSize } from '@/libs/utils'
import { RRD_STEP_FROM_STRING } from '@/libs/xapi-stats'
import { useHostMetricsStore } from '@/stores/xen-api/host-metrics.store'
import { useHostStore } from '@/stores/xen-api/host.store'
import type { LinearChartData } from '@/types/chart'
import { UiCardTitleLevel } from '@/types/enums'
import { IK_HOST_LAST_WEEK_STATS } from '@/types/injection-keys'
import type { LinearChartData } from '@core/types/chart'
import { sumBy } from 'lodash-es'
import { computed, defineAsyncComponent, inject } from 'vue'
import { useI18n } from 'vue-i18n'
const LinearChart = defineAsyncComponent(() => import('@/components/charts/LinearChart.vue'))
const LinearChart = defineAsyncComponent(() => import('@core/components/charts/LinearChart.vue'))
const { runningHosts, isFetching, hasError } = useHostStore().subscribe()
const { getHostMemory } = useHostMetricsStore().subscribe()

View File

@@ -17,21 +17,17 @@
<script lang="ts" setup>
import UiIcon from '@/components/ui/icon/UiIcon.vue'
import { useContext } from '@/composables/context.composable'
import { DisabledContext } from '@/context'
import { useDisabled } from '@core/composables/disabled.composable'
import type { IconDefinition } from '@fortawesome/fontawesome-common-types'
const props = withDefaults(
defineProps<{
busy?: boolean
disabled?: boolean
icon?: IconDefinition
active?: boolean
}>(),
{ disabled: undefined }
)
const props = defineProps<{
busy?: boolean
disabled?: boolean
icon?: IconDefinition
active?: boolean
}>()
const isDisabled = useContext(DisabledContext, () => props.disabled)
const isDisabled = useDisabled(() => props.disabled)
</script>
<style lang="postcss" scoped>

View File

@@ -8,9 +8,10 @@
<script lang="ts" setup>
import { useContext } from '@/composables/context.composable'
import { ColorContext, DisabledContext } from '@/context'
import { ColorContext } from '@/context'
import type { Color } from '@/types'
import { IK_MODAL } from '@/types/injection-keys'
import { useDisabled } from '@core/composables/disabled.composable'
import { useMagicKeys, whenever } from '@vueuse/core'
import { inject, useAttrs } from 'vue'
@@ -28,7 +29,7 @@ const attrs = useAttrs()
const modal = inject(IK_MODAL)!
useContext(ColorContext, () => props.color)
useContext(DisabledContext, () => props.disabled || modal.isBusy)
useDisabled(() => props.disabled || modal.isBusy)
const { escape } = useMagicKeys()

View File

@@ -1,7 +1,13 @@
<template>
<VtsMenuItem :icon="faCopy" v-bind="menuItem">
<MenuItem
v-tooltip="!areAllSelectedVmsHalted && $t(isSingleAction ? 'vm-is-running' : 'selected-vms-in-execution')"
:busy="areSomeSelectedVmsCloning"
:disabled="isDisabled"
:icon="faCopy"
@click="handleCopy"
>
{{ $t('copy') }}
</VtsMenuItem>
</MenuItem>
</template>
<script lang="ts" setup>
@@ -10,42 +16,41 @@ import { VM_OPERATION, VM_POWER_STATE } from '@/libs/xen-api/xen-api.enums'
import type { XenApiVm } from '@/libs/xen-api/xen-api.types'
import { useVmStore } from '@/stores/xen-api/vm.store'
import { useXenApiStore } from '@/stores/xen-api.store'
import VtsMenuItem from '@core/components/menu/VtsMenuItem.vue'
import { type MenuLike, useMenuAction } from '@core/packages/menu'
import MenuItem from '@core/components/menu/MenuItem.vue'
import { vTooltip } from '@core/directives/tooltip.directive'
import { faCopy } from '@fortawesome/free-solid-svg-icons'
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
const props = defineProps<{
menu: MenuLike
selectedRefs: XenApiVm['$ref'][]
isSingleAction?: boolean
}>()
const { t } = useI18n()
const { getByOpaqueRef } = useVmStore().subscribe()
const { getByOpaqueRefs } = useVmStore().subscribe()
const selectedVms = computed(() => getByOpaqueRefs(props.selectedRefs))
const vmRefsToClone = computed(() =>
Object.fromEntries(selectedVms.value.map(vm => [vm.$ref, `${vm.name_label} (COPY)`]))
const selectedVms = computed(() =>
props.selectedRefs.map(vmRef => getByOpaqueRef(vmRef)).filter((vm): vm is XenApiVm => vm !== undefined)
)
const menuItem = useMenuAction({
parent: props.menu,
handler: () => useXenApiStore().getXapi().vm.clone(vmRefsToClone.value),
disabled: () => {
if (props.selectedRefs.length === 0) {
return true
}
const areAllSelectedVmsHalted = computed(
() =>
selectedVms.value.length > 0 &&
selectedVms.value.every(selectedVm => selectedVm.power_state === VM_POWER_STATE.HALTED)
)
if (!selectedVms.value.every(selectedVm => selectedVm.power_state === VM_POWER_STATE.HALTED)) {
return props.isSingleAction ? t('vm-is-running') : t('selected-vms-in-execution')
}
const areSomeSelectedVmsCloning = computed(() =>
selectedVms.value.some(vm => isVmOperationPending(vm, VM_OPERATION.CLONE))
)
return false
},
busy: computed(() => selectedVms.value.some(vm => isVmOperationPending(vm, VM_OPERATION.CLONE))),
const isDisabled = computed(() => {
return selectedVms.value.length === 0 || !areAllSelectedVmsHalted.value
})
const handleCopy = async () => {
const xapiStore = useXenApiStore()
const vmRefsToClone = Object.fromEntries(selectedVms.value.map(vm => [vm.$ref, `${vm.name_label} (COPY)`]))
await xapiStore.getXapi().vm.clone(vmRefsToClone)
}
</script>

View File

@@ -1,7 +1,12 @@
<template>
<VtsMenuItem :icon="faTrashCan" v-bind="menuItem">
<MenuItem
v-tooltip="areSomeVmsInExecution && $t('selected-vms-in-execution')"
:disabled="isDisabled"
:icon="faTrashCan"
@click="openDeleteModal"
>
{{ $t('delete') }}
</VtsMenuItem>
</MenuItem>
</template>
<script lang="ts" setup>
@@ -9,39 +14,25 @@ import { useModal } from '@/composables/modal.composable'
import { VM_POWER_STATE } from '@/libs/xen-api/xen-api.enums'
import type { XenApiVm } from '@/libs/xen-api/xen-api.types'
import { useVmStore } from '@/stores/xen-api/vm.store'
import VtsMenuItem from '@core/components/menu/VtsMenuItem.vue'
import { type MenuLike, useMenuAction } from '@core/packages/menu'
import MenuItem from '@core/components/menu/MenuItem.vue'
import { vTooltip } from '@core/directives/tooltip.directive'
import { faTrashCan } from '@fortawesome/free-solid-svg-icons'
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
const props = defineProps<{
menu: MenuLike
vmRefs: XenApiVm['$ref'][]
}>()
const { t } = useI18n()
const { getByOpaqueRef: getVm } = useVmStore().subscribe()
const { getByOpaqueRefs: getVms } = useVmStore().subscribe()
const vms = computed<XenApiVm[]>(() => props.vmRefs.map(getVm).filter((vm): vm is XenApiVm => vm !== undefined))
const vms = computed(() => getVms(props.vmRefs))
const areSomeVmsInExecution = computed(() => vms.value.some(vm => vm.power_state !== VM_POWER_STATE.HALTED))
const menuItem = useMenuAction({
parent: props.menu,
handler: () =>
useModal(() => import('@/components/modals/VmDeleteModal.vue'), {
vmRefs: props.vmRefs,
}),
disabled: computed(() => {
if (vms.value.length === 0) {
return true
}
const isDisabled = computed(() => vms.value.length === 0 || areSomeVmsInExecution.value)
if (vms.value.some(vm => vm.power_state !== VM_POWER_STATE.HALTED)) {
return t('selected-vms-in-execution')
}
return false
}),
})
const openDeleteModal = () =>
useModal(() => import('@/components/modals/VmDeleteModal.vue'), {
vmRefs: props.vmRefs,
})
</script>

View File

@@ -1,7 +1,14 @@
<template>
<VtsMenuItem :icon="faDisplay" v-bind="menuItem">
<MenuItem
v-tooltip="
vmRefs.length > 0 && !isSomeExportable && $t(isSingleAction ? 'vm-is-running' : 'no-selected-vm-can-be-exported')
"
:disabled="isDisabled"
:icon="faDisplay"
@click="openModal"
>
{{ $t(isSingleAction ? 'export-vm' : 'export-vms') }}
</VtsMenuItem>
</MenuItem>
</template>
<script lang="ts" setup>
@@ -10,40 +17,28 @@ import { areSomeVmOperationAllowed } from '@/libs/vm'
import { VM_OPERATION } from '@/libs/xen-api/xen-api.enums'
import type { XenApiVm } from '@/libs/xen-api/xen-api.types'
import { useVmStore } from '@/stores/xen-api/vm.store'
import VtsMenuItem from '@core/components/menu/VtsMenuItem.vue'
import { type MenuLike, useMenuAction } from '@core/packages/menu'
import MenuItem from '@core/components/menu/MenuItem.vue'
import { useDisabled } from '@core/composables/disabled.composable'
import { vTooltip } from '@core/directives/tooltip.directive'
import { faDisplay } from '@fortawesome/free-solid-svg-icons'
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
const props = defineProps<{
menu: MenuLike
vmRefs: XenApiVm['$ref'][]
isSingleAction?: boolean
}>()
const { t } = useI18n()
const { getByOpaqueRefs } = useVmStore().subscribe()
const vms = computed(() => getByOpaqueRefs(props.vmRefs))
const isSomeExportable = computed(() =>
getByOpaqueRefs(props.vmRefs).some(vm => areSomeVmOperationAllowed(vm, VM_OPERATION.EXPORT))
)
const menuItem = useMenuAction({
parent: props.menu,
handler: () =>
useModal(() => import('@/components/modals/VmExportModal.vue'), {
vmRefs: props.vmRefs,
}),
disabled: () => {
if (props.vmRefs.length === 0) {
return true
}
const isDisabled = useDisabled(() => !isSomeExportable.value)
if (!vms.value.some(vm => areSomeVmOperationAllowed(vm, VM_OPERATION.EXPORT))) {
return props.isSingleAction ? t('vm-is-running') : t('no-selected-vm-can-be-exported')
}
return false
},
})
const openModal = () => {
useModal(() => import('@/components/modals/VmExportModal.vue'), {
vmRefs: props.vmRefs,
})
}
</script>

View File

@@ -1,19 +1,16 @@
<template>
<li>
<VtsMenuTrigger :icon="faFileExport" v-bind="toggle.$trigger">
{{ $t('export') }}
</VtsMenuTrigger>
<VtsMenuList v-bind="toggle.$target">
<VmActionExportItem :menu="toggle" :vm-refs="vmRefs" />
<VtsMenuItem :icon="faCode" v-bind="toggle.exportAsJson">
<MenuItem :icon="faFileExport">
{{ $t('export') }}
<template #submenu>
<VmActionExportItem :vm-refs="vmRefs" />
<MenuItem :icon="faCode" @click="exportVmsAsJsonFile(vms, `vms_${new Date().toISOString()}.json`)">
{{ $t('export-table-to', { type: '.json' }) }}
</VtsMenuItem>
<VtsMenuItem :icon="faFileCsv" v-bind="toggle.exportAsCsv">
</MenuItem>
<MenuItem :icon="faFileCsv" @click="exportVmsAsCsvFile(vms, `vms_${new Date().toISOString()}.csv`)">
{{ $t('export-table-to', { type: '.csv' }) }}
</VtsMenuItem>
</VtsMenuList>
</li>
</MenuItem>
</template>
</MenuItem>
</template>
<script lang="ts" setup>
@@ -21,31 +18,14 @@ import VmActionExportItem from '@/components/vm/VmActionItems/VmActionExportItem
import { exportVmsAsCsvFile, exportVmsAsJsonFile } from '@/libs/vm'
import type { XenApiVm } from '@/libs/xen-api/xen-api.types'
import { useVmStore } from '@/stores/xen-api/vm.store'
import VtsMenuItem from '@core/components/menu/VtsMenuItem.vue'
import VtsMenuList from '@core/components/menu/VtsMenuList.vue'
import VtsMenuTrigger from '@core/components/menu/VtsMenuTrigger.vue'
import { action, type MenuLike, useMenuToggle } from '@core/packages/menu'
import MenuItem from '@core/components/menu/MenuItem.vue'
import { faCode, faFileCsv, faFileExport } from '@fortawesome/free-solid-svg-icons'
import { computed } from 'vue'
const props = defineProps<{
menu: MenuLike
vmRefs: XenApiVm['$ref'][]
}>()
const { getByOpaqueRefs } = useVmStore().subscribe()
const vms = computed(() => getByOpaqueRefs(props.vmRefs))
const toggle = useMenuToggle({
parent: props.menu,
items: {
exportAsJson: action(() => exportVmsAsJsonFile(vms.value, `vms_${new Date().toISOString()}.json`), {
disabled: computed(() => vms.value.length === 0),
}),
exportAsCsv: action(() => exportVmsAsCsvFile(vms.value, `vms_${new Date().toISOString()}.csv`), {
disabled: computed(() => vms.value.length === 0),
}),
},
})
const { getByOpaqueRef: getVm } = useVmStore().subscribe()
const vms = computed(() => props.vmRefs.map(getVm).filter((vm): vm is XenApiVm => vm !== undefined))
</script>

View File

@@ -1,7 +1,17 @@
<template>
<VtsMenuItem :icon="faRoute" v-bind="menuItem">
<MenuItem
v-tooltip="
selectedRefs.length > 0 &&
!isMigratable &&
$t(isSingleAction ? 'this-vm-cant-be-migrated' : 'no-selected-vm-can-be-migrated')
"
:busy="isMigrating"
:disabled="isDisabled"
:icon="faRoute"
@click="openModal()"
>
{{ $t('migrate') }}
</VtsMenuItem>
</MenuItem>
</template>
<script lang="ts" setup>
@@ -10,41 +20,35 @@ import { areSomeVmOperationAllowed, isVmOperationPending } from '@/libs/vm'
import { VM_OPERATION } from '@/libs/xen-api/xen-api.enums'
import type { XenApiVm } from '@/libs/xen-api/xen-api.types'
import { useVmStore } from '@/stores/xen-api/vm.store'
import VtsMenuItem from '@core/components/menu/VtsMenuItem.vue'
import { type MenuLike, useMenuAction } from '@core/packages/menu'
import MenuItem from '@core/components/menu/MenuItem.vue'
import { useDisabled } from '@core/composables/disabled.composable'
import { vTooltip } from '@core/directives/tooltip.directive'
import { faRoute } from '@fortawesome/free-solid-svg-icons'
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
const props = defineProps<{
menu: MenuLike
selectedRefs: XenApiVm['$ref'][]
isSingleAction?: boolean
}>()
const { t } = useI18n()
const { getByOpaqueRefs } = useVmStore().subscribe()
const vms = computed(() => getByOpaqueRefs(props.selectedRefs))
const isMigratable = computed(() =>
getByOpaqueRefs(props.selectedRefs).some(vm =>
areSomeVmOperationAllowed(vm, [VM_OPERATION.POOL_MIGRATE, VM_OPERATION.MIGRATE_SEND])
)
)
const menuItem = useMenuAction({
parent: props.menu,
handler: () =>
useModal(() => import('@/components/modals/VmMigrateModal.vue'), {
vmRefs: props.selectedRefs,
}),
disabled: () => {
if (props.selectedRefs.length === 0) {
return true
}
const isMigrating = computed(() =>
getByOpaqueRefs(props.selectedRefs).some(vm =>
isVmOperationPending(vm, [VM_OPERATION.POOL_MIGRATE, VM_OPERATION.MIGRATE_SEND])
)
)
if (!vms.value.some(vm => areSomeVmOperationAllowed(vm, [VM_OPERATION.POOL_MIGRATE, VM_OPERATION.MIGRATE_SEND]))) {
return props.isSingleAction ? t('this-vm-cant-be-migrated') : t('no-selected-vm-can-be-migrated')
}
},
busy: computed(() =>
vms.value.some(vm => isVmOperationPending(vm, [VM_OPERATION.POOL_MIGRATE, VM_OPERATION.MIGRATE_SEND]))
),
})
const isDisabled = useDisabled(() => !isMigratable.value)
const openModal = () =>
useModal(() => import('@/components/modals/VmMigrateModal.vue'), {
vmRefs: props.selectedRefs,
})
</script>

View File

@@ -1,13 +1,11 @@
<template>
<VtsMenuItem :icon="faPlay" v-bind="startItem">
<MenuItem :busy="areVmsBusyToStart" :disabled="!areVmsHalted" :icon="faPlay" @click="xenApi.vm.start(vmRefs)">
{{ $t('start') }}
</VtsMenuItem>
<li>
<VtsMenuTrigger :icon="faServer" v-bind="startOnHostItem.$trigger">
{{ $t('start-on-host') }}
</VtsMenuTrigger>
<VtsMenuList border v-bind="startOnHostItem.$target">
<VtsMenuItem v-for="host in hosts" :key="host.$ref" :icon="faServer" v-bind="startOnHostItem[host.$ref]">
</MenuItem>
<MenuItem :busy="areVmsBusyToStartOnHost" :disabled="!areVmsHalted" :icon="faServer">
{{ $t('start-on-host') }}
<template #submenu>
<MenuItem v-for="host in hosts" :key="host.$ref" :icon="faServer" @click="xenApi.vm.startOn(vmRefs, host.$ref)">
<div class="wrapper">
{{ host.name_label }}
<div>
@@ -15,30 +13,55 @@
<PowerStateIcon :state="getHostState(host)" />
</div>
</div>
</VtsMenuItem>
</VtsMenuList>
</li>
<VtsMenuItem :icon="faPause" v-bind="pauseItem">
</MenuItem>
</template>
</MenuItem>
<MenuItem :busy="areVmsBusyToPause" :disabled="!areVmsRunning" :icon="faPause" @click="xenApi.vm.pause(vmRefs)">
{{ $t('pause') }}
</VtsMenuItem>
<VtsMenuItem :icon="faMoon" v-bind="suspendItem">
</MenuItem>
<MenuItem :busy="areVmsBusyToSuspend" :disabled="!areVmsRunning" :icon="faMoon" @click="xenApi.vm.suspend(vmRefs)">
{{ $t('suspend') }}
</VtsMenuItem>
<VtsMenuItem :icon="faCirclePlay" v-bind="resumeItem">
</MenuItem>
<MenuItem
:busy="areVmsBusyToResume"
:disabled="!areVmsSuspended && !areVmsPaused"
:icon="faCirclePlay"
@click="xenApi.vm.resume(vmRefsWithPowerState)"
>
{{ $t('resume') }}
</VtsMenuItem>
<VtsMenuItem :icon="faRotateLeft" v-bind="rebootItem">
</MenuItem>
<MenuItem
:busy="areVmsBusyToReboot"
:disabled="!areVmsRunning"
:icon="faRotateLeft"
@click="xenApi.vm.reboot(vmRefs)"
>
{{ $t('reboot') }}
</VtsMenuItem>
<VtsMenuItem :icon="faRepeat" v-bind="forceRebootItem">
</MenuItem>
<MenuItem
:busy="areVmsBusyToForceReboot"
:disabled="!areVmsRunning && !areVmsPaused"
:icon="faRepeat"
@click="xenApi.vm.reboot(vmRefs, true)"
>
{{ $t('force-reboot') }}
</VtsMenuItem>
<VtsMenuItem :icon="faPowerOff" v-bind="shutdownItem">
</MenuItem>
<MenuItem
:busy="areVmsBusyToShutdown"
:disabled="!areVmsRunning"
:icon="faPowerOff"
@click="xenApi.vm.shutdown(vmRefs)"
>
{{ $t('shutdown') }}
</VtsMenuItem>
<VtsMenuItem :icon="faPlug" v-bind="forceShutdownItem">
</MenuItem>
<MenuItem
:busy="areVmsBusyToForceShutdown"
:disabled="!areVmsRunning && !areVmsSuspended && !areVmsPaused"
:icon="faPlug"
@click="xenApi.vm.shutdown(vmRefs, true)"
>
{{ $t('force-shutdown') }}
</VtsMenuItem>
</MenuItem>
</template>
<script lang="ts" setup>
@@ -52,10 +75,7 @@ import { useHostStore } from '@/stores/xen-api/host.store'
import { usePoolStore } from '@/stores/xen-api/pool.store'
import { useVmStore } from '@/stores/xen-api/vm.store'
import { useXenApiStore } from '@/stores/xen-api.store'
import VtsMenuItem from '@core/components/menu/VtsMenuItem.vue'
import VtsMenuList from '@core/components/menu/VtsMenuList.vue'
import VtsMenuTrigger from '@core/components/menu/VtsMenuTrigger.vue'
import { action, type MenuLike, useMenuAction, useMenuToggle } from '@core/packages/menu'
import MenuItem from '@core/components/menu/MenuItem.vue'
import {
faCirclePlay,
faMoon,
@@ -71,26 +91,17 @@ import {
import { computed } from 'vue'
const props = defineProps<{
menu: MenuLike
vmRefs: XenApiVm['$ref'][]
}>()
const { getByOpaqueRefs } = useVmStore().subscribe()
const { getByOpaqueRef: getVm } = useVmStore().subscribe()
const { records: hosts } = useHostStore().subscribe()
const { pool } = usePoolStore().subscribe()
const { isHostRunning } = useHostMetricsStore().subscribe()
const vms = computed(() => getByOpaqueRefs(props.vmRefs))
const vms = computed(() => props.vmRefs.map(getVm).filter((vm): vm is XenApiVm => vm !== undefined))
const vmRefsWithPowerState = computed(() =>
vms.value.reduce(
(acc, vm) => ({
...acc,
[vm.$ref]: vm.power_state,
}),
{}
)
)
const vmRefsWithPowerState = computed(() => vms.value.reduce((acc, vm) => ({ ...acc, [vm.$ref]: vm.power_state }), {}))
const xenApi = useXenApiStore().getXapi()
@@ -112,78 +123,6 @@ const areVmsBusyToForceReboot = computed(() => areOperationsPending(VM_OPERATION
const areVmsBusyToShutdown = computed(() => areOperationsPending(VM_OPERATION.CLEAN_SHUTDOWN))
const areVmsBusyToForceShutdown = computed(() => areOperationsPending(VM_OPERATION.HARD_SHUTDOWN))
const getHostState = (host: XenApiHost) => (isHostRunning(host) ? VM_POWER_STATE.RUNNING : VM_POWER_STATE.HALTED)
const hasVm = computed(() => vms.value.length > 0)
const startItem = useMenuAction({
parent: props.menu,
busy: areVmsBusyToStart,
disabled: computed(() => !hasVm.value || !areVmsHalted.value),
handler: () => xenApi.vm.start(props.vmRefs),
})
const startOnHostItem = useMenuToggle({
parent: props.menu,
placement: 'right-start',
items: Object.fromEntries(
hosts.value.map(host => [
host.$ref,
action(() => xenApi.vm.startOn(props.vmRefs, host.$ref), {
busy: areVmsBusyToStartOnHost,
disabled: computed(() => !hasVm.value || !areVmsHalted.value),
}),
])
),
})
const pauseItem = useMenuAction({
parent: props.menu,
busy: areVmsBusyToPause,
disabled: computed(() => !hasVm.value || !areVmsRunning.value),
handler: () => xenApi.vm.pause(props.vmRefs),
})
const suspendItem = useMenuAction({
parent: props.menu,
busy: areVmsBusyToSuspend,
disabled: computed(() => !hasVm.value || !areVmsRunning.value),
handler: () => xenApi.vm.suspend(props.vmRefs),
})
const resumeItem = useMenuAction({
parent: props.menu,
busy: areVmsBusyToResume,
disabled: computed(() => !hasVm.value || (!areVmsSuspended.value && !areVmsPaused.value)),
handler: () => xenApi.vm.resume(vmRefsWithPowerState.value),
})
const rebootItem = useMenuAction({
parent: props.menu,
busy: areVmsBusyToReboot,
disabled: computed(() => !hasVm.value || !areVmsRunning.value),
handler: () => xenApi.vm.reboot(props.vmRefs),
})
const forceRebootItem = useMenuAction({
parent: props.menu,
busy: areVmsBusyToForceReboot,
disabled: computed(() => !hasVm.value || (!areVmsRunning.value && !areVmsPaused.value)),
handler: () => xenApi.vm.reboot(props.vmRefs, true),
})
const shutdownItem = useMenuAction({
parent: props.menu,
busy: areVmsBusyToShutdown,
disabled: computed(() => !hasVm.value || !areVmsRunning.value),
handler: () => xenApi.vm.shutdown(props.vmRefs),
})
const forceShutdownItem = useMenuAction({
parent: props.menu,
busy: areVmsBusyToForceShutdown,
disabled: computed(() => !hasVm.value || (!areVmsRunning.value && !areVmsSuspended.value && !areVmsPaused.value)),
handler: () => xenApi.vm.shutdown(props.vmRefs, true),
})
</script>
<style lang="postcss" scoped>

View File

@@ -1,7 +1,7 @@
<template>
<VtsMenuItem :icon="faCamera" v-bind="menuItem">
<MenuItem :busy="areSomeVmsSnapshoting" :disabled="isDisabled" :icon="faCamera" @click="handleSnapshot">
{{ $t('snapshot') }}
</VtsMenuItem>
</MenuItem>
</template>
<script lang="ts" setup>
@@ -10,28 +10,28 @@ import { VM_OPERATION } from '@/libs/xen-api/xen-api.enums'
import type { XenApiVm } from '@/libs/xen-api/xen-api.types'
import { useVmStore } from '@/stores/xen-api/vm.store'
import { useXenApiStore } from '@/stores/xen-api.store'
import VtsMenuItem from '@core/components/menu/VtsMenuItem.vue'
import { type MenuLike, useMenuAction } from '@core/packages/menu'
import MenuItem from '@core/components/menu/MenuItem.vue'
import { faCamera } from '@fortawesome/free-solid-svg-icons'
import { computed } from 'vue'
const props = defineProps<{
menu: MenuLike
vmRefs: XenApiVm['$ref'][]
}>()
const { getByOpaqueRefs } = useVmStore().subscribe()
const { getByOpaqueRef } = useVmStore().subscribe()
const vms = computed(() => getByOpaqueRefs(props.vmRefs))
const vmRefsToSnapshot = computed(() =>
Object.fromEntries(vms.value.map(vm => [vm.$ref, `${vm.name_label}_${new Date().toISOString()}`]))
const vms = computed(() =>
props.vmRefs.map(vmRef => getByOpaqueRef(vmRef)).filter((vm): vm is XenApiVm => vm !== undefined)
)
const menuItem = useMenuAction({
parent: props.menu,
handler: () => useXenApiStore().getXapi().vm.snapshot(vmRefsToSnapshot.value),
busy: computed(() => vms.value.some(vm => isVmOperationPending(vm, VM_OPERATION.SNAPSHOT))),
disabled: computed(() => vms.value.length === 0),
})
const areSomeVmsSnapshoting = computed(() => vms.value.some(vm => isVmOperationPending(vm, VM_OPERATION.SNAPSHOT)))
const isDisabled = computed(() => vms.value.length === 0 || areSomeVmsSnapshoting.value)
const handleSnapshot = () => {
const vmRefsToSnapshot = Object.fromEntries(
vms.value.map(vm => [vm.$ref, `${vm.name_label}_${new Date().toISOString()}`])
)
return useXenApiStore().getXapi().vm.snapshot(vmRefsToSnapshot)
}
</script>

View File

@@ -1,39 +1,50 @@
<template>
<TitleBar :icon="faDisplay">
{{ name }}
<template v-if="vm !== undefined" #actions>
<UiDropdownButton :icon="faPowerOff" v-bind="omit(powerMenu.$trigger, ['as', 'submenu'])">
{{ $t('change-state') }}
</UiDropdownButton>
<VtsMenuList border v-bind="powerMenu.$target">
<VmActionPowerStateItems :menu="powerMenu" :vm-refs="[vm.$ref]" />
</VtsMenuList>
<UiButtonIcon
v-tooltip="{
placement: 'left',
content: $t('more-actions'),
}"
:icon="faEllipsisVertical"
accent="info"
class="more-actions-button"
size="large"
v-bind="omit(moreMenu.$trigger, ['as', 'submenu'])"
/>
<VtsMenuList border v-bind="moreMenu.$target">
<VmActionCopyItem :menu="moreMenu" :selected-refs="[vm.$ref]" is-single-action />
<VmActionExportItem :menu="moreMenu" :vm-refs="[vm.$ref]" is-single-action />
<VmActionSnapshotItem :menu="moreMenu" :vm-refs="[vm.$ref]" />
<VmActionMigrateItem :menu="moreMenu" :selected-refs="[vm.$ref]" is-single-action />
</VtsMenuList>
<template #actions>
<MenuList v-if="vm !== undefined" placement="bottom-end">
<template #trigger="{ open, isOpen }">
<UiButton
size="medium"
accent="info"
variant="primary"
:class="{ pressed: isOpen }"
:left-icon="faPowerOff"
@click="open"
>
{{ $t('change-state') }}
<UiIcon :icon="faAngleDown" />
</UiButton>
</template>
<VmActionPowerStateItems :vm-refs="[vm.$ref]" />
</MenuList>
<MenuList v-if="vm !== undefined" placement="bottom-end">
<template #trigger="{ open, isOpen }">
<UiButtonIcon
v-tooltip="{
placement: 'left',
content: $t('more-actions'),
}"
:selected="isOpen"
:icon="faEllipsisVertical"
accent="info"
class="more-actions-button"
size="large"
@click="open"
/>
</template>
<VmActionCopyItem :selected-refs="[vm.$ref]" is-single-action />
<VmActionExportItem :vm-refs="[vm.$ref]" is-single-action />
<VmActionSnapshotItem :vm-refs="[vm.$ref]" />
<VmActionMigrateItem :selected-refs="[vm.$ref]" is-single-action />
</MenuList>
</template>
</TitleBar>
</template>
<script lang="ts" setup>
import TitleBar from '@/components/TitleBar.vue'
import UiIcon from '@/components/ui/icon/UiIcon.vue'
import VmActionCopyItem from '@/components/vm/VmActionItems/VmActionCopyItem.vue'
import VmActionExportItem from '@/components/vm/VmActionItems/VmActionExportItem.vue'
import VmActionMigrateItem from '@/components/vm/VmActionItems/VmActionMigrateItem.vue'
@@ -41,13 +52,11 @@ import VmActionPowerStateItems from '@/components/vm/VmActionItems/VmActionPower
import VmActionSnapshotItem from '@/components/vm/VmActionItems/VmActionSnapshotItem.vue'
import type { XenApiVm } from '@/libs/xen-api/xen-api.types'
import { useVmStore } from '@/stores/xen-api/vm.store'
import VtsMenuList from '@core/components/menu/VtsMenuList.vue'
import MenuList from '@core/components/menu/MenuList.vue'
import UiButton from '@core/components/ui/button/UiButton.vue'
import UiButtonIcon from '@core/components/ui/button-icon/UiButtonIcon.vue'
import UiDropdownButton from '@core/components/ui/dropdown-button/UiDropdownButton.vue'
import { vTooltip } from '@core/directives/tooltip.directive'
import { useMenuToggle } from '@core/packages/menu'
import { faDisplay, faEllipsisVertical, faPowerOff } from '@fortawesome/free-solid-svg-icons'
import { objectOmit as omit } from '@vueuse/shared'
import { faAngleDown, faDisplay, faEllipsisVertical, faPowerOff } from '@fortawesome/free-solid-svg-icons'
import { computed } from 'vue'
import { useRouter } from 'vue-router'
@@ -57,8 +66,4 @@ const { currentRoute } = useRouter()
const vm = computed(() => getVmByUuid(currentRoute.value.params.uuid as XenApiVm['uuid']))
const name = computed(() => vm.value?.name_label)
const powerMenu = useMenuToggle({ placement: 'bottom-end' })
const moreMenu = useMenuToggle({ placement: 'bottom-end' })
</script>

View File

@@ -1,29 +1,29 @@
<template>
<UiButtonIcon v-if="isMobile" :icon="faEllipsis" accent="info" size="medium" v-bind="menu.$trigger" />
<VtsMenuList
:border="isMobile"
<MenuList
:disabled="selectedRefs.length === 0"
:horizontal="!isMobile"
:no-border="!isMobile"
class="vms-actions-bar"
v-bind="isMobile ? menu.$target : undefined"
placement="bottom-end"
>
<VtsMenuItem :icon="faPowerOff" v-bind="menu.powerState.$trigger">
<template v-if="isMobile" #trigger="{ isOpen, open }">
<UiButtonIcon accent="info" size="medium" :selected="isOpen" :icon="faEllipsis" @click="open" />
</template>
<MenuItem :icon="faPowerOff">
{{ $t('change-state') }}
</VtsMenuItem>
<Teleport to="body">
<VtsMenuList border v-bind="menu.powerState.$target">
<VmActionPowerStateItems :menu="menu.powerState" :vm-refs="selectedRefs" />
</VtsMenuList>
</Teleport>
<VmActionMigrateItem :menu :selected-refs="selectedRefs" />
<VmActionCopyItem :menu :selected-refs="selectedRefs" />
<VtsMenuItem :icon="faEdit" v-bind="menu.editConfig">
<template #submenu>
<VmActionPowerStateItems :vm-refs="selectedRefs" />
</template>
</MenuItem>
<VmActionMigrateItem :selected-refs="selectedRefs" />
<VmActionCopyItem :selected-refs="selectedRefs" />
<MenuItem v-tooltip="$t('coming-soon')" :icon="faEdit">
{{ $t('edit-config') }}
</VtsMenuItem>
<VmActionSnapshotItem :menu :vm-refs="selectedRefs" />
<VmActionExportItems :menu :vm-refs="selectedRefs" />
<VmActionDeleteItem :menu :vm-refs="selectedRefs" />
</VtsMenuList>
</MenuItem>
<VmActionSnapshotItem :vm-refs="selectedRefs" />
<VmActionExportItems :vm-refs="selectedRefs" />
<VmActionDeleteItem :vm-refs="selectedRefs" />
</MenuList>
</template>
<script lang="ts" setup>
@@ -34,32 +34,20 @@ import VmActionMigrateItem from '@/components/vm/VmActionItems/VmActionMigrateIt
import VmActionPowerStateItems from '@/components/vm/VmActionItems/VmActionPowerStateItems.vue'
import VmActionSnapshotItem from '@/components/vm/VmActionItems/VmActionSnapshotItem.vue'
import type { XenApiVm } from '@/libs/xen-api/xen-api.types'
import VtsMenuItem from '@core/components/menu/VtsMenuItem.vue'
import VtsMenuList from '@core/components/menu/VtsMenuList.vue'
import MenuItem from '@core/components/menu/MenuItem.vue'
import MenuList from '@core/components/menu/MenuList.vue'
import UiButtonIcon from '@core/components/ui/button-icon/UiButtonIcon.vue'
import { action, toggle, useMenuToggle } from '@core/packages/menu'
import { vTooltip } from '@core/directives/tooltip.directive'
import { useUiStore } from '@core/stores/ui.store'
import { faEdit, faEllipsis, faPowerOff } from '@fortawesome/free-solid-svg-icons'
import { noop } from '@vueuse/shared'
import { storeToRefs } from 'pinia'
import { useI18n } from 'vue-i18n'
defineProps<{
disabled?: boolean
selectedRefs: XenApiVm['$ref'][]
}>()
const { t } = useI18n()
const { isMobile } = storeToRefs(useUiStore())
const menu = useMenuToggle({
placement: 'bottom-end',
items: {
powerState: toggle({}),
editConfig: action(noop, { disabled: t('coming-soon') }),
},
})
</script>
<style lang="postcss" scoped>

View File

@@ -2,8 +2,6 @@ import { createContext } from '@/composables/context.composable'
import type { Color } from '@/types'
import { computed } from 'vue'
export const DisabledContext = createContext(false)
export const ColorContext = createContext('info' as Color, color => ({
name: color,
textClass: computed(() => `context-color-${color.value}`),

View File

@@ -0,0 +1,283 @@
{
"about": "A propósito",
"access-xoa": "Acceder a la XOA",
"add": "Añadir",
"add-filter": "Añadir un filtro",
"add-or": "+o",
"add-sort": "Añadir un sorteo",
"admin-login": "Nombre del usuario administrador",
"admin-password": "Contraseña del administrador",
"admin-password-confirm": "Confirmar la contraseña del administrador",
"alarm-type.cpu_usage": "El uso del CPU sobrepasa el {n}%",
"alarm-type.disk_usage": "El uso del disco sobrepasa el {n}%",
"alarm-type.fs_usage": "El uso del sistema de ficheros sobrepasa el {n}%",
"alarm-type.log_fs_usage": "El uso de la bicácora del sistema de ficheros sobrepasa el {n}%",
"alarm-type.mem_usage": "El uso de la memoria sobrepasa el {n}%",
"alarm-type.memory_free_kib": "La memoria libre está debajo del {n}%",
"alarm-type.network_usage": "El uso de la red sobrepasa el {n}%",
"alarm-type.physical_utilisation": "El uso físico sobrepasa el {n}%",
"alarm-type.sr_io_throughput_total_per_host": "El uso total de Entrada/Salida SR por servido físico sobrepasa el {n}%",
"alarm-type.unknown": "Alarma desconocida",
"all-good": "Todo bien!",
"allow-self-signed-ssl": "Es posible que tendrá que permitir certificados SSL autofirmados dentro de su navegador",
"appearance": "Aparencia",
"ascending": "ascendente",
"available-properties-for-advanced-filter": "Propriedades disponibles para filtros avanzados",
"back-pool-dashboard": "Regresa al panel de estado del conjunto de servidores",
"backup": "Salvaguarda",
"cancel": "Anular",
"change-state": "Cambiar estado",
"check-errors": "Revisar los errores :",
"click-to-display-alarms": "Haga clic para mostra alarmas :",
"click-to-return-default-pool": "Haga clic para regresar al conjunto de servidores predeterminado",
"close": "Cerrar",
"community": "Comunidad",
"community-name": "Comunidad {name}",
"configuration": "Configuración",
"confirm-cancel": "¿Estás seguro que deseas cancelar?",
"confirm-delete": "Estás a punto de eliminar {0}",
"console-unavailable": "Cónsola no disponible",
"copy": "Copiar",
"cpu-provisioning": "Aprovisionamiento de CPU",
"cpu-provisioning-warning": "La cantidad de vCPU asignadas excede la cantidad de CPU físicas disponibles. El rendimiento del sistema puede verse afectado.",
"cpu-usage": "Uso del CPU",
"delete": "Borrar",
"delete-vms": "Borrar una máquina virtual | Borrar {n} máquinas virtuales",
"deploy": "Desplegar",
"deploy-xoa": "Desplegar XOA",
"deploy-xoa-available-on-desktop": "El despliegue de XOA está disponible en la computadora",
"deploy-xoa-custom-ntp-servers": "Servidores NTP para la XOA (seperados por espacios)",
"deploy-xoa-status.configuring": "Configurando la XOA…",
"deploy-xoa-status.importing": "Importando la XOA…",
"deploy-xoa-status.not-responding": "La XOA no responde",
"deploy-xoa-status.ready": "La XOA está lista!",
"deploy-xoa-status.starting": "Iniciando la XOA…",
"deploy-xoa-status.waiting": "Esperando respuesta de la XOA…",
"descending": "descendente",
"description": "Descripción",
"dhcp": "DHCP",
"dns": "DNS",
"disabled": "Desactivado",
"display": "Mostrar",
"do-you-have-needs": "¿Falta algo? Háganos saber",
"documentation": "Documentación",
"edit-config": "Modificar configuración",
"enabled": "Activado",
"error-occurred": "Hubo un error",
"export": "Exportar",
"export-n-vms": "Exportar una máquina virtual | Exportar {n} máquinas virtuales",
"export-n-vms-manually": "Exportar una máquina virtual a mano | Exportar {n} máquinas virtuales a mano",
"export-table-to": "Exportar la tabla a {type}",
"export-vm": "Exportar una máquina virtual",
"export-vms": "Exportar unas máquinas virtuales",
"export-vms-manually-information": "Algunas exportaciones de máquina virtual no pudieron iniciarse automáticamente, posiblemente debido a la configuración del navegador. Para exportarlos, tendrás que hacer clic en cada uno de ellos. (o copiar el enlace).",
"fetching-fresh-data": "Refrescando datos",
"filter.comparison.contains": "Contiene",
"filter.comparison.ends-with": "Termina con",
"filter.comparison.equals": "Está egual a",
"filter.comparison.is": "Es",
"filter.comparison.is-false": "Es falso",
"filter.comparison.is-not": "No es",
"filter.comparison.is-true": "Es verdad",
"filter.comparison.matches-regex": "Corresponde a la regex",
"filter.comparison.not-contain": "No contiene",
"filter.comparison.not-end-with": "No termina con",
"filter.comparison.not-equal": "No es egual a",
"filter.comparison.not-match-regex": "No corresponde a la regex",
"filter.comparison.not-start-with": "No comienza con",
"filter.comparison.starts-with": "Comienza con",
"following-hosts-unreachable": "Imposible comunicar con los servidores siguientes",
"force-reboot": "Forzar un reinicio",
"force-shutdown": "Forzar un apagado",
"fullscreen-leave": "Salir de pantalla completa",
"n-gb-left": "{n} Go libres",
"n-gb-required": "{n} Go requeridos",
"go-back": "Regresa",
"gzip": "gzip",
"here": "Aquí",
"host.active": "Activo | Activo | Activos",
"host.inactive": "Inactivo | Inactivo | Inactivos",
"invalid-field": "Campo inválido",
"job.vm-copy.bad-power-state": "La máquina virtual debe estar apagada",
"job.vm-copy.in-progress": "Copiando…",
"job.vm-copy.missing-vms": "No hay máquinas virtuales para copiar",
"job.vm-delete.bad-power-state": "La máquina virtual debe estar apagada",
"job.vm-delete.in-progress": "Borrándola…",
"job.vm-delete.missing-vms": "No hay máquinas virtuales para borrar",
"job.vm-force-reboot.bad-power-state": "La máquina virtual debe estar en marcha o pausada",
"job.vm-force-reboot.in-progress": "Reinicio forzado en curso…",
"job.vm-force-reboot.missing-vms": "No hay máquinas virtuales para reiniciar por fuerza",
"job.vm-force-shutdown.bad-power-state": "La máquina virtual debe estar en marcha, suspendida o pausada",
"job.vm-force-shutdown.in-progress": "Parada forzada en curso…",
"job.vm-force-shutdown.missing-vms": "No hay máquinas virtuales para parar por fuerza",
"job.vm-export.in-progress": "Exportación en curso…",
"job.vm-export.missing-compression": "Se requiere compresión",
"job.vm-export.missing-vms": "No hay máquinas virtuales para exportar",
"job.vm-export.not-allowed": "Prohibido exportar una máquina virtual",
"job.vm-migrate.bad-power-state": "La máquina virtual debe estar en marcha",
"job.vm-migrate.in-progress": "Migrándola…",
"job.vm-migrate.missing-host": "Se requiere servidor de destino",
"job.vm-migrate.missing-vms": "No hay máquina virtual que migrar",
"job.vm-pause.bad-power-state": "La máquina virtual debe estar en marcha",
"job.vm-pause.in-progress": "Pausándola…",
"job.vm-pause.missing-vms": "No hay máquina virtual que pausar",
"job.vm-reboot.bad-power-state": "La máquina virtual debe estar en marcha",
"job.vm-reboot.in-progress": "Reiniciándola…",
"job.vm-reboot.missing-vms": "No hay máquina virtual que reiniciar",
"job.vm-resume.bad-power-state": "La máquina virtual debe estar pausada o suspendida",
"job.vm-resume.in-progress": "Reanudándola…",
"job.vm-resume.missing-vms": "No hay máquina virtual que reanudir",
"job.vm-shutdown.bad-power-state": "La máquina virtual debe estar en marcha",
"job.vm-shutdown.in-progress": "Apagándola…",
"job.vm-shutdown.missing-vms": "No hay máquina virtual que apagar",
"job.vm-snapshot.in-progress": "Creando instantánea…",
"job.vm-snapshot.missing-vms": "No hay máquina virtual que instantanear",
"job.vm-start.bad-power-state": "La máquina virtual debe estar parada",
"job.vm-start.in-progress": "Démarrage en curso…",
"job.vm-start.missing-vms": "No hay máquina virtual que parar",
"job.vm-start-on.bad-power-state": "La máquina virtual debe estar parada",
"job.vm-start-on.in-progress": "Iniciándola…",
"job.vm-start-on.missing-host": "Se requiere servidor de destino",
"job.vm-start-on.missing-vms": "No hay máquina virtual que iniciar",
"job.vm-suspend.bad-power-state": "La máquina virtual debe estar en marcha",
"job.vm-suspend.in-progress": "Suspendiéndola…",
"job.vm-suspend.missing-vms": "No hay máquina virtual que suspender",
"keep-me-logged": "Mantenerme conectado",
"keep-page-open": "Hasta que termine el despliegue, no refresca ni cierra esta página",
"language": "Idioma",
"last-week": "Semana pasada",
"load-now": "Cargar ahora",
"loading-hosts": "Cargando servidores…",
"login": "Conectarse",
"login-only-on-master": "Sólo es posible conectarse al servidor principal",
"more-actions": "Acciones adicionales",
"migrate": "Migrar",
"migrate-n-vms": "Migrar una máquina virtual | Migrar {n} máquinas virtuales",
"n-hosts-awaiting-patch": "{n} servidor falta este parche | {n} servidores faltan este parche",
"n-missing": "Falta {n} | Faltan {n}",
"name": "Nombre",
"netmask": "Máscara de red",
"network-download": "Rendimiento de descarga de red",
"network-throughput": "Rendimiento de red",
"network-upload": "Rendimiento de carga de red",
"new-features-are-coming": "Próximamente nuevas funciones!",
"news": "Actualidades",
"news-name": "Actualidades {name}",
"no-alarm-triggered": "No hay alarma",
"no-result": "No hay resultados",
"no-selected-vm-can-be-exported": "Ninguna máquina virtual selectionada puede exportarse",
"no-selected-vm-can-be-migrated": "Ninguna máquina virtual selectionada puede migrarse",
"no-tasks": "No hay tareas",
"not-found": "No encontrado",
"object": "Objeto",
"ok": "OK",
"on-object": "sobre {object}",
"or": "o/u",
"password": "Contraseña",
"password-invalid": "La contraseña no es válido",
"pause": "Pausar",
"please-confirm": "Confirme, por favor",
"pool-cpu-usage": "Uso CPU del conjunto de servidores",
"pool-ram-usage": "Uso RAM del conjunto de servidores",
"power-state": "Estado alimentación electrica",
"property": "Propriedad",
"ram-usage": "Uso RAM",
"reboot": "Reiniciar",
"reformat": "Reformatear",
"relative-time.day": "1 día| {n} días",
"relative-time.future": "Dentro de {str}",
"relative-time.hour": "1 hora | {n} horas",
"relative-time.minute": "Un minuto | {n} minutos",
"relative-time.month": "Un mes | {n} meses",
"relative-time.now": "Ahora mismo",
"relative-time.past": "Hace {str}",
"relative-time.second": "Un secundo | {n} secundos",
"relative-time.year": "1 año | {n} años",
"resume": "Reanudar",
"save": "Guardar",
"select-compression": "Elige modo de compression",
"select-destination-host": "Elige servidor destino",
"selected-vms-in-execution": "Algunas de las máquinas virtuales selectionadas están en marcha",
"send-us-feedback": "Envíenos sus comentarios",
"select.network": "Elige una red",
"select.storage": "Elige un SR",
"settings": "Parámetros",
"shutdown": "Apagar",
"snapshot": "Instantanear",
"sort-by": "Ordenar por",
"ssh-account": "Cuenta SSH",
"ssh-login": "Nombre de usuario SSH",
"ssh-password": "Contraseña SSH",
"ssh-password-confirm": "Confirmar la contraseña SSH",
"stacked-cpu-usage": "Uso CPU apilado",
"stacked-ram-usage": "Uso RAM apilado",
"start": "Iniciar",
"start-on-host": "Iniciar en un servidor específico",
"static-ip": "IP estático",
"status": "Estatus",
"storage-usage": "Uso de almacenamiento",
"support": "Apoyo",
"suspend": "Suspender",
"switch-theme": "Cambiar el tema",
"task.estimated-end": "Fin estimado",
"task.progress": "Progreso",
"task.started": "Iniciado",
"theme-auto": "Auto",
"theme-dark": "Oscuro",
"theme-light": "Claro",
"this-vm-cant-be-migrated": "Esta máquina virtual no se puede migrar",
"top-#": "{n} principales",
"total-free:": "Total libre :",
"total-used:": "Total usado :",
"unreachable-hosts": "Servidores no accesibles",
"unreachable-hosts-reload-page": "Acabado. Refresca la página",
"vcpus": "vCPUs",
"vcpus-used": "vCPUs usados",
"version": "Versión",
"vm.active": "Activo | Activo | Activos",
"vm.inactive": "No activo | No activo | No activos",
"vm-is-running": "La máquina virtual está en marcha",
"xo-lite-under-construction": "XOLite está en desarroyo",
"xoa-admin-account": "Cuanta administrador de la XOA",
"xoa-deploy": "Despliegue de la XOA",
"xoa-deploy-failed": "Error en el despliegue de la XOA !",
"xoa-deploy-retry": "Vuelva a intentar el despliegue de la XOA",
"xoa-deploy-successful": "Despliegue de la XOA succedió !",
"xoa-ip": "Dirección IP de la XOA",
"xoa-password-confirm-different": "No se confirmó la contraseña",
"xoa-ssh-account": "Cuenta SSH de la XOA",
"xoa-ssh-password-confirm-different": "No se confirmó la contraseña SSH",
"you-are-currently-on": "Estás conectado al : {0}",
"zstd": "zstd"
}

View File

@@ -13,10 +13,10 @@
</template>
<script lang="ts" setup>
import LinearChart from '@/components/charts/LinearChart.vue'
import ComponentStory from '@/components/component-story/ComponentStory.vue'
import { prop } from '@/libs/story/story-param'
import type { LinearChartData } from '@/types/chart'
import type { LinearChartData } from '@core/types/chart'
import LinearChart from '@core/components/charts/LinearChart.vue'
// eslint-disable-next-line import/no-named-as-default
import humanFormat from 'human-format'

View File

@@ -1,12 +1,9 @@
import type { FetchedStats, Stat } from '@/composables/fetch-stats.composable'
import type { HostStats, VmStats } from '@/libs/xapi-stats'
import type { XenApiHost } from '@/libs/xen-api/xen-api.types'
import type { ValueFormatter } from '@/types/chart'
import type { ModalController } from '@/types/index'
import type { ComputedRef, InjectionKey } from 'vue'
export const IK_CHART_VALUE_FORMATTER = Symbol('IK_CHART_VALUE_FORMATTER') as InjectionKey<ComputedRef<ValueFormatter>>
export const IK_INPUT_TYPE = Symbol('IK_INPUT_TYPE') as InjectionKey<'select' | 'textarea'>
export const IK_CHECKBOX_TYPE = Symbol('IK_CHECKBOX_TYPE') as InjectionKey<'checkbox' | 'radio' | 'toggle'>

View File

@@ -5,12 +5,7 @@
<UiStatusPanel v-else-if="!isHostRunning" :image-source="monitor" :title="$t('power-on-host-for-console')" />
<template v-else-if="host && hostConsole">
<VtsLayoutConsole>
<RemoteConsole
ref="consoleElement"
:is-console-available="isConsoleAvailable"
:location="hostConsole.location"
class="remote-console"
/>
<VtsRemoteConsole v-if="url" ref="console-element" :url :is-console-available="isConsoleAvailable" />
<template #actions>
<VtsActionsConsole
:open-in-new-tab="openInNewTab"
@@ -28,7 +23,6 @@
<script lang="ts" setup>
import monitor from '@/assets/monitor.svg'
import RemoteConsole from '@/components/RemoteConsole.vue'
import UiSpinner from '@/components/ui/UiSpinner.vue'
import UiStatusPanel from '@/components/ui/UiStatusPanel.vue'
import { isHostOperationPending } from '@/libs/host'
@@ -38,12 +32,14 @@ import { usePageTitleStore } from '@/stores/page-title.store'
import { useConsoleStore } from '@/stores/xen-api/console.store'
import { useControlDomainStore } from '@/stores/xen-api/control-domain.store'
import { useHostStore } from '@/stores/xen-api/host.store'
import { useXenApiStore } from '@/stores/xen-api.store'
import VtsActionsConsole from '@core/components/console/VtsActionsConsole.vue'
import VtsClipboardConsole from '@core/components/console/VtsClipboardConsole.vue'
import VtsLayoutConsole from '@core/components/console/VtsLayoutConsole.vue'
import VtsRemoteConsole from '@core/components/console/VtsRemoteConsole.vue'
import VtsDivider from '@core/components/divider/VtsDivider.vue'
import { useUiStore } from '@core/stores/ui.store'
import { computed, ref } from 'vue'
import { computed, useTemplateRef } from 'vue'
import { useI18n } from 'vue-i18n'
import { useRoute, useRouter } from 'vue-router'
@@ -54,6 +50,7 @@ usePageTitleStore().setTitle(useI18n().t('console'))
const router = useRouter()
const route = useRoute()
const uiStore = useUiStore()
const xenApiStore = useXenApiStore()
const {
isReady: isHostReady,
@@ -91,11 +88,21 @@ const isHostRunning = computed(() => {
return runningHosts.value.some(runningHost => runningHost.uuid === host.value?.uuid)
})
const url = computed(() => {
if (xenApiStore.currentSessionId == null) {
return
}
const _url = new URL(hostConsole.value!.location)
_url.protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'
_url.searchParams.set('session_id', xenApiStore.currentSessionId)
return _url
})
const isConsoleAvailable = computed(() =>
controlDomain.value !== undefined ? !isHostOperationPending(host.value!, STOP_OPERATIONS) : false
)
const consoleElement = ref()
const consoleElement = useTemplateRef('console-element')
const sendCtrlAltDel = () => consoleElement.value?.sendCtrlAltDel()
@@ -128,12 +135,6 @@ const openInNewTab = () => {
height: 10rem;
}
.remote-console {
flex: 1;
max-width: 100%;
height: 100%;
}
.not-available {
display: flex;
align-items: center;

View File

@@ -5,12 +5,7 @@
<UiStatusPanel v-else-if="!isVmRunning" :image-source="monitor" :title="$t('power-on-vm-for-console')" />
<template v-else-if="vm && vmConsole">
<VtsLayoutConsole>
<RemoteConsole
ref="consoleElement"
:is-console-available="isConsoleAvailable"
:location="vmConsole.location"
class="remote-console"
/>
<VtsRemoteConsole v-if="url" ref="console-element" :url :is-console-available="isConsoleAvailable" />
<template #actions>
<VtsActionsConsole
:open-in-new-tab="openInNewTab"
@@ -28,7 +23,6 @@
<script lang="ts" setup>
import monitor from '@/assets/monitor.svg'
import RemoteConsole from '@/components/RemoteConsole.vue'
import UiSpinner from '@/components/ui/UiSpinner.vue'
import UiStatusPanel from '@/components/ui/UiStatusPanel.vue'
import { isVmOperationPending } from '@/libs/vm'
@@ -37,14 +31,16 @@ import type { XenApiVm } from '@/libs/xen-api/xen-api.types'
import { usePageTitleStore } from '@/stores/page-title.store'
import { useConsoleStore } from '@/stores/xen-api/console.store'
import { useVmStore } from '@/stores/xen-api/vm.store'
import { useXenApiStore } from '@/stores/xen-api.store'
import VtsActionsConsole from '@core/components/console/VtsActionsConsole.vue'
import VtsClipboardConsole from '@core/components/console/VtsClipboardConsole.vue'
import VtsLayoutConsole from '@core/components/console/VtsLayoutConsole.vue'
import VtsRemoteConsole from '@core/components/console/VtsRemoteConsole.vue'
import VtsDivider from '@core/components/divider/VtsDivider.vue'
import { useUiStore } from '@core/stores/ui.store'
import { useActiveElement, useMagicKeys, whenever } from '@vueuse/core'
import { logicAnd } from '@vueuse/math'
import { computed, ref } from 'vue'
import { computed, useTemplateRef } from 'vue'
import { useI18n } from 'vue-i18n'
import { useRoute, useRouter } from 'vue-router'
@@ -63,6 +59,7 @@ usePageTitleStore().setTitle(useI18n().t('console'))
const router = useRouter()
const route = useRoute()
const uiStore = useUiStore()
const xenApiStore = useXenApiStore()
const { isReady: isVmReady, getByUuid: getVmByUuid, hasError: hasVmError } = useVmStore().subscribe()
@@ -90,11 +87,21 @@ const vmConsole = computed(() => {
return getConsoleByOpaqueRef(consoleOpaqueRef)
})
const url = computed(() => {
if (xenApiStore.currentSessionId == null) {
return
}
const _url = new URL(vmConsole.value!.location)
_url.protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'
_url.searchParams.set('session_id', xenApiStore.currentSessionId)
return _url
})
const isConsoleAvailable = computed(() =>
vm.value !== undefined ? !isVmOperationPending(vm.value, STOP_OPERATIONS) : false
)
const consoleElement = ref()
const consoleElement = useTemplateRef('console-element')
const sendCtrlAltDel = () => consoleElement.value?.sendCtrlAltDel()
@@ -133,10 +140,4 @@ whenever(logicAnd(escape, canClose), toggleFullScreen)
width: 10rem;
height: 10rem;
}
.remote-console {
flex: 1;
max-width: 100%;
height: 100%;
}
</style>

View File

@@ -0,0 +1,19 @@
{
"parser": "@typescript-eslint/parser",
"plugins": ["@typescript-eslint"],
"extends": ["eslint:recommended", "plugin:@typescript-eslint/recommended"],
"overrides": [
{
"files": ["*.ts"],
"parserOptions": {
"sourceType": "module"
},
"rules": {
"no-extra-semi": "off",
"no-use-before-define": "off",
"@typescript-eslint/no-explicit-any": "off"
}
}
],
"ignorePatterns": ["src/open-api/routes/routes.ts"]
}

1
@xen-orchestra/rest-api/.gitignore vendored Normal file
View File

@@ -0,0 +1 @@
src/open-api/*

View File

@@ -0,0 +1,34 @@
{
"author": "Vates",
"name": "@xen-orchestra/rest-api",
"version": "0.1.0",
"description": "",
"main": "./dist/index.js",
"license": "MIT",
"private": true,
"type": "module",
"engines": {
"node": ">=20"
},
"scripts": {
"build": "tsoa spec-and-routes && tsc",
"dev": "tsoa spec-and-routes && tsc --watch"
},
"devDependencies": {
"@types/express": "^5.0.0",
"@types/node": "^22.10.6",
"@types/swagger-ui-express": "^4.1.7",
"@typescript-eslint/eslint-plugin": "^8.20.0",
"@typescript-eslint/parser": "^8.20.0",
"eslint": "^9.18.0",
"typescript": "~5.6.3"
},
"dependencies": {
"express": "^4.21.2",
"inversify": "^6.2.1",
"inversify-binding-decorators": "^4.0.0",
"swagger-ui-express": "^5.0.1",
"tsoa": "^6.6.0",
"xo-common": "^0.8.0"
}
}

View File

@@ -0,0 +1,30 @@
import { getRestApi } from '../index.js'
import { XapiXoObject } from '../xoApp.type.js'
import { Controller } from 'tsoa'
export abstract class XapiXoController<T extends XapiXoObject> extends Controller {
protected restApi = getRestApi()
protected type
constructor(type: T['type']) {
super()
this.type = type
}
// wrap these methods with permission (filter only objects that the users can see)
protected getObjects(): Record<T['id'], T> | undefined {
return this.restApi.getObjectsByType(this.type) as Record<T['id'], T> | undefined
}
protected getById(id: T['id']): T {
return this.restApi.getObject(id, this.type)
}
protected getXapiObject(objOrId: T['id'] | T) {
return this.restApi.getXapiObject(objOrId, this.type)
}
// --------------------------------------------------------------------------------
protected getObjectIds(): T['id'][] {
return Object.keys(this.getObjects() ?? {})
}
}

View File

@@ -0,0 +1,37 @@
import { getRestApi } from '../index.js'
import { NonXapiObject } from '../xoApp.type.js'
import { Controller } from 'tsoa'
type XoType = 'server'
export abstract class XoController<T extends NonXapiObject> extends Controller {
#restApi = getRestApi()
#type: XoType
#fnByType = {
server: {
getAll: () => this.#restApi.getServers(),
getById: (id: NonXapiObject['id']) => this.#restApi.getServer(id),
},
}
constructor(type: XoType) {
super()
this.#type = type
}
protected getObjects() {
return this.#fnByType[this.#type].getAll()
}
protected getObject(id: T['id']) {
return this.#fnByType[this.#type].getById(id)
}
protected async getObjectIds(): Promise<NonXapiObject['id'][]> {
const objects = await this.getObjects()
if (Array.isArray(objects)) {
return objects.map(obj => obj.id)
}
return Object.keys(objects)
}
}

View File

@@ -0,0 +1,36 @@
import { Controller, Get, Request, Route } from 'tsoa'
import DashboardService from './dashboard.service.js'
import { inject } from 'inversify'
import { provideSingleton } from '../ioc/helper.js'
import { Request as ExReq } from 'express'
export type Dashboard = {
vmsStatus: {
running: number
inactive: number
unknown: number
}
poolsStatus: {
connected: number
unreachable: number
unknown: number
}
}
@Route('dashboard')
@provideSingleton(DashboardController)
export class DashboardController extends Controller {
#dashboardService
constructor(@inject(DashboardService) dashboardService: DashboardService) {
super()
this.#dashboardService = dashboardService
}
@Get()
public async getDashboard(@Request() req: ExReq): Promise<Dashboard> {
const resp = req.res!
resp.setHeader('Access-Control-Allow-Origin', '*') // TODO: remove this. Only used for test
return this.#dashboardService.getDashboard()
}
}

View File

@@ -0,0 +1,107 @@
import { getRestApi } from '../index.js'
import { Dashboard } from './dashboard.controller.js'
import { provideSingleton } from '../ioc/helper.js'
// cache used to compare changes and trigger dashboard events
type CacheDashboard = {
vmsStatus: Dashboard['vmsStatus']
poolsStatus: Dashboard['poolsStatus']
}
const cache = new Map<keyof CacheDashboard, CacheDashboard[keyof CacheDashboard]>()
@provideSingleton(DashboardService)
export default class DashboardService {
#restApi
#fnById = {
// arrow fn to ensure to call the method in the good context
vmsStatus: () => this.#getVmsStatus(),
poolsStatus: () => this.#getPoolsStatus(),
}
constructor() {
this.#restApi = getRestApi()
this.#registerListener()
}
async getDashboard(): Promise<Dashboard> {
const vmsStatus = this.#getVmsStatus()
const poolsStatus = await this.#getPoolsStatus()
return { vmsStatus, poolsStatus }
}
#getVmsStatus(): Dashboard['vmsStatus'] {
const vms = Object.values(this.#restApi.getObjectsByType('VM') ?? {}) as any
let running = 0
let inactive = 0
let unknown = 0
vms.forEach(vm => {
if (vm.power_state === 'Running' || vm.power_state === 'Paused') {
running++
} else if (vm.power_state === 'Halted' || vm.power_state === 'Suspended') {
inactive++
} else {
unknown++
}
})
const result = { running, inactive, unknown }
cache.set('vmsStatus', result)
return result
}
async #getPoolsStatus(): Promise<Dashboard['poolsStatus']> {
const servers = await this.#restApi.getServers()
const poolIds = Object.keys(this.#restApi.getObjectsByType('pool') ?? {})
let nConnectedServers = 0
let nUnreachableServers = 0
let nUnknownServers = 0
servers.forEach(server => {
// it may happen that some servers are marked as "connected", but no pool matches "server.pool"
// so they are counted as `nUnknownServers`
if (server.status === 'connected' && poolIds.includes(server.poolId)) {
nConnectedServers++
return
}
if (
server.status === 'disconnected' &&
server.error !== undefined &&
server.error.connectedServerId === undefined
) {
nUnreachableServers++
return
}
if (server.status === 'disconnected') {
return
}
nUnknownServers++
})
const result = { connected: nConnectedServers, unreachable: nUnreachableServers, unknown: nUnknownServers }
cache.set('poolsStatus', result)
return result
}
async #maybeSendEvent(key: keyof Dashboard) {
const stringifiedCacheValue = JSON.stringify(cache.get(key))
const newValue = await this.#fnById[key]()
if (JSON.stringify(newValue) === stringifiedCacheValue) {
return
}
this.#restApi.sendData(key, 'dashboard', newValue, 'update')
}
#registerListener() {
this.#restApi.ee.on('vm', () => this.#maybeSendEvent('vmsStatus'))
this.#restApi.ee.on('pool', () => this.#maybeSendEvent('poolsStatus'))
// this.#restApi.ee.on('host', () => this.#maybeSendEvent('hostsStatus'))
}
}

View File

@@ -0,0 +1,34 @@
import { Request as ExRequest } from 'express'
import { getRestApi } from '../index.js'
import { Controller, Get, Request, Route } from 'tsoa'
import { provideSingleton } from '../ioc/helper.js'
@Route('events')
@provideSingleton(EventsController)
export class EventsController extends Controller {
/**
* subscribe to all evenements
*/
@Get()
public async getEvent(@Request() req: ExRequest): Promise<void> {
const restApi = getRestApi()
const res = req.res!
res.setHeader('Access-Control-Allow-Origin', '*') // TODO: remove this. Only used for test
res.setHeader('Content-Type', 'text/event-stream')
res.setHeader('Cache-Control', 'no-cache')
res.setHeader('Connection', 'keep-alive')
res.write(`data:${JSON.stringify({ data: null, operation: 'initial' })}\n\n`)
const symbol = Symbol('client-id')
restApi.addSseClient(symbol, res)
res.on('close', () => {
restApi.removeSseClient(symbol)
res.end()
})
}
}

View File

@@ -0,0 +1,134 @@
import swaggerUi from 'swagger-ui-express'
import { Express, NextFunction, Request, Response } from 'express'
import swaggerOpenApiSpec from './open-api/spec/swagger.json' assert { type: 'json' }
import { RegisterRoutes } from './open-api/routes/routes.js'
import { XapiXoObject, XoApp } from './xoApp.type.js'
import { EventEmitter } from 'events'
import DashboardService from './dashboard/dashboard.service.js'
import { iocContainer } from './ioc/ioc.js'
import { errorHandler } from './middleware/error.middleware.js'
import fs from 'node:fs/promises'
import { dirname } from 'node:path'
import { fileURLToPath } from 'node:url'
const __dirname = dirname(fileURLToPath(import.meta.url))
class RestApi {
#sseClients: Map<symbol, Response> = new Map()
#express
ee = new EventEmitter()
authenticateUser
getObject
getObjects
getServers: XoApp['getAllXenServers']
getServer: XoApp['getXenServer']
getXapiObject: XoApp['getXapiObject']
getObjectsByType
constructor(xoApp: XoApp, express: Express) {
console.log('INITIALIZE')
if (restApi !== undefined) {
throw new Error('RestApi is a singleton')
}
this.#express = express
// existing xo methods
this.authenticateUser = xoApp.authenticateUser.bind(xoApp)
this.getObject = xoApp.getObject.bind(xoApp)
this.getObjects = xoApp.getObjects.bind(xoApp)
this.getServers = () => xoApp.getAllXenServers()
this.getXapiObject = (idOrObj, type) => xoApp.getXapiObject(idOrObj, type)
this.getServer = id => xoApp.getXenServer(id)
// helpers
this.getObjectsByType = <T extends keyof typeof xoApp.objects.indexes.type>(type: T) =>
xoApp.objects.indexes.type[type]
// @ts-ignore
this.#registerListener(xoApp.objects.allIndexes.type)
}
addSseClient(id: symbol, client: Response) {
this.#sseClients.set(id, client)
}
removeSseClient(id: symbol) {
this.#sseClients.delete(id)
}
sendData(objId: string, objType: string | undefined, obj: any | undefined, operation: 'update' | 'add' | 'remove') {
this.#sseClients.forEach(client => {
client.write(`data: ${JSON.stringify({ id: objId, type: objType, data: obj, operation })}\n\n`)
})
}
async registerRoute(
endpoint: string,
cb: (req: Request, res: Response, next: NextFunction) => any,
openapiSpec: Object,
httpMethod: 'get' | 'post' | 'put' | 'patch' | 'delete' = 'get'
) {
if (endpoint[0] !== '/') {
endpoint = '/' + endpoint
}
this.#express[httpMethod](`/rest/v1${endpoint}`, cb)
const currentConfig = JSON.parse(
await fs.readFile(`${__dirname}/open-api/spec/swagger-tmp.json`, { encoding: 'utf-8' })
)
currentConfig.paths[endpoint] = openapiSpec
await fs.writeFile(`${__dirname}/open-api/spec/swagger-tmp.json`, JSON.stringify(currentConfig), {
encoding: 'utf-8',
})
}
#registerListener(obj: EventEmitter) {
// XO events
obj.on('remove', (type, data) => this.#handleXapiEvent(type, data, 'remove'))
obj.on('add', (type, data) => this.#handleXapiEvent(type, data, 'add'))
obj.on('update', (type, data) => this.#handleXapiEvent(type, data, 'update'))
}
async #handleXapiEvent(type: string, data: XapiXoObject, operation: 'update' | 'add' | 'remove') {
if ((data as any).type === 'message') {
// just to avoid client to be spammed by message for the moment
return
}
this.ee.emit(type.toLowerCase(), data, operation)
this.sendData(data.id, data.type, data, operation)
}
}
let restApi: RestApi
export const getRestApi = () => {
if (restApi === undefined) {
throw new Error('The REST API is not instantiated')
}
return restApi
}
export default async function setupRestApi(express: Express, xoApp: XoApp) {
restApi = new RestApi(xoApp, express)
await fs.copyFile(`${__dirname}/open-api/spec/swagger.json`, `${__dirname}/open-api/spec/swagger-tmp.json`)
express.use((req, res, next) => {
res.setHeader('Access-Control-Allow-Origin', '*') // TODO: remove this. Only used for test
next()
})
express.use('/rest/v1/api-doc', swaggerUi.serve, async (req, res, next) => {
const spec = JSON.parse(await fs.readFile(`${__dirname}/open-api/spec/swagger-tmp.json`, { encoding: 'utf-8' }))
swaggerUi.setup(spec)(req, res, next)
})
RegisterRoutes(express)
express.use(errorHandler)
// in order to create the instance of the service (and start to listen for dashboard changes)
iocContainer.get(DashboardService)
}

View File

@@ -0,0 +1,8 @@
import { interfaces } from 'inversify'
import { fluentProvide, provide } from 'inversify-binding-decorators'
const provideSingleton = function <T>(identifier: interfaces.ServiceIdentifier<T>) {
return fluentProvide(identifier).inSingletonScope().done()
}
export { provideSingleton, provide }

View File

@@ -0,0 +1,11 @@
import { Container, decorate, injectable } from 'inversify'
import { buildProviderModule } from 'inversify-binding-decorators'
import { Controller } from 'tsoa'
const iocContainer = new Container()
decorate(injectable(), Controller)
iocContainer.load(buildProviderModule())
export { iocContainer }

View File

@@ -0,0 +1,39 @@
import * as express from 'express'
import { getRestApi } from '../index.js'
export async function expressAuthentication(
req: express.Request,
securityName: string,
scopes?: string[]
): Promise<any> {
const restApi = getRestApi()
const ip = req.ip
const permission = scopes?.[0]
if (securityName === 'token') {
const token = req.cookies.token ?? req.cookies.authenticationToken
const { user } = await restApi.authenticateUser({ token }, { ip })
if (permission !== undefined && permission !== user.permission) {
return Promise.reject(new Error('unauthorize'))
}
return Promise.resolve(user)
}
if (securityName === 'basic_auth') {
const authorization = req.headers.authorization ?? ''
const [, encodedCredentials] = authorization.split(' ')
if (encodedCredentials === undefined) {
return Promise.reject(new Error('missing credentials'))
}
const [username, password] = Buffer.from(encodedCredentials, 'base64').toString().split(':')
const { user } = await restApi.authenticateUser({ username, password }, { ip })
if (permission !== undefined && permission !== user.permission) {
return Promise.reject(new Error('unauthorize'))
}
return Promise.resolve(user)
}
throw new Error('unimplemented')
}

View File

@@ -0,0 +1,15 @@
import { noSuchObject } from 'xo-common/api-errors.js'
import { NextFunction, Request, Response } from 'express'
export function errorHandler(err: Error, _req: Request, res: Response, next: NextFunction) {
console.error(err) // use log.error instead
if (noSuchObject.is(err)) {
res.status(404).json({ error: err.message })
return next()
}
res.json({ error: err.message })
next()
}

View File

@@ -0,0 +1,24 @@
import { Get, Path, Route, Security } from 'tsoa'
import { XoServer } from './server.type.js'
import { provideSingleton } from '../ioc/helper.js'
import { XoController } from '../abstract/xo.controller.js'
@Route('servers')
@provideSingleton(ServersController)
export class ServersController extends XoController<XoServer> {
constructor() {
super('server')
}
@Security('token', ['admin'])
@Get()
public getServers(): Promise<string[]> {
return this.getObjectIds()
}
@Security('basic_auth', ['admin'])
@Get('{id}')
public getServer(@Path() id: XoServer['id']): Promise<XoServer> {
return this.getObject(id)
}
}

View File

@@ -0,0 +1,14 @@
import { NonXapiObject } from '../xoApp.type.js'
export interface XoServer extends NonXapiObject {
allowUnauthorized: boolean
host: string
label: string
password: string
username: string
enabled: boolean
readOnly: boolean
status: 'connected' | 'disconnected'
error?: any
poolId: string /* XoPool['id'] */
}

View File

@@ -0,0 +1,57 @@
import { FormField, Get, Middlewares, Path, Post, Put, Query, Request, Response, Route, UploadedFile } from 'tsoa'
import { Request as ExRequest } from 'express'
import { provideSingleton } from '../ioc/helper.js'
import { XapiXoController } from '../abstract/xapi-xo.controller.js'
/**
* Return a task ID or T if ?watch=true
*/
type WatchAction<T> = string | T
@Route('srs')
@provideSingleton(SrsController)
export class SrsController extends XapiXoController<any> {
constructor() {
super('SR')
}
/**
* Some description
*/
@Get()
@Response(401, 'unautorhized')
public getSrs(): string[] {
return this.getObjectIds()
}
@Get('{id}')
@Response(404, 'Not found')
@Response(401, 'unautorhized')
public getSr(@Path() id: string) {
return this.getById(id)
}
@Post('{id}/actions/import_vdi')
public async importVdi(
@Request() req: ExRequest & { length: number },
@Path() id: string,
@Query() name_label: string,
@Query() name_description: string,
@Query() format: 'raw' | 'vhd'
): Promise<WatchAction<string>> {
// create task and add the possibility to watch/await the task
const sr = this.restApi.getXapiObject(id, this.type) as any
const length = req.headers['content-length']
if (length !== undefined) {
req.length = Number(length)
}
const vdiRef = await sr.$importVdi(req, {
format,
name_label,
name_description,
})
const vdiUUid = await sr.$xapi.getField('VDI', vdiRef, 'uuid')
return vdiUUid
}
}

View File

@@ -0,0 +1,50 @@
import { FormField, Get, Middlewares, Path, Post, Put, Query, Request, Res, Response, Route, UploadedFile } from 'tsoa'
import { Request as ExRequest, Response as ExResp } from 'express'
import { provideSingleton } from '../ioc/helper.js'
import { XapiXoController } from '../abstract/xapi-xo.controller.js'
import { pipeline } from 'node:stream/promises'
@Route('vdis')
@provideSingleton(VdiController)
export class VdiController extends XapiXoController<any> {
constructor() {
super('VDI')
}
/**
* Some description
*/
@Get()
@Response(401, 'unautorhized')
public getVdis(): string[] {
return this.getObjectIds()
}
@Get('{id}.{format}')
public async exportVdi(
@Request() req: ExRequest,
@Path() id: string,
@Path() format: 'vhd' | 'raw',
@Query() nbdConcurrency: number,
@Query() perferNbd = false
) {
const res = req.res!
const vdi = this.getXapiObject(id) as any
const stream = await vdi.$exportContent({ format, perferNbd, nbdConcurrency })
const headers = { 'content-disposition': 'attachment', 'content-length': undefined }
const { length } = stream
if (length !== undefined) {
headers['content-length'] = length
}
res.writeHead(200, 'OK', headers)
await pipeline(stream, res)
}
@Get('{id}')
@Response(404, 'Not found')
@Response(401, 'unautorhized')
public getVdi(@Path() id: string) {
return this.getById(id)
}
}

View File

@@ -0,0 +1,50 @@
import { Get, Path, Post, Query, Response, Route } from 'tsoa'
import { XapiVm, XoVm } from './vm.type.js'
import { provideSingleton } from '../ioc/helper.js'
import { XapiXoController } from '../abstract/xapi-xo.controller.js'
interface ApiError {
error: string
}
@Route('vms')
@provideSingleton(VmsController)
export class VmsController extends XapiXoController<XoVm> {
constructor() {
super('VM')
}
/**
* Some description
*/
@Get()
@Response<ApiError>(401, 'unautorhized')
public getVms(): XoVm['id'][] {
return this.getObjectIds()
}
@Get('{id}')
@Response<ApiError>(404, 'Not found')
@Response<ApiError>(401, 'unautorhized')
public getVm(@Path() id: XoVm['id']) {
return this.getById(id)
}
@Post('{id}/actions/start')
@Response(404, 'Not found')
@Response<ApiError>(401, 'unautorhized')
public startVm(@Path() id: XoVm['id']): void {
const vm = this.restApi.getXapiObject(id, this.type) as XapiVm
// create task and add the possibility to watch/await the task
vm.$callAsync<void>('start', false, false).catch(() => {})
}
@Post('{id}/actions/shutdown')
@Response(404, 'Not found')
@Response<ApiError>(401, 'unautorhized')
public stopVm(@Path() id: XoVm['id'], @Query() hard?: boolean): void {
const vm = this.restApi.getXapiObject(id, this.type) as XapiVm
// create task and add the possibility to watch/await the task
vm.$callAsync<void>(`${hard ? 'hard' : 'clean'}_shutdown`).catch(() => {})
}
}

View File

@@ -0,0 +1,15 @@
import { XapiObject, XapiXoObject } from '../xoApp.type.js'
export interface XapiVm extends XapiObject {
uuid: string
name_label: string
power_state: 'Running' | 'Paused' | 'Halted' | 'Suspended'
}
export interface XoVm extends XapiXoObject {
type: 'VM'
name_label: XapiVm['name_label']
uuid: XapiVm['uuid']
power_state: XapiVm['power_state']
}

View File

@@ -0,0 +1,71 @@
import EventEmitter from 'events'
import { XoVm } from './vms/vm.type.js'
import { Response } from 'express'
import { XoServer } from './servers/server.type.js'
// -----
interface XoPool extends XapiXoObject {
id: string
type: 'pool'
}
interface XoUser extends NonXapiObject {
id: string
permission: string
}
// -----
export type XapiObject = {
$ref: string
$id: string
$type: 'VM' | 'pool'
$xapi: any
$call: <T>(method: string, ...args: any) => Promise<T>
$callAsync: <T>(method: string, ...args: any) => Promise<T>
}
/**
* XapiXoObject can be every "xapi-to-xo" object
*/
export type XapiXoObject = {
id: string
type: XapiObject['$type']
}
/**
* Every object that is not aa XapiXoObject
*/
export type NonXapiObject = {
id: string
}
/**
* Can be every type of object handled by XO
*/
export type XoObject = XapiXoObject | NonXapiObject
interface Objects<T extends XapiXoObject> extends EventEmitter {
indexes: {
type: {
[K in T['type']]: Record<T['id'], Extract<T, { type: K }>> | undefined
}
}
}
export interface XoApp extends EventEmitter {
authenticateUser: (
params: { token?: string; username?: string; password?: string },
optional?: { ip?: string }
) => Promise<{ bypassOtp: boolean; user: XoUser }>
objects: Objects<XoVm | XoPool> /** Nedd to add all XapiXoType here */
getObjects: (opts?: { filter?: (obj: XapiXoObject) => boolean }) => Record<XapiXoObject['id'], XapiXoObject>
getObject: <T extends XapiXoObject>(id: T['id'], type: T['type']) => T
// how to return the right type for getXapiObject?
getXapiObject: <T extends XapiObject>(objOrId: XapiXoObject['id'] | XapiXoObject, type: XapiXoObject['type']) => T
getAllXenServers: () => Promise<XoServer[]>
getXenServer: (id: XoServer['id']) => Promise<XoServer>
addSseClient: (id: symbol, client: Response) => void
removeSseClient: (id: symbol) => void
}

View File

@@ -0,0 +1,39 @@
{
"compilerOptions": {
/* Basic Options */
"incremental": true,
"target": "ESNext",
"module": "NodeNext",
"outDir": "./dist",
"rootDir": "./src",
"allowJs": true,
/* Strict Type-Checking Options */
"strict": true,
"strictFunctionTypes": true,
"strictBindCallApply": true,
"strictPropertyInitialization": true,
"noImplicitThis": true,
"noImplicitAny": false,
"alwaysStrict": true,
/* Additional Checks */
"noUnusedLocals": false,
"noUnusedParameters": false,
"noImplicitReturns": true,
"noFallthroughCasesInSwitch": true,
"skipLibCheck": true,
/* Module Resolution Options */
"baseUrl": "./",
"esModuleInterop": true,
/* Experimental Options */
"experimentalDecorators": true,
/* Advanced Options */
"moduleResolution": "nodenext",
"resolveJsonModule": true
},
"include": ["./src/**/*"]
}

View File

@@ -0,0 +1,49 @@
{
"entryFile": "./src/index.ts",
"noImplicitAdditionalProperties": "throw-on-extras",
"controllerPathGlobs": ["./src/**/*.controller.ts"],
"spec": {
"outputDirectory": "./src/open-api/spec",
"specVersion": 3,
"basePath": "/rest/v1",
"securityDefinitions": {
"basic_auth": {
"type": "http",
"scheme": "basic"
},
"token": {
"type": "apiKey",
"name": "authenticationToken",
"in": "cookie"
}
},
"specMerging": "recursive",
"spec": {
"paths": {
"/srs/{id}/actions/import_vdi": {
"post": {
"requestBody": {
"required": true,
"content": {
"application/octet-stream": {
"schema": {
"type": "string",
"format": "binary"
}
}
}
}
}
}
}
}
},
"routes": {
"routesDir": "./src/open-api/routes",
"esm": true,
"middleware": "express",
"basePath": "/rest/v1",
"iocModule": "./src/ioc/ioc.ts",
"authenticationModule": "./src/middleware/authentication.ts"
}
}

View File

@@ -1,268 +0,0 @@
# Component definition
Lexicon:
- DS: Design System
- SFC: Single-File Component
- Component: A Vue component (being defined in the DS or not)
- DS Component: A component specifically defined in the Design System (DS)
- Subcomponent: A component that is part of a Component or a DS Component
## Components and Subcomponents MUST be defined as Vue SFC (Single-File Component)
## DS Components MUST be stored in their own directory.
## Directory name MUST be in kebab-case (e.g. `my-component`)
## Component name MUST be in PascalCase
## DS Component/Subcomponent name MUST start with `Vts` (e.g. `VtsMyComponent.vue`)
❌ Bad
`components/Square.vue`
✅ Good
`components/square/VtsSquare.vue`
## Components SHOULD be kept short and be split into multiple subcomponents if necessary, stored in the same directory as the main component.
❌ Bad
```
/components/
/square/
/VtsSquare.vue
/square-icon/
/VtsSquareIcon.vue <- This component is not part of the DS and will be used only in Square.vue
```
✅ Good
```
/components/
/square/
/VtsSquare.vue
/VtsSquareIcon.vue
```
> [!WARNING]
> If you think that a subcomponent is likely to be reused in other components,
> ask the DS team to define it in the DS.
>
> It will be then moved in its own directory, following the DS guidelines.
## DS Components MUST start with an HTML comment containing the implemented version
In the form `v1`, `v2`, etc.
> [!TIP]
> The DS team can use a minor version to indicate a change in the DS that does not affect the component style.
>
> It must not be added to the Vue component version.
❌ Bad
```vue
<!-- v1.2 -->
<template>
<!-- code -->
</template>
```
✅ Good
```vue
<!-- v1 -->
<template>
<!-- code -->
</template>
```
## Subcomponents MUST NOT have a version number
If a component from the DS is split into multiple subcomponents, only the main component will have a version number
## Component tags MUST follow `template`, `script` then `style` order, separated with an empty line
## Class names MUST use kebab-case
## Component root element's class name MUST be named after the component name
If no style is applied to the root element, the class name will be omitted
❌ Bad
```vue
<!-- VtsSquare.vue -->
<template>
<div class="my-shape" />
</template>
```
```vue
<!-- VtsSquare.vue -->
<template>
<div class="square" />
</template>
```
✅ Good
```vue
<!-- VtsSquare.vue -->
<template>
<div class="vts-square" />
</template>
```
```vue
<!-- VtsSquareIcon.vue -->
<template>
<div class="vts-square-icon" />
</template>
```
## Class names SHOULD be short and MUST be meaningful
❌ Bad
```vue
<template>
<div class="vts-square">
<Icon :icon="faSmile" class="square-icon" />
<div class="component-label">
<slot />
</div>
</div>
</template>
```
✅ Good
```vue
<template>
<div class="vts-square">
<Icon :icon="faSmile" class="icon" />
<div class="label">
<slot />
</div>
</div>
</template>
```
## Component MUST use `<style lang="postcss" scoped>`
## Component CSS must be contained under the root CSS classname
❌ Bad
```vue
<style scoped>
.vts-square {
/* styles... */
}
.icon {
/* styles... */
}
</style>
```
✅ Good
```vue
<style scoped>
.vts-square {
/* styles... */
.icon {
/* styles... */
}
}
</style>
```
> [!TIP]
> See also [Component variants guidelines](component-variants.md)
> to learn how to handle different component styles based on its props or states.
## Optional slots container SHOULD use `v-if="$slots.<slotName>"`
❌ Bad
```vue
<template>
<div class="vts-foobar">
<div class="header">
<slot name="header" />
</div>
<slot />
<div class="footer">
<slot name="footer" />
</div>
</div>
</template>
```
✅ Good
```vue
<template>
<div class="vts-foobar">
<div v-if="$slots.header" class="header">
<slot name="header" />
</div>
<slot />
<div v-if="$slots.footer" class="footer">
<slot name="footer" />
</div>
</div>
</template>
```
## Component MUST use `defineSlots` when slots are used
❌ Bad
```vue
<template>
<div class="vts-foobar">
<slot />
<slot name="footer" />
</div>
</template>
```
```vue
<template>
<div class="vts-foobar">
<slot foo="bar" />
</div>
</template>
```
✅ Good
```vue
<template>
<div class="vts-foobar">
<slot />
<slot name="footer" foo="bar" />
</div>
</template>
<script lang="ts" setup>
defineSlots<{
default(): any
footer(props: { foo: string }): any
}>()
</script>
```
## Component SHOULD have a Story
> [!TIP]
> For now, stories are stored in
> `@xen-orchestra/lite/src/stories` and can only be written for XO Lite and XO Core components.

View File

@@ -7,14 +7,27 @@ These variants are defined in the Design System and are reflected in the compone
See also:
[toVariants utility](../lib/utils/to-variants.util.md) to help you generate variant CSS classes for your components.
## Base class
The root element of a component will have a specific CSS class following the pattern `vts-<component-name>`.
Component props that affect the variant MUST be specified in the component's `defineProps()`. They also MUST be **required**.
> [!TIP]
> Example:
>
> The class for a "Button" component would be `vts-button`.
> The `accent` and `variant` props for a "Button" component are required and must be specified when using the component:
>
> ```vue
> <template>
> <Button accent="info" variant="primary">Label</Button>
> </template>
> ```
## Base class
The root element of a component will have a specific CSS class following the pattern `ui-<component-name>` if the component is part of the DS, or `vts-<component-name>` if it's a _web-core_ component shared between XO 6 and XO Lite.
> [!TIP]
> Example:
>
> The class for a DS "Button" component would be `ui-button`.
## Variant classes
@@ -25,286 +38,233 @@ The pattern for these classes is `<prop-name>--<prop-value>` (or `<prop-name>` f
> [!TIP]
> Example:
>
> If `color` prop is `success` and `size` prop is `medium` then the classes `color--success` and `size--medium`
> If `accent` prop is `success` and `size` prop is `medium` then the classes `accent--success` and `size--medium`
> would be applied to the root element.
## Converting Design System props into Vue props
The first step will be to convert the Design System's props into Vue props.
Some are easy to map, like `color` or `size`, which have a specific list of possible values.
Some are easy to map, like `accent` or `size`, which have a specific list of possible values. See [the list below](#ds-props-and-vue-props-list) for the full list of values.
But others are more tricky, like a `state` prop in the Design System having values like `default`, `hover`, `active`, or
`disabled`.
But others are more tricky, like an `interaction` prop in the Design System having values like `default`, `hover`, `active`, or `disabled`.
We can't simply create a `state` prop in Vue with these values (it wouldn't make sense for "hover" and "active" states).
We can't simply create an `interaction` prop in Vue with these values (it wouldn't make sense for "hover" and "active" states).
So in this case:
- the "default" state would be represented as "no class applied."
- the "hover" and "active" states would be represented as `:hover` and `:active` pseudo-classes.
- the "disabled" state would be represented as a `disabled` `boolean` prop which would add a `disabled` class when `true`.
- the "default" interaction would be represented as "no class applied."
- the "hover" and "active" interactions would be represented as `:hover` and `:active` pseudo-classes.
- the "disabled" interaction would be represented as a `disabled` `boolean` prop which would add a `muted` or `disabled` class when `true` (depending on if the component can have an HTML `disabled` attribute or not).
## CSS variables
### DS props and Vue props list
Each CSS property that can be affected by a variant should have a corresponding CSS variable.
Here is the list of all possible values for each DS prop and Vue props:
The format for these variables is `--<base-class>--<property-name>`.
- `accent`:
- `brand`
- `info`
- `neutral`
- `success`
- `warning`
- `danger`
- `muted`
- `size`:
- `small`
- `medium`
- `large`
- `variant`:
- `primary`
- `secondary`
- `tertiary`
## How to define variants' props
First, we need to find in the DS, which values each prop can have.
For example, imagine a "Button" component that can have three variants (`primary`, `secondary` and `tertiary`), four different accent colors (`info`, `success`, `warning` and `danger`), three sizes (`small`, `medium`, `large`) and can be `disabled`.
Since we use TypeScript, it's best practice to type the variants' props.
### Component variants typing
We can start by creating the TypeScript types that these props will take.
To keep things related, the typing can be done directly inside the `script` section of the component.
Example for a "Button" component:
```ts
type ButtonVariant = 'primary' | 'secondary' | 'tertiary'
type ButtonAccent = 'info' | 'success' | 'warning' | 'danger'
type ButtonSize = 'small' | 'medium' | 'large'
```
There is no need to create a type for the `disabled` interaction, as it only a simple `boolean` and the type can be specified directly in `defineProps()`.
### Define props
Then, we can define the props needed to handle the variants:
```ts
// TS types here
const { variant, accent, size, disabled } = defineProps<{
variant: ButtonVariant
accent: ButtonAccent
size: ButtonSize
disabled?: boolean
}>()
```
## How to define variants' CSS classes using the `toVariants()` helper
Given the props we defined above, and to match the class names convention, we can define the classes with the `toVariants()` helper.
A way to do it is as follows:
```ts
const classNames = computed(() => toVariants({ variant, accent, size, disabled }))
```
> [!TIP]
> The `toVariants()` helper SHOULD be used in the script section of the component, and not in the template section (see [best practices](./guidelines/best-practices.md#component-should-not-use-functions-in-the-template-to-compute-states-or-values) for more information).
Let's take an example where we want to use the "Button" component with this DS variant:
```
variant: primary
accent: info
size: medium
disabled: true
```
By using the helper, the following classes would be generated, and be ready to use in the `template` section of the component:
`variant--primary accent--info size--medium disabled`
> [!TIP]
> Example:
>
> For a `VtsButton` component, the CSS variables could be `--vts-button--background-color`, or `--vts-button--padding`.
### CSS variables for child elements
If the CSS property to be changed is owned by a child element, the variable name should reflect that.
The format for these variables is `--<base-class>__<child-class>--<property-name>`.
> [!TIP]
> Example:
> This code:
>
> If we need to change `color` of a `.icon` inside our `VtsButton` component, the CSS variable will be
> `--vts-button__icon--color`.
> `<button :class="classNames" />`
>
> will be rendered as:
>
> `<button class="variant--primary accent--info size--medium disabled" />`
## Identifying which DS props affect which CSS variables
The next step is to implement the CSS part for these classes.
The next step is to identify which CSS variables will be affected by each DS prop.
## How to implement variants
For example, we could imagine:
### Start with the base styles for the component
- a `color` prop affecting the `background-color`, `color`, and `border-color` properties.
- a "state" affecting the `background-color` property.
- a `size` prop affecting the `padding`, `gap`, and `font-size` properties.
- a `level` prop affecting the `color` and `padding` properties.
## Grouping CSS variables declarations
Once we know which CSS variables will be affected by each prop, we can group them accordingly.
From the previous example, we can see that:
- `border-color` is affected by `color` only.
- `gap` and `font-size` are affected by `size` only.
- `background-color` is affected by both "state" and `color`.
- `color` is affected by both `color` and `level`.
- `padding` is affected by both `size` and `level`
So we could prepare our variables groups like this:
Start with the base styles for the component, and any styles not affected by the variants.
```postcss
/*
COLOR
--vts-button--border-color
*/
.vts-button {
/* We'll define the border-color here */
}
/*
SIZE
--vts-button--gap
--vts-button--font-size
*/
.vts-button {
/* We'll define the gap and font-size here */
}
/*
COLOR + STATE
--vts-button--background-color
*/
.vts-button {
/* We'll define the background-color here */
}
/*
COLOR + LEVEL
--vts-button--color
*/
.vts-button {
/* We'll define the color here */
}
/*
SIZE + LEVEL
--vts-button--padding
*/
.vts-button {
/* We'll define the padding here */
.ui-button {
display: flex;
gap: 1rem;
/* Other base styles */
}
```
## Filling the groups
Because of the way we handle variants, there is no concept of a "default" variant for DS components. Since the variants' props are required, we know that the "default" variant will always be defined, and in fact, we don't need to think about which variant should be the "default" one.
Lastly, we can now fill in the CSS variables accordingly.
### Then add the variants' styles
Let's start with the `COLOR` group:
There is no specific order, but generally the question that might help to decide is: "Which CSS property is affected by what?"
Example: the `background-color` CSS property of a "Button" component is affected by its interaction state, which is different depending on the `variant` and `accent` props.
So, in this case, the implementation could look something like:
```postcss
/*
COLOR
--vts-button--border-color
*/
.vts-button {
&.color--blue {
--vts-button--border-color: blue;
}
.ui-button {
/* Base styles */
&.color--red {
--vts-button--border-color: red;
/* ACCENT + VARIANT */
&.accent--info {
&.variant--primary {
background-color: var(--color-info-item-base); /* Default style for this variant */
&:hover {
background-color: var(--color-info-item-hover);
}
&:active {
background-color: var(--color-info-item-active);
}
&:is(:disabled, .disabled) {
background-color: var(--color-info-item-disabled);
}
}
&.variant--secondary {
background-color: var(--color-neutral-background-primary);
&:hover {
background-color: var(--color-neutral-background-primary);
}
&:active {
background-color: var(--color-neutral-background-primary);
}
&:is(:disabled, .disabled) {
background-color: var(--color-neutral-background-disabled);
}
}
&.variant--tertiary {
background-color: transparent;
&:hover {
background-color: var(--color-info-background-hover);
}
&:active {
background-color: var(--color-info-background-active);
}
&:is(:disabled, .disabled) {
background-color: transparent;
}
}
}
}
```
Then the `SIZE` group:
A comment can be used to provide more separation between base styles and variant styles (e.g. `/* ACCENT + VARIANT */`).
The same structure can be applied for the other `accent` variants, like `success`, `warning` etc.
Then, if the "Button" has different sizes, which depend on the `size` prop:
```postcss
/*
SIZE
--vts-button--gap
--vts-button--font-size
*/
.vts-button {
.ui-button {
/* Base styles */
/* ACCENT + VARIANT */
/* ... */
/* SIZE */
&.size--small {
--vts-button--gap: 0.5rem;
--vts-button--font-size: 0.75rem;
padding: 0.4rem 0.8rem;
}
&.size--medium {
--vts-button--gap: 1rem;
--vts-button--font-size: 1rem;
padding: 0.8rem 1.6rem;
}
&.size--large {
padding: 1.6rem 2.4rem;
}
}
```
Let's continue with the `COLOR + STATE` group:
```postcss
/*
COLOR + STATE
--vts-button--background-color
*/
.vts-button {
&.color--blue {
& {
/* default state */
--vts-button--background-color: blue;
}
&:hover {
--vts-button--background-color: skyblue;
}
&:active {
--vts-button--background-color: darkblue;
}
&:disabled {
--vts-button--background-color: aliceblue;
}
}
&.color--red {
& {
/* default state */
--vts-button--background-color: red;
}
&:hover {
--vts-button--background-color: salmon;
}
&:active {
--vts-button--background-color: darkred;
}
&:disabled {
--vts-button--background-color: lightpink;
}
}
}
```
Moving on to the `COLOR + LEVEL` group:
```postcss
/*
COLOR + LEVEL
--vts-button--color
*/
.vts-button {
&.color--blue {
&.level--primary {
--vts-button--color: blue;
}
&.level--secondary {
--vts-button--color: lightblue;
}
}
&.color--red {
&.level--primary {
--vts-button--color: red;
}
&.level--secondary {
--vts-button--color: lightcoral;
}
}
}
```
And finally, the `SIZE + LEVEL` group:
```postcss
/*
SIZE + LEVEL
--vts-button--padding
*/
.vts-button {
&.size--small {
&.level--primary {
--vts-button--padding: 0.25rem 0.5rem;
}
&.level--secondary {
--vts-button--padding: 0.5rem 1rem;
}
}
&.size--medium {
&.level--primary {
--vts-button--padding: 0.5rem 1rem;
}
&.level--secondary {
--vts-button--padding: 1rem 2rem;
}
}
}
```
## Implementing the component base CSS
Now that we have our CSS variables defined, we can implement the base CSS for our component.
```postcss
/* ... variables definitions ... */
/* IMPLEMENTATION */
.vts-button {
display: inline-flex;
align-items: center;
justify-content: center;
cursor: pointer;
border: 0.1rem solid var(--vts-button--border-color);
gap: var(--vts-button--gap);
font-size: var(--vts-button--font-size);
padding: var(--vts-button--padding);
background-color: var(--vts-button--background-color);
color: var(--vts-button--color);
}
```
Applying this structure for each variant in the components will allow keeping consistency across the project.

View File

@@ -0,0 +1,279 @@
# Best practices
## JS/TS variables and props naming SHOULD be meaningful
- Avoid using a single letter or non-meaningful names for variables:
```ts
// ❌ Bad
const m = 'foo'
```
```ts
// ✅ Good
const message = 'foo'
```
- If the variable is an array of items, use plural for the variable naming:
```ts
// ❌ Bad
const vm: VM[] = []
```
```ts
// ✅ Good
const vms: VM[] = []
```
- When iterating over arrays, use a meaningful name for each item:
```ts
// ❌ Bad
vms.map(item => item.id)
```
```ts
// ✅ Good
vms.map(vm => vm.id)
```
- Add blank lines between code blocks for better readability:
❌ Bad
```ts
const foo = doThis()
if (!foo) {
return 'that'
}
return bars.filter(bar => bar.do(foo))
```
✅ Good
```ts
const foo = doThis()
if (!foo) {
return 'that'
}
return bars.filter(bar => bar.do(foo))
```
## Component SHOULD use `defineProps()`, `defineEmits()`, `defineSlots()`, `defineModel()` macros whenever it's necessary
## Component SHOULD use props destructuring
As we are using Vue 3.5, you can use props destructuring:
```vue
<script setup lang="ts">
const { accent, label = 'foo' } = defineProps<{
accent: 'success' | 'warning'
label?: string
}>()
</script>
```
[See documentation](https://vuejs.org/guide/extras/reactivity-transform.html#reactive-props-destructure) for more information.
## Component and composables SHOULD use `useId()`
As we are using Vue 3.5, you can use `useId()` to help you generate IDs in components or composables. [See documentation](https://vuejs.org/api/composition-api-helpers.html#useid) for more information.
## Component SHOULD NOT use options API style for `slots`, `route`, `attrs`, etc.
❌ Bad
```vue
<template>
<div class="ui-foobar">
<div class="header" v-bind="$attrs">...</div>
</div>
</template>
<script setup lang="ts">
defineOptions({ inheritAttrs: false })
</script>
```
✅ Good
```vue
<template>
<div class="ui-foobar">
<div class="header" v-bind="attrs">...</div>
</div>
</template>
<script setup lang="ts">
import { useAttrs } from 'vue'
defineOptions({ inheritAttrs: false })
const attrs = useAttrs()
</script>
```
## Use slot + prop for better flexibility
Using slots with fallback to props helps to maintain flexibility: simple data can be passed as props, while slots allow injecting complex content (HTML, conditional logic, etc.) when needed, making components more versatile and reusable.
Take this example, where a component needs to display a child icon component:
```vue
<template>
<div>
<slot name="icon" />
<!-- rest of the code -->
</div>
</template>
```
Most of the time you will want to specify which icon to use, rather than having to explicitly use the icon component in the parent component. But in some cases you may want to retain some flexibility and use a different icon component, a more complex one, or rely on a condition to use specific HTML, etc.
In this case, you can combine a slot and a prop as a fallback to handle all these cases.
The component would look like this:
```vue
<template>
<div>
<slot name="icon">
<UiIcon :icon />
</slot>
<!-- rest of the code -->
</div>
</template>
<script setup lang="ts">
import { defineSlots } from '@vue/runtime-core'
defineProps<{
icon?: IconType
}>()
defineSlots<{
icon?(): any
}>()
</script>
```
In this case, you can use the `icon` prop for simple uses, and keep the flexibility of using the slot when needed, as the `UiIcon` component is overwritten when the slot is used.
## Component MUST use inline handler or inline function for event handling
To avoid possible side effects, use inline handlers or function, or call methods in inline handlers instead of using method handlers.
Here is an explanation on why we use this rule:
`@click="deleteMe"` is the equivalent of `@click="deleteMe($event)"` (or `@click="(event) => deleteMe(event)"`).
Let's take this code:
```vue
<template>
<button @click="deleteMe">Delete</button>
<button @click="deleteMe(true)">Force Delete</button>
</template>
<script lang="ts" setup>
function deleteMe(force?: boolean) {
if (force) {
// ...
} else {
// ...
}
}
</script>
```
In this example, the first button will call `deleteMe(event)` and thus will always take the first condition path.
❌ Bad
```vue
<template>
<div @click="doSomething" />
<div @click="doSomething($event)" />
<div @click="() => count++" />
</template>
```
✅ Good
```vue
<template>
<div @click="doSomething()" />
<div @click="value => doSomething(value)" />
<div @click="count++" />
</template>
```
See also: [inline handlers](https://vuejs.org/guide/essentials/event-handling.html#inline-handlers) in Vue.js documentation.
## Component SHOULD NOT use functions in the template to compute states or values
If using functions in the template to compute states or values, it will be called on every render, which can lead to performance issues.
Use `computed` instead, which will cache the result, and only recompute when the dependencies change.
❌ Bad
```vue
<template>
<UiButton :icon="getIcon(object.connected)" />
</template>
<script setup lang="ts">
function getIcon(connected) {
return connected ? 'check' : 'close'
}
</script>
```
✅ Good
```vue
<template>
<UiButton :icon="objectIcon" />
</template>
<script setup lang="ts">
import { computed } from 'vue'
const objectIcon = computed(() => (object.connected ? 'check' : 'close'))
</script>
```
If you find yourself using this kind of functions in the template, maybe it's a good sign that a refactoring is needed (i.e., extracting the logic in a subcomponent).
There are some exceptions, for example, when using `$t` for translations inside the template.
## Components SHOULD have a Story
> [!TIP]
> For now, stories are stored in
> `@xen-orchestra/lite/src/stories` and can only be written for XO Lite and XO Core components.
## Helpers and utilities SHOULD be used when necessary
Some common utilities are already available in the projects, so be sure to check if there is already a helper or utility that can be used before reinventing the wheel.
You'll find them in these directories:
- Web Core:
- `@xen-orchestra/web-core/src/composables`
- `@xen-orchestra/web-core/src/utils`
- `@xen-orchestra/web-core/src/packages`
- XO 6:
- `@xen-orchestra/web/src/composables`
- `@xen-orchestra/web/src/utils`
- XO Lite:
- `@xen-orchestra/lite/src/composables`
## VueUse SHOULD be used when necessary
If you don't find what you need in our helpers, [VueUse](https://vueuse.org) provides a lot of utilities for common use cases (e.g., accessing browser APIs, using local storage, etc.).

View File

@@ -0,0 +1,242 @@
# Component example
Here is a fake `UiComponent.vue` component that uses most of the guidelines documented:
```vue
<!-- v4 -->
<template>
<!-- Root class name matches the component's name -->
<div class="ui-component" :class="classNames">
<!-- Default slot-->
<slot />
<!-- Child component used only in this component-->
<!-- Note the shorthand version for "icon" prop binding -->
<SquareIcon :icon accent="current" class="icon" />
<!-- Render the element only if the slot is used -->
<div v-if="slots.info">
<slot name="info" />
</div>
<!-- Slot + prop pattern -->
<slot name="message">
{{ message }}
</slot>
</div>
</template>
<script lang="ts" setup>
import SquareIcon from '@core/components/ui/button/SquareIcon.vue'
import { toVariants } from '@core/utils/to-variants.util'
import { computed } from 'vue'
export type ButtonAccent = 'info' | 'success' // Export type, if used in another component
type ButtonVariant = 'primary' | 'secondary'
type ButtonSize = 'small' | 'medium' | 'large'
const {
variant,
accent,
size,
disabled,
message = 'Default message',
} = defineProps<{
// Props destructuring, with default value
variant: ButtonVariant
accent: ButtonAccent
size: ButtonSize
disabled?: boolean // Optional props are placed after the required ones
message?: string
}>()
const slots = defineSlots<{
default(): any // Required slot
info?(): any // Optional slot
message?(): any // Optional slot
}>()
const fontClasses = {
small: 'typo p3-medium', // Typography classes, "typo" is required
medium: 'typo h6-medium',
large: 'typo h3-semi-bold',
}
const classNames = computed(() => [
fontClasses[size], // Use the correct font size based on the size variant
toVariants({ accent, variant, size, disabled }), // Use the "toVariants" utility to generate the correct CSS classes
])
</script>
<style lang="postcss" scoped>
.ui-component {
/* Base styles */
display: inline-flex;
align-items: center;
justify-content: center;
cursor: pointer;
vertical-align: middle;
gap: 0.8rem;
border-width: 0.1rem;
outline: none;
border-style: solid;
/* Focus-visible utility */
&:focus-visible {
outline: none;
&::before {
content: '';
position: absolute;
inset: -0.5rem;
border-radius: 0.4rem;
border-width: 0.2rem;
border-style: solid;
}
}
/* Child element selector if needed */
.icon {
font-size: 0.8rem;
}
/* Variants implementation */
/* ACCENT + VARIANT */
&.accent--info {
&.variant--primary {
background-color: var(--color-info-item-base);
border-color: var(--color-info-item-base);
color: var(--color-info-txt-item);
&:hover {
background-color: var(--color-info-item-hover);
border-color: var(--color-info-item-hover);
color: var(--color-info-txt-item);
}
&:focus-visible::before {
border-color: var(--color-info-txt-base);
}
&:active {
background-color: var(--color-info-item-active);
border-color: var(--color-info-item-active);
color: var(--color-info-txt-item);
}
&:is(:disabled, .disabled) {
background-color: var(--color-info-item-disabled);
border-color: var(--color-info-item-disabled);
color: var(--color-neutral-txt-secondary);
}
}
&.variant--secondary {
background-color: var(--color-neutral-background-primary);
border-color: var(--color-info-item-base);
color: var(--color-info-txt-base);
&:hover {
background-color: var(--color-neutral-background-primary);
border-color: var(--color-info-item-hover);
color: var(--color-info-txt-hover);
}
&:focus-visible::before {
border-color: var(--color-info-txt-base);
}
&:active {
background-color: var(--color-neutral-background-primary);
border-color: var(--color-info-item-active);
color: var(--color-info-txt-active);
}
&:is(:disabled, .disabled) {
background-color: var(--color-neutral-background-disabled);
border-color: var(--color-neutral-txt-secondary);
color: var(--color-neutral-txt-secondary);
}
}
}
&.accent--success {
&.variant--primary {
background-color: var(--color-success-item-base);
border-color: var(--color-success-item-base);
color: var(--color-success-txt-item);
&:hover {
background-color: var(--color-success-item-hover);
border-color: var(--color-success-item-hover);
color: var(--color-success-txt-item);
}
&:focus-visible::before {
border-color: var(--color-success-txt-base);
}
&:active {
background-color: var(--color-success-item-active);
border-color: var(--color-success-item-active);
color: var(--color-success-txt-item);
}
&:is(:disabled, .disabled) {
background-color: var(--color-success-item-disabled);
border-color: var(--color-success-item-disabled);
color: var(--color-neutral-txt-secondary);
}
}
&.variant--secondary {
background-color: var(--color-neutral-background-primary);
border-color: var(--color-success-txt-base);
color: var(--color-success-txt-base);
&:hover {
background-color: var(--color-neutral-background-primary);
border-color: var(--color-success-txt-hover);
color: var(--color-success-txt-hover);
}
&:focus-visible::before {
border-color: var(--color-success-txt-base);
}
&:active {
background-color: var(--color-neutral-background-primary);
border-color: var(--color-success-txt-active);
color: var(--color-success-txt-active);
}
&:is(:disabled, .disabled) {
background-color: var(--color-neutral-background-disabled);
border-color: var(--color-neutral-txt-secondary);
color: var(--color-neutral-txt-secondary);
}
}
}
/* SIZE */
&.size--small {
padding: 0.4rem 0.8rem;
border-radius: 0.2rem;
}
&.size--medium {
padding: 0.8rem 1.6rem;
border-radius: 0.4rem;
}
&.size--large {
padding: 1.6rem 2.4rem;
border-radius: 0.8rem;
}
}
</style>
```

View File

@@ -0,0 +1,99 @@
## Components and Subcomponents MUST be defined as Vue SFC (Single-File Component)
## DS Components MUST be stored in the `/ui` directory of `/web-core`.
## DS Components MUST be stored in their own directory.
## Directory name MUST be in kebab-case (e.g. `my-component`)
## Component name MUST be in PascalCase
## DS Component name MUST start with `Ui` (e.g. `UiMyComponent.vue`)
❌ Bad
`components/ui/Square.vue`
✅ Good
`components/ui/square/UiSquare.vue`
## Components SHOULD be kept short and be split into multiple subcomponents if necessary, stored in the same directory as the main component
❌ Bad
```
/components/
/ui/
/square/
/UiSquare.vue
/square-icon/
/UiSquareIcon.vue <- This component is not part of the DS and will be used only in Square.vue
```
✅ Good
```
/components/
/ui/
/square/
/UiSquare.vue
/SquareIcon.vue
```
> [!WARNING]
> If you think that a subcomponent is likely to be reused in other components,
> ask the DS team to define it in the DS.
>
> It will be then moved in its own directory, following the DS guidelines.
## Components that are not part of the DS MUST have their name start with `Vts`, and be stored outside the `/ui` directory
This is the case for components that are not designed in the DS, but are shared between XO 6 and XO lite, such as "helpers" and "wrappers" components, for example.
❌ Bad
`components/ui/divider/Divider.vue`
✅ Good
`components/divider/VtsDivider.vue`
## DS Components MUST start with an HTML comment containing the implemented version
In the form `v1`, `v2`, etc.
> [!TIP]
> The DS team can use a minor version to indicate a change in the DS that does not affect the component style.
>
> It must not be added to the Vue component version.
❌ Bad
```vue
<!-- v1.2 -->
<template>
<!-- code -->
</template>
```
✅ Good
```vue
<!-- v1 -->
<template>
<!-- code -->
</template>
```
## Subcomponents MUST NOT have a version number
If a component from the DS is split into multiple subcomponents, only the main component will have a version number
## Component tags MUST follow `template`, `script` then `style` order, separated with an empty line
## Component props that are used to alter the variants MUST be required
> [!TIP]
> See also [Component variants guidelines](../component-variants.md)
> to learn how to handle different component styles based on its props or states.

View File

@@ -0,0 +1,163 @@
# CSS guidelines
## Class names MUST use kebab-case
## Component root element's class name MUST be named after the component name
If no style is applied to the root element, the class name will be omitted
❌ Bad
```vue
<!-- UiSquare.vue -->
<template>
<div class="my-shape" />
</template>
```
```vue
<!-- UiSquare.vue -->
<template>
<div class="square" />
</template>
```
✅ Good
```vue
<!-- UiSquare.vue -->
<template>
<div class="ui-square" />
</template>
```
```vue
<!-- SquareIcon.vue -->
<template>
<div class="square-icon" />
</template>
```
```vue
<!-- VtsDivider.vue -->
<template>
<div class="vts-divider" />
</template>
```
## Class names SHOULD be short and MUST be meaningful
❌ Bad
```vue
<!-- UiSquare.vue -->
<template>
<div class="ui-square">
<Icon :icon="faSmile" class="square-icon" />
<div class="component-label">
<slot />
</div>
</div>
</template>
```
✅ Good
```vue
<!-- UiSquare.vue -->
<template>
<div class="ui-square">
<Icon :icon="faSmile" class="icon" />
<div class="label">
<slot />
</div>
</div>
</template>
```
## Component MUST use `<style lang="postcss" scoped>`
## Component CSS MUST be contained under the root CSS classname
❌ Bad
```vue
<style scoped>
.ui-square {
/* styles... */
}
.icon {
/* styles... */
}
</style>
```
✅ Good
```vue
<style scoped>
.ui-square {
/* styles... */
.icon {
/* styles... */
}
}
</style>
```
## `rem` unit SHOULD be used instead of `px`
## Component MUST use helper classes for typography
The DS relies on a fixed set of font sizes, weights, styles, etc.
To avoid redefining these values in every component, every time a specific font style is needed, there are utility classes available globally.
The full list of classes is available in the typography directory. See [font sizes](../../lib/assets/css/typography/_size.pcss) file, for example.
These classes MUST be used to ensure the typography is consistent across all components.
The `typo` class MUST be used to "activate" the typography behavior on the element that needs it.
> [!TIP]
> Example:
>
> In the DS, a component needs to style its label with the "Paragraph/P1 - 16 regular underline".
>
> In the component, you can use typography classes like this:
>
> `<div class="typo p1-regular-underline` />
## New CSS features support
We try to keep up to date with new CSS features and use them when they address a problem or specific integration use case.
As a rule of thumb, we use features that are _newly available_ in [Baseline](https://developer.mozilla.org/en-US/docs/Glossary/Baseline/Compatibility) for a year or more.
So be sure to check the [MDN documentation](https://developer.mozilla.org) and [Can I Use](https://caniuse.com) for the compatibility of the feature you want to use.
## `:focus-visible` utility
Some components need to show a visual indication when they are in `:focus-visible` state (i.e., when navigating with keyboard).
As the style of the `:focus-visible` is the same across all components, but components don't have the same `border-radius`, it is not possible to simply rely on the `outline` property to achieve the correct styling.
So, a small utility can be used to add this style on the element that needs it:
```postcss
&:focus-visible {
outline: none;
&::before {
content: '';
position: absolute;
inset: -0.4rem;
border: 0.2rem solid var(--color-info-txt-base);
border-radius: 0.4rem;
}
}
```
Then, you can tweak the `inset` property to get the desired result.

View File

@@ -0,0 +1,19 @@
# Component definition
Lexicon:
- DS: Design System
- SFC: Single-File Component
- Component: A Vue component (being defined in the DS or not)
- DS Component: A component specifically defined in the Design System (DS)
- Subcomponent: A component that is part of a Component or a DS Component
For more in-depth information, see:
- [Components structure](./components.md)
- [Best practices](./best-practices.md)
- [CSS](./css.md)
- [Slots](./slots.md)
- [Typescript](./typescript.md)
To summary all the above guidelines, see the example component here: [Component example](./component-example.md).

View File

@@ -0,0 +1,109 @@
## Component MUST use `defineSlots` when slots are used
❌ Bad
```vue
<template>
<div class="ui-foobar">
<slot />
<slot name="footer" />
</div>
</template>
```
```vue
<template>
<div class="ui-foobar">
<slot foo="bar" />
</div>
</template>
```
✅ Good
```vue
<template>
<div class="ui-foobar">
<slot />
<slot name="footer" foo="bar" />
</div>
</template>
<script lang="ts" setup>
defineSlots<{
default(): any
footer(props: { foo: string }): any
}>()
</script>
```
## Optional slots container SHOULD use `v-if="slots.<slotName>"`
The `slots` variable should be defined in the `script` tag of the component, like this: `const slots = defineSlots<{...}>()`
❌ Bad
```vue
<template>
<div class="ui-foobar">
<div class="header">
<slot name="header" />
</div>
<slot />
<div class="footer">
<slot name="footer" />
</div>
</div>
</template>
```
✅ Good
```vue
<template>
<div class="ui-foobar">
<div v-if="slots.header" class="header">
<slot name="header" />
</div>
<slot />
<div v-if="slots.footer" class="footer">
<slot name="footer" />
</div>
</div>
</template>
<script setup lang="ts">
const slots = defineSlots<{
// Define the `slots` variable here
default(): any
header?(): any
footer?(): any
}>()
</script>
```
## Optional slots SHOULD be marked as is in the script tag
Let's take an example of two slots, the `default` one is required, and the `header` slot is optional:
❌ Bad
```vue
<script setup lang="ts">
const slots = defineSlots<{
default(): any
header(): any
}>()
</script>
```
✅ Good
```vue
<script setup lang="ts">
const slots = defineSlots<{
default(): any
header?(): any // <- optional slot, note the question mark
}>()
</script>
```

View File

@@ -0,0 +1,37 @@
# Typescript
## Types' naming SHOULD be meaningful
When adding types for variants' props, try to use the component name and the prop name.
Example of a type representing the values a prop `accent` can have:
```ts
// ❌ Bad
type BtnColor = 'info' | 'success' | 'warning'
```
```ts
// ✅ Good
type ButtonAccent = 'info' | 'success' | 'warning'
```
## Types SHOULD be defined in the component
This will add better readability and avoid navigating to other files.
If necessary, the types can be exported.
## Always validate your typing before committing
It's a best practice to run the `type-check` command in a terminal for both `XO 6` and `XO Lite` projects during development.
For example, when working in `XO 6`, you can run this command in the root directory of `XO 6`:
```bash
yarn type-check --watch
```
The `--watch` option will ensure that any live update is taken into account.
There SHOULD be no errors when committing your changes.

View File

@@ -1,6 +1,6 @@
# Guidelines
- [Component Definition](./component-definition.md)
- [Component Definition](./guidelines/index.md)
- [Component Variants](./component-variants.md)
- [Internationalization (i18n)](./i18n.md)
- [Icons](./icons.md)

View File

@@ -3,8 +3,8 @@
</template>
<script lang="ts" setup>
import type { LinearChartData, ValueFormatter } from '@/types/chart'
import { IK_CHART_VALUE_FORMATTER } from '@/types/injection-keys'
import type { LinearChartData, ValueFormatter } from '@core/types/chart'
import { IK_CHART_VALUE_FORMATTER } from '@core/utils/injection-keys.util'
import { utcFormat } from 'd3-time-format'
import type { EChartsOption } from 'echarts'
import { LineChart } from 'echarts/charts'

View File

@@ -25,12 +25,10 @@
{{ $t('open-console-in-new-tab') }}
</UiButton>
<UiButton
v-tooltip="sendCtrlAltDel === undefined ? $t('coming-soon') : undefined"
class="button"
accent="info"
variant="tertiary"
size="medium"
:disabled="sendCtrlAltDel === undefined"
:left-icon="faKeyboard"
@click="sendCtrlAltDel"
>

View File

@@ -1,51 +1,60 @@
<template>
<div :class="uiStore.isMobile ? 'mobile' : undefined" class="vts-remote-console">
<div ref="consoleContainer" class="console" />
<VtsLoadingHero :disabled="isReady" type="panel" />
<div ref="console-container" class="console" />
</div>
</template>
<script lang="ts" setup>
import VtsLoadingHero from '@core/components/state-hero/VtsLoadingHero.vue'
import { useUiStore } from '@core/stores/ui.store'
import VncClient from '@novnc/novnc/lib/rfb'
import { promiseTimeout } from '@vueuse/shared'
import { fibonacci } from 'iterable-backoff'
import { onBeforeUnmount, ref, watchEffect } from 'vue'
import { onBeforeUnmount, ref, useTemplateRef, watchEffect } from 'vue'
const props = defineProps<{
url: URL
isConsoleAvailable: boolean
}>()
const uiStore = useUiStore()
const N_TOTAL_TRIES = 8
const FIBONACCI_MS_ARRAY: number[] = Array.from(fibonacci().toMs().take(N_TOTAL_TRIES))
const consoleContainer = ref<HTMLDivElement | null>(null)
const consoleContainer = useTemplateRef<HTMLDivElement | null>('console-container')
const isReady = ref(false)
let vncClient: VncClient | undefined
let nConnectionAttempts = 0
function handleDisconnectionEvent() {
clearVncClient()
if (props.isConsoleAvailable) {
nConnectionAttempts++
nConnectionAttempts++
if (nConnectionAttempts > N_TOTAL_TRIES) {
console.error('The number of reconnection attempts has been exceeded for:', props.url)
return
}
if (nConnectionAttempts > N_TOTAL_TRIES) {
console.error('The number of reconnection attempts has been exceeded for:', props.url)
return
console.error(
`Connection lost for the remote console: ${props.url}. New attempt in ${
FIBONACCI_MS_ARRAY[nConnectionAttempts - 1]
}ms`
)
createVncConnection()
}
console.error(
`Connection lost for the remote console: ${props.url}. New attempt in ${
FIBONACCI_MS_ARRAY[nConnectionAttempts - 1]
}ms`
)
createVncConnection()
}
function handleConnectionEvent() {
nConnectionAttempts = 0
isReady.value = true
}
function clearVncClient() {
isReady.value = false
if (vncClient === undefined) {
return
}
@@ -75,10 +84,22 @@ async function createVncConnection() {
vncClient.addEventListener('disconnect', handleDisconnectionEvent)
vncClient.addEventListener('connect', handleConnectionEvent)
const canvas = consoleContainer.value?.querySelector('canvas') as HTMLCanvasElement | null
if (canvas !== null) {
// Todo: See with Clémence to specify the desired focus behavior
canvas.setAttribute('tabindex', '0')
canvas.addEventListener('focus', () => {
canvas.classList.add('focused')
})
canvas.addEventListener('blur', () => {
canvas.classList.remove('focused')
})
}
}
watchEffect(() => {
if (consoleContainer.value === null) {
if (consoleContainer.value === null || !props.isConsoleAvailable) {
return
}
@@ -92,7 +113,9 @@ onBeforeUnmount(() => {
clearVncClient()
})
const uiStore = useUiStore()
defineExpose({
sendCtrlAltDel: () => vncClient?.sendCtrlAltDel(),
})
</script>
<style lang="postcss" scoped>
@@ -113,6 +136,12 @@ const uiStore = useUiStore()
/* Required because the library adds "margin: auto" to the canvas which makes the canvas centered in space and not aligned to the rest of the layout */
:deep(canvas) {
margin: 0 auto !important;
cursor: default !important;
border: 6px solid transparent;
&.focused {
border: var(--color-success-txt-base) 6px solid;
}
}
}
</style>

View File

@@ -11,7 +11,7 @@
>
<slot />
</MenuTrigger>
<MenuList v-else :disabled="isDisabled" border>
<MenuList v-else :disabled="isDisabled">
<template #trigger="{ open, isOpen }">
<MenuTrigger :active="isOpen" :busy="isBusy" :disabled="isDisabled" :icon @click="open">
<slot />
@@ -23,36 +23,28 @@
</li>
</template>
<script lang="ts">
/** @deprecated Use VtsMenuItem or VtsMenuTrigger */
</script>
<script lang="ts" setup>
import VtsIcon from '@core/components/icon/VtsIcon.vue'
import MenuList from '@core/components/menu/MenuList.vue'
import MenuTrigger from '@core/components/menu/MenuTrigger.vue'
import { useContext } from '@core/composables/context.composable'
import { DisabledContext } from '@core/context'
import { useDisabled } from '@core/composables/disabled.composable'
import { IK_CLOSE_MENU, IK_MENU_HORIZONTAL } from '@core/utils/injection-keys.util'
import type { IconDefinition } from '@fortawesome/fontawesome-common-types'
import { faAngleDown, faAngleRight } from '@fortawesome/free-solid-svg-icons'
import { computed, inject, ref } from 'vue'
const props = withDefaults(
defineProps<{
icon?: IconDefinition
onClick?: () => any
disabled?: boolean
busy?: boolean
}>(),
{ disabled: undefined }
)
const props = defineProps<{
icon?: IconDefinition
onClick?: () => any
disabled?: boolean
busy?: boolean
}>()
const isParentHorizontal = inject(
IK_MENU_HORIZONTAL,
computed(() => false)
)
const isDisabled = useContext(DisabledContext, () => props.disabled)
const isDisabled = useDisabled(() => props.disabled)
const submenuIcon = computed(() => (isParentHorizontal.value ? faAngleDown : faAngleRight))

View File

@@ -2,19 +2,20 @@
<template>
<slot :is-open="isOpen" :open="open" name="trigger" />
<Teleport :disabled="!shouldTeleport" to="body">
<ul v-if="!hasTrigger || isOpen" ref="menu" :class="{ horizontal, border }" class="menu-list" v-bind="$attrs">
<ul
v-if="!hasTrigger || isOpen"
ref="menu"
:class="{ horizontal, border: !noBorder }"
class="menu-list"
v-bind="$attrs"
>
<slot />
</ul>
</Teleport>
</template>
<script lang="ts">
/** @deprecated Use VtsMenuList */
</script>
<script lang="ts" setup>
import { useContext } from '@core/composables/context.composable'
import { DisabledContext } from '@core/context'
import { useDisabled } from '@core/composables/disabled.composable'
import { IK_CLOSE_MENU, IK_MENU_HORIZONTAL, IK_MENU_TELEPORTED } from '@core/utils/injection-keys.util'
import { onClickOutside, unrefElement, whenever } from '@vueuse/core'
import placementJs, { type Options } from 'placement.js'
@@ -24,15 +25,12 @@ defineOptions({
inheritAttrs: false,
})
const props = withDefaults(
defineProps<{
horizontal?: boolean
border?: boolean
disabled?: boolean
placement?: Options['placement']
}>(),
{ disabled: undefined }
)
const props = defineProps<{
horizontal?: boolean
noBorder?: boolean
disabled?: boolean
placement?: Options['placement']
}>()
const slots = useSlots()
const isOpen = ref(false)
@@ -46,7 +44,7 @@ provide(
computed(() => props.horizontal ?? false)
)
useContext(DisabledContext, () => props.disabled)
useDisabled(() => props.disabled)
let clearClickOutsideEvent: (() => void) | undefined

View File

@@ -6,10 +6,6 @@
</div>
</template>
<script lang="ts">
/** @deprecated Use VtsMenuTrigger */
</script>
<script lang="ts" setup>
import VtsIcon from '@core/components/icon/VtsIcon.vue'
import type { IconDefinition } from '@fortawesome/fontawesome-common-types'

View File

@@ -1,19 +0,0 @@
<template>
<li>
<VtsMenuTrigger v-bind="{ ...props, ...attrs }">
<slot />
</VtsMenuTrigger>
</li>
</template>
<script lang="ts" setup>
import VtsMenuTrigger, { type MenuTriggerProps } from '@core/components/menu/VtsMenuTrigger.vue'
import { useAttrs } from 'vue'
defineOptions({
inheritAttrs: false,
})
const props = defineProps<MenuTriggerProps>()
const attrs = useAttrs()
</script>

View File

@@ -1,43 +0,0 @@
<!-- v1.0 -->
<template>
<ul :class="{ horizontal, border }" class="menu-list">
<slot />
</ul>
</template>
<script lang="ts" setup>
import { IK_MENU_HORIZONTAL } from '@core/utils/injection-keys.util'
import { computed, provide } from 'vue'
const { horizontal, border } = defineProps<{
horizontal?: boolean
border?: boolean
}>()
provide(
IK_MENU_HORIZONTAL,
computed(() => horizontal ?? false)
)
</script>
<style lang="postcss" scoped>
.menu-list {
z-index: 1;
display: flex;
flex-direction: column;
padding: 0.4rem;
cursor: default;
color: var(--color-neutral-txt-primary);
border-radius: 0.4rem;
background-color: var(--color-neutral-background-primary);
gap: 0.2rem;
&.horizontal {
flex-direction: row;
}
&.border {
border: 0.1rem solid var(--color-neutral-border);
}
}
</style>

View File

@@ -1,83 +0,0 @@
<!-- v1.0 -->
<template>
<component
:is="as"
v-tooltip="tooltip ?? false"
:class="{ selected, disabled }"
class="vts-menu-trigger typo p2-medium"
>
<VtsIcon :busy :icon accent="current" />
<slot />
<VtsIcon v-if="submenu" :fixed-width="false" :icon="submenuIcon" accent="current" class="submenu-icon" />
</component>
</template>
<script lang="ts" setup>
import VtsIcon from '@core/components/icon/VtsIcon.vue'
import { type TooltipDirectiveContent, vTooltip } from '@core/directives/tooltip.directive'
import { IK_MENU_HORIZONTAL } from '@core/utils/injection-keys.util'
import type { IconDefinition } from '@fortawesome/fontawesome-common-types'
import { faAngleDown, faAngleRight } from '@fortawesome/free-solid-svg-icons'
import { type Component, computed, inject } from 'vue'
export type MenuTriggerProps = {
as: string | Component
tooltip?: TooltipDirectiveContent
selected?: boolean
busy?: boolean
disabled?: boolean
icon?: IconDefinition
submenu?: boolean
}
defineProps<MenuTriggerProps>()
defineSlots<{
default(): any
}>()
const isParentHorizontal = inject(IK_MENU_HORIZONTAL, undefined)
const submenuIcon = computed(() => (isParentHorizontal?.value ? faAngleDown : faAngleRight))
</script>
<style lang="postcss" scoped>
.vts-menu-trigger {
display: flex;
width: 100%;
align-items: center;
height: 4.4rem;
padding-right: 1.5rem;
padding-left: 1.5rem;
white-space: nowrap;
border-radius: 0.8rem;
gap: 1rem;
color: var(--color-neutral-txt-primary);
background-color: var(--color-neutral-background-primary);
border: none;
text-decoration: none;
&.disabled {
color: var(--color-neutral-txt-secondary);
}
&:not(.disabled) {
cursor: pointer;
&:hover {
color: var(--color-neutral-txt-primary);
background-color: var(--color-info-background-selected);
}
&:active,
&.selected {
color: var(--color-neutral-txt-primary);
background-color: var(--color-info-background-hover);
}
}
.submenu-icon {
margin-left: auto;
}
}
</style>

View File

@@ -6,8 +6,7 @@
</template>
<script lang="ts" setup>
import { useContext } from '@core/composables/context.composable'
import { DisabledContext } from '@core/context'
import { useDisabled } from '@core/composables/disabled.composable'
import { useUiStore } from '@core/stores/ui.store'
import { storeToRefs } from 'pinia'
import { computed } from 'vue'
@@ -18,12 +17,12 @@ const props = withDefaults(
active?: boolean
tag?: string
}>(),
{ tag: 'span', disabled: undefined }
{ tag: 'span' }
)
const { isMobile } = storeToRefs(useUiStore())
const isDisabled = useContext(DisabledContext, () => props.disabled)
const isDisabled = useDisabled(() => props.disabled)
const classNames = computed(() => {
return [

View File

@@ -6,17 +6,13 @@
</template>
<script lang="ts" setup>
import { useContext } from '@core/composables/context.composable'
import { DisabledContext } from '@core/context'
import { useDisabled } from '@core/composables/disabled.composable'
const props = withDefaults(
defineProps<{
disabled?: boolean
}>(),
{ disabled: undefined }
)
const props = defineProps<{
disabled?: boolean
}>()
useContext(DisabledContext, () => props.disabled)
useDisabled(() => props.disabled)
</script>
<style lang="postcss" scoped>

View File

@@ -1,6 +1,6 @@
<!-- v1.0 -->
<template>
<MenuList :disabled placement="bottom-start" border>
<MenuList :disabled placement="bottom-start">
<template #trigger="{ open, isOpen }">
<th
:class="{ interactive, disabled, focus: isOpen }"

View File

@@ -7,15 +7,14 @@
<script lang="ts" setup>
import UiUserLogo from '@core/components/ui/user-logo/UiUserLogo.vue'
import { useContext } from '@core/composables/context.composable'
import { DisabledContext } from '@core/context'
import { useDisabled } from '@core/composables/disabled.composable'
defineProps<{
size: 'small' | 'medium'
selected?: boolean
}>()
const isDisabled = useContext(DisabledContext)
const isDisabled = useDisabled()
</script>
<style lang="postcss" scoped>

View File

@@ -9,8 +9,7 @@
<script lang="ts" setup>
import VtsIcon from '@core/components/icon/VtsIcon.vue'
import { useContext } from '@core/composables/context.composable'
import { DisabledContext } from '@core/context'
import { useDisabled } from '@core/composables/disabled.composable'
import { toVariants } from '@core/utils/to-variants.util'
import type { IconDefinition } from '@fortawesome/fontawesome-common-types'
import { faLock } from '@fortawesome/free-solid-svg-icons'
@@ -20,26 +19,21 @@ type ButtonVariant = 'primary' | 'secondary' | 'tertiary'
type ButtonAccent = 'info' | 'success' | 'warning' | 'danger'
type ButtonSize = 'small' | 'medium' | 'large'
const props = withDefaults(
defineProps<{
variant: ButtonVariant
accent: ButtonAccent
size: ButtonSize
busy?: boolean
disabled?: boolean
lockIcon?: boolean
leftIcon?: IconDefinition
}>(),
{
disabled: undefined,
}
)
const props = defineProps<{
variant: ButtonVariant
accent: ButtonAccent
size: ButtonSize
busy?: boolean
disabled?: boolean
lockIcon?: boolean
leftIcon?: IconDefinition
}>()
defineSlots<{
default(): any
}>()
const isDisabled = useContext(DisabledContext, () => props.disabled)
const isDisabled = useDisabled(() => props.disabled)
const fontClasses = {
small: 'typo p3-medium',

View File

@@ -1,16 +1,16 @@
<!-- v3 -->
<template>
<label class="ui-checkbox" :class="classNames" v-bind="wrapperAttrs">
<label :class="classNames" class="ui-checkbox" v-bind="wrapperAttrs">
<input
v-model="checkboxModel"
:class="{ indeterminate: isIndeterminate }"
:disabled="isDisabled"
type="checkbox"
class="input"
type="checkbox"
v-bind="attrs"
/>
<span class="fake-checkbox">
<VtsIcon :icon class="icon" accent="info" />
<VtsIcon :icon accent="info" class="icon" />
</span>
<span v-if="slots.default" class="typo p1-regular">
<slot />
@@ -24,8 +24,7 @@
<script lang="ts" setup>
import VtsIcon from '@core/components/icon/VtsIcon.vue'
import UiInfo from '@core/components/ui/info/UiInfo.vue'
import { useContext } from '@core/composables/context.composable'
import { DisabledContext } from '@core/context'
import { useDisabled } from '@core/composables/disabled.composable'
import { toVariants } from '@core/utils/to-variants.util'
import { faCheck, faMinus } from '@fortawesome/free-solid-svg-icons'
import { computed, type LabelHTMLAttributes, useAttrs } from 'vue'
@@ -34,14 +33,11 @@ type CheckboxAccent = 'info' | 'success' | 'warning' | 'danger'
defineOptions({ inheritAttrs: false })
const props = withDefaults(
defineProps<{
accent: CheckboxAccent
disabled?: boolean
wrapperAttrs?: LabelHTMLAttributes
}>(),
{ disabled: undefined }
)
const props = defineProps<{
accent: CheckboxAccent
disabled?: boolean
wrapperAttrs?: LabelHTMLAttributes
}>()
const checkboxModel = defineModel<boolean | undefined>({ default: undefined })
@@ -50,7 +46,7 @@ const slots = defineSlots<{
info?(): any
}>()
const isDisabled = useContext(DisabledContext, () => props.disabled)
const isDisabled = useDisabled(() => props.disabled)
const classNames = computed(() => [
toVariants({

View File

@@ -1,6 +1,6 @@
<!-- v3 -->
<template>
<button type="button" class="ui-dropdown-item" :class="{ selected }" :disabled="isDisabled">
<button :class="{ selected }" :disabled="isDisabled" class="ui-dropdown-item" type="button">
<VtsIcon :icon accent="current" class="left-icon" fixed-width />
<span class="typo p1-regular label">
<slot />
@@ -11,24 +11,20 @@
<script lang="ts" setup>
import VtsIcon from '@core/components/icon/VtsIcon.vue'
import { useContext } from '@core/composables/context.composable'
import { DisabledContext } from '@core/context'
import { useDisabled } from '@core/composables/disabled.composable'
import type { IconDefinition } from '@fortawesome/fontawesome-common-types'
import { faAngleDown } from '@fortawesome/free-solid-svg-icons'
const props = withDefaults(
defineProps<{
disabled?: boolean
selected?: boolean
icon?: IconDefinition
}>(),
{ disabled: undefined }
)
const { disabled, selected, icon } = defineProps<{
disabled?: boolean
selected?: boolean
icon?: IconDefinition
}>()
const isDisabled = useContext(DisabledContext, () => props.disabled)
const isDisabled = useDisabled(() => disabled)
</script>
<style scoped lang="postcss">
<style lang="postcss" scoped>
.ui-dropdown-item {
display: inline-flex;
align-items: center;

View File

@@ -2,7 +2,7 @@
<template>
<label :class="variant" class="ui-radio-button typo p1-regular">
<span class="radio-container">
<input v-model="model" :value :disabled="isDisabled" class="input" type="radio" />
<input v-model="model" :disabled="isDisabled" :value class="input" type="radio" />
<VtsIcon :icon="faCircle" accent="current" class="radio-icon" />
</span>
<slot />
@@ -11,31 +11,26 @@
<script lang="ts" setup>
import VtsIcon from '@core/components/icon/VtsIcon.vue'
import { useContext } from '@core/composables/context.composable'
import { DisabledContext } from '@core/context'
import { useDisabled } from '@core/composables/disabled.composable'
import { toVariants } from '@core/utils/to-variants.util'
import { faCircle } from '@fortawesome/free-solid-svg-icons'
import { computed } from 'vue'
const props = withDefaults(
defineProps<{
accent: 'info' | 'success' | 'warning' | 'danger'
value: any
disabled?: boolean
}>(),
{
disabled: undefined,
}
)
const { accent, value, disabled } = defineProps<{
accent: 'info' | 'success' | 'warning' | 'danger'
value: any
disabled?: boolean
}>()
const model = defineModel<boolean>()
defineSlots<{
default(): any
}>()
const variant = computed(() => toVariants({ accent: props.accent }))
const variant = computed(() => toVariants({ accent }))
const isDisabled = useContext(DisabledContext, () => props.disabled)
const isDisabled = useDisabled(() => disabled)
</script>
<style lang="postcss" scoped>
@@ -60,6 +55,7 @@ const isDisabled = useContext(DisabledContext, () => props.disabled)
&:has(input:focus-visible) {
outline: none;
&::after {
position: absolute;
content: '';
@@ -68,6 +64,7 @@ const isDisabled = useContext(DisabledContext, () => props.disabled)
border-radius: 0.4rem;
}
}
&:has(.input:disabled) {
cursor: not-allowed;

View File

@@ -13,16 +13,12 @@
<script lang="ts" setup>
import UiLoader from '@core/components/ui/loader/UiLoader.vue'
import { useContext } from '@core/composables/context.composable'
import { DisabledContext } from '@core/context'
import { useDisabled } from '@core/composables/disabled.composable'
const props = withDefaults(
defineProps<{
disabled?: boolean
busy?: boolean
}>(),
{ disabled: undefined }
)
const { busy, disabled } = defineProps<{
disabled?: boolean
busy?: boolean
}>()
const checked = defineModel<boolean>()
@@ -30,7 +26,7 @@ defineSlots<{
default(): any
}>()
const isDisabled = useContext(DisabledContext, () => props.disabled)
const isDisabled = useDisabled(() => disabled)
</script>
<style lang="postcss" scoped>

View File

@@ -0,0 +1,15 @@
import { IK_DISABLED } from '@core/utils/injection-keys.util'
import { computed, inject, type MaybeRefOrGetter, provide, toValue } from 'vue'
export function useDisabled(condition?: MaybeRefOrGetter<boolean>) {
const previousValue = inject(
IK_DISABLED,
computed(() => false)
)
const currentValue = computed(() => previousValue.value || toValue(condition ?? false))
provide(IK_DISABLED, currentValue)
return currentValue
}

View File

@@ -28,8 +28,9 @@ export type BooleanActions = { toggle: (value?: boolean) => void }
export type Actions = SetActions<any> & MapActions<any, any> & ArrayActions<any> & BooleanActions
export type GuessActions<TData> =
TData extends Set<infer TValue>
export type GuessActions<TData> = TData extends string | number
? EmptyObject
: TData extends Set<infer TValue>
? SetActions<TValue>
: TData extends (infer TValue)[]
? ArrayActions<TValue>

View File

@@ -1,10 +0,0 @@
import { createContext } from '@core/composables/context.composable'
import type { Color } from '@core/types/color.type'
import { computed } from 'vue'
export const DisabledContext = createContext(false)
export const ColorContext = createContext('info' as Color, (color, previousColor) => ({
name: color,
colorContextClass: computed(() => (previousColor.value === color.value ? undefined : `color-context-${color.value}`)),
}))

View File

@@ -13,6 +13,10 @@ export const locales: Locales = {
code: 'en',
name: 'English',
},
es: {
code: 'es',
name: 'Español',
},
fr: {
code: 'fr',
name: 'Français',
@@ -87,6 +91,48 @@ export default createI18n({
minute: '2-digit',
},
},
es: {
date_short: {
year: 'numeric',
month: 'numeric',
day: 'numeric',
},
date_medium: {
year: 'numeric',
month: 'short',
day: 'numeric',
},
date_long: {
year: 'numeric',
month: 'long',
day: 'numeric',
},
datetime_short: {
year: 'numeric',
month: 'numeric',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
},
datetime_medium: {
year: 'numeric',
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
},
datetime_long: {
year: 'numeric',
month: 'long',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
},
time: {
hour: '2-digit',
minute: '2-digit',
},
},
fr: {
date_short: {
year: 'numeric',
@@ -222,6 +268,13 @@ export default createI18n({
maximumFractionDigits: 2,
},
},
es: {
percent: {
style: 'percent',
minimumFractionDigits: 0,
maximumFractionDigits: 2,
},
},
fr: {
percent: {
style: 'percent',

View File

@@ -0,0 +1,97 @@
{
"alarms": "Alarmas",
"bytes.ki": "Kio",
"bytes.mi": "Mio",
"bytes.gi": "Gio",
"coming-soon": "¡Vendrá pronto!",
"console": "Consola",
"console-actions": "Acciones de la cónsola",
"console-clipboard": "Portapales de la cónsola",
"core.copied": "Copiar",
"core.copy-id": "Copiar la ID",
"core.close": "Cerrar",
"core.current": "Actual",
"core.filter": "Filtrar",
"core.group": "Grupar",
"core.hide": "Ocultar",
"core.open": "Abrir",
"core.quick-actions": "Acciones rápidas",
"core.search": "Buscar",
"core.show-by": "Mostrar por...",
"core.query-search-bar.label": "Buscador",
"core.query-search-bar.placeholder": "Entre su búsqueda",
"core.query-search-bar.use-query-builder": "Utilizar la constructora de búsquedas",
"core.select.all": "Selectionar todos",
"core.select.none": "Selectionar ninguno",
"core.select.unselect": "Deseleccionar todos",
"core.select.n-object-of": "{from} - {to} de {total} objectos",
"core.select.n-selected-of": "{count} objectos selectionados de un total de {total}",
"core.sidebar.close": "Cerrar la barra lateral",
"core.sidebar.lock": "Bloquear abierta la barra lateral",
"core.sidebar.open": "Abrir la barra lateral",
"core.sidebar.unlock": "Desbloquear la barra lateral",
"core.sort.ascending": "Ordenar",
"core.sort.descending": "Ordenar descendentemente",
"dark-mode.enable": "Activar modo oscuro",
"dark-mode.disable": "Desactivar modo oscuro",
"dark-mode.auto": "Modo oscuro automática",
"access-forum": "Acceso al foro",
"connected": "Conectado",
"dashboard": "Panel",
"disconnected": "Disconectado",
"disconnected-from-physical-device": "Disconectado del aparato físico",
"documentation-name": "Documentación {name}",
"error-no-data": "Error, imposible recoger datos.",
"exit-fullscreen": "Salir de pantala completa",
"fullscreen": "Pantalla completa",
"gateway": "Puerta de enlace",
"hosts": "Servidores",
"learn-more": "Saber más",
"loading-in-progress": "Cargando…",
"log-out": "Desconectarse",
"master": "Servidor principal",
"n-vms": "1 VM | {n} VMs",
"network": "Red",
"no-data": "No hay datos",
"object-not-found": "No se encuentra el objecto {id}…",
"open-console-in-new-tab": "Abrir la cónsola en una nueva pestaña",
"other": "Otro",
"page-not-found": "No se encuentra esta página",
"partially-connected": "Parcialmente connectado",
"patches": "Parches",
"power-on-vm-for-console": "Poner la MV en marcha para acceder a su cónsola",
"power-on-host-for-console": "Poner en marcha el servidor para acceder a su cónsola",
"professional-support": "Soporte profesional",
"receive": "Recibir",
"running-vm": "MV en marcha | MVs en marcha",
"see-all": "Ver todos",
"select-to-see-details": "Elegir un elemento para ver las detalles",
"send": "Enviar",
"send-ctrl-alt-del": "Enviar Ctrl+Alt+Supr",
"speed": "Velocidad",
"stats": "Stats",
"storage": "Amacenamiento",
"system": "Sistema",
"tasks": "Tareas",
"tasks.n-subtasks": "{n} subtarea | {n} subtareas",
"tasks.no-tasks": "Ninguna tarea",
"tasks.quick-view": "Vista rápida de tareas",
"tasks.quick-view.all": "Todas",
"tasks.quick-view.done": "Terminadas",
"tasks.quick-view.failed": "Falladas",
"tasks.quick-view.in-progress": "En progreso",
"total": "Total",
"total-cpus": "Total CPUs",
"vms": "VMs"
}

View File

@@ -6,18 +6,18 @@ A menu system for Vue applications supporting props binding for actions, links,
```vue
<template>
<VtsMenuList>
<VtsMenuItem v-bind="menu.save">Save</VtsMenuItem>
<VtsMenuItem v-bind="menu.doc">Documentation</VtsMenuItem>
<VtsMenuItem v-bind="menu.profile">Profile</VtsMenuItem>
<MenuList>
<li><MenuTrigger v-bind="menu.save">Save</MenuTrigger></li>
<li><MenuTrigger v-bind="menu.doc">Documentation</MenuTrigger></li>
<li><MenuTrigger v-bind="menu.profile">Profile</MenuTrigger></li>
<li>
<VtsMenuTrigger v-bind="menu.more.$trigger">More...</VtsMenuTrigger>
<VtsMenuList v-bind="menu.more.$target">
<VtsMenuItem v-bind="menu.more.settings">Settings</VtsMenuItem>
<VtsMenuItem v-bind="menu.more.logout">Logout</VtsMenuItem>
</VtsMenuList>
<MenuTrigger v-bind="menu.more.$trigger">More...</MenuTrigger>
<MenuList v-bind="menu.more.$target">
<MenuTrigger v-bind="menu.more.settings">Settings</MenuTrigger>
<MenuTrigger v-bind="menu.more.logout">Logout</MenuTrigger>
</MenuList>
</li>
</VtsMenuList>
</MenuList>
</template>
<script lang="ts" setup>
@@ -35,8 +35,6 @@ const menu = useMenu({
</script>
```
> [!NOTE] > `VtsMenuItem` is a `VtsMenuTrigger` wrapped in a `<li>`
## Core Composables
### useMenu
@@ -168,15 +166,15 @@ type Props = {
```vue
<template>
<VtsMenuTrigger v-bind="menu.save">Save</VtsMenuTrigger>
<VtsMenuTrigger v-bind="menu.docs">Documentation</VtsMenuTrigger>
<VtsMenuTrigger v-bind="menu.profile">Profile</VtsMenuTrigger>
<MenuTrigger v-bind="menu.save">Save</MenuTrigger>
<MenuTrigger v-bind="menu.docs">Documentation</MenuTrigger>
<MenuTrigger v-bind="menu.profile">Profile</MenuTrigger>
<!-- Toggle/Dropdown menu -->
<VtsMenuTrigger v-bind="menu.more.$trigger">More</VtsMenuTrigger>
<MenuTrigger v-bind="menu.more.$trigger">More</MenuTrigger>
<div v-bind="menu.more.$target">
<VtsMenuTrigger v-bind="menu.more.settings">Settings</VtsMenuTrigger>
<VtsMenuTrigger v-bind="menu.more.logout">Logout</VtsMenuTrigger>
<MenuTrigger v-bind="menu.more.settings">Settings</MenuTrigger>
<MenuTrigger v-bind="menu.more.logout">Logout</MenuTrigger>
</div>
</template>
```

View File

@@ -69,7 +69,7 @@ export function parseConfigHolder<TConfigHolder extends ConfigHolder>(
$target: target.props,
$isOpen: trigger.isOpen,
[MENU_SYMBOL]: trigger.subMenu,
...parseStructure(trigger.subMenu, configHolder.config.items ?? {}),
...parseStructure(trigger.subMenu, configHolder.config.items),
} as ParseConfigHolder<TConfigHolder>
}

View File

@@ -37,6 +37,8 @@ export class MenuToggleTarget {
get props(): MenuToggleTargetProps {
return reactive({
as: 'button',
type: 'button',
ref: (element: HTMLElement | null | undefined) => {
const newElement = unrefElement(element)

View File

@@ -1,5 +1,8 @@
import type { ValueFormatter } from '@core/types/chart'
import type { ComputedRef, InjectionKey, Ref } from 'vue'
export const IK_CHART_VALUE_FORMATTER = Symbol('IK_CHART_VALUE_FORMATTER') as InjectionKey<ComputedRef<ValueFormatter>>
export const IK_TREE_ITEM_HAS_CHILDREN = Symbol('IK_TREE_ITEM_HAS_CHILDREN') as InjectionKey<Ref<boolean>>
export const IK_TREE_ITEM_TOGGLE = Symbol('IK_TREE_ITEM_TOGGLE') as InjectionKey<(force?: boolean) => void>
@@ -15,3 +18,5 @@ export const IK_MENU_HORIZONTAL = Symbol('IK_MENU_HORIZONTAL') as InjectionKey<C
export const IK_CLOSE_MENU = Symbol('IK_CLOSE_MENU') as InjectionKey<() => void>
export const IK_MENU_TELEPORTED = Symbol('IK_MENU_TELEPORTED') as InjectionKey<boolean>
export const IK_DISABLED = Symbol('IK_DISABLED') as InjectionKey<ComputedRef<boolean>>

View File

@@ -17,11 +17,15 @@
"@fortawesome/free-solid-svg-icons": "^6.5.1",
"@fortawesome/vue-fontawesome": "^3.0.5",
"@novnc/novnc": "^1.4.0",
"@types/d3-time-format": "^4.0.3",
"@vueuse/core": "^10.7.1",
"@vueuse/shared": "^10.7.1",
"d3-time-format": "^4.1.0",
"echarts": "^5.4.3",
"iterable-backoff": "^0.1.0",
"lodash-es": "^4.17.21",
"placement.js": "^1.0.0-beta.5"
"placement.js": "^1.0.0-beta.5",
"vue-echarts": "^6.6.8"
},
"peerDependencies": {
"pinia": "^2.2.6",

View File

@@ -25,6 +25,7 @@
"@vue/tsconfig": "^0.5.1",
"@vueuse/core": "^10.7.1",
"@vueuse/math": "^10.7.1",
"@vueuse/integrations": "^10.7.1",
"@xen-orchestra/web-core": "^0.10.0",
"human-format": "^1.2.1",
"lodash-es": "^4.17.21",
@@ -35,6 +36,7 @@
"postcss-custom-media": "^10.0.2",
"postcss-nested": "^6.0.1",
"typescript": "~5.6.3",
"universal-cookie": "^7.2.2",
"unplugin-vue-router": "^0.10.1",
"vite": "^5.4.10",
"vue": "~3.5.12",

View File

@@ -8,13 +8,20 @@
<script lang="ts" setup>
import AppLayout from '@/layouts/AppLayout.vue'
import VtsTooltipList from '@core/components/tooltip-list/VtsTooltipList.vue'
import { locales } from '@core/i18n'
import { useUiStore } from '@core/stores/ui.store'
import { useActiveElement, useMagicKeys, whenever } from '@vueuse/core'
import { useCookies } from '@vueuse/integrations/useCookies'
import { logicAnd } from '@vueuse/math'
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
const uiStore = useUiStore()
const { locale } = useI18n()
const { get } = useCookies()
const cookieLang = get('lang')
locale.value = cookieLang && locales[cookieLang] ? cookieLang : 'en'
if (import.meta.env.DEV) {
const { locale } = useI18n()

View File

@@ -1,42 +1,64 @@
<template>
<AccountMenuTrigger v-bind="omit(menu.$trigger, ['as', 'submenu'])" />
<VtsMenuList border v-bind="menu.$target">
<VtsMenuItem :icon="faBook" v-bind="menu.documentation">
{{ $t('documentation-name', { name: 'XCP-ng' }) }}
</VtsMenuItem>
<VtsMenuItem :icon="faHeadset" v-bind="menu.support">
{{ $t('professional-support') }}
</VtsMenuItem>
<VtsMenuItem :icon="faArrowRightFromBracket" class="logout" v-bind="menu.logout">
<MenuList :disabled placement="bottom-end">
<template #trigger="{ isOpen, open }">
<UiAccountMenuButton
v-tooltip="isOpen ? false : { content: $t('account-organization-more'), placement: 'bottom-end' }"
:selected="isOpen"
size="medium"
@click="open($event)"
/>
</template>
<MenuItem :icon="faBook">
<a
class="link typo p2-medium"
href="https://docs.xcp-ng.org?utm_campaign=xo6&utm_term=xcpdoc"
rel="noopener noreferrer"
target="_blank"
>
{{ $t('documentation-name', { name: 'XCP-ng' }) }}
</a>
</MenuItem>
<MenuItem :icon="faHeadset">
<a
class="link typo p2-medium"
href="https://vates.tech/pricing-and-support?utm_campaign=xo6&utm_term=pricing"
rel="noopener noreferrer"
target="_blank"
>
{{ $t('professional-support') }}
</a>
</MenuItem>
<MenuItem :icon="faArrowRightFromBracket" class="logout" @click="logout()">
{{ $t('log-out') }}
</VtsMenuItem>
</VtsMenuList>
</MenuItem>
</MenuList>
</template>
<script lang="ts" setup>
import AccountMenuTrigger from '@/components/account-menu/AccountMenuTrigger.vue'
import VtsMenuItem from '@core/components/menu/VtsMenuItem.vue'
import VtsMenuList from '@core/components/menu/VtsMenuList.vue'
import { link, useMenuToggle } from '@core/packages/menu'
import MenuItem from '@core/components/menu/MenuItem.vue'
import MenuList from '@core/components/menu/MenuList.vue'
import UiAccountMenuButton from '@core/components/ui/account-menu-button/UiAccountMenuButton.vue'
import { vTooltip } from '@core/directives/tooltip.directive'
import { faArrowRightFromBracket, faBook, faHeadset } from '@fortawesome/free-solid-svg-icons'
import { objectOmit as omit } from '@vueuse/core'
defineProps<{
disabled?: boolean
}>()
const menu = useMenuToggle({
placement: 'bottom-end',
items: {
documentation: link('https://docs.xcp-ng.org?utm_campaign=xo6&utm_term=xcpdoc'),
support: link('https://vates.tech/pricing-and-support?utm_campaign=xo6&utm_term=pricing'),
logout: link('/signout', { target: '_self' }), // TODO: Fetch the XO 5 mount path from API when available
},
})
// TODO: Fetch the XO 5 mount path from API when available
const logout = () => window.location.assign('/signout')
</script>
<style lang="postcss" scoped>
.link {
text-decoration: none;
color: var(--color-neutral-txt-primary);
/* Make the link take the height of the MenuItem component */
padding-block: 1.15rem;
/* Make the link take the available width in the MenuItem component */
flex-grow: 1;
}
.logout {
color: var(--color-danger-txt-base);
}

Some files were not shown because too many files have changed in this diff Show More