Compare commits

..

3 Commits

Author SHA1 Message Date
Olivier Floch
8d2dd32e87 feat(xo6/core): update tree-view ui 2024-02-16 17:03:31 +01:00
Olivier Floch
a0a6b73bce feedback 2024-02-16 16:28:39 +01:00
Olivier Floch
64bba27923 feat(xo6/core): add ui tree-view components in web-core 2024-02-16 16:28:39 +01:00
51 changed files with 1449 additions and 316 deletions

View File

@@ -191,14 +191,13 @@ export class RemoteAdapter {
// check if we will be allowed to merge a a vhd created in this adapter
// with the vhd at path `path`
async isMergeableParent(packedParentUid, path) {
return await Disposable.use(VhdSynthetic.fromVhdChain(this.handler, path), vhd => {
return await Disposable.use(openVhd(this.handler, path), vhd => {
// this baseUuid is not linked with this vhd
if (!vhd.footer.uuid.equals(packedParentUid)) {
return false
}
// check if all the chain is composed of vhd directory
const isVhdDirectory = vhd.checkVhdsClass(VhdDirectory)
const isVhdDirectory = vhd instanceof VhdDirectory
return isVhdDirectory
? this.useVhdDirectory() && this.#getCompressionType() === vhd.compressionType
: !this.useVhdDirectory()

View File

@@ -2,7 +2,6 @@ import { asyncEach } from '@vates/async-each'
import { decorateMethodsWith } from '@vates/decorate-with'
import { defer } from 'golike-defer'
import assert from 'node:assert'
import * as UUID from 'uuid'
import isVhdDifferencingDisk from 'vhd-lib/isVhdDifferencingDisk.js'
import mapValues from 'lodash/mapValues.js'
@@ -10,48 +9,11 @@ import { AbstractRemote } from './_AbstractRemote.mjs'
import { forkDeltaExport } from './_forkDeltaExport.mjs'
import { IncrementalRemoteWriter } from '../_writers/IncrementalRemoteWriter.mjs'
import { Task } from '../../Task.mjs'
import { Disposable } from 'promise-toolbox'
import { openVhd } from 'vhd-lib'
import { getVmBackupDir } from '../../_getVmBackupDir.mjs'
class IncrementalRemoteVmBackupRunner extends AbstractRemote {
_getRemoteWriter() {
return IncrementalRemoteWriter
}
async _selectBaseVm(metadata) {
// for each disk , get the parent
const baseUuidToSrcVdi = new Map()
// no previous backup for a base( =key) backup
if (metadata.isBase) {
return
}
await asyncEach(Object.entries(metadata.vdis), async ([id, vdi]) => {
const isDifferencing = metadata.isVhdDifferencing[`${id}.vhd`]
if (isDifferencing) {
const vmDir = getVmBackupDir(metadata.vm.uuid)
const path = `${vmDir}/${metadata.vhds[id]}`
// don't catch error : we can't recover if the source vhd are missing
await Disposable.use(openVhd(this._sourceRemoteAdapter._handler, path), vhd => {
baseUuidToSrcVdi.set(UUID.stringify(vhd.header.parentUuid), vdi.$snapshot_of$uuid)
})
}
})
const presentBaseVdis = new Map(baseUuidToSrcVdi)
await this._callWriters(
writer => presentBaseVdis.size !== 0 && writer.checkBaseVdis(presentBaseVdis),
'writer.checkBaseVdis()',
false
)
// check if the parent vdi are present in all the remotes
baseUuidToSrcVdi.forEach((srcVdiUuid, baseUuid) => {
if (!presentBaseVdis.has(baseUuid)) {
throw new Error(`Missing vdi ${baseUuid} which is a base for a delta`)
}
})
// yeah , let's go
}
async _run($defer) {
const transferList = await this._computeTransferList(({ mode }) => mode === 'delta')
await this._callWriters(async writer => {
@@ -64,7 +26,7 @@ class IncrementalRemoteVmBackupRunner extends AbstractRemote {
if (transferList.length > 0) {
for (const metadata of transferList) {
assert.strictEqual(metadata.mode, 'delta')
await this._selectBaseVm(metadata)
await this._callWriters(writer => writer.prepare({ isBase: metadata.isBase }), 'writer.prepare()')
const incrementalExport = await this._sourceRemoteAdapter.readIncrementalVmBackup(metadata, undefined, {
useChain: false,
@@ -88,17 +50,6 @@ class IncrementalRemoteVmBackupRunner extends AbstractRemote {
}),
'writer.transfer()'
)
// this will update parent name with the needed alias
await this._callWriters(
writer =>
writer.updateUuidAndChain({
isVhdDifferencing,
timestamp: metadata.timestamp,
vdis: incrementalExport.vdis,
}),
'writer.updateUuidAndChain()'
)
await this._callWriters(writer => writer.cleanup(), 'writer.cleanup()')
// for healthcheck
this._tags = metadata.vm.tags

View File

@@ -78,18 +78,6 @@ export const IncrementalXapi = class IncrementalXapiVmBackupRunner extends Abstr
'writer.transfer()'
)
// we want to control the uuid of the vhd in the chain
// and ensure they are correctly chained
await this._callWriters(
writer =>
writer.updateUuidAndChain({
isVhdDifferencing,
timestamp,
vdis: deltaExport.vdis,
}),
'writer.updateUuidAndChain()'
)
this._baseVm = exportedVm
if (baseVm !== undefined) {
@@ -145,7 +133,7 @@ export const IncrementalXapi = class IncrementalXapiVmBackupRunner extends Abstr
])
const srcVdi = srcVdis[snapshotOf]
if (srcVdi !== undefined) {
baseUuidToSrcVdi.set(baseUuid, srcVdi.uuid)
baseUuidToSrcVdi.set(baseUuid, srcVdi)
} else {
debug('ignore snapshot VDI because no longer present on VM', {
vdi: baseUuid,
@@ -166,18 +154,18 @@ export const IncrementalXapi = class IncrementalXapiVmBackupRunner extends Abstr
}
const fullVdisRequired = new Set()
baseUuidToSrcVdi.forEach((srcVdiUuid, baseUuid) => {
baseUuidToSrcVdi.forEach((srcVdi, baseUuid) => {
if (presentBaseVdis.has(baseUuid)) {
debug('found base VDI', {
base: baseUuid,
vdi: srcVdiUuid,
vdi: srcVdi.uuid,
})
} else {
debug('missing base VDI', {
base: baseUuid,
vdi: srcVdiUuid,
vdi: srcVdi.uuid,
})
fullVdisRequired.add(srcVdiUuid)
fullVdisRequired.add(srcVdi.uuid)
}
})

View File

@@ -1,15 +1,17 @@
import assert from 'node:assert'
import mapValues from 'lodash/mapValues.js'
import ignoreErrors from 'promise-toolbox/ignoreErrors'
import { asyncEach } from '@vates/async-each'
import { asyncMap } from '@xen-orchestra/async-map'
import { chainVhd, openVhd } from 'vhd-lib'
import { chainVhd, checkVhdChain, openVhd, VhdAbstract } from 'vhd-lib'
import { createLogger } from '@xen-orchestra/log'
import { decorateClass } from '@vates/decorate-with'
import { defer } from 'golike-defer'
import { dirname, basename } from 'node:path'
import { dirname } from 'node:path'
import { formatFilenameDate } from '../../_filenameDate.mjs'
import { getOldEntries } from '../../_getOldEntries.mjs'
import { TAG_BASE_DELTA } from '../../_incrementalVm.mjs'
import { Task } from '../../Task.mjs'
import { MixinRemoteWriter } from './_MixinRemoteWriter.mjs'
@@ -21,45 +23,42 @@ import { Disposable } from 'promise-toolbox'
const { warn } = createLogger('xo:backups:DeltaBackupWriter')
export class IncrementalRemoteWriter extends MixinRemoteWriter(AbstractIncrementalWriter) {
#parentVdiPaths
#vhds
async checkBaseVdis(baseUuidToSrcVdi) {
this.#parentVdiPaths = {}
const { handler } = this._adapter
const adapter = this._adapter
const vdisDir = `${this._vmBackupDir}/vdis/${this._job.id}`
await asyncMap(baseUuidToSrcVdi, async ([baseUuid, srcVdiUuid]) => {
let parentDestPath
const vhdDir = `${vdisDir}/${srcVdiUuid}`
await asyncMap(baseUuidToSrcVdi, async ([baseUuid, srcVdi]) => {
let found = false
try {
const vhds = await handler.list(vhdDir, {
const vhds = await handler.list(`${vdisDir}/${srcVdi.uuid}`, {
filter: _ => _[0] !== '.' && _.endsWith('.vhd'),
ignoreMissing: true,
prependDir: true,
})
const packedBaseUuid = packUuid(baseUuid)
// the last one is probably the right one
for (let i = vhds.length - 1; i >= 0 && parentDestPath === undefined; i--) {
const path = vhds[i]
await asyncMap(vhds, async path => {
try {
if (await adapter.isMergeableParent(packedBaseUuid, path)) {
parentDestPath = path
}
await checkVhdChain(handler, path)
// Warning, this should not be written as found = found || await adapter.isMergeableParent(packedBaseUuid, path)
//
// since all the checks of a path are done in parallel, found would be containing
// only the last answer of isMergeableParent which is probably not the right one
// this led to the support tickets https://help.vates.fr/#ticket/zoom/4751 , 4729, 4665 and 4300
const isMergeable = await adapter.isMergeableParent(packedBaseUuid, path)
found = found || isMergeable
} catch (error) {
warn('checkBaseVdis', { error })
await ignoreErrors.call(VhdAbstract.unlink(handler, path))
}
}
})
} catch (error) {
warn('checkBaseVdis', { error })
}
// no usable parent => the runner will have to decide to fall back to a full or stop backup
if (parentDestPath === undefined) {
if (!found) {
baseUuidToSrcVdi.delete(baseUuid)
} else {
this.#parentVdiPaths[vhdDir] = parentDestPath
}
})
}
@@ -124,44 +123,6 @@ export class IncrementalRemoteWriter extends MixinRemoteWriter(AbstractIncrement
}
}
async updateUuidAndChain({ isVhdDifferencing, vdis }) {
assert.notStrictEqual(
this.#vhds,
undefined,
'_transfer must be called before updateUuidAndChain for incremental backups'
)
const parentVdiPaths = this.#parentVdiPaths
const { handler } = this._adapter
const vhds = this.#vhds
await asyncEach(Object.entries(vdis), async ([id, vdi]) => {
const isDifferencing = isVhdDifferencing[`${id}.vhd`]
const path = `${this._vmBackupDir}/${vhds[id]}`
if (isDifferencing) {
assert.notStrictEqual(
parentVdiPaths,
'checkbasevdi must be called before updateUuidAndChain for incremental backups'
)
const parentPath = parentVdiPaths[dirname(path)]
// we are in a incremental backup
// we already computed the chain in checkBaseVdis
assert.notStrictEqual(parentPath, undefined, 'A differential VHD must have a parent')
// forbid any kind of loop
assert.ok(basename(parentPath) < basename(path), `vhd must be sorted to be chained`)
await chainVhd(handler, parentPath, handler, path)
}
// set the correct UUID in the VHD if needed
await Disposable.use(openVhd(handler, path), async vhd => {
if (!vhd.footer.uuid.equals(packUuid(vdi.uuid))) {
vhd.footer.uuid = packUuid(vdi.uuid)
await vhd.readBlockAllocationTable() // required by writeFooter()
await vhd.writeFooter()
}
})
})
}
async _deleteOldEntries() {
const adapter = this._adapter
const oldEntries = this._oldEntries
@@ -180,10 +141,16 @@ export class IncrementalRemoteWriter extends MixinRemoteWriter(AbstractIncrement
const jobId = job.id
const handler = adapter.handler
let metadataContent = await this._isAlreadyTransferred(timestamp)
if (metadataContent !== undefined) {
// 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)
// update this.#vhds before eventually skipping transfer, so that
// updateUuidAndChain has all the mandatory data
const vhds = (this.#vhds = mapValues(
const vhds = mapValues(
deltaExport.vdis,
vdi =>
`vdis/${jobId}/${
@@ -193,15 +160,7 @@ export class IncrementalRemoteWriter extends MixinRemoteWriter(AbstractIncrement
vdi.uuid
: vdi.$snapshot_of$uuid
}/${adapter.getVhdFileName(basename)}`
))
let metadataContent = await this._isAlreadyTransferred(timestamp)
if (metadataContent !== undefined) {
// 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 }
}
)
metadataContent = {
isVhdDifferencing,
@@ -217,13 +176,38 @@ export class IncrementalRemoteWriter extends MixinRemoteWriter(AbstractIncrement
vm,
vmSnapshot,
}
const { size } = await Task.run({ name: 'transfer' }, async () => {
let transferSize = 0
await asyncEach(
Object.keys(deltaExport.vdis),
async id => {
Object.entries(deltaExport.vdis),
async ([id, vdi]) => {
const path = `${this._vmBackupDir}/${vhds[id]}`
const isDifferencing = isVhdDifferencing[`${id}.vhd`]
let parentPath
if (isDifferencing) {
const vdiDir = dirname(path)
parentPath = (
await handler.list(vdiDir, {
filter: filename => filename[0] !== '.' && filename.endsWith('.vhd'),
prependDir: true,
})
)
.sort()
.pop()
assert.notStrictEqual(
parentPath,
undefined,
`missing parent of ${id} in ${dirname(path)}, looking for ${vdi.other_config[TAG_BASE_DELTA]}`
)
parentPath = parentPath.slice(1) // remove leading slash
// TODO remove when this has been done before the export
await checkVhd(handler, parentPath)
}
// don't write it as transferSize += await async function
// since i += await asyncFun lead to race condition
// as explained : https://eslint.org/docs/latest/rules/require-atomic-updates
@@ -235,6 +219,17 @@ export class IncrementalRemoteWriter extends MixinRemoteWriter(AbstractIncrement
writeBlockConcurrency: this._config.writeBlockConcurrency,
})
transferSize += transferSizeOneDisk
if (isDifferencing) {
await chainVhd(handler, parentPath, handler, path)
}
// set the correct UUID in the VHD
await Disposable.use(openVhd(handler, path), async vhd => {
vhd.footer.uuid = packUuid(vdi.uuid)
await vhd.readBlockAllocationTable() // required by writeFooter()
await vhd.writeFooter()
})
},
{
concurrency: settings.diskPerVmConcurrency,

View File

@@ -1,4 +1,3 @@
import assert from 'node:assert'
import { asyncMap, asyncMapSettled } from '@xen-orchestra/async-map'
import ignoreErrors from 'promise-toolbox/ignoreErrors'
import { formatDateTime } from '@xen-orchestra/xapi'
@@ -15,7 +14,6 @@ import find from 'lodash/find.js'
export class IncrementalXapiWriter extends MixinXapiWriter(AbstractIncrementalWriter) {
async checkBaseVdis(baseUuidToSrcVdi, baseVm) {
assert.notStrictEqual(baseVm, undefined)
const sr = this._sr
const replicatedVm = listReplicatedVms(sr.$xapi, this._job.id, sr.uuid, this._vmUuid).find(
vm => vm.other_config[TAG_COPY_SRC] === baseVm.uuid
@@ -38,9 +36,7 @@ export class IncrementalXapiWriter extends MixinXapiWriter(AbstractIncrementalWr
}
}
}
updateUuidAndChain() {
// nothing to do, the chaining is not modified in this case
}
prepare({ isFull }) {
// create the task related to this export and ensure all methods are called in this context
const task = new Task({

View File

@@ -5,10 +5,6 @@ export class AbstractIncrementalWriter extends AbstractWriter {
throw new Error('Not implemented')
}
updateUuidAndChain() {
throw new Error('Not implemented')
}
cleanup() {
throw new Error('Not implemented')
}

View File

@@ -230,7 +230,6 @@ Settings are described in [`@xen-orchestra/backups/\_runners/VmsXapi.mjs``](http
- `checkBaseVdis(baseUuidToSrcVdi, baseVm)`
- `prepare({ isFull })`
- `transfer({ timestamp, deltaExport, sizeContainers })`
- `updateUuidAndChain({ isVhdDifferencing, vdis })`
- `cleanup()`
- `healthCheck()` // is not executed if no health check sr or tag doesn't match
- **Full**

View File

@@ -50,17 +50,7 @@ const RRD_POINTS_PER_STEP: { [key in RRD_STEP]: number } = {
// Utils
// -------------------------------------------------------------------
function parseNumber(value: number | string) {
// 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
}
function convertNanToNull(value: number) {
return isNaN(value) ? null : value
}
@@ -69,7 +59,7 @@ function parseNumber(value: number | string) {
// -------------------------------------------------------------------
const computeValues = (dataRow: any, legendIndex: number, transformValue = identity) =>
map(dataRow, ({ values }) => transformValue(parseNumber(values[legendIndex])))
map(dataRow, ({ values }) => transformValue(convertNanToNull(values[legendIndex])))
const createGetProperty = (obj: object, property: string, defaultValue: unknown) =>
defaults(obj, { [property]: defaultValue })[property] as any
@@ -329,14 +319,8 @@ export default class XapiStats {
},
abortSignal,
})
const text = await resp.text()
try {
// starting from XAPI 23.31, the response is valid JSON
return JSON.parse(text)
} catch (error) {
// eslint-disable-next-line import/no-named-as-default-member -- https://github.com/json5/json5/issues/287
return JSON5.parse(text)
}
// eslint-disable-next-line import/no-named-as-default-member -- https://github.com/json5/json5/issues/287
return JSON5.parse(await resp.text())
}
// To avoid multiple requests, we keep a cache for the stats and
@@ -399,10 +383,7 @@ export default class XapiStats {
abortSignal,
})
const actualStep = parseNumber(json.meta.step)
if (actualStep !== step) {
throw new FaultyGranularity(`Unable to get the true granularity: ${actualStep}`)
}
const actualStep = json.meta.step as number
if (json.data.length > 0) {
// fetched data is organized from the newest to the oldest
@@ -426,15 +407,14 @@ export default class XapiStats {
let stepStats = xoObjectStats[actualStep]
let cacheStepStats = cacheXoObjectStats[actualStep]
const endTimestamp = parseNumber(json.meta.end)
if (stepStats === undefined || stepStats.endTimestamp !== endTimestamp) {
if (stepStats === undefined || stepStats.endTimestamp !== json.meta.end) {
stepStats = xoObjectStats[actualStep] = {
endTimestamp,
endTimestamp: json.meta.end,
interval: actualStep,
canBeExpired: false,
}
cacheStepStats = cacheXoObjectStats[actualStep] = {
endTimestamp,
endTimestamp: json.meta.end,
interval: actualStep,
canBeExpired: true,
}
@@ -458,6 +438,10 @@ export default class XapiStats {
})
})
}
if (actualStep !== step) {
throw new FaultyGranularity(`Unable to get the true granularity: ${actualStep}`)
}
} catch (error) {
if (error instanceof Error && error.name === 'AbortError') {
return

View File

@@ -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: {

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View File

@@ -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>

View 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>

View 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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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">&nbsp;</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>

View 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`
```

View File

@@ -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)
}

View 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}`),
}))

View 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>
```

View 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)
},
}

View File

@@ -0,0 +1,6 @@
{
"core": {
"master": "Primary host",
"quick-actions": "Quick actions"
}
}

View File

@@ -0,0 +1,6 @@
{
"core": {
"master": "Hôte principal",
"quick-actions": "Actions rapides"
}
}

View 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)!,
}
})
}),
}
})

View File

@@ -0,0 +1 @@
export type Color = 'info' | 'error' | 'warning' | 'success'

View File

@@ -0,0 +1 @@
export type POWER_STATE = 'running' | 'paused' | 'halted' | 'suspended'

View 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
}

View 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>

View 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}`
}

View File

@@ -10,8 +10,18 @@
}
},
"devDependencies": {
"@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/tsconfig": "^0.5.1"
"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",

View File

@@ -9,10 +9,6 @@
- Disable search engine indexing via a `robots.txt`
- [Stats] Support format used by XAPI 23.31
- [REST API] Export host [SMT](https://en.wikipedia.org/wiki/Simultaneous_multithreading) status at `/hosts/:id/smt` [Forum#71374](https://xcp-ng.org/forum/post/71374)
- [Home & REST API] `$container` field of an halted VM now points to a host if a VDI is on a local storage [Forum#71769](https://xcp-ng.org/forum/post/71769)
- [Size Input] Ability to select two new units in the dropdown (`TiB`, `PiB`) (PR [#7382](https://github.com/vatesfr/xen-orchestra/pull/7382))
### Bug fixes
@@ -21,8 +17,6 @@
- [Settings/XO Config] Sort backups from newest to oldest
- [Plugins/audit] Don't log `tag.getAllConfigured` calls
- [Remotes] Correctly clear error when the remote is tested with success
- [Import/VMWare] Fix importing last snapshot (PR [#7370](https://github.com/vatesfr/xen-orchestra/pull/7370))
- [Host/Reboot] Fix false positive warning when restarting an host after updates (PR [#7366](https://github.com/vatesfr/xen-orchestra/pull/7366))
### Packages to release
@@ -46,6 +40,6 @@
- vhd-lib patch
- xo-server minor
- xo-server-audit patch
- xo-web minor
- xo-web patch
<!--packages-end-->

View File

@@ -46,9 +46,9 @@ const VhdSynthetic = class VhdSynthetic extends VhdAbstract {
}
get compressionType() {
const compressionType = this.#vhds[0].compressionType
for (let i = 0; i < this.#vhds.length; i++) {
if (compressionType !== this.#vhds[i].compressionType) {
const compressionType = this.vhds[0].compressionType
for (let i = 0; i < this.vhds.length; i++) {
if (compressionType !== this.vhds[i].compressionType) {
return 'MIXED'
}
}

View File

@@ -1,4 +1,3 @@
import semver from 'semver'
import { createLogger } from '@xen-orchestra/log'
import assert from 'assert'
import { format } from 'json-rpc-peer'
@@ -137,38 +136,13 @@ export async function restart({
const pool = this.getObject(host.$poolId, 'pool')
const master = this.getObject(pool.master, 'host')
const hostRebootRequired = host.rebootRequired
// we are currently in an host upgrade process
if (hostRebootRequired && host.id !== master.id) {
// this error is not ideal but it means that the pool master must be fully upgraded/rebooted before the current host can be rebooted.
//
// there is a single error for the 3 cases because the client must handle them the same way
const throwError = () =>
incorrectState({
actual: hostRebootRequired,
expected: false,
object: master.id,
property: 'rebootRequired',
})
if (semver.lt(master.version, host.version)) {
log.error(`master version (${master.version}) is older than the host version (${host.version})`, {
masterId: master.id,
hostId: host.id,
})
throwError()
}
if (semver.eq(master.version, host.version)) {
if ((await this.getXapi(host).listMissingPatches(master._xapiId)).length > 0) {
log.error('master has missing patches', { masterId: master.id })
throwError()
}
if (master.rebootRequired) {
log.error('master needs to reboot')
throwError()
}
}
if (hostRebootRequired && host.id !== master.id && host.version === master.version) {
throw incorrectState({
actual: hostRebootRequired,
expected: false,
object: master.id,
property: 'rebootRequired',
})
}
}

View File

@@ -1,27 +0,0 @@
export async function scan({ host }) {
await this.getXapi(host).call('PUSB.scan', host._xapiRef)
}
scan.params = {
host: { type: 'string' },
}
scan.resolve = {
host: ['host', 'host', 'operate'],
}
export async function set({ pusb, enabled }) {
const xapi = this.getXapi(pusb)
if (enabled !== undefined && enabled !== pusb.passthroughEnabled) {
await xapi.call('PUSB.set_passthrough_enabled', pusb._xapiRef, enabled)
}
}
set.params = {
id: { type: 'string' },
enabled: { type: 'boolean', optional: true },
}
set.resolve = {
pusb: ['id', 'PUSB', 'administrate'],
}

View File

@@ -328,34 +328,6 @@ const TRANSFORMS = {
const { creation } = xoData.extract(obj) ?? {}
let $container
if (obj.resident_on !== 'OpaqueRef:NULL') {
// resident_on is set when the VM is running (or paused or suspended on a host)
$container = link(obj, 'resident_on')
} else {
// if the VM is halted, the $container is the pool
$container = link(obj, 'pool')
// unless one of its VDI is on a non shared SR
//
// linked objects may not be there when this code run, and it will only be
// refreshed when the VM XAPI record change, this value is not guaranteed
// to be up-to-date, but it practice it appears to work fine thanks to
// `VBDs` and `current_operations` changing when a VDI is
// added/removed/migrated
for (const vbd of obj.$VBDs) {
const sr = vbd?.$VDI?.$SR
if (sr !== undefined && !sr.shared) {
const pbd = sr.$PBDs[0]
const hostId = pbd && link(pbd, 'host')
if (hostId !== undefined) {
$container = hostId
break
}
}
}
}
const vm = {
// type is redefined after for controllers/, templates &
// snapshots.
@@ -450,7 +422,8 @@ const TRANSFORMS = {
xenTools,
...getVmGuestToolsProps(obj),
$container,
// TODO: handle local VMs (`VM.get_possible_hosts()`).
$container: isRunning ? link(obj, 'resident_on') : link(obj, 'pool'),
$VBDs: link(obj, 'VBDs'),
// TODO: dedupe
@@ -889,17 +862,6 @@ const TRANSFORMS = {
vm: link(obj, 'VM'),
}
},
pusb(obj) {
return {
type: 'PUSB',
description: obj.description,
host: link(obj, 'host'),
passthroughEnabled: obj.passthrough_enabled,
usbGroup: link(obj, 'USB_group'),
}
},
}
// ===================================================================

View File

@@ -280,7 +280,7 @@ export default class MigrateVm {
const stream = vhd.stream()
await vdi.$importContent(stream, { format: VDI_FORMAT_VHD })
}
return { vdi, vhd }
return vhd
})
)
)

View File

@@ -253,10 +253,6 @@ export default class RestApi {
const host = req.xapiObject
res.json(await host.$xapi.listMissingPatches(host))
},
async smt({ xapiObject }, res) {
res.json({ enabled: await xapiObject.$xapi.isHyperThreadingEnabled(xapiObject.$id) })
},
}
collections.pools.routes = {

View File

@@ -99,7 +99,7 @@ test('An ova file is generated correctly', async () => {
try {
await execXmllint(xml, [
'--schema',
path.join(__dirname, '..', 'src', 'ova-schema', 'dsp8023_1.1.1.xsd'),
path.join(__dirname, 'ova-schema', 'dsp8023_1.1.1.xsd'),
'--noout',
'--nonet',
'-',

View File

@@ -138,7 +138,7 @@ export class Range extends Component {
export Toggle from './toggle'
const UNITS = ['kiB', 'MiB', 'GiB', 'TiB', 'PiB']
const UNITS = ['kiB', 'MiB', 'GiB']
const DEFAULT_UNIT = 'GiB'
export class SizeInput extends BaseComponent {

View File

@@ -54,9 +54,13 @@ export default class IsoDevice extends Component {
() => this.props.vm.$pool,
() => this.props.vm.$container,
(vmPool, vmContainer) => sr => {
const vmRunning = vmContainer !== vmPool
const sameHost = vmContainer === sr.$container
const samePool = vmPool === sr.$pool
return (
vmPool === sr.$pool &&
(sr.shared || vmContainer === sr.$container) &&
samePool &&
(vmRunning ? sr.shared || sameHost : true) &&
(sr.SR_type === 'iso' || (sr.SR_type === 'udev' && sr.size))
)
}

View File

@@ -3,6 +3,7 @@ import _ from 'intl'
import Copiable from 'copiable'
import decorate from 'apply-decorators'
import Icon from 'icon'
import map from 'lodash/map'
import React from 'react'
import store from 'store'
import HomeTags from 'home-tags'
@@ -23,21 +24,10 @@ export default decorate([
provideState({
computed: {
areHostsVersionsEqual: ({ areHostsVersionsEqualByPool }, { host }) => areHostsVersionsEqualByPool[host.$pool],
inMemoryVms: (_, { vms }) => {
const result = []
for (const key of Object.keys(vms)) {
const vm = vms[key]
const { power_state } = vm
if (power_state === 'Running' || power_state === 'Paused') {
result.push(vm)
}
}
return result
},
},
}),
injectState,
({ statsOverview, host, nVms, vmController, state: { areHostsVersionsEqual, inMemoryVms } }) => {
({ statsOverview, host, nVms, vmController, vms, state: { areHostsVersionsEqual } }) => {
const pool = getObject(store.getState(), host.$pool)
const vmsFilter = encodeURIComponent(new CM.Property('$container', new CM.String(host.id)).toString())
return (
@@ -130,7 +120,7 @@ export default decorate([
tooltip={`${host.productBrand} (${formatSize(vmController.memory.size)})`}
value={vmController.memory.size}
/>
{inMemoryVms.map(vm => (
{map(vms, vm => (
<UsageElement
tooltip={`${vm.name_label} (${formatSize(vm.memory.size)})`}
key={vm.id}