Compare commits
17 Commits
fix_mirror
...
xo6/update
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8d2dd32e87 | ||
|
|
a0a6b73bce | ||
|
|
64bba27923 | ||
|
|
eedaca0195 | ||
|
|
9ffa52cc01 | ||
|
|
e9a23755b6 | ||
|
|
5712f29a58 | ||
|
|
509ebf900e | ||
|
|
757a8915d9 | ||
|
|
35c660dbf6 | ||
|
|
f23fd69e7e | ||
|
|
39c10a7197 | ||
|
|
7a1bc16468 | ||
|
|
93dd1a63da | ||
|
|
b4e1064914 | ||
|
|
810cdc1a77 | ||
|
|
1023131828 |
@@ -65,10 +65,11 @@ module.exports = {
|
||||
typescript: true,
|
||||
'eslint-import-resolver-custom-alias': {
|
||||
alias: {
|
||||
'@core': '../web-core/lib',
|
||||
'@': './src',
|
||||
},
|
||||
extensions: ['.ts'],
|
||||
packages: ['@xen-orchestra/lite'],
|
||||
packages: ['@xen-orchestra/lite', '@xen-orchestra/web'],
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -160,10 +160,10 @@ export class ImportVmBackup {
|
||||
// update the stream with the negative vhd stream
|
||||
stream = await negativeVhd.stream()
|
||||
vdis[vdiRef].baseVdi = snapshotCandidate
|
||||
} catch (err) {
|
||||
} catch (error) {
|
||||
// can be a broken VHD chain, a vhd chain with a key backup, ....
|
||||
// not an irrecuperable error, don't dispose parentVhd, and fallback to full restore
|
||||
warn(`can't use differential restore`, err)
|
||||
warn(`can't use differential restore`, { error })
|
||||
disposableDescendants?.dispose()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -143,8 +143,10 @@ export class IncrementalRemoteWriter extends MixinRemoteWriter(AbstractIncrement
|
||||
|
||||
let metadataContent = await this._isAlreadyTransferred(timestamp)
|
||||
if (metadataContent !== undefined) {
|
||||
// @todo : should skip backup while being vigilant to not stuck the forked stream
|
||||
// skip backup while being vigilant to not stuck the forked stream
|
||||
Task.info('This backup has already been transfered')
|
||||
Object.values(deltaExport.streams).forEach(stream => stream.destroy())
|
||||
return { size: 0 }
|
||||
}
|
||||
|
||||
const basename = formatFilenameDate(timestamp)
|
||||
|
||||
@@ -113,13 +113,13 @@ export const MixinRemoteWriter = (BaseClass = Object) =>
|
||||
)
|
||||
}
|
||||
|
||||
_isAlreadyTransferred(timestamp) {
|
||||
async _isAlreadyTransferred(timestamp) {
|
||||
const vmUuid = this._vmUuid
|
||||
const adapter = this._adapter
|
||||
const backupDir = getVmBackupDir(vmUuid)
|
||||
try {
|
||||
const actualMetadata = JSON.parse(
|
||||
adapter._handler.readFile(`${backupDir}/${formatFilenameDate(timestamp)}.json`)
|
||||
await adapter._handler.readFile(`${backupDir}/${formatFilenameDate(timestamp)}.json`)
|
||||
)
|
||||
return actualMetadata
|
||||
} catch (error) {}
|
||||
|
||||
@@ -20,5 +20,7 @@ export function split(path) {
|
||||
return parts
|
||||
}
|
||||
|
||||
export const relativeFromFile = (file, path) => relative(dirname(file), path)
|
||||
// paths are made absolute otherwise fs.relative() would resolve them against working directory
|
||||
export const relativeFromFile = (file, path) => relative(dirname(normalize(file)), normalize(path))
|
||||
|
||||
export const resolveFromFile = (file, path) => resolve('/', dirname(file), path).slice(1)
|
||||
|
||||
17
@xen-orchestra/fs/src/path.test.js
Normal file
17
@xen-orchestra/fs/src/path.test.js
Normal file
@@ -0,0 +1,17 @@
|
||||
import { describe, it } from 'test'
|
||||
import { strict as assert } from 'assert'
|
||||
|
||||
import { relativeFromFile } from './path.js'
|
||||
|
||||
describe('relativeFromFile()', function () {
|
||||
for (const [title, args] of Object.entries({
|
||||
'file absolute and path absolute': ['/foo/bar/file.vhd', '/foo/baz/path.vhd'],
|
||||
'file relative and path absolute': ['foo/bar/file.vhd', '/foo/baz/path.vhd'],
|
||||
'file absolute and path relative': ['/foo/bar/file.vhd', 'foo/baz/path.vhd'],
|
||||
'file relative and path relative': ['foo/bar/file.vhd', 'foo/baz/path.vhd'],
|
||||
})) {
|
||||
it('works with ' + title, function () {
|
||||
assert.equal(relativeFromFile(...args), '../baz/path.vhd')
|
||||
})
|
||||
}
|
||||
})
|
||||
@@ -54,10 +54,10 @@ async function handleExistingFile(root, indexPath, path) {
|
||||
await indexFile(fullPath, indexPath)
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
if (err.code !== 'EEXIST') {
|
||||
} catch (error) {
|
||||
if (error.code !== 'EEXIST') {
|
||||
// there can be a symbolic link in the tree
|
||||
warn('handleExistingFile', err)
|
||||
warn('handleExistingFile', { error })
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -106,7 +106,7 @@ export async function watchRemote(remoteId, { root, immutabilityDuration, rebuil
|
||||
await File.liftImmutability(settingPath)
|
||||
} catch (error) {
|
||||
// file may not exists, and it's not really a problem
|
||||
info('lifting immutability on current settings', error)
|
||||
info('lifting immutability on current settings', { error })
|
||||
}
|
||||
await fs.writeFile(
|
||||
settingPath,
|
||||
|
||||
@@ -1,13 +1,15 @@
|
||||
{
|
||||
"extends": "@vue/tsconfig/tsconfig.dom.json",
|
||||
"include": ["env.d.ts", "src/**/*", "src/**/*.vue"],
|
||||
"include": ["env.d.ts", "src/**/*", "src/**/*.vue", "../web-core/lib/**/*", "../web-core/lib/**/*.vue"],
|
||||
"exclude": ["src/**/__tests__/*"],
|
||||
"compilerOptions": {
|
||||
"composite": true,
|
||||
"noEmit": true,
|
||||
"baseUrl": ".",
|
||||
"rootDir": "..",
|
||||
"paths": {
|
||||
"@/*": ["./src/*"]
|
||||
"@/*": ["./src/*"],
|
||||
"@core/*": ["../web-core/lib/*"]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,7 +13,7 @@ export default defineConfig({
|
||||
plugins: [
|
||||
vue(),
|
||||
vueI18n({
|
||||
include: resolve(__dirname, 'src/locales/**'),
|
||||
include: [resolve(__dirname, 'src/locales/**'), resolve(__dirname, '../web-core/lib/locales/**')],
|
||||
}),
|
||||
],
|
||||
define: {
|
||||
@@ -23,6 +23,7 @@ export default defineConfig({
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': fileURLToPath(new URL('./src', import.meta.url)),
|
||||
'@core': fileURLToPath(new URL('../web-core/lib', import.meta.url)),
|
||||
},
|
||||
},
|
||||
|
||||
|
||||
@@ -27,6 +27,16 @@ log.error('could not join server', {
|
||||
})
|
||||
```
|
||||
|
||||
A logging method has the following signature:
|
||||
|
||||
```ts
|
||||
interface LoggingMethod {
|
||||
(error): void
|
||||
|
||||
(message: string, data?: { error?: Error; [property: string]: any }): void
|
||||
}
|
||||
```
|
||||
|
||||
### Consumer
|
||||
|
||||
Then, at application level, configure the logs are handled:
|
||||
|
||||
@@ -45,6 +45,16 @@ log.error('could not join server', {
|
||||
})
|
||||
```
|
||||
|
||||
A logging method has the following signature:
|
||||
|
||||
```ts
|
||||
interface LoggingMethod {
|
||||
(error): void
|
||||
|
||||
(message: string, data?: { error?: Error; [property: string]: any }): void
|
||||
}
|
||||
```
|
||||
|
||||
### Consumer
|
||||
|
||||
Then, at application level, configure the logs are handled:
|
||||
|
||||
43
@xen-orchestra/web-core/lib/components/PowerStateIcon.vue
Normal file
43
@xen-orchestra/web-core/lib/components/PowerStateIcon.vue
Normal file
@@ -0,0 +1,43 @@
|
||||
<template>
|
||||
<UiIcon :class="className" :icon="icon" class="power-state-icon" />
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import UiIcon from '@core/components/icon/UiIcon.vue'
|
||||
import type { POWER_STATE } from '@core/types/power-state.type'
|
||||
import { faMoon, faPause, faPlay, faQuestion, faStop } from '@fortawesome/free-solid-svg-icons'
|
||||
import { computed } from 'vue'
|
||||
|
||||
const props = defineProps<{
|
||||
state: POWER_STATE
|
||||
}>()
|
||||
|
||||
const icons = {
|
||||
running: faPlay,
|
||||
paused: faPause,
|
||||
suspended: faMoon,
|
||||
halted: faStop,
|
||||
}
|
||||
|
||||
const icon = computed(() => icons[props.state] ?? faQuestion)
|
||||
|
||||
const className = computed(() => `state-${props.state.toLocaleLowerCase()}`)
|
||||
</script>
|
||||
|
||||
<style lang="postcss" scoped>
|
||||
.power-state-icon {
|
||||
color: var(--color-purple-d60);
|
||||
|
||||
&.state-running {
|
||||
color: var(--color-green-base);
|
||||
}
|
||||
|
||||
&.state-paused {
|
||||
color: var(--color-purple-l40);
|
||||
}
|
||||
|
||||
&.state-halted {
|
||||
color: var(--color-red-base);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
74
@xen-orchestra/web-core/lib/components/UiCounter.vue
Normal file
74
@xen-orchestra/web-core/lib/components/UiCounter.vue
Normal file
@@ -0,0 +1,74 @@
|
||||
<template>
|
||||
<span :class="[classProp, size]" class="ui-counter">
|
||||
<span class="value" :class="{ overflow: value > 99 }">
|
||||
{{ value }}
|
||||
</span>
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { useContext } from '@core/composables/context.composable'
|
||||
import { ColorContext } from '@core/context'
|
||||
import type { Color } from '@core/types/color.type'
|
||||
import { computed } from 'vue'
|
||||
|
||||
const props = defineProps<{
|
||||
value: number
|
||||
color?: Color | 'black'
|
||||
size?: 'small' | 'medium'
|
||||
}>()
|
||||
|
||||
const { name: contextColor } = useContext(ColorContext, () => props.color as Color)
|
||||
|
||||
const classProp = computed(() => `color-${contextColor.value}`)
|
||||
</script>
|
||||
|
||||
<style lang="postcss" scoped>
|
||||
.ui-counter {
|
||||
font-weight: 500;
|
||||
font-size: 1.6rem;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: var(--size);
|
||||
color: var(--color-grey-600);
|
||||
border-radius: calc(var(--size) / 2);
|
||||
background-color: var(--background-color);
|
||||
--background-color: var(--color-grey-300);
|
||||
--size: 2.4rem;
|
||||
|
||||
&.small {
|
||||
--size: 1.5rem;
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.value {
|
||||
padding: 0 0.4rem;
|
||||
}
|
||||
|
||||
.overflow {
|
||||
font-size: 0.8em;
|
||||
}
|
||||
|
||||
&.color-info {
|
||||
--background-color: var(--color-purple-base);
|
||||
}
|
||||
|
||||
&.color-success {
|
||||
--background-color: var(--color-green-base);
|
||||
}
|
||||
|
||||
&.color-warning {
|
||||
--background-color: var(--color-orange-base);
|
||||
}
|
||||
|
||||
&.color-error {
|
||||
--background-color: var(--color-red-base);
|
||||
}
|
||||
|
||||
&.color-black {
|
||||
--background-color: var(--color-grey-000);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
47
@xen-orchestra/web-core/lib/components/UiSpinner.vue
Normal file
47
@xen-orchestra/web-core/lib/components/UiSpinner.vue
Normal file
@@ -0,0 +1,47 @@
|
||||
<!-- Adapted from https://www.benmvp.com/blog/how-to-create-circle-svg-gradient-loading-spinner/ -->
|
||||
|
||||
<template>
|
||||
<svg class="ui-spinner" fill="none" viewBox="0 0 400 400" xmlns="http://www.w3.org/2000/svg">
|
||||
<defs>
|
||||
<linearGradient :id="secondHalfId">
|
||||
<stop offset="0%" stop-color="currentColor" stop-opacity="0" />
|
||||
<stop offset="100%" stop-color="currentColor" stop-opacity="0.5" />
|
||||
</linearGradient>
|
||||
<linearGradient :id="firstHalfId">
|
||||
<stop offset="0%" stop-color="currentColor" stop-opacity="1" />
|
||||
<stop offset="100%" stop-color="currentColor" stop-opacity="0.5" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
|
||||
<g stroke-width="40">
|
||||
<path :stroke="`url(#${secondHalfId})`" d="M 30 200 A 170 170 180 0 1 370 200" />
|
||||
<path :stroke="`url(#${firstHalfId})`" d="M 370 200 A 170 170 0 0 1 30 200" />
|
||||
<path d="M 30 200 A 170 170 180 0 1 30 200" stroke="currentColor" stroke-linecap="round" />
|
||||
</g>
|
||||
</svg>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { uniqueId } from '@core/utils/unique-id.util'
|
||||
|
||||
const firstHalfId = uniqueId('spinner-first-half-')
|
||||
const secondHalfId = uniqueId('spinner-second-half-')
|
||||
</script>
|
||||
|
||||
<style lang="postcss" scoped>
|
||||
.ui-spinner {
|
||||
width: 1.2em;
|
||||
height: 1.2em;
|
||||
animation: rotate 1s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes rotate {
|
||||
from {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
100
@xen-orchestra/web-core/lib/components/button/ButtonIcon.vue
Normal file
100
@xen-orchestra/web-core/lib/components/button/ButtonIcon.vue
Normal file
@@ -0,0 +1,100 @@
|
||||
<template>
|
||||
<button class="button-icon" type="button" :class="[color, { small }]">
|
||||
<UiIcon class="icon" :icon="icon" />
|
||||
</button>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import UiIcon from '@core/components/icon/UiIcon.vue'
|
||||
import type { Color } from '@core/types/color.type'
|
||||
import type { IconDefinition } from '@fortawesome/fontawesome-common-types'
|
||||
|
||||
withDefaults(
|
||||
defineProps<{
|
||||
icon: IconDefinition
|
||||
color?: Color
|
||||
small?: boolean
|
||||
}>(),
|
||||
{ color: 'info' }
|
||||
)
|
||||
</script>
|
||||
|
||||
<style scoped lang="postcss">
|
||||
.button-icon {
|
||||
border: none;
|
||||
background: none;
|
||||
padding: 0;
|
||||
cursor: pointer;
|
||||
border-radius: 0.2rem;
|
||||
display: inline-flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
font-size: 1.6rem;
|
||||
--size: calc(2em - 0.8rem);
|
||||
width: var(--size);
|
||||
height: var(--size);
|
||||
color: var(--color);
|
||||
|
||||
&.small {
|
||||
font-size: 1.2rem;
|
||||
}
|
||||
|
||||
&:focus-visible {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
&:not(:disabled) {
|
||||
&:hover,
|
||||
&:focus {
|
||||
color: var(--color-focus);
|
||||
background-color: var(--background-color-focus);
|
||||
}
|
||||
|
||||
&:active {
|
||||
color: var(--color-active);
|
||||
background-color: var(--background-color-active);
|
||||
}
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
color: var(--color-disabled);
|
||||
cursor: not-allowed;
|
||||
}
|
||||
}
|
||||
|
||||
.info {
|
||||
--color: var(--color-purple-base);
|
||||
--color-focus: var(--color-purple-d20);
|
||||
--color-active: var(--color-purple-d40);
|
||||
--color-disabled: var(--color-grey-400);
|
||||
--background-color-focus: var(--background-color-purple-20);
|
||||
--background-color-active: var(--background-color-purple-30);
|
||||
}
|
||||
|
||||
.success {
|
||||
--color: var(--color-green-base);
|
||||
--color-focus: var(--color-green-d20);
|
||||
--color-active: var(--color-green-d40);
|
||||
--color-disabled: var(--color-green-l60);
|
||||
--background-color-focus: var(--background-color-green-20);
|
||||
--background-color-active: var(--background-color-green-30);
|
||||
}
|
||||
|
||||
.warning {
|
||||
--color: var(--color-orange-base);
|
||||
--color-focus: var(--color-orange-d20);
|
||||
--color-active: var(--color-orange-d40);
|
||||
--color-disabled: var(--color-orange-l60);
|
||||
--background-color-focus: var(--background-color-orange-20);
|
||||
--background-color-active: var(--background-color-orange-30);
|
||||
}
|
||||
|
||||
.error {
|
||||
--color: var(--color-red-base);
|
||||
--color-focus: var(--color-red-d20);
|
||||
--color-active: var(--color-red-d40);
|
||||
--color-disabled: var(--color-red-l60);
|
||||
--background-color-focus: var(--background-color-red-20);
|
||||
--background-color-active: var(--background-color-red-30);
|
||||
}
|
||||
</style>
|
||||
16
@xen-orchestra/web-core/lib/components/icon/UiIcon.vue
Normal file
16
@xen-orchestra/web-core/lib/components/icon/UiIcon.vue
Normal file
@@ -0,0 +1,16 @@
|
||||
<template>
|
||||
<UiSpinner v-if="busy" class="ui-icon" />
|
||||
<FontAwesomeIcon v-else-if="icon !== undefined" :fixed-width="fixedWidth" :icon="icon" class="ui-icon" />
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import UiSpinner from '@core/components/UiSpinner.vue'
|
||||
import type { IconDefinition } from '@fortawesome/fontawesome-common-types'
|
||||
import { FontAwesomeIcon } from '@fortawesome/vue-fontawesome'
|
||||
|
||||
defineProps<{
|
||||
busy?: boolean
|
||||
icon?: IconDefinition
|
||||
fixedWidth?: boolean
|
||||
}>()
|
||||
</script>
|
||||
29
@xen-orchestra/web-core/lib/components/icon/VmIcon.vue
Normal file
29
@xen-orchestra/web-core/lib/components/icon/VmIcon.vue
Normal file
@@ -0,0 +1,29 @@
|
||||
<template>
|
||||
<FontAwesomeLayers>
|
||||
<UiIcon :icon="faDisplay" />
|
||||
<PowerStateIcon :state="state" />
|
||||
</FontAwesomeLayers>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import UiIcon from '@core/components/icon/UiIcon.vue'
|
||||
import PowerStateIcon from '@core/components/PowerStateIcon.vue'
|
||||
import type { POWER_STATE } from '@core/types/power-state.type'
|
||||
import { faDisplay } from '@fortawesome/free-solid-svg-icons'
|
||||
import { FontAwesomeLayers } from '@fortawesome/vue-fontawesome'
|
||||
|
||||
defineProps<{
|
||||
state: POWER_STATE
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<style lang="postcss" scoped>
|
||||
.fa-layers {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.power-state-icon {
|
||||
font-size: 0.7em;
|
||||
transform: translate(80%, 70%);
|
||||
}
|
||||
</style>
|
||||
81
@xen-orchestra/web-core/lib/components/menu/MenuItem.vue
Normal file
81
@xen-orchestra/web-core/lib/components/menu/MenuItem.vue
Normal file
@@ -0,0 +1,81 @@
|
||||
<template>
|
||||
<li class="menu-item">
|
||||
<MenuTrigger
|
||||
v-if="!$slots.submenu"
|
||||
:active="isBusy"
|
||||
:busy="isBusy"
|
||||
:disabled="isDisabled"
|
||||
:icon="icon"
|
||||
@click="handleClick"
|
||||
>
|
||||
<slot />
|
||||
</MenuTrigger>
|
||||
<AppMenu v-else :disabled="isDisabled" shadow>
|
||||
<template #trigger="{ open, isOpen }">
|
||||
<MenuTrigger :active="isOpen" :busy="isBusy" :disabled="isDisabled" :icon="icon" @click="open">
|
||||
<slot />
|
||||
<UiIcon :fixed-width="false" :icon="submenuIcon" class="submenu-icon" />
|
||||
</MenuTrigger>
|
||||
</template>
|
||||
<slot name="submenu" />
|
||||
</AppMenu>
|
||||
</li>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import UiIcon from '@core/components/icon/UiIcon.vue'
|
||||
import MenuTrigger from '@core/components/menu/MenuTrigger.vue'
|
||||
import AppMenu from '@core/components/menu/UiMenu.vue'
|
||||
import { useContext } from '@core/composables/context.composable'
|
||||
import { DisabledContext } from '@core/context'
|
||||
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 isParentHorizontal = inject(
|
||||
IK_MENU_HORIZONTAL,
|
||||
computed(() => false)
|
||||
)
|
||||
const isDisabled = useContext(DisabledContext, () => props.disabled)
|
||||
|
||||
const submenuIcon = computed(() => (isParentHorizontal.value ? faAngleDown : faAngleRight))
|
||||
|
||||
const isHandlingClick = ref(false)
|
||||
const isBusy = computed(() => isHandlingClick.value || props.busy === true)
|
||||
const closeMenu = inject(IK_CLOSE_MENU, undefined)
|
||||
|
||||
const handleClick = async () => {
|
||||
if (isDisabled.value || isBusy.value) {
|
||||
return
|
||||
}
|
||||
|
||||
isHandlingClick.value = true
|
||||
try {
|
||||
await props.onClick?.()
|
||||
closeMenu?.()
|
||||
} finally {
|
||||
isHandlingClick.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="postcss" scoped>
|
||||
.menu-item {
|
||||
color: var(--color-grey-000);
|
||||
}
|
||||
|
||||
.submenu-icon {
|
||||
margin-left: auto;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,26 @@
|
||||
<template>
|
||||
<li :class="{ horizontal }" class="menu-separator" />
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { IK_MENU_HORIZONTAL } from '@core/utils/injection-keys.util'
|
||||
import { computed, inject } from 'vue'
|
||||
|
||||
const horizontal = inject(
|
||||
IK_MENU_HORIZONTAL,
|
||||
computed(() => false)
|
||||
)
|
||||
</script>
|
||||
|
||||
<style lang="postcss" scoped>
|
||||
.menu-separator {
|
||||
&.horizontal {
|
||||
margin: 0 0.5rem;
|
||||
border-right: 1px solid var(--color-grey-500);
|
||||
}
|
||||
|
||||
&:not(.horizontal) {
|
||||
border-bottom: 1px solid var(--color-grey-500);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
51
@xen-orchestra/web-core/lib/components/menu/MenuTrigger.vue
Normal file
51
@xen-orchestra/web-core/lib/components/menu/MenuTrigger.vue
Normal file
@@ -0,0 +1,51 @@
|
||||
<template>
|
||||
<div :class="{ active, disabled }" class="menu-trigger">
|
||||
<UiIcon :busy="busy" :icon="icon" />
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import UiIcon from '@core/components/icon/UiIcon.vue'
|
||||
import type { IconDefinition } from '@fortawesome/fontawesome-common-types'
|
||||
|
||||
defineProps<{
|
||||
active?: boolean
|
||||
busy?: boolean
|
||||
disabled?: boolean
|
||||
icon?: IconDefinition
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<style lang="postcss" scoped>
|
||||
.menu-trigger {
|
||||
font-size: 1.6rem;
|
||||
font-weight: 400;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
height: 4.4rem;
|
||||
padding-right: 1.5rem;
|
||||
padding-left: 1.5rem;
|
||||
white-space: nowrap;
|
||||
border-radius: 0.8rem;
|
||||
gap: 1rem;
|
||||
background-color: var(--color-grey-600);
|
||||
|
||||
&.disabled {
|
||||
color: var(--color-grey-500);
|
||||
}
|
||||
|
||||
&:not(.disabled) {
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
background-color: var(--background-color-purple-10);
|
||||
}
|
||||
|
||||
&:active,
|
||||
&.active {
|
||||
background-color: var(--background-color-purple-20);
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
103
@xen-orchestra/web-core/lib/components/menu/UiMenu.vue
Normal file
103
@xen-orchestra/web-core/lib/components/menu/UiMenu.vue
Normal file
@@ -0,0 +1,103 @@
|
||||
<template>
|
||||
<slot :is-open="isOpen" :open="open" name="trigger" />
|
||||
<Teleport to="body" :disabled="!shouldTeleport">
|
||||
<ul v-if="!hasTrigger || isOpen" ref="menu" :class="{ horizontal, shadow }" class="ui-menu" v-bind="$attrs">
|
||||
<slot />
|
||||
</ul>
|
||||
</Teleport>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { useContext } from '@core/composables/context.composable'
|
||||
import { DisabledContext } from '@core/context'
|
||||
import { IK_CLOSE_MENU, IK_MENU_HORIZONTAL, IK_MENU_TELEPORTED } from '@core/utils/injection-keys.util'
|
||||
import placementJs, { type Options } from 'placement.js'
|
||||
import { computed, inject, nextTick, provide, ref, useSlots } from 'vue'
|
||||
import { onClickOutside, unrefElement, whenever } from '@vueuse/core'
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
horizontal?: boolean
|
||||
shadow?: boolean
|
||||
disabled?: boolean
|
||||
placement?: Options['placement']
|
||||
}>(),
|
||||
{ disabled: undefined }
|
||||
)
|
||||
|
||||
defineOptions({
|
||||
inheritAttrs: false,
|
||||
})
|
||||
|
||||
const slots = useSlots()
|
||||
const isOpen = ref(false)
|
||||
const menu = ref()
|
||||
const isParentHorizontal = inject(
|
||||
IK_MENU_HORIZONTAL,
|
||||
computed(() => false)
|
||||
)
|
||||
provide(
|
||||
IK_MENU_HORIZONTAL,
|
||||
computed(() => props.horizontal ?? false)
|
||||
)
|
||||
|
||||
useContext(DisabledContext, () => props.disabled)
|
||||
|
||||
let clearClickOutsideEvent: (() => void) | undefined
|
||||
|
||||
const hasTrigger = useSlots().trigger !== undefined
|
||||
|
||||
const shouldTeleport = hasTrigger && !inject(IK_MENU_TELEPORTED, false)
|
||||
|
||||
if (shouldTeleport) {
|
||||
provide(IK_MENU_TELEPORTED, true)
|
||||
}
|
||||
|
||||
whenever(
|
||||
() => !isOpen.value,
|
||||
() => clearClickOutsideEvent?.()
|
||||
)
|
||||
if (slots.trigger && inject(IK_CLOSE_MENU, undefined) === undefined) {
|
||||
provide(IK_CLOSE_MENU, () => (isOpen.value = false))
|
||||
}
|
||||
|
||||
const open = (event: MouseEvent) => {
|
||||
if (isOpen.value) {
|
||||
return (isOpen.value = false)
|
||||
}
|
||||
|
||||
isOpen.value = true
|
||||
|
||||
nextTick(() => {
|
||||
clearClickOutsideEvent = onClickOutside(menu, () => (isOpen.value = false), {
|
||||
ignore: [event.currentTarget as HTMLElement],
|
||||
})
|
||||
|
||||
placementJs(event.currentTarget as HTMLElement, unrefElement(menu), {
|
||||
placement: props.placement ?? (isParentHorizontal.value ? 'bottom-start' : 'right-start'),
|
||||
})
|
||||
})
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="postcss" scoped>
|
||||
.ui-menu {
|
||||
z-index: 1;
|
||||
display: inline-flex;
|
||||
flex-direction: column;
|
||||
padding: 0.4rem;
|
||||
cursor: default;
|
||||
color: var(--color-grey-200);
|
||||
border-radius: 0.4rem;
|
||||
background-color: var(--color-grey-600);
|
||||
gap: 0.2rem;
|
||||
|
||||
&.horizontal {
|
||||
flex-direction: row;
|
||||
}
|
||||
|
||||
&.shadow {
|
||||
box-shadow: var(--shadow-300);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,24 @@
|
||||
<template>
|
||||
<li class="tree-item">
|
||||
<slot />
|
||||
<slot v-if="isExpanded" name="sublist" />
|
||||
</li>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { IK_TREE_ITEM_EXPANDED, IK_TREE_ITEM_HAS_CHILDREN, IK_TREE_ITEM_TOGGLE } from '@core/utils/injection-keys.util'
|
||||
import { useToggle } from '@vueuse/core'
|
||||
import { computed, provide } from 'vue'
|
||||
|
||||
const slots = defineSlots<{
|
||||
default: () => void
|
||||
sublist: () => void
|
||||
}>()
|
||||
|
||||
const [isExpanded, toggle] = useToggle(true)
|
||||
|
||||
const hasChildren = computed(() => slots.sublist !== undefined)
|
||||
provide(IK_TREE_ITEM_HAS_CHILDREN, hasChildren)
|
||||
provide(IK_TREE_ITEM_TOGGLE, toggle)
|
||||
provide(IK_TREE_ITEM_EXPANDED, isExpanded)
|
||||
</script>
|
||||
@@ -0,0 +1,22 @@
|
||||
<template>
|
||||
<div class="tree-item-action">
|
||||
<ButtonIcon :icon="faEllipsis" :aria-label="label" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import ButtonIcon from '@core/components/button/ButtonIcon.vue'
|
||||
import { faEllipsis } from '@fortawesome/free-solid-svg-icons'
|
||||
|
||||
defineProps<{
|
||||
label: string
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<style lang="postcss" scoped>
|
||||
.tree-item-action {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,15 @@
|
||||
<template>
|
||||
<li class="tree-item-error">
|
||||
<slot />
|
||||
</li>
|
||||
</template>
|
||||
|
||||
<style lang="postcss" scoped>
|
||||
.tree-item-error {
|
||||
padding-left: 3rem;
|
||||
font-weight: 700;
|
||||
font-size: 1.6rem;
|
||||
line-height: 150%;
|
||||
color: var(--color-red-base);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,132 @@
|
||||
<template>
|
||||
<RouterLink v-slot="{ isExactActive, href, navigate }" :to="route" custom>
|
||||
<div
|
||||
:class="isExactActive ? 'exact-active' : $props.active ? 'active' : undefined"
|
||||
class="tree-item-label"
|
||||
v-bind="$attrs"
|
||||
>
|
||||
<template v-if="depth > 1">
|
||||
<TreeLine
|
||||
v-for="i in depth - 1"
|
||||
:key="i"
|
||||
full-height
|
||||
:half-height="(!hasToggle && i === depth - 1) || !isExpanded"
|
||||
:right="i === depth - 1"
|
||||
/>
|
||||
</template>
|
||||
<UiIcon v-if="hasToggle" :icon="isExpanded ? faAngleDown : faAngleRight" fixed-width @click="toggle()" />
|
||||
<TreeLine v-else-if="!noIndent" />
|
||||
<a v-tooltip="hasTooltip" :href="href" class="link" @click="navigate">
|
||||
<slot name="icon">
|
||||
<UiIcon :icon="icon" class="icon" />
|
||||
</slot>
|
||||
<div ref="textElement" class="text">
|
||||
<slot />
|
||||
</div>
|
||||
</a>
|
||||
<slot name="addons" />
|
||||
</div>
|
||||
</RouterLink>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import UiIcon from '@core/components/icon/UiIcon.vue'
|
||||
import TreeLine from '@core/components/tree-view/TreeLine.vue'
|
||||
import { vTooltip } from '@core/directives/tooltip.directive'
|
||||
import { hasEllipsis } from '@core/utils/has-ellipsis.util'
|
||||
import {
|
||||
IK_TREE_ITEM_EXPANDED,
|
||||
IK_TREE_ITEM_HAS_CHILDREN,
|
||||
IK_TREE_ITEM_TOGGLE,
|
||||
IK_TREE_LIST_DEPTH,
|
||||
} 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'
|
||||
import type { RouteLocationRaw } from 'vue-router'
|
||||
|
||||
defineProps<{
|
||||
icon?: IconDefinition
|
||||
route: RouteLocationRaw
|
||||
active?: boolean
|
||||
noIndent?: boolean
|
||||
}>()
|
||||
|
||||
const textElement = ref<HTMLElement>()
|
||||
const hasTooltip = computed(() => hasEllipsis(textElement.value))
|
||||
|
||||
const hasToggle = inject(
|
||||
IK_TREE_ITEM_HAS_CHILDREN,
|
||||
computed(() => false)
|
||||
)
|
||||
|
||||
const toggle = inject(IK_TREE_ITEM_TOGGLE, () => undefined)
|
||||
const isExpanded = inject(IK_TREE_ITEM_EXPANDED, ref(true))
|
||||
|
||||
const depth = inject(IK_TREE_LIST_DEPTH, 0)
|
||||
</script>
|
||||
|
||||
<style lang="postcss" scoped>
|
||||
.tree-item-label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
color: var(--color-grey-100);
|
||||
border-radius: 0.8rem;
|
||||
background-color: var(--background-color-primary);
|
||||
gap: 0.4rem;
|
||||
padding: 0 0.8rem;
|
||||
margin-bottom: 0.2rem;
|
||||
|
||||
&:hover:not(:has(.ui-button-icon:hover)) {
|
||||
color: var(--color-grey-100);
|
||||
background-color: var(--background-color-purple-20);
|
||||
}
|
||||
|
||||
&:active:not(:has(.ui-button-icon:hover)) {
|
||||
background-color: var(--background-color-purple-30);
|
||||
}
|
||||
|
||||
&.exact-active:not(:has(.ui-button-icon:hover)) {
|
||||
background-color: var(--background-color-purple-10);
|
||||
|
||||
&:hover {
|
||||
background-color: var(--background-color-purple-20);
|
||||
}
|
||||
|
||||
&:active {
|
||||
background-color: var(--background-color-purple-30);
|
||||
}
|
||||
}
|
||||
|
||||
> .ui-icon {
|
||||
cursor: pointer;
|
||||
color: var(--color-purple-base);
|
||||
}
|
||||
}
|
||||
|
||||
.link {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
padding: 0.8rem 0;
|
||||
text-decoration: none;
|
||||
color: inherit;
|
||||
gap: 1.2rem;
|
||||
font-weight: 500;
|
||||
font-size: 2rem;
|
||||
|
||||
&:hover,
|
||||
.icon {
|
||||
color: var(--color-grey-100);
|
||||
}
|
||||
}
|
||||
|
||||
.text {
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
font-size: 1.4rem;
|
||||
padding-inline-end: 0.4rem;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,56 @@
|
||||
<template>
|
||||
<div class="tree-line">
|
||||
<div
|
||||
class="tree-line-vertical"
|
||||
:class="{ 'tree-line-half-height': halfHeight, 'tree-line-full-height': fullHeight }"
|
||||
/>
|
||||
<div class="tree-line-horizontal" :class="{ right }" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
defineProps<{
|
||||
halfHeight?: boolean
|
||||
fullHeight?: boolean
|
||||
right?: boolean
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<style lang="postcss" scoped>
|
||||
.tree-line {
|
||||
flex: 0 1 1.25em;
|
||||
align-self: stretch;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: end;
|
||||
|
||||
.tree-line-vertical {
|
||||
width: 0.1rem;
|
||||
background: var(--color-purple-base);
|
||||
|
||||
&.tree-line-full-height {
|
||||
height: calc(100% + 0.5rem);
|
||||
transform: translateY(-0.5rem);
|
||||
}
|
||||
}
|
||||
|
||||
.tree-line-horizontal {
|
||||
height: 0.1rem;
|
||||
width: 50%;
|
||||
background: transparent;
|
||||
|
||||
&.right {
|
||||
background: var(--color-purple-base);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.ui-tree-item:last-child {
|
||||
> .ui-tree-item-label {
|
||||
.tree-line-half-height {
|
||||
align-self: start;
|
||||
height: calc(50% + 0.5rem);
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,13 @@
|
||||
<template>
|
||||
<ul class="tree-list">
|
||||
<slot />
|
||||
</ul>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { IK_TREE_LIST_DEPTH } from '@core/utils/injection-keys.util'
|
||||
import { inject, provide } from 'vue'
|
||||
|
||||
const depth = inject(IK_TREE_LIST_DEPTH, 0)
|
||||
provide(IK_TREE_LIST_DEPTH, depth + 1)
|
||||
</script>
|
||||
@@ -0,0 +1,63 @@
|
||||
<template>
|
||||
<li class="tree-loading-item">
|
||||
<div class="tree-loading-item-label-placeholder">
|
||||
<div class="link-placeholder">
|
||||
<template v-if="depth > 1">
|
||||
<TreeLine v-for="i in depth - 1" :key="i" full-height :right="i === depth - 1" />
|
||||
</template>
|
||||
<UiIcon :icon="icon" class="icon" />
|
||||
<div class="loader"> </div>
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import UiIcon from '@core/components/icon/UiIcon.vue'
|
||||
import TreeLine from '@core/components/tree-view/TreeLine.vue'
|
||||
import { IK_TREE_LIST_DEPTH } from '@core/utils/injection-keys.util'
|
||||
import type { IconDefinition } from '@fortawesome/fontawesome-common-types'
|
||||
import { inject } from 'vue'
|
||||
|
||||
defineProps<{
|
||||
icon: IconDefinition
|
||||
}>()
|
||||
|
||||
const depth = inject(IK_TREE_LIST_DEPTH, 0)
|
||||
</script>
|
||||
|
||||
<style lang="postcss" scoped>
|
||||
.tree-loading-item-label-placeholder {
|
||||
display: flex;
|
||||
height: 4rem;
|
||||
background-color: var(--background-color-primary);
|
||||
}
|
||||
|
||||
.icon {
|
||||
color: var(--color-grey-100);
|
||||
}
|
||||
|
||||
.link-placeholder {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex: 1;
|
||||
padding: 0 0.8rem;
|
||||
gap: 0.4rem;
|
||||
}
|
||||
|
||||
.loader {
|
||||
flex: 1;
|
||||
animation: pulse alternate 1s infinite;
|
||||
background-color: var(--background-color-purple-10);
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0% {
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
100% {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
152
@xen-orchestra/web-core/lib/composables/context.composable.md
Normal file
152
@xen-orchestra/web-core/lib/composables/context.composable.md
Normal file
@@ -0,0 +1,152 @@
|
||||
<!-- TOC -->
|
||||
|
||||
- [Overview](#overview)
|
||||
- [Simple Context](#simple-context)
|
||||
- [1. Create the context](#1-create-the-context)
|
||||
- [2. Use the context](#2-use-the-context)
|
||||
- [2.1. Read](#21-read)
|
||||
- [2.2. Update](#22-update)
|
||||
- [Advanced Context](#advanced-context)
|
||||
- [1. Create the context](#1-create-the-context-1)
|
||||
- [2. Use the context](#2-use-the-context-1)
|
||||
- [2.1. Read](#21-read-1)
|
||||
- [2.2. Update](#22-update-1)
|
||||
- [Caveats (boolean props)](#caveats-boolean-props)
|
||||
<!-- TOC -->
|
||||
|
||||
# Overview
|
||||
|
||||
`createContext` lets you create a context that is both readable and writable, and is accessible by a component and all
|
||||
its descendants at any depth.
|
||||
|
||||
Each descendant has the ability to change the context value, affecting itself and all of its descendants at any level.
|
||||
|
||||
## Simple Context
|
||||
|
||||
### 1. Create the context
|
||||
|
||||
`createContext` takes the initial context value as first argument.
|
||||
|
||||
```ts
|
||||
// context.ts
|
||||
|
||||
const CounterContext = createContext(0)
|
||||
```
|
||||
|
||||
### 2. Use the context
|
||||
|
||||
#### 2.1. Read
|
||||
|
||||
You can get the current Context value by using `useContext(CounterContext)`.
|
||||
|
||||
```ts
|
||||
const counter = useContext(CounterContext)
|
||||
|
||||
console.log(counter.value) // 0
|
||||
```
|
||||
|
||||
#### 2.2. Update
|
||||
|
||||
You can pass a `MaybeRefOrGetter` as second argument to update the context value.
|
||||
|
||||
```ts
|
||||
// MyComponent.vue
|
||||
|
||||
const props = defineProps<{
|
||||
counter?: number
|
||||
}>()
|
||||
|
||||
const counter = useContext(CounterContext, () => props.counter)
|
||||
|
||||
// When calling <MyComponent />
|
||||
console.log(counter.value) // 0
|
||||
|
||||
// When calling <MyComponent :counter="20" />
|
||||
console.log(counter.value) // 20
|
||||
```
|
||||
|
||||
## Advanced Context
|
||||
|
||||
To customize the context output, you can pass a custom context builder as the second argument of `createContext`.
|
||||
|
||||
### 1. Create the context
|
||||
|
||||
```ts
|
||||
// context.ts
|
||||
|
||||
// Example 1. Return a object
|
||||
const CounterContext = createContext(10, counter => ({
|
||||
counter,
|
||||
isEven: computed(() => counter.value % 2 === 0),
|
||||
}))
|
||||
|
||||
// Example 2. Return a computed value
|
||||
const DoubleContext = createContext(10, num => computed(() => num.value * 2))
|
||||
```
|
||||
|
||||
### 2. Use the context
|
||||
|
||||
#### 2.1. Read
|
||||
|
||||
When using the context, it will return your custom value.
|
||||
|
||||
```ts
|
||||
const { counter, isEven } = useContext(CounterContext)
|
||||
const double = useContext(DoubleContext)
|
||||
|
||||
console.log(counter.value) // 10
|
||||
console.log(isEven.value) // true
|
||||
console.log(double.value) // 20
|
||||
```
|
||||
|
||||
#### 2.2. Update
|
||||
|
||||
Same as with a simple context, you can pass a `MaybeRefOrGetter` as second argument.
|
||||
|
||||
```ts
|
||||
// Parent.vue
|
||||
useContext(CounterContext, 99)
|
||||
useContext(DoubleContext, 99)
|
||||
|
||||
// Child.vue
|
||||
const { isEven } = useContext(CounterContext)
|
||||
const double = useContext(DoubleContext)
|
||||
|
||||
console.log(isEven.value) // false
|
||||
console.log(double.value) // 198
|
||||
```
|
||||
|
||||
## Caveats (boolean props)
|
||||
|
||||
When working with `boolean` props, there's an important caveat to be aware of.
|
||||
|
||||
If the `MaybeRefOrGetter` returns any other value than `undefined`, the context will be updated according to this value.
|
||||
|
||||
This could be problematic if the value comes from a `boolean` prop.
|
||||
|
||||
```ts
|
||||
const props = defineProps<{
|
||||
disabled?: boolean
|
||||
}>()
|
||||
|
||||
useContext(MyBooleanContext, () => props.disabled) // Update to `false` if `undefined`
|
||||
```
|
||||
|
||||
In that case, Vue will automatically set the default value for `disabled` prop to `false`.
|
||||
|
||||
Even if the `disabled` prop in not provided at all, the current context will not be used and will be replaced
|
||||
by `false`.
|
||||
|
||||
To circumvent this issue, you need to use `withDefaults` and specifically set the default value for `boolean` props
|
||||
to `undefined`:
|
||||
|
||||
```ts
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
disabled?: boolean
|
||||
}>(),
|
||||
{ disabled: undefined }
|
||||
)
|
||||
|
||||
useContext(MyBoolean, () => props.disabled) // Keep parent value if `undefined`
|
||||
```
|
||||
@@ -0,0 +1,36 @@
|
||||
import type { ComputedRef, InjectionKey, MaybeRefOrGetter } from 'vue'
|
||||
import { computed, inject, provide, toValue } from 'vue'
|
||||
|
||||
export const createContext = <T, Output = ComputedRef<T>>(
|
||||
initialValue: MaybeRefOrGetter<T>,
|
||||
customBuilder?: (value: ComputedRef<T>) => Output
|
||||
) => {
|
||||
return {
|
||||
id: Symbol('CONTEXT_ID') as InjectionKey<MaybeRefOrGetter<T>>,
|
||||
initialValue,
|
||||
builder: customBuilder ?? (value => value as Output),
|
||||
}
|
||||
}
|
||||
|
||||
type Context<T = any, Output = any> = ReturnType<typeof createContext<T, Output>>
|
||||
|
||||
type ContextOutput<Ctx extends Context> = Ctx extends Context<any, infer Output> ? Output : never
|
||||
|
||||
type ContextValue<Ctx extends Context> = Ctx extends Context<infer T> ? T : never
|
||||
|
||||
export const useContext = <Ctx extends Context, T extends ContextValue<Ctx>>(
|
||||
context: Ctx,
|
||||
newValue?: MaybeRefOrGetter<T | undefined>
|
||||
): ContextOutput<Ctx> => {
|
||||
const currentValue = inject(context.id, context.initialValue)
|
||||
|
||||
const build = (value: MaybeRefOrGetter<T>) => context.builder(computed(() => toValue(value)))
|
||||
|
||||
if (newValue !== undefined) {
|
||||
const updatedValue = () => toValue(newValue) ?? toValue(currentValue)
|
||||
provide(context.id, updatedValue)
|
||||
return build(updatedValue)
|
||||
}
|
||||
|
||||
return build(currentValue)
|
||||
}
|
||||
12
@xen-orchestra/web-core/lib/context.ts
Normal file
12
@xen-orchestra/web-core/lib/context.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
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('neutral' as Color, color => ({
|
||||
name: color,
|
||||
textClass: computed(() => `context-color-${color.value}`),
|
||||
backgroundClass: computed(() => `context-background-color-${color.value}`),
|
||||
borderClass: computed(() => `context-border-color-${color.value}`),
|
||||
}))
|
||||
65
@xen-orchestra/web-core/lib/directives/tooltip.directive.md
Normal file
65
@xen-orchestra/web-core/lib/directives/tooltip.directive.md
Normal file
@@ -0,0 +1,65 @@
|
||||
# Tooltip Directive
|
||||
|
||||
By default, the tooltip will appear centered above the target element.
|
||||
|
||||
## Directive argument
|
||||
|
||||
The directive argument can be either:
|
||||
|
||||
- The tooltip content
|
||||
- An object containing the tooltip content and/or placement: `{ content: "...", placement: "..." }` (both optional)
|
||||
|
||||
## Tooltip content
|
||||
|
||||
The tooltip content can be either:
|
||||
|
||||
- `false` or an empty-string to disable the tooltip
|
||||
- `true` or `undefined` to enable the tooltip and extract its content from the element's innerText.
|
||||
- Non-empty string to enable the tooltip and use the string as content.
|
||||
|
||||
## Tooltip placement
|
||||
|
||||
Tooltip can be placed on the following positions:
|
||||
|
||||
- `top`
|
||||
- `top-start`
|
||||
- `top-end`
|
||||
- `bottom`
|
||||
- `bottom-start`
|
||||
- `bottom-end`
|
||||
- `left`
|
||||
- `left-start`
|
||||
- `left-end`
|
||||
- `right`
|
||||
- `right-start`
|
||||
- `right-end`
|
||||
|
||||
## Usage
|
||||
|
||||
```vue
|
||||
<template>
|
||||
<!-- Boolean / Undefined -->
|
||||
<span v-tooltip="true">This content will be ellipsized by CSS but displayed entirely in the tooltip</span>
|
||||
<span v-tooltip>This content will be ellipsized by CSS but displayed entirely in the tooltip</span>
|
||||
|
||||
<!-- String -->
|
||||
<span v-tooltip="'Tooltip content'">Item</span>
|
||||
|
||||
<!-- Object -->
|
||||
<span v-tooltip="{ content: 'Foobar', placement: 'left-end' }">Item</span>
|
||||
|
||||
<!-- Dynamic -->
|
||||
<span v-tooltip="myTooltip">Item</span>
|
||||
|
||||
<!-- Conditional -->
|
||||
<span v-tooltip="isTooltipEnabled && 'Foobar'">Item</span>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref } from 'vue'
|
||||
import { vTooltip } from '@/directives/tooltip.directive'
|
||||
|
||||
const myTooltip = ref('Content') // or ref({ content: "Content", placement: "left-end" })
|
||||
const isTooltipEnabled = ref(true)
|
||||
</script>
|
||||
```
|
||||
43
@xen-orchestra/web-core/lib/directives/tooltip.directive.ts
Normal file
43
@xen-orchestra/web-core/lib/directives/tooltip.directive.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
import type { TooltipEvents, TooltipOptions } from '@core/stores/tooltip.store'
|
||||
import { useTooltipStore } from '@core/stores/tooltip.store'
|
||||
import { isObject } from 'lodash-es'
|
||||
import type { Options } from 'placement.js'
|
||||
import type { Directive } from 'vue'
|
||||
|
||||
type TooltipDirectiveContent = undefined | boolean | string
|
||||
|
||||
type TooltipDirectiveOptions =
|
||||
| TooltipDirectiveContent
|
||||
| {
|
||||
content?: TooltipDirectiveContent
|
||||
placement?: Options['placement']
|
||||
}
|
||||
|
||||
const parseOptions = (options: TooltipDirectiveOptions, target: HTMLElement): TooltipOptions => {
|
||||
const { placement, content } = isObject(options) ? options : { placement: undefined, content: options }
|
||||
|
||||
return {
|
||||
placement,
|
||||
content: content === true || content === undefined ? target.innerText.trim() : content,
|
||||
}
|
||||
}
|
||||
|
||||
export const vTooltip: Directive<HTMLElement, TooltipDirectiveOptions> = {
|
||||
mounted(target, binding) {
|
||||
const store = useTooltipStore()
|
||||
|
||||
const events: TooltipEvents = binding.modifiers.focus
|
||||
? { on: 'focusin', off: 'focusout' }
|
||||
: { on: 'mouseenter', off: 'mouseleave' }
|
||||
|
||||
store.register(target, parseOptions(binding.value, target), events)
|
||||
},
|
||||
updated(target, binding) {
|
||||
const store = useTooltipStore()
|
||||
store.updateOptions(target, parseOptions(binding.value, target))
|
||||
},
|
||||
beforeUnmount(target) {
|
||||
const store = useTooltipStore()
|
||||
store.unregister(target)
|
||||
},
|
||||
}
|
||||
6
@xen-orchestra/web-core/lib/locales/en.json
Normal file
6
@xen-orchestra/web-core/lib/locales/en.json
Normal file
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"core": {
|
||||
"master": "Primary host",
|
||||
"quick-actions": "Quick actions"
|
||||
}
|
||||
}
|
||||
6
@xen-orchestra/web-core/lib/locales/fr.json
Normal file
6
@xen-orchestra/web-core/lib/locales/fr.json
Normal file
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"core": {
|
||||
"master": "Hôte principal",
|
||||
"quick-actions": "Actions rapides"
|
||||
}
|
||||
}
|
||||
71
@xen-orchestra/web-core/lib/stores/tooltip.store.ts
Normal file
71
@xen-orchestra/web-core/lib/stores/tooltip.store.ts
Normal file
@@ -0,0 +1,71 @@
|
||||
import { useEventListener, type WindowEventName } from '@vueuse/core'
|
||||
import { uniqueId } from '@core/utils/unique-id.util'
|
||||
import { defineStore } from 'pinia'
|
||||
import type { Options } from 'placement.js'
|
||||
import { computed, type EffectScope, effectScope, ref } from 'vue'
|
||||
|
||||
export type TooltipOptions = {
|
||||
content: string | false
|
||||
placement: Options['placement']
|
||||
}
|
||||
|
||||
export type TooltipEvents = { on: WindowEventName; off: WindowEventName }
|
||||
|
||||
export const useTooltipStore = defineStore('tooltip', () => {
|
||||
const targetsScopes = new WeakMap<HTMLElement, EffectScope>()
|
||||
const targets = ref(new Set<HTMLElement>())
|
||||
const targetsOptions = ref(new Map<HTMLElement, TooltipOptions>())
|
||||
const targetsIds = ref(new Map<HTMLElement, string>())
|
||||
|
||||
const register = (target: HTMLElement, options: TooltipOptions, events: TooltipEvents) => {
|
||||
const scope = effectScope()
|
||||
|
||||
targetsScopes.set(target, scope)
|
||||
targetsOptions.value.set(target, options)
|
||||
targetsIds.value.set(target, uniqueId('tooltip-'))
|
||||
|
||||
scope.run(() => {
|
||||
useEventListener(target, events.on, () => {
|
||||
targets.value.add(target)
|
||||
|
||||
scope.run(() => {
|
||||
useEventListener(
|
||||
target,
|
||||
events.off,
|
||||
() => {
|
||||
targets.value.delete(target)
|
||||
},
|
||||
{ once: true }
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
const updateOptions = (target: HTMLElement, options: TooltipOptions) => {
|
||||
targetsOptions.value.set(target, options)
|
||||
}
|
||||
|
||||
const unregister = (target: HTMLElement) => {
|
||||
targets.value.delete(target)
|
||||
targetsOptions.value.delete(target)
|
||||
targetsScopes.get(target)?.stop()
|
||||
targetsScopes.delete(target)
|
||||
targetsIds.value.delete(target)
|
||||
}
|
||||
|
||||
return {
|
||||
register,
|
||||
unregister,
|
||||
updateOptions,
|
||||
tooltips: computed(() => {
|
||||
return Array.from(targets.value.values()).map(target => {
|
||||
return {
|
||||
target,
|
||||
options: targetsOptions.value.get(target)!,
|
||||
key: targetsIds.value.get(target)!,
|
||||
}
|
||||
})
|
||||
}),
|
||||
}
|
||||
})
|
||||
1
@xen-orchestra/web-core/lib/types/color.type.ts
Normal file
1
@xen-orchestra/web-core/lib/types/color.type.ts
Normal file
@@ -0,0 +1 @@
|
||||
export type Color = 'info' | 'error' | 'warning' | 'success'
|
||||
1
@xen-orchestra/web-core/lib/types/power-state.type.ts
Normal file
1
@xen-orchestra/web-core/lib/types/power-state.type.ts
Normal file
@@ -0,0 +1 @@
|
||||
export type POWER_STATE = 'running' | 'paused' | 'halted' | 'suspended'
|
||||
11
@xen-orchestra/web-core/lib/utils/has-ellipsis.util.ts
Normal file
11
@xen-orchestra/web-core/lib/utils/has-ellipsis.util.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
export const hasEllipsis = (target: Element | undefined | null, { vertical = false }: { vertical?: boolean } = {}) => {
|
||||
if (target == null) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (vertical) {
|
||||
return target.clientHeight < target.scrollHeight
|
||||
}
|
||||
|
||||
return target.clientWidth < target.scrollWidth
|
||||
}
|
||||
15
@xen-orchestra/web-core/lib/utils/injection-keys.util.ts
Normal file
15
@xen-orchestra/web-core/lib/utils/injection-keys.util.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import type { ComputedRef, InjectionKey, Ref } from 'vue'
|
||||
|
||||
export const IK_TREE_ITEM_HAS_CHILDREN = Symbol('IK_TREE_ITEM_HAS_CHILDREN') as InjectionKey<ComputedRef<boolean>>
|
||||
|
||||
export const IK_TREE_ITEM_TOGGLE = Symbol('IK_TREE_ITEM_TOGGLE') as InjectionKey<(force?: boolean) => void>
|
||||
|
||||
export const IK_TREE_ITEM_EXPANDED = Symbol('IK_TREE_ITEM_EXPANDED') as InjectionKey<Ref<boolean>>
|
||||
|
||||
export const IK_TREE_LIST_DEPTH = Symbol('IK_TREE_LIST_DEPTH') as InjectionKey<number>
|
||||
|
||||
export const IK_MENU_HORIZONTAL = Symbol('IK_MENU_HORIZONTAL') as InjectionKey<ComputedRef<boolean>>
|
||||
|
||||
export const IK_CLOSE_MENU = Symbol('IK_CLOSE_MENU') as InjectionKey<() => void>
|
||||
|
||||
export const IK_MENU_TELEPORTED = Symbol('IK_MENU_TELEPORTED') as InjectionKey<boolean>
|
||||
8
@xen-orchestra/web-core/lib/utils/unique-id.util.ts
Normal file
8
@xen-orchestra/web-core/lib/utils/unique-id.util.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
const uniqueIds = new Map<string | undefined, number>()
|
||||
|
||||
export const uniqueId = (prefix?: string) => {
|
||||
const id = uniqueIds.get(prefix) || 0
|
||||
uniqueIds.set(prefix, id + 1)
|
||||
|
||||
return prefix !== undefined ? `${prefix}-${id}` : `${id}`
|
||||
}
|
||||
@@ -10,7 +10,18 @@
|
||||
}
|
||||
},
|
||||
"devDependencies": {
|
||||
"vue": "^3.4.13"
|
||||
"@fortawesome/fontawesome-svg-core": "^6.5.1",
|
||||
"@fortawesome/free-regular-svg-icons": "^6.5.1",
|
||||
"@fortawesome/free-solid-svg-icons": "^6.5.1",
|
||||
"@fortawesome/vue-fontawesome": "^3.0.5",
|
||||
"@types/lodash-es": "^4.17.12",
|
||||
"@vue/tsconfig": "^0.5.1",
|
||||
"@vueuse/core": "^10.7.1",
|
||||
"lodash-es": "^4.17.21",
|
||||
"pinia": "^2.1.7",
|
||||
"placement.js": "^1.0.0-beta.5",
|
||||
"vue": "^3.4.13",
|
||||
"vue-router": "^4.2.5"
|
||||
},
|
||||
"homepage": "https://github.com/vatesfr/xen-orchestra/tree/master/@xen-orchestra/web-core",
|
||||
"bugs": "https://github.com/vatesfr/xen-orchestra/issues",
|
||||
@@ -25,6 +36,6 @@
|
||||
},
|
||||
"license": "AGPL-3.0-or-later",
|
||||
"engines": {
|
||||
"node": ">=8.10"
|
||||
"node": ">=18"
|
||||
}
|
||||
}
|
||||
|
||||
12
@xen-orchestra/web-core/tsconfig.json
Normal file
12
@xen-orchestra/web-core/tsconfig.json
Normal file
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"extends": "@vue/tsconfig/tsconfig.dom.json",
|
||||
"include": ["env.d.ts", "lib/**/*", "lib/**/*.vue"],
|
||||
"exclude": ["lib/**/__tests__/*"],
|
||||
"compilerOptions": {
|
||||
"noEmit": true,
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@core/*": ["./lib/*"]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,13 +1,22 @@
|
||||
{
|
||||
"extends": "@vue/tsconfig/tsconfig.dom.json",
|
||||
"include": ["env.d.ts", "typed-router.d.ts", "src/**/*", "src/**/*.vue"],
|
||||
"include": [
|
||||
"env.d.ts",
|
||||
"typed-router.d.ts",
|
||||
"src/**/*",
|
||||
"src/**/*.vue",
|
||||
"../web-core/lib/**/*",
|
||||
"../web-core/lib/**/*.vue"
|
||||
],
|
||||
"exclude": ["src/**/__tests__/*"],
|
||||
"compilerOptions": {
|
||||
"composite": true,
|
||||
"noEmit": true,
|
||||
"baseUrl": ".",
|
||||
"rootDir": "..",
|
||||
"paths": {
|
||||
"@/*": ["./src/*"]
|
||||
"@/*": ["./src/*"],
|
||||
"@core/*": ["../web-core/lib/*"]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ export default defineConfig({
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': fileURLToPath(new URL('./src', import.meta.url)),
|
||||
'@core': fileURLToPath(new URL('../web-core/lib', import.meta.url)),
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
@@ -21,12 +21,23 @@ export default class Vif {
|
||||
MAC = '',
|
||||
} = {}
|
||||
) {
|
||||
if (device === undefined) {
|
||||
const allowedDevices = await this.call('VM.get_allowed_VIF_devices', VM)
|
||||
if (allowedDevices.length === 0) {
|
||||
const error = new Error('could not find an allowed VIF device')
|
||||
error.poolUuid = this.pool.uuid
|
||||
error.vmRef = VM
|
||||
throw error
|
||||
}
|
||||
|
||||
device = allowedDevices[0]
|
||||
}
|
||||
|
||||
const [powerState, ...rest] = await Promise.all([
|
||||
this.getField('VM', VM, 'power_state'),
|
||||
device ?? (await this.call('VM.get_allowed_VIF_devices', VM))[0],
|
||||
MTU ?? (await this.getField('network', network, 'MTU')),
|
||||
MTU ?? this.getField('network', network, 'MTU'),
|
||||
])
|
||||
;[device, MTU] = rest
|
||||
;[MTU] = rest
|
||||
|
||||
const vifRef = await this.call('VIF.create', {
|
||||
currently_attached: powerState === 'Suspended' ? currently_attached : undefined,
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
> Users must be able to say: “Nice enhancement, I'm eager to test it”
|
||||
|
||||
- Disable search engine indexing via a `robots.txt`
|
||||
- [Stats] Support format used by XAPI 23.31
|
||||
|
||||
### Bug fixes
|
||||
|
||||
@@ -33,7 +34,11 @@
|
||||
|
||||
<!--packages-start-->
|
||||
|
||||
- xo-server patch
|
||||
- @xen-orchestra/backups patch
|
||||
- @xen-orchestra/fs patch
|
||||
- @xen-orchestra/xapi patch
|
||||
- vhd-lib patch
|
||||
- xo-server minor
|
||||
- xo-server-audit patch
|
||||
- xo-web patch
|
||||
|
||||
|
||||
15
docs/xoa.md
15
docs/xoa.md
@@ -93,6 +93,21 @@ Follow the instructions:
|
||||
|
||||
You can also download XOA from xen-orchestra.com in an XVA file. Once you've got the XVA file, you can import it with `xe vm-import filename=xoa_unified.xva` or via XenCenter.
|
||||
|
||||
If you want to use static IP address for your appliance:
|
||||
|
||||
```sh
|
||||
xe vm-param-set uuid="$uuid" \
|
||||
xenstore-data:vm-data/ip="$ip" \
|
||||
xenstore-data:vm-data/netmask="$netmask" \
|
||||
xenstore-data:vm-data/gateway="$gateway"
|
||||
```
|
||||
|
||||
If you want to replace the default DNS server:
|
||||
|
||||
```sh
|
||||
xe vm-param-set uuid="$uuid" xenstore-data:vm-data/dns="$dns"
|
||||
```
|
||||
|
||||
After the VM is imported, you just need to start it with `xe vm-start vm="XOA"` or with XenCenter.
|
||||
|
||||
## First console connection
|
||||
|
||||
@@ -96,7 +96,7 @@
|
||||
},
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"build": "turbo run build --scope xo-server --scope xo-server-'*' --scope xo-web",
|
||||
"build": "TURBO_TELEMETRY_DISABLED=1 turbo run build --scope xo-server --scope xo-server-'*' --scope xo-web",
|
||||
"build:xo-lite": "turbo run build --scope @xen-orchestra/lite",
|
||||
"clean": "scripts/run-script.js --parallel clean",
|
||||
"dev": "scripts/run-script.js --parallel --concurrency 0 --verbose dev",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
'use strict'
|
||||
|
||||
const { dirname, relative } = require('path')
|
||||
const { relativeFromFile } = require('@xen-orchestra/fs/path')
|
||||
|
||||
const { openVhd } = require('./openVhd')
|
||||
const { DISK_TYPES } = require('./_constants')
|
||||
@@ -21,7 +21,7 @@ module.exports = async function chain(parentHandler, parentPath, childHandler, c
|
||||
}
|
||||
await childVhd.readBlockAllocationTable()
|
||||
|
||||
const parentName = relative(dirname(childPath), parentPath)
|
||||
const parentName = relativeFromFile(childPath, parentPath)
|
||||
header.parentUuid = parentVhd.footer.uuid
|
||||
header.parentUnicodeName = parentName
|
||||
await childVhd.setUniqueParentLocator(parentName)
|
||||
|
||||
@@ -27,7 +27,7 @@ async function sendToNagios(app, jobName, vmBackupInfo) {
|
||||
jobName
|
||||
)
|
||||
} catch (error) {
|
||||
warn('sendToNagios:', error)
|
||||
warn('sendToNagios:', { error })
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -45,7 +45,17 @@ const RRD_POINTS_PER_STEP = {
|
||||
// Utils
|
||||
// -------------------------------------------------------------------
|
||||
|
||||
function convertNanToNull(value) {
|
||||
function parseNumber(value) {
|
||||
// Starting from XAPI 23.31, numbers in the JSON payload are encoded as
|
||||
// strings to support NaN, Infinity and -Infinity
|
||||
if (typeof value === 'string') {
|
||||
const asNumber = +value
|
||||
if (isNaN(asNumber) && value !== 'NaN') {
|
||||
throw new Error('cannot parse number: ' + value)
|
||||
}
|
||||
value = asNumber
|
||||
}
|
||||
|
||||
return isNaN(value) ? null : value
|
||||
}
|
||||
|
||||
@@ -58,7 +68,7 @@ async function getServerTimestamp(xapi, hostRef) {
|
||||
// -------------------------------------------------------------------
|
||||
|
||||
const computeValues = (dataRow, legendIndex, transformValue = identity) =>
|
||||
map(dataRow, ({ values }) => transformValue(convertNanToNull(values[legendIndex])))
|
||||
map(dataRow, ({ values }) => transformValue(parseNumber(values[legendIndex])))
|
||||
|
||||
const combineStats = (stats, path, combineValues) => zipWith(...map(stats, path), (...values) => combineValues(values))
|
||||
|
||||
@@ -245,7 +255,15 @@ export default class XapiStats {
|
||||
start: timestamp,
|
||||
},
|
||||
})
|
||||
.then(response => response.text().then(JSON5.parse))
|
||||
.then(response => response.text())
|
||||
.then(data => {
|
||||
try {
|
||||
// starting from XAPI 23.31, the response is valid JSON
|
||||
return JSON.parse(data)
|
||||
} catch (_) {
|
||||
return JSON5.parse(data)
|
||||
}
|
||||
})
|
||||
.catch(err => {
|
||||
delete this.#hostCache[hostUuid][step]
|
||||
throw err
|
||||
@@ -299,7 +317,7 @@ export default class XapiStats {
|
||||
// To avoid crossing over the boundary, we ask for one less step
|
||||
const optimumTimestamp = currentTimeStamp - maxDuration + step
|
||||
const json = await this._getJson(xapi, host, optimumTimestamp, step)
|
||||
const actualStep = json.meta.step
|
||||
const actualStep = parseNumber(json.meta.step)
|
||||
|
||||
if (actualStep !== step) {
|
||||
throw new FaultyGranularity(`Unable to get the true granularity: ${actualStep}`)
|
||||
@@ -326,9 +344,10 @@ export default class XapiStats {
|
||||
return
|
||||
}
|
||||
|
||||
if (stepStats === undefined || stepStats.endTimestamp !== json.meta.end) {
|
||||
const endTimestamp = parseNumber(json.meta.end)
|
||||
if (stepStats === undefined || stepStats.endTimestamp !== endTimestamp) {
|
||||
stepStats = {
|
||||
endTimestamp: json.meta.end,
|
||||
endTimestamp,
|
||||
interval: actualStep,
|
||||
stats: {},
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import asyncMapSettled from '@xen-orchestra/async-map/legacy.js'
|
||||
import { basename } from 'path'
|
||||
import { createLogger } from '@xen-orchestra/log'
|
||||
import { format, parse } from 'xo-remote-parser'
|
||||
import {
|
||||
DEFAULT_ENCRYPTION_ALGORITHM,
|
||||
@@ -17,17 +18,35 @@ import { Remotes } from '../models/remote.mjs'
|
||||
|
||||
// ===================================================================
|
||||
|
||||
const { warn } = createLogger('xo:mixins:remotes')
|
||||
|
||||
const obfuscateRemote = ({ url, ...remote }) => {
|
||||
const parsedUrl = parse(url)
|
||||
remote.url = format(sensitiveValues.obfuscate(parsedUrl))
|
||||
return remote
|
||||
}
|
||||
|
||||
function validatePath(url) {
|
||||
const { path } = parse(url)
|
||||
// these properties should be defined on the remote object itself and not as
|
||||
// part of the remote URL
|
||||
//
|
||||
// there is a bug somewhere that keep putting them into the URL, this list
|
||||
// is here to help track it
|
||||
const INVALID_URL_PARAMS = ['benchmarks', 'id', 'info', 'name', 'proxy', 'enabled', 'error', 'url']
|
||||
|
||||
function validateUrl(url) {
|
||||
const parsedUrl = parse(url)
|
||||
|
||||
const { path } = parsedUrl
|
||||
if (path !== undefined && basename(path) === 'xo-vm-backups') {
|
||||
throw invalidParameters('remote url should not end with xo-vm-backups')
|
||||
}
|
||||
|
||||
for (const param of INVALID_URL_PARAMS) {
|
||||
if (Object.hasOwn(parsedUrl, param)) {
|
||||
// log with stack trace
|
||||
warn(new Error('invalid remote URL param ' + param))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default class {
|
||||
@@ -182,6 +201,22 @@ export default class {
|
||||
if (remote === undefined) {
|
||||
throw noSuchObject(id, 'remote')
|
||||
}
|
||||
|
||||
const parsedUrl = parse(remote.url)
|
||||
let fixed = false
|
||||
for (const param of INVALID_URL_PARAMS) {
|
||||
if (Object.hasOwn(parsedUrl, param)) {
|
||||
// delete the value to trace its real origin when it's added back
|
||||
// with `updateRemote()`
|
||||
delete parsedUrl[param]
|
||||
fixed = true
|
||||
}
|
||||
}
|
||||
if (fixed) {
|
||||
remote.url = format(parsedUrl)
|
||||
this._remotes.update(remote).catch(warn)
|
||||
}
|
||||
|
||||
return remote
|
||||
}
|
||||
|
||||
@@ -202,7 +237,7 @@ export default class {
|
||||
}
|
||||
|
||||
async createRemote({ name, options, proxy, url }) {
|
||||
validatePath(url)
|
||||
validateUrl(url)
|
||||
|
||||
const params = {
|
||||
enabled: false,
|
||||
@@ -219,6 +254,10 @@ export default class {
|
||||
}
|
||||
|
||||
updateRemote(id, { enabled, name, options, proxy, url }) {
|
||||
if (url !== undefined) {
|
||||
validateUrl(url)
|
||||
}
|
||||
|
||||
const handlers = this._handlers
|
||||
const handler = handlers[id]
|
||||
if (handler !== undefined) {
|
||||
@@ -238,7 +277,7 @@ export default class {
|
||||
@synchronized()
|
||||
async _updateRemote(id, { url, ...props }) {
|
||||
if (url !== undefined) {
|
||||
validatePath(url)
|
||||
validateUrl(url)
|
||||
}
|
||||
|
||||
const remote = await this._getRemote(id)
|
||||
|
||||
@@ -75,7 +75,7 @@ export const reportOnSupportPanel = async ({ files = [], formatMessage = identit
|
||||
ADDITIONAL_FILES.map(({ fetch, name }) =>
|
||||
timeout.call(fetch(), ADDITIONAL_FILES_FETCH_TIMEOUT).then(
|
||||
file => formData.append('attachments', createBlobFromString(file), name),
|
||||
error => logger.warn(`cannot get ${name}`, error)
|
||||
error => logger.warn(`cannot get ${name}`, { error })
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user