Compare commits

..

16 Commits

Author SHA1 Message Date
Olivier Floch
3d46fa9e3e feedback 2024-02-23 09:48:13 +01:00
Olivier Floch
e8eb2fe6a7 feat(xo6/core): improve color context 2024-02-23 09:25:04 +01:00
mathieuRA
aefcce45ff feat(xo-server/pusb): implement methods for USB passthrough 2024-02-22 14:58:55 +01:00
mathieuRA
367fb4d8a6 feat(xo-server): implement PUSB in xapi-object-to-xo 2024-02-22 14:58:55 +01:00
Julien Fontanet
e54a0bfc80 fix(xo-web/iso-device): fix SR predicate
Introduced by 1718649e0
2024-02-22 10:58:11 +01:00
Julien Fontanet
9e5541703b fix(xo-web/host): only count memory of running VMs
Introduced by 1718649e0

FIxes https://xcp-ng.org/forum/post/71886
2024-02-22 10:31:06 +01:00
Mathieu
039d5687c0 fix(xo-server/host): fix false positives when restarting host after updates (#7366)
The previous implementation only considered version upgrades and did not take into account the installation of missing patches.

See zammad#21487
Introduced by 85ec261
2024-02-21 15:05:05 +01:00
Florent Beauchamp
b89195eb80 fix(backups/IncrementalRemote): ensure chaining is ok and mutualize code with IncrementalXapi 2024-02-21 10:27:56 +01:00
Florent Beauchamp
822cdc3fb8 refactor(backups/IncrementalRemoteWriter): reuse parent path from checkBaseVdis 2024-02-21 10:27:56 +01:00
Florent Beauchamp
c7b5b715a3 refactor(backups/checkBaseVdi): use uuid, don't check vhd multiple times 2024-02-21 10:27:56 +01:00
Florent Beauchamp
56b427c09c fix(vhd-lib/VhdSynthetic): compression type computation 2024-02-21 10:27:56 +01:00
Mathieu
0e45c52bbc feat(lite/xapi-stats): handle new format (#7383)
Similar to 757a8915d9

Starting from XAPI 23.31, stats are in valid JSON but numbers are encoded as strings.
2024-02-20 17:55:57 +01:00
Mathieu
4fd2b91fc4 feat(xo-web/SizeInput): added 'TiB' and 'PiB' units (#7382) 2024-02-20 17:43:21 +01:00
Florent BEAUCHAMP
7890320a7d fix(xo-server/import): error during import of last snapshot of running VM (#7370)
From zammad#21710

Introduced by 2d047c4fef
2024-02-20 17:39:39 +01:00
Julien Fontanet
1718649e0c feat(xo-server/vm.$container): points to host if VDI on local SR
Fixes https://xcp-ng.org/forum/post/71769
2024-02-20 16:49:53 +01:00
Julien Fontanet
7fc5d62ca9 feat(xo-server/rest-api): export hosts' SMT status
Fixes https://xcp-ng.org/forum/post/71374
2024-02-20 16:33:33 +01:00
24 changed files with 710 additions and 149 deletions

View File

@@ -191,13 +191,14 @@ 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(openVhd(this.handler, path), vhd => {
return await Disposable.use(VhdSynthetic.fromVhdChain(this.handler, path), vhd => {
// this baseUuid is not linked with this vhd
if (!vhd.footer.uuid.equals(packedParentUid)) {
return false
}
const isVhdDirectory = vhd instanceof VhdDirectory
// check if all the chain is composed of vhd directory
const isVhdDirectory = vhd.checkVhdsClass(VhdDirectory)
return isVhdDirectory
? this.useVhdDirectory() && this.#getCompressionType() === vhd.compressionType
: !this.useVhdDirectory()

View File

@@ -2,6 +2,7 @@ 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'
@@ -9,11 +10,48 @@ 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 => {
@@ -26,7 +64,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,
@@ -50,6 +88,17 @@ 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,6 +78,18 @@ 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) {
@@ -133,7 +145,7 @@ export const IncrementalXapi = class IncrementalXapiVmBackupRunner extends Abstr
])
const srcVdi = srcVdis[snapshotOf]
if (srcVdi !== undefined) {
baseUuidToSrcVdi.set(baseUuid, srcVdi)
baseUuidToSrcVdi.set(baseUuid, srcVdi.uuid)
} else {
debug('ignore snapshot VDI because no longer present on VM', {
vdi: baseUuid,
@@ -154,18 +166,18 @@ export const IncrementalXapi = class IncrementalXapiVmBackupRunner extends Abstr
}
const fullVdisRequired = new Set()
baseUuidToSrcVdi.forEach((srcVdi, baseUuid) => {
baseUuidToSrcVdi.forEach((srcVdiUuid, baseUuid) => {
if (presentBaseVdis.has(baseUuid)) {
debug('found base VDI', {
base: baseUuid,
vdi: srcVdi.uuid,
vdi: srcVdiUuid,
})
} else {
debug('missing base VDI', {
base: baseUuid,
vdi: srcVdi.uuid,
vdi: srcVdiUuid,
})
fullVdisRequired.add(srcVdi.uuid)
fullVdisRequired.add(srcVdiUuid)
}
})

View File

@@ -1,17 +1,15 @@
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, checkVhdChain, openVhd, VhdAbstract } from 'vhd-lib'
import { chainVhd, openVhd } from 'vhd-lib'
import { createLogger } from '@xen-orchestra/log'
import { decorateClass } from '@vates/decorate-with'
import { defer } from 'golike-defer'
import { dirname } from 'node:path'
import { dirname, basename } 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'
@@ -23,42 +21,45 @@ 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, srcVdi]) => {
let found = false
await asyncMap(baseUuidToSrcVdi, async ([baseUuid, srcVdiUuid]) => {
let parentDestPath
const vhdDir = `${vdisDir}/${srcVdiUuid}`
try {
const vhds = await handler.list(`${vdisDir}/${srcVdi.uuid}`, {
const vhds = await handler.list(vhdDir, {
filter: _ => _[0] !== '.' && _.endsWith('.vhd'),
ignoreMissing: true,
prependDir: true,
})
const packedBaseUuid = packUuid(baseUuid)
await asyncMap(vhds, async path => {
try {
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
// the last one is probably the right one
const isMergeable = await adapter.isMergeableParent(packedBaseUuid, path)
found = found || isMergeable
for (let i = vhds.length - 1; i >= 0 && parentDestPath === undefined; i--) {
const path = vhds[i]
try {
if (await adapter.isMergeableParent(packedBaseUuid, path)) {
parentDestPath = path
}
} catch (error) {
warn('checkBaseVdis', { error })
await ignoreErrors.call(VhdAbstract.unlink(handler, path))
}
})
}
} catch (error) {
warn('checkBaseVdis', { error })
}
if (!found) {
// no usable parent => the runner will have to decide to fall back to a full or stop backup
if (parentDestPath === undefined) {
baseUuidToSrcVdi.delete(baseUuid)
} else {
this.#parentVdiPaths[vhdDir] = parentDestPath
}
})
}
@@ -123,6 +124,44 @@ 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
@@ -141,16 +180,10 @@ 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)
const vhds = mapValues(
// update this.#vhds before eventually skipping transfer, so that
// updateUuidAndChain has all the mandatory data
const vhds = (this.#vhds = mapValues(
deltaExport.vdis,
vdi =>
`vdis/${jobId}/${
@@ -160,7 +193,15 @@ 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,
@@ -176,38 +217,13 @@ export class IncrementalRemoteWriter extends MixinRemoteWriter(AbstractIncrement
vm,
vmSnapshot,
}
const { size } = await Task.run({ name: 'transfer' }, async () => {
let transferSize = 0
await asyncEach(
Object.entries(deltaExport.vdis),
async ([id, vdi]) => {
Object.keys(deltaExport.vdis),
async id => {
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
@@ -219,17 +235,6 @@ 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,3 +1,4 @@
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'
@@ -14,6 +15,7 @@ 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
@@ -36,7 +38,9 @@ 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,6 +5,10 @@ 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,6 +230,7 @@ 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,7 +50,17 @@ const RRD_POINTS_PER_STEP: { [key in RRD_STEP]: number } = {
// Utils
// -------------------------------------------------------------------
function convertNanToNull(value: number) {
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
}
return isNaN(value) ? null : value
}
@@ -59,7 +69,7 @@ function convertNanToNull(value: number) {
// -------------------------------------------------------------------
const computeValues = (dataRow: any, legendIndex: number, transformValue = identity) =>
map(dataRow, ({ values }) => transformValue(convertNanToNull(values[legendIndex])))
map(dataRow, ({ values }) => transformValue(parseNumber(values[legendIndex])))
const createGetProperty = (obj: object, property: string, defaultValue: unknown) =>
defaults(obj, { [property]: defaultValue })[property] as any
@@ -319,8 +329,14 @@ export default class XapiStats {
},
abortSignal,
})
// eslint-disable-next-line import/no-named-as-default-member -- https://github.com/json5/json5/issues/287
return JSON5.parse(await resp.text())
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)
}
}
// To avoid multiple requests, we keep a cache for the stats and
@@ -383,7 +399,10 @@ export default class XapiStats {
abortSignal,
})
const actualStep = json.meta.step as number
const actualStep = parseNumber(json.meta.step)
if (actualStep !== step) {
throw new FaultyGranularity(`Unable to get the true granularity: ${actualStep}`)
}
if (json.data.length > 0) {
// fetched data is organized from the newest to the oldest
@@ -407,14 +426,15 @@ export default class XapiStats {
let stepStats = xoObjectStats[actualStep]
let cacheStepStats = cacheXoObjectStats[actualStep]
if (stepStats === undefined || stepStats.endTimestamp !== json.meta.end) {
const endTimestamp = parseNumber(json.meta.end)
if (stepStats === undefined || stepStats.endTimestamp !== endTimestamp) {
stepStats = xoObjectStats[actualStep] = {
endTimestamp: json.meta.end,
endTimestamp,
interval: actualStep,
canBeExpired: false,
}
cacheStepStats = cacheXoObjectStats[actualStep] = {
endTimestamp: json.meta.end,
endTimestamp,
interval: actualStep,
canBeExpired: true,
}
@@ -438,10 +458,6 @@ 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

@@ -0,0 +1,158 @@
<!-- 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))
// Example 3. Use a previous value
const ColorContext = createContext('info' as Color, (color, previousColor) => ({
name: color,
colorContextClass: computed(() => (previousColor.value === color.value ? undefined : `color-context-${color.value}`)),
}))
```
### 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,142 @@
<!-- TOC -->
- [Overview](#overview)
- [CSS variables](#css-variables)
- [Available color contexts](#available-color-contexts)
- [Usage](#usage)
<!-- TOC -->
# Overview
The color context provides a way to apply a set of colors variants to 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.
The purpose is to colorize a component and its descendants by applying a single CSS class on the parent component (e.g., applying the class on a modal component container will style all children components using the context).
## CSS variables
The color context relies on the usage of the following variables:
```css
--color-context-primary;
--color-context-primary-hover;
--color-context-primary-active;
--color-context-primary-disabled;
--color-context-secondary;
--color-context-secondary-hover;
--color-context-secondary-active;
--color-context-tertiary;
```
Any component can use these variables for `color`, `background-color` or any other CSS property, to be usable with the color context.
When you set a color context, the variables are updated with the help of CSS classes defined in `_context.pcss`:
```css
.color-context-info {
--color-context-primary: var(--color-purple-base);
--color-context-primary-hover: var(--color-purple-d20);
--color-context-primary-active: var(--color-purple-d40);
--color-context-primary-disabled: var(--color-grey-400);
--color-context-secondary: var(--background-color-purple-10);
--color-context-secondary-hover: var(--background-color-purple-20);
--color-context-secondary-active: var(--background-color-purple-30);
}
.color-context-success {
--color-context-primary: var(--color-green-base);
--color-context-primary-hover: var(--color-green-d20);
/*...*/
}
```
You can add any other context by adding a `color-context-<my-context>` class and setting the desired values for the variables.
**Important note: remember to set a value for all variables to avoid any missing styles.**
## Available color contexts
Color contexts rely on the type `Color` defined in `/lib/types/color.type.ts`:
- `info` (_purple_)
- `success` (_green_)
- `warning` (_orange_)
- `error` (_red_)
## Usage
To get and set the color context in a component, you can pass the `ColorContext` to `useContext` and apply the `colorContextClass` to the root component:
```ts
// ParentComponent.vue
import { useContext } from '@core/composables/context.composable'
import { ColorContext } from '@core/context'
import type { Color } from '@core/types/color.type'
import { defineProps } from 'vue'
const props = defineProps<{
color?: Color
}>()
const { colorContextClass } = useContext(ColorContext, () => props.color)
```
All the components using the CSS variables will inherit the color context applied by the `colorContextClass`.
It's possible to change the color of a component on demand, if the component has a `color` prop, and passing it as the second parameter of the composable.
Then, the only thing to do is to apply the class in the component's `template`:
```vue
<!-- ParentComponent.vue -->
<template>
<div :class="colorContextClass">
<!-- Will use the color context defined by the class above-->
<MyComponent />
<!-- Will use the color "info" instead of the context-->
<MyComponent color="info" />
</div>
</template>
```
`MyComponent` using the context:
```vue
<!-- MyComponent.vue -->
<template>
<div :class="colorContextClass" class="my-component">
<p>Lorem ipsum dolor sit amet.</p>
</div>
</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 { defineProps } from 'vue'
const props = defineProps<{
color?: Color
}>()
const { colorContextClass } = useContext(ColorContext, () => props.color)
</script>
<style lang="postcss" scoped>
.my-component {
background-color: var(--color-context-secondary);
color: var(--color-context-primary);
}
</style>
```
In the example above, if the `color` prop is not set, the component will use the color context (i.e., if its parent uses a `success` color context, `MyComponent` will be styled with the `success` colors defined in `_context.pcss`).
If the `color` prop is set, the component will use the prop value to update the context for itself and its descendants.

View File

@@ -45,3 +45,55 @@
.context-border-color-info {
border-color: var(--color-purple-base);
}
.color-context-info {
--color-context-primary: var(--color-purple-base);
--color-context-primary-hover: var(--color-purple-d20);
--color-context-primary-active: var(--color-purple-d40);
--color-context-primary-disabled: var(--color-grey-400);
--color-context-secondary: var(--background-color-purple-10);
--color-context-secondary-hover: var(--background-color-purple-20);
--color-context-secondary-active: var(--background-color-purple-30);
--color-context-tertiary: var(--background-color-primary);
}
.color-context-success {
--color-context-primary: var(--color-green-base);
--color-context-primary-hover: var(--color-green-d20);
--color-context-primary-active: var(--color-green-d40);
--color-context-primary-disabled: var(--color-green-l60);
--color-context-secondary: var(--background-color-green-10);
--color-context-secondary-hover: var(--background-color-green-20);
--color-context-secondary-active: var(--background-color-green-30);
--color-context-tertiary: var(--background-color-primary);
}
.color-context-warning {
--color-context-primary: var(--color-orange-base);
--color-context-primary-hover: var(--color-orange-d20);
--color-context-primary-active: var(--color-orange-d40);
--color-context-primary-disabled: var(--color-orange-l60);
--color-context-secondary: var(--background-color-orange-10);
--color-context-secondary-hover: var(--background-color-orange-20);
--color-context-secondary-active: var(--background-color-orange-30);
--color-context-tertiary: var(--background-color-primary);
}
.color-context-error {
--color-context-primary: var(--color-red-base);
--color-context-primary-hover: var(--color-red-d20);
--color-context-primary-active: var(--color-red-d40);
--color-context-primary-disabled: var(--color-red-l60);
--color-context-secondary: var(--background-color-red-10);
--color-context-secondary-hover: var(--background-color-red-20);
--color-context-secondary-active: var(--background-color-red-30);
--color-context-tertiary: var(--background-color-primary);
}

View File

@@ -0,0 +1,34 @@
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>, previousValue: 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, undefined)
const updatedValue = () => toValue(newValue) ?? toValue(currentValue) ?? context.initialValue
provide(context.id, updatedValue)
return context.builder(
computed(() => toValue(updatedValue)),
computed(() => toValue(currentValue))
)
}

View File

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

View File

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

View File

@@ -9,6 +9,10 @@
- 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
@@ -17,6 +21,8 @@
- [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
@@ -40,6 +46,6 @@
- vhd-lib patch
- xo-server minor
- xo-server-audit patch
- xo-web patch
- xo-web minor
<!--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,3 +1,4 @@
import semver from 'semver'
import { createLogger } from '@xen-orchestra/log'
import assert from 'assert'
import { format } from 'json-rpc-peer'
@@ -136,13 +137,38 @@ export async function restart({
const pool = this.getObject(host.$poolId, 'pool')
const master = this.getObject(pool.master, 'host')
const hostRebootRequired = host.rebootRequired
if (hostRebootRequired && host.id !== master.id && host.version === master.version) {
throw incorrectState({
actual: hostRebootRequired,
expected: false,
object: master.id,
property: '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()
}
}
}
}

View File

@@ -0,0 +1,27 @@
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,6 +328,34 @@ 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.
@@ -422,8 +450,7 @@ const TRANSFORMS = {
xenTools,
...getVmGuestToolsProps(obj),
// TODO: handle local VMs (`VM.get_possible_hosts()`).
$container: isRunning ? link(obj, 'resident_on') : link(obj, 'pool'),
$container,
$VBDs: link(obj, 'VBDs'),
// TODO: dedupe
@@ -862,6 +889,17 @@ 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 vhd
return { vdi, vhd }
})
)
)

View File

@@ -72,11 +72,8 @@ async function* makeObjectsStream(iterable, makeResult, json) {
async function sendObjects(iterable, req, res, path = req.path) {
const { query } = req
const { baseUrl } = req
const makeUrl = item => {
const { id } = item
return join(baseUrl, typeof path === 'function' ? path(item) : path, typeof id === 'number' ? String(id) : id)
}
const basePath = join(req.baseUrl, path)
const makeUrl = ({ id }) => join(basePath, typeof id === 'number' ? String(id) : id)
let makeResult
let { fields } = query
@@ -256,6 +253,10 @@ 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 = {
@@ -280,36 +281,6 @@ export default class RestApi {
},
}
{
async function vdis(req, res) {
const { object, query } = req
const vdiIds = new Set()
for (const vbdId of object.$VBDs) {
const vbd = app.getObject(vbdId, 'VBD')
const vdiId = vbd.VDI
if (vdiId !== undefined) {
vdiIds.add(vdiId)
}
}
await sendObjects(
handleArray(
Array.from(vdiIds, id => app.getObject(id, ['VDI', 'VDI-snapshot'])),
query.filter,
ifDef(query.limit, Number)
),
req,
res,
({ type }) => type.toLowerCase() + 's'
)
}
for (const collection of ['vms', 'vm-snapshots', 'vm-templates']) {
collections[collection].routes.vdis = vdis
}
}
collections.pools.actions = {
create_vm: withParams(
defer(async ($defer, { xapiObject: { $xapi } }, { affinity, boot, install, template, ...params }, req) => {

View File

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

View File

@@ -54,13 +54,9 @@ 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 (
samePool &&
(vmRunning ? sr.shared || sameHost : true) &&
vmPool === sr.$pool &&
(sr.shared || vmContainer === sr.$container) &&
(sr.SR_type === 'iso' || (sr.SR_type === 'udev' && sr.size))
)
}

View File

@@ -3,7 +3,6 @@ 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'
@@ -24,10 +23,21 @@ 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, vms, state: { areHostsVersionsEqual } }) => {
({ statsOverview, host, nVms, vmController, state: { areHostsVersionsEqual, inMemoryVms } }) => {
const pool = getObject(store.getState(), host.$pool)
const vmsFilter = encodeURIComponent(new CM.Property('$container', new CM.String(host.id)).toString())
return (
@@ -120,7 +130,7 @@ export default decorate([
tooltip={`${host.productBrand} (${formatSize(vmController.memory.size)})`}
value={vmController.memory.size}
/>
{map(vms, vm => (
{inMemoryVms.map(vm => (
<UsageElement
tooltip={`${vm.name_label} (${formatSize(vm.memory.size)})`}
key={vm.id}