mirror of
https://github.com/vatesfr/xen-orchestra.git
synced 2026-11-15 11:30:56 -06:00
Compare commits
51 Commits
web-stack/
...
open-api-p
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2102bec236 | ||
|
|
f3ff78f790 | ||
|
|
d97873242a | ||
|
|
ce651df896 | ||
|
|
bdcd8925c7 | ||
|
|
235d0ae103 | ||
|
|
5ad7caeb1c | ||
|
|
34da6fd857 | ||
|
|
a62aec0e0b | ||
|
|
4bf887076d | ||
|
|
06dd432803 | ||
|
|
344906e5df | ||
|
|
6df0ebc135 | ||
|
|
22bc90847a | ||
|
|
300d16ae1c | ||
|
|
d11526b277 | ||
|
|
8c7a427225 | ||
|
|
d0252cb13a | ||
|
|
906f35cf7b | ||
|
|
58c2235737 | ||
|
|
10026de7a4 | ||
|
|
1e84568dc6 | ||
|
|
86effd8117 | ||
|
|
9af58185cf | ||
|
|
2b63829e91 | ||
|
|
300bb18a64 | ||
|
|
42f205e473 | ||
|
|
fe5276217f | ||
|
|
1765600437 | ||
|
|
f0680df1e1 | ||
|
|
6d34cf67fb | ||
|
|
9c97ac903f | ||
|
|
a96d5e64de | ||
|
|
4744071098 | ||
|
|
dc9dbd9018 | ||
|
|
6405eeb1b5 | ||
|
|
7c666d1156 | ||
|
|
7ca6829c27 | ||
|
|
97cd50af44 | ||
|
|
5bfd462616 | ||
|
|
2b964576f2 | ||
|
|
d044dda59a | ||
|
|
11a4ceddce | ||
|
|
b97268727c | ||
|
|
3a21290fde | ||
|
|
299a109eb6 | ||
|
|
4235e98166 | ||
|
|
281ccbb8ba | ||
|
|
7053fc4a08 | ||
|
|
cd2f74cd63 | ||
|
|
6d836df6bf |
@@ -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
|
||||
|
||||
@@ -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`, '')
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}`,
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}`),
|
||||
|
||||
283
@xen-orchestra/lite/src/locales/es.json
Normal file
283
@xen-orchestra/lite/src/locales/es.json
Normal 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"
|
||||
}
|
||||
@@ -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'
|
||||
|
||||
|
||||
@@ -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'>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
19
@xen-orchestra/rest-api/.eslintrc.json
Normal file
19
@xen-orchestra/rest-api/.eslintrc.json
Normal 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
1
@xen-orchestra/rest-api/.gitignore
vendored
Normal file
@@ -0,0 +1 @@
|
||||
src/open-api/*
|
||||
34
@xen-orchestra/rest-api/package.json
Normal file
34
@xen-orchestra/rest-api/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
30
@xen-orchestra/rest-api/src/abstract/xapi-xo.controller.ts
Normal file
30
@xen-orchestra/rest-api/src/abstract/xapi-xo.controller.ts
Normal 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() ?? {})
|
||||
}
|
||||
}
|
||||
37
@xen-orchestra/rest-api/src/abstract/xo.controller.ts
Normal file
37
@xen-orchestra/rest-api/src/abstract/xo.controller.ts
Normal 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)
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
107
@xen-orchestra/rest-api/src/dashboard/dashboard.service.ts
Normal file
107
@xen-orchestra/rest-api/src/dashboard/dashboard.service.ts
Normal 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'))
|
||||
}
|
||||
}
|
||||
34
@xen-orchestra/rest-api/src/events/event.controller.ts
Normal file
34
@xen-orchestra/rest-api/src/events/event.controller.ts
Normal 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()
|
||||
})
|
||||
}
|
||||
}
|
||||
134
@xen-orchestra/rest-api/src/index.ts
Normal file
134
@xen-orchestra/rest-api/src/index.ts
Normal 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)
|
||||
}
|
||||
8
@xen-orchestra/rest-api/src/ioc/helper.ts
Normal file
8
@xen-orchestra/rest-api/src/ioc/helper.ts
Normal 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 }
|
||||
11
@xen-orchestra/rest-api/src/ioc/ioc.ts
Normal file
11
@xen-orchestra/rest-api/src/ioc/ioc.ts
Normal 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 }
|
||||
39
@xen-orchestra/rest-api/src/middleware/authentication.ts
Normal file
39
@xen-orchestra/rest-api/src/middleware/authentication.ts
Normal 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')
|
||||
}
|
||||
15
@xen-orchestra/rest-api/src/middleware/error.middleware.ts
Normal file
15
@xen-orchestra/rest-api/src/middleware/error.middleware.ts
Normal 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()
|
||||
}
|
||||
24
@xen-orchestra/rest-api/src/servers/server.controller.ts
Normal file
24
@xen-orchestra/rest-api/src/servers/server.controller.ts
Normal 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)
|
||||
}
|
||||
}
|
||||
14
@xen-orchestra/rest-api/src/servers/server.type.ts
Normal file
14
@xen-orchestra/rest-api/src/servers/server.type.ts
Normal 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'] */
|
||||
}
|
||||
57
@xen-orchestra/rest-api/src/srs/sr.controller.ts
Normal file
57
@xen-orchestra/rest-api/src/srs/sr.controller.ts
Normal 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
|
||||
}
|
||||
}
|
||||
50
@xen-orchestra/rest-api/src/vdi/vdi.controller.ts
Normal file
50
@xen-orchestra/rest-api/src/vdi/vdi.controller.ts
Normal 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)
|
||||
}
|
||||
}
|
||||
50
@xen-orchestra/rest-api/src/vms/vm.controller.ts
Normal file
50
@xen-orchestra/rest-api/src/vms/vm.controller.ts
Normal 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(() => {})
|
||||
}
|
||||
}
|
||||
15
@xen-orchestra/rest-api/src/vms/vm.type.ts
Normal file
15
@xen-orchestra/rest-api/src/vms/vm.type.ts
Normal 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']
|
||||
}
|
||||
71
@xen-orchestra/rest-api/src/xoApp.type.ts
Normal file
71
@xen-orchestra/rest-api/src/xoApp.type.ts
Normal 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
|
||||
}
|
||||
39
@xen-orchestra/rest-api/tsconfig.json
Normal file
39
@xen-orchestra/rest-api/tsconfig.json
Normal 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/**/*"]
|
||||
}
|
||||
49
@xen-orchestra/rest-api/tsoa.json
Normal file
49
@xen-orchestra/rest-api/tsoa.json
Normal 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"
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
|
||||
279
@xen-orchestra/web-core/docs/guidelines/best-practices.md
Normal file
279
@xen-orchestra/web-core/docs/guidelines/best-practices.md
Normal 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.).
|
||||
242
@xen-orchestra/web-core/docs/guidelines/component-example.md
Normal file
242
@xen-orchestra/web-core/docs/guidelines/component-example.md
Normal 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>
|
||||
```
|
||||
99
@xen-orchestra/web-core/docs/guidelines/components.md
Normal file
99
@xen-orchestra/web-core/docs/guidelines/components.md
Normal 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.
|
||||
163
@xen-orchestra/web-core/docs/guidelines/css.md
Normal file
163
@xen-orchestra/web-core/docs/guidelines/css.md
Normal 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.
|
||||
19
@xen-orchestra/web-core/docs/guidelines/index.md
Normal file
19
@xen-orchestra/web-core/docs/guidelines/index.md
Normal 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).
|
||||
109
@xen-orchestra/web-core/docs/guidelines/slots.md
Normal file
109
@xen-orchestra/web-core/docs/guidelines/slots.md
Normal 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>
|
||||
```
|
||||
37
@xen-orchestra/web-core/docs/guidelines/typescript.md
Normal file
37
@xen-orchestra/web-core/docs/guidelines/typescript.md
Normal 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.
|
||||
@@ -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)
|
||||
|
||||
@@ -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'
|
||||
@@ -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"
|
||||
>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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))
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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 [
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 }"
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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}`)),
|
||||
}))
|
||||
@@ -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',
|
||||
|
||||
97
@xen-orchestra/web-core/lib/locales/es.json
Normal file
97
@xen-orchestra/web-core/lib/locales/es.json
Normal 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"
|
||||
}
|
||||
@@ -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>
|
||||
```
|
||||
|
||||
@@ -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>
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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>>
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user